Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc9b83ef84 | |||
| ac9b71ed16 | |||
| 5b0535f6df | |||
| 6d6c3313ee | |||
| 9f902317a3 | |||
| 5e291203a5 | |||
| 8f149d35ab | |||
| 974c3eb07d | |||
| 3a5b929284 | |||
| 55c17614b8 |
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../docs/guides/ai-subsystem.md
|
||||||
@@ -25,6 +25,7 @@ logs/
|
|||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
# Local DB snapshots that aren't part of the package
|
# Local DB snapshots that aren't part of the package
|
||||||
|
app.db
|
||||||
proof-of-work-*.db
|
proof-of-work-*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# AGENTS.md — Proof of Work
|
||||||
|
|
||||||
|
Self-hosted multi-user workout logger (Next.js app) packaged as a StartOS 0.4 `s9pk`, published to a private Start9 registry.
|
||||||
|
|
||||||
|
## Stack (versions that matter)
|
||||||
|
|
||||||
|
- **Next.js 14** (App Router, server components + server actions, SSE streaming)
|
||||||
|
- **React 18**, **TypeScript 5**, **TailwindCSS 3**
|
||||||
|
- **Prisma 5** ORM over **SQLite** (WAL mode; tuned at boot)
|
||||||
|
- **bcrypt** (native — NOT bcryptjs), **zod 3** for validation
|
||||||
|
- **Vitest 4** for tests
|
||||||
|
- **@start9labs/start-sdk** for the 0.4 packaging layer
|
||||||
|
- Node **>= 20** to build
|
||||||
|
|
||||||
|
## Layout (two projects in one repo)
|
||||||
|
|
||||||
|
```
|
||||||
|
proof-of-work/ ← the Next.js app (THIS is where you run npm)
|
||||||
|
app/ ← App Router routes; app/api/** = route handlers
|
||||||
|
app/main/ ← authed UI; navigation.tsx = sidebar
|
||||||
|
components/ ← React components (workouts/, ai/, settings/)
|
||||||
|
lib/ai/ ← AI subsystem (see below)
|
||||||
|
lib/ai/providers/ ← claude.ts openai.ts gemini.ts ollama.ts + index.ts (getProvider; openai.ts exports both openai + openai-compatible = 5 registered providers)
|
||||||
|
prisma/schema.prisma ← schema (mirror; real DB migrates via entrypoint ALTERs)
|
||||||
|
prisma/*.seed.json ← curated exercise library + AI templates (reconciled each boot)
|
||||||
|
tests/ ← Vitest specs (ai-*.test.ts, routes-*.test.ts, ...)
|
||||||
|
start9/0.4/ ← StartOS packaging wrapper
|
||||||
|
docker_entrypoint.sh ← boot: first-boot seed, additive ALTERs, library reconcile
|
||||||
|
Makefile / s9pk.mk ← s9pk build (ARCHES := x86)
|
||||||
|
startos/versions/ ← one file per ExVer version + index.ts (the version graph)
|
||||||
|
~/.proof-of-work/ ← publish.sh + unpublish.sh (NOT in repo; self-hosted registry)
|
||||||
|
```
|
||||||
|
|
||||||
|
`workout-planner/` is scratch (only `logs/`) — ignore. `start9/0.4/*.s9pk` are build artifacts.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run app commands **from `proof-of-work/`** (running tsc/vitest/next from repo root fails — wrong cwd):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd proof-of-work
|
||||||
|
npm run dev # local dev server
|
||||||
|
npm run build # next build (run this to catch route/type errors before shipping)
|
||||||
|
npm run lint # next lint
|
||||||
|
npm test # vitest run (full suite)
|
||||||
|
npx vitest run tests/ai-pricing.test.ts # single file
|
||||||
|
npx vitest run -t "findPrice" # single test by name
|
||||||
|
npx tsc --noEmit # typecheck only
|
||||||
|
npx prisma generate # REQUIRED after editing schema.prisma (else TS can't see new fields)
|
||||||
|
```
|
||||||
|
|
||||||
|
Build/sideload the s9pk (from `start9/0.4/`): `make x86` then `make install`. Targets come from `s9pk.mk` (the wrapper `Makefile` just sets `ARCHES := x86`):
|
||||||
|
|
||||||
|
- `make x86` — build the x86 s9pk.
|
||||||
|
- `make install` — sideload the newest local `.s9pk` to the StartOS box at `host:` in `~/.startos/config.yaml` (via `start-cli package install`).
|
||||||
|
- `make publish` — upload every `.s9pk` to the S3 bucket (`s9pk-s3base:`) and index it on `registry:` from `~/.startos/config.yaml` (via `s3cmd` + `start-cli s9pk publish`). **Distinct from `~/.proof-of-work/publish.sh`** below.
|
||||||
|
- `make clean` — remove build artifacts.
|
||||||
|
|
||||||
|
Both `install` and `publish` read host/registry config from `~/.startos/config.yaml`, which is **not in the repo** — verify against the live setup, not from a checkout.
|
||||||
|
|
||||||
|
Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds, uploads to FileBrowser, registers) — separate from the generic `make publish`. Unpublish: `~/.proof-of-work/unpublish.sh`.
|
||||||
|
|
||||||
|
`npm run db:seed` (= `tsx prisma/seed.ts`) seeds the `InstanceSettings` singleton + curated library; it is **live, not dead** — invoked at Docker image-build time (`start9/0.4/Dockerfile`) to bake the library into the image, and also the local-dev first-run path (`proof-of-work/README.md`). Runtime first-boot/upgrade seeding is handled separately by `docker_entrypoint.sh`.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Versioning is ExVer**: `1.1.0:4` (note the colon). Every release = a new `start9/0.4/startos/versions/vMAJOR.MINOR.PATCH.N.ts` file, imported into `versions/index.ts` and promoted to `current` (previous `current` moves into `other[]`).
|
||||||
|
- **Bump the version BEFORE building the s9pk** — Start9 0.4 won't recognize a rebuild as an update otherwise.
|
||||||
|
- **Schema changes are additive ALTERs in `docker_entrypoint.sh`**, guarded by `PRAGMA table_info` checks. Keep `schema.prisma` in sync as the mirror, but the entrypoint is what migrates live `/data`. Never write a destructive migration.
|
||||||
|
- **Commit subject** = `vX.Y.Z:N — short summary`, imperative, body explains the *why*.
|
||||||
|
- **Git remote is self-hosted** (a private Start9 registry + a FileBrowser artifact host), NOT GitHub. The actual registry/file-host URLs are constants in `~/.proof-of-work/{publish,unpublish}.sh`; FileBrowser creds live in `~/.keysat/filebrowser.env` (outside the repo, gitignored). Default branch is `master`.
|
||||||
|
- Tests live in `proof-of-work/tests/`; mock server-action deps with `vi.hoisted()` + `vi.mock`.
|
||||||
|
- **Before editing the AI subsystem (`proof-of-work/lib/ai/**` or the generate/generations routes), read `docs/guides/ai-subsystem.md`** — provider abstraction, SSE/lenient-JSON, pricing/model menus, and the background-runner architecture live there.
|
||||||
|
|
||||||
|
## Always
|
||||||
|
|
||||||
|
- Run `npx prisma generate` after any `schema.prisma` edit, then `npx tsc --noEmit`.
|
||||||
|
- Run `npm test` AND `npm run build` before shipping a version.
|
||||||
|
- Add the boot-time `ALTER TABLE` (with an existence guard) for any new column, in `docker_entrypoint.sh`.
|
||||||
|
- Treat API keys / secrets as plaintext in `/data` BY DESIGN (threat model: the operator owns `/data`). Reference env-var names (`DATABASE_URL`, etc.); never hardcode values.
|
||||||
|
- Keep migrations idempotent and additive; data already on a user's server must survive upgrades.
|
||||||
|
- Verify the published file actually changed (size / 404 / Last-Modified) after publish.sh.
|
||||||
|
|
||||||
|
## Never
|
||||||
|
|
||||||
|
- **Never add `Co-Authored-By` / "Generated with" trailers** to commits — the user authors commits solo. (This was done wrong in earlier commits; do not repeat.)
|
||||||
|
- **Never reintroduce nonce-based CSP** — it broke first paint. Use the static `'unsafe-inline'` CSP in `next.config.js`.
|
||||||
|
- **Never run app commands from the repo root** — always `cd proof-of-work` first.
|
||||||
|
- **Never export non-HTTP-method symbols from a `route.ts`** — Next.js rejects the build (helpers go in `lib/`, e.g. `lib/ai/activateConfig.ts`).
|
||||||
|
- **Never commit `app.db`, `*.bak`, or any user data** — they're gitignored; double-check `git status` before `git add`.
|
||||||
|
- **Never click Uninstall on a StartOS package during a data cutover** — it destroys the volume; use Stop.
|
||||||
|
- **Never assume GitHub** — don't add a GitHub remote or push there.
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
Latest version is **1.1.0:7** (built locally, installed on the StartOS server). The registry is currently **empty** — all versions were unpublished; nothing is downloadable until `publish.sh` runs again.
|
||||||
|
|
||||||
|
Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (5 providers, multi-config, background generation, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history).
|
||||||
|
|
||||||
|
In progress: none — repo is at a clean checkpoint.
|
||||||
|
|
||||||
|
Decided but not implemented: tiered AI prompt formatting — JSON-Schema enforcement (Ollama `format` / OpenAI `response_format`), pipe-separated library, XML-tagged sections, Ollama-only few-shot. Targets local-model output quality; would ship as 1.1.0:8.
|
||||||
|
|
||||||
|
Known issues: earlier commits (`8f149d3`–`5b0535f`) carry `Co-Authored-By` trailers to scrub from history. publish.sh Step 3 (registry register) silently no-op'd on 1.1.0:6 and :7 — uploaded the file but didn't register; investigate before relying on those versions appearing in the registry.
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Scrub `Co-Authored-By` trailers from history (filter-branch or rebase).
|
||||||
|
2. Re-publish current version once the Step-3 registry-register failure is diagnosed.
|
||||||
|
3. Implement the tiered AI prompt formatting (1.1.0:8).
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# ROADMAP — Proof of Work
|
||||||
|
|
||||||
|
Longer-term backlog. Near-term state + next steps live in `AGENTS.md` → Current state.
|
||||||
|
|
||||||
|
## AI quality
|
||||||
|
|
||||||
|
- Tiered prompt formatting (also the immediate next step): JSON-Schema output enforcement via Ollama `format` and OpenAI `response_format`; pipe-separated library rows; XML-tagged prompt sections; Ollama-only few-shot example; stable prefix first for prompt-cache hits.
|
||||||
|
- Keep `MODEL_MENU` / `PRICES` current as providers ship new models.
|
||||||
|
|
||||||
|
## Packaging / distribution
|
||||||
|
|
||||||
|
- Diagnose and fix the `publish.sh` Step-3 registry-register silent no-op.
|
||||||
|
- Build for `arm` / additional arches once StartOS 0.4 supports them on the host.
|
||||||
|
- Consider submission to the Start9 community registry (use the start9-spec-checker agent first).
|
||||||
|
|
||||||
|
## Product
|
||||||
|
|
||||||
|
- Adherence tracking: compare logged workouts against the planned `ProgramDay` (the `programDayId` link already exists).
|
||||||
|
- Per-user export/import polish and scheduled backups.
|
||||||
|
- Charts/progress views over history (the data and 1RM estimates already exist).
|
||||||
|
|
||||||
|
## Hygiene
|
||||||
|
|
||||||
|
- Scrub `Co-Authored-By` trailers from git history.
|
||||||
|
- Revisit `workout-planner/` scratch dir — remove if truly unused.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- proof-of-work/lib/ai/**
|
||||||
|
- proof-of-work/app/api/ai/**
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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.ts` kicks 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, library
|
||||||
|
`exerciseId`s only, suggested weights) + the template's coaching prompt +
|
||||||
|
`PROGRAM_OUTPUT_SHAPE` + library + optional history block (`historyContext.ts`).
|
||||||
|
- Multi-config: `AIConfigProfile` rows per user; `UserPreferences.activeAIConfigId`
|
||||||
|
points at the active one and is mirrored into the legacy `ai*` columns for back-compat.
|
||||||
|
|
||||||
|
## Provider abstraction
|
||||||
|
|
||||||
|
- Each provider yields an async iterable of `GenerateChunk` (`text` / `usage` / `done` /
|
||||||
|
`error`); add new ones under `lib/ai/providers/` and register in `index.ts`.
|
||||||
|
`openai.ts` exports both `openai` and `openai-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).
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/ai/generations — admin-only "clear all generation
|
||||||
|
* history". Body optionally narrows:
|
||||||
|
* { userId?: string } — only that user
|
||||||
|
* { olderThanDays?: number } — purge older
|
||||||
|
* Default = wipe ALL rows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
userId: z.string().optional(),
|
||||||
|
olderThanDays: z.number().int().positive().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const me = await getCurrentUser();
|
||||||
|
if (!me) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
if (!me.isAdmin)
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
/* empty body OK */
|
||||||
|
}
|
||||||
|
const parsed = bodySchema.safeParse(body);
|
||||||
|
const filter = parsed.success ? parsed.data : {};
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (filter.userId) where.userId = filter.userId;
|
||||||
|
if (filter.olderThanDays) {
|
||||||
|
where.createdAt = {
|
||||||
|
lt: new Date(Date.now() - filter.olderThanDays * 86_400_000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.aIGeneration.deleteMany({ where });
|
||||||
|
return NextResponse.json({ deleted: result.count });
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { aiProgramSchema, type AIProgram } from '@/lib/ai/programSchema';
|
||||||
|
import { applyAIProgram } from '@/lib/ai/apply';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/apply
|
||||||
|
*
|
||||||
|
* Body: { generationId: string, program: AIProgram, startDate?: string,
|
||||||
|
* isActive?: boolean }
|
||||||
|
*
|
||||||
|
* Materializes the (possibly user-edited) AIProgram into a real
|
||||||
|
* Program row + nested Weeks/Days/Exercises. Updates the
|
||||||
|
* AIGeneration row to status='applied' and stores the new program id
|
||||||
|
* in appliedProgramId.
|
||||||
|
*
|
||||||
|
* The caller is the preview UI: after the user fixes any unresolved
|
||||||
|
* exercises and tweaks fields they don't like, they POST the cleaned
|
||||||
|
* AIProgram back here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
generationId: z.string().min(1),
|
||||||
|
program: aiProgramSchema,
|
||||||
|
startDate: z.string().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = bodySchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid body', details: parsed.error.errors },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generation = await prisma.aIGeneration.findFirst({
|
||||||
|
where: { id: parsed.data.generationId, userId: user.id },
|
||||||
|
});
|
||||||
|
if (!generation) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Generation not found' },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (generation.status === 'applied') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'This generation has already been applied to a program.',
|
||||||
|
appliedProgramId: generation.appliedProgramId,
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = parsed.data.startDate
|
||||||
|
? new Date(parsed.data.startDate)
|
||||||
|
: new Date();
|
||||||
|
|
||||||
|
const result = await applyAIProgram(
|
||||||
|
prisma,
|
||||||
|
parsed.data.program as AIProgram,
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
startDate,
|
||||||
|
isActive: parsed.data.isActive ?? false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.aIGeneration.update({
|
||||||
|
where: { id: generation.id },
|
||||||
|
data: {
|
||||||
|
status: 'applied',
|
||||||
|
appliedProgramId: result.programId,
|
||||||
|
// Stash the user-edited version so history reflects what was
|
||||||
|
// actually written (vs the raw model output which is also kept).
|
||||||
|
parsedProgram: JSON.stringify(parsed.data.program),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
programId: result.programId,
|
||||||
|
weeksCreated: result.weeksCreated,
|
||||||
|
daysCreated: result.daysCreated,
|
||||||
|
exercisesCreated: result.exercisesCreated,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/ai/apply error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (err as Error).message ?? 'Internal server error' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ai/config — read this user's AI provider config.
|
||||||
|
* API key is NOT returned in plaintext (only
|
||||||
|
* a "configured: true|false" flag) so it
|
||||||
|
* doesn't leak via Settings page reload.
|
||||||
|
* POST /api/ai/config — update. Pass null/empty to clear a field.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const prefs = await prisma.userPreferences.findUnique({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { aiProvider: true, aiModel: true, aiBaseUrl: true, aiApiKey: true },
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
aiProvider: prefs?.aiProvider ?? null,
|
||||||
|
aiModel: prefs?.aiModel ?? null,
|
||||||
|
aiBaseUrl: prefs?.aiBaseUrl ?? null,
|
||||||
|
aiKeyConfigured: !!prefs?.aiApiKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
aiProvider: z
|
||||||
|
.enum(['claude', 'openai', 'openai-compatible', 'gemini', 'ollama'])
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
aiModel: z.string().nullable().optional(),
|
||||||
|
aiBaseUrl: z.string().url().nullable().optional().or(z.literal('')),
|
||||||
|
aiApiKey: z.string().nullable().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 = bodySchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid body', details: parsed.error.errors },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty string -> null (UI sometimes sends "")
|
||||||
|
const norm = (v: string | null | undefined) =>
|
||||||
|
v === '' || v == null ? null : v;
|
||||||
|
|
||||||
|
const data: Record<string, string | null> = {};
|
||||||
|
if (parsed.data.aiProvider !== undefined)
|
||||||
|
data.aiProvider = parsed.data.aiProvider ?? null;
|
||||||
|
if (parsed.data.aiModel !== undefined) data.aiModel = norm(parsed.data.aiModel);
|
||||||
|
if (parsed.data.aiBaseUrl !== undefined)
|
||||||
|
data.aiBaseUrl = norm(parsed.data.aiBaseUrl);
|
||||||
|
if (parsed.data.aiApiKey !== undefined)
|
||||||
|
data.aiApiKey = norm(parsed.data.aiApiKey);
|
||||||
|
|
||||||
|
// Make sure the prefs row exists.
|
||||||
|
await prisma.userPreferences.upsert({
|
||||||
|
where: { userId: user.id },
|
||||||
|
update: data,
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
theme: 'system',
|
||||||
|
defaultWeightUnit: 'lbs',
|
||||||
|
defaultRestSeconds: 90,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { activate } from '@/lib/ai/activateConfig';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/configs/[id]/activate
|
||||||
|
*
|
||||||
|
* Set the named profile as the actor's active AI config. Mirrors the
|
||||||
|
* profile's fields into UserPreferences (legacy single-config columns)
|
||||||
|
* so api/ai/generate + api/ai/test continue to work as-is.
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const profile = await prisma.aIConfigProfile.findFirst({
|
||||||
|
where: { id: params.id, userId: user.id },
|
||||||
|
});
|
||||||
|
if (!profile) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
|
||||||
|
await activate(user.id, profile.id, {
|
||||||
|
provider: profile.provider,
|
||||||
|
model: profile.model,
|
||||||
|
baseUrl: profile.baseUrl,
|
||||||
|
apiKey: profile.apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, activeId: profile.id });
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ai/configs/[id] Single config (apiKey redacted).
|
||||||
|
* PATCH /api/ai/configs/[id] Update fields. Empty/null clears.
|
||||||
|
* Re-mirrors to UserPreferences if active.
|
||||||
|
* DELETE /api/ai/configs/[id] Remove. If it was active, falls back to
|
||||||
|
* the most-recently-created remaining
|
||||||
|
* profile (or clears if none left).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const p = await prisma.aIConfigProfile.findFirst({
|
||||||
|
where: { id: params.id, userId: user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
provider: true,
|
||||||
|
model: true,
|
||||||
|
baseUrl: true,
|
||||||
|
apiKey: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!p) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
return NextResponse.json({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
provider: p.provider,
|
||||||
|
model: p.model,
|
||||||
|
baseUrl: p.baseUrl,
|
||||||
|
keyConfigured: !!p.apiKey,
|
||||||
|
createdAt: p.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchSchema = z.object({
|
||||||
|
name: z.string().min(1).max(80).optional(),
|
||||||
|
model: z.string().min(1).max(200).optional(),
|
||||||
|
baseUrl: z.string().url().nullable().optional().or(z.literal('')),
|
||||||
|
apiKey: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = patchSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid body', details: parsed.error.errors },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.aIConfigProfile.findFirst({
|
||||||
|
where: { id: params.id, userId: user.id },
|
||||||
|
});
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const data: Record<string, string | null> = {};
|
||||||
|
if (parsed.data.name !== undefined) data.name = parsed.data.name;
|
||||||
|
if (parsed.data.model !== undefined) data.model = parsed.data.model;
|
||||||
|
if (parsed.data.baseUrl !== undefined)
|
||||||
|
data.baseUrl = parsed.data.baseUrl || null;
|
||||||
|
if (parsed.data.apiKey !== undefined)
|
||||||
|
data.apiKey = parsed.data.apiKey || null;
|
||||||
|
|
||||||
|
const updated = await prisma.aIConfigProfile.update({
|
||||||
|
where: { id: params.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If this was the active config, mirror the new fields back into
|
||||||
|
// UserPreferences so existing read paths (api/ai/test, api/ai/generate
|
||||||
|
// current implementation) see the latest values.
|
||||||
|
const prefs = await prisma.userPreferences.findUnique({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { activeAIConfigId: true },
|
||||||
|
});
|
||||||
|
if (prefs?.activeAIConfigId === params.id) {
|
||||||
|
await activate(user.id, params.id, {
|
||||||
|
provider: updated.provider,
|
||||||
|
model: updated.model,
|
||||||
|
baseUrl: updated.baseUrl,
|
||||||
|
apiKey: updated.apiKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const existing = await prisma.aIConfigProfile.findFirst({
|
||||||
|
where: { id: params.id, userId: user.id },
|
||||||
|
});
|
||||||
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
|
||||||
|
await prisma.aIConfigProfile.delete({ where: { id: params.id } });
|
||||||
|
|
||||||
|
// If we just deleted the active config, demote-or-remove gracefully.
|
||||||
|
const prefs = await prisma.userPreferences.findUnique({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { activeAIConfigId: true },
|
||||||
|
});
|
||||||
|
if (prefs?.activeAIConfigId === params.id) {
|
||||||
|
const fallback = await prisma.aIConfigProfile.findFirst({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
if (fallback) {
|
||||||
|
await activate(user.id, fallback.id, {
|
||||||
|
provider: fallback.provider,
|
||||||
|
model: fallback.model,
|
||||||
|
baseUrl: fallback.baseUrl,
|
||||||
|
apiKey: fallback.apiKey,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.userPreferences.update({
|
||||||
|
where: { userId: user.id },
|
||||||
|
data: {
|
||||||
|
activeAIConfigId: null,
|
||||||
|
aiProvider: null,
|
||||||
|
aiModel: null,
|
||||||
|
aiBaseUrl: null,
|
||||||
|
aiApiKey: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import {
|
||||||
|
PROGRAM_OUTPUT_SHAPE,
|
||||||
|
} from '@/lib/ai/programSchema';
|
||||||
|
import {
|
||||||
|
buildHistorySummary,
|
||||||
|
formatHistoryContext,
|
||||||
|
} from '@/lib/ai/historyContext';
|
||||||
|
import { buildBaseSystemPrompt } from '@/lib/ai/systemPromptBase';
|
||||||
|
import { kickoffGeneration } from '@/lib/ai/generationRunner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/generate
|
||||||
|
*
|
||||||
|
* Body: { templateId?: string, userInput: string, includeHistory?: boolean }
|
||||||
|
*
|
||||||
|
* v1.1.0:4: this endpoint now KICKS OFF a background runner and returns
|
||||||
|
* the new generation id immediately. The caller subscribes to live
|
||||||
|
* deltas via GET /api/ai/generations/[id]/stream (SSE) or polls via
|
||||||
|
* GET /api/ai/generations/[id]. Navigating away no longer cancels the
|
||||||
|
* generation — the runner keeps writing to the row in the background.
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* 201 { id: "...generationId..." }
|
||||||
|
* 400 { error: "..." }
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
templateId: z.string().optional().nullable(),
|
||||||
|
userInput: z.string().min(1),
|
||||||
|
includeHistory: z.boolean().optional().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
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 = bodySchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid body', details: parsed.error.errors },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefs = await prisma.userPreferences.findUnique({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
if (!prefs?.aiProvider || !prefs?.aiModel) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'AI is not configured. Open Settings → AI integration and pick a provider + model.',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the template if provided.
|
||||||
|
let template:
|
||||||
|
| { id: string; name: string; systemPrompt: string; userPromptTemplate: string }
|
||||||
|
| null = null;
|
||||||
|
if (parsed.data.templateId) {
|
||||||
|
const t = await prisma.aIPromptTemplate.findFirst({
|
||||||
|
where: {
|
||||||
|
id: parsed.data.templateId,
|
||||||
|
OR: [{ userId: user.id }, { userId: null }],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
systemPrompt: true,
|
||||||
|
userPromptTemplate: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!t) {
|
||||||
|
return NextResponse.json({ error: 'Template not found.' }, { status: 404 });
|
||||||
|
}
|
||||||
|
template = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library for the prompt.
|
||||||
|
const exercises = await prisma.exercise.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { id: true, name: true, type: true, muscleGroups: true },
|
||||||
|
});
|
||||||
|
const libraryJson = JSON.stringify(
|
||||||
|
exercises.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
name: e.name,
|
||||||
|
type: e.type,
|
||||||
|
muscleGroups: (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(e.muscleGroups);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// History context if requested.
|
||||||
|
let historyBlock = '';
|
||||||
|
if (parsed.data.includeHistory) {
|
||||||
|
const summary = await buildHistorySummary(prisma, user.id);
|
||||||
|
historyBlock = formatHistoryContext(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.1.0:4 base prompt with output contract + weight rules. Stitched
|
||||||
|
// BEFORE the template's coaching philosophy so output rules win when
|
||||||
|
// they conflict.
|
||||||
|
const weightUnit = (prefs.defaultWeightUnit as 'lbs' | 'kg') || 'lbs';
|
||||||
|
const isLocalModel = prefs.aiProvider === 'ollama';
|
||||||
|
const basePrompt = buildBaseSystemPrompt({
|
||||||
|
weightUnit,
|
||||||
|
hasHistoryContext: parsed.data.includeHistory,
|
||||||
|
isLocalModel,
|
||||||
|
});
|
||||||
|
const templatePrompt = template?.systemPrompt ?? DEFAULT_TEMPLATE_PROMPT;
|
||||||
|
|
||||||
|
const systemPrompt = `${basePrompt}
|
||||||
|
|
||||||
|
# COACHING PHILOSOPHY (template-specific)
|
||||||
|
|
||||||
|
${templatePrompt}
|
||||||
|
|
||||||
|
# OUTPUT SHAPE
|
||||||
|
|
||||||
|
${PROGRAM_OUTPUT_SHAPE}
|
||||||
|
|
||||||
|
# LIBRARY (use these exerciseIds; do not invent ids)
|
||||||
|
|
||||||
|
${libraryJson}${historyBlock}`;
|
||||||
|
|
||||||
|
const userPromptBody =
|
||||||
|
template?.userPromptTemplate.replace(/{{userInput}}/g, parsed.data.userInput) ??
|
||||||
|
parsed.data.userInput;
|
||||||
|
|
||||||
|
const id = await kickoffGeneration({
|
||||||
|
prisma,
|
||||||
|
userId: user.id,
|
||||||
|
templateId: template?.id ?? null,
|
||||||
|
templateName: template?.name ?? null,
|
||||||
|
userInput: parsed.data.userInput,
|
||||||
|
systemPrompt,
|
||||||
|
userPrompt: userPromptBody,
|
||||||
|
provider: prefs.aiProvider,
|
||||||
|
model: prefs.aiModel,
|
||||||
|
apiKey: prefs.aiApiKey,
|
||||||
|
baseUrl: prefs.aiBaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ id }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATE_PROMPT = `You are a strength and conditioning coach. The user will describe what they want; design a program that matches their goal, experience, equipment, and time budget. Pick exercises from the LIBRARY and stay close to evidence-based programming for the requested goal (hypertrophy / strength / power / conditioning / general fitness).`;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ai/generations/[id] — full row including the raw
|
||||||
|
* model response and parsed
|
||||||
|
* program (if any). Scoped to
|
||||||
|
* the actor.
|
||||||
|
* DELETE /api/ai/generations/[id] — remove a single generation row.
|
||||||
|
* The associated Program (if any)
|
||||||
|
* stays — only the AI history
|
||||||
|
* entry goes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const row = await prisma.aIGeneration.findFirst({
|
||||||
|
where: { id: params.id, userId: user.id },
|
||||||
|
});
|
||||||
|
if (!row) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
return NextResponse.json(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const existing = await prisma.aIGeneration.findFirst({
|
||||||
|
where: { id: params.id, userId: user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing)
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
|
||||||
|
await prisma.aIGeneration.delete({ where: { id: params.id } });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { subscribe } from '@/lib/ai/generationRunner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ai/generations/[id]/stream
|
||||||
|
*
|
||||||
|
* SSE attach to an in-flight generation. The runner that POST
|
||||||
|
* /api/ai/generate kicked off lives in this Node process; this
|
||||||
|
* endpoint subscribes to its in-memory bus and forwards each delta
|
||||||
|
* as an SSE event.
|
||||||
|
*
|
||||||
|
* Late-joining (after some text has streamed): the runner buffers
|
||||||
|
* everything emitted so far, and the subscription replays the buffer
|
||||||
|
* on attach, so refresh / new tab catches up cleanly.
|
||||||
|
*
|
||||||
|
* Already-finished: subscribe() replays the buffer and returns a
|
||||||
|
* no-op unsubscribe. We close the connection right after the buffer
|
||||||
|
* drains.
|
||||||
|
*
|
||||||
|
* Cross-process resume (pod restart, separate process): the in-memory
|
||||||
|
* bus is empty, so the SSE will be silent. The client should fall
|
||||||
|
* back to polling /api/ai/generations/[id] for `progressText` until
|
||||||
|
* the row hits a terminal status. The Generate UI does this.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
// Authorize.
|
||||||
|
const row = await prisma.aIGeneration.findFirst({
|
||||||
|
where: { id: params.id, userId: user.id },
|
||||||
|
select: { id: true, status: true, progressText: true, errorMessage: true, parsedProgram: true, tokensIn: true, tokensOut: true, durationMs: true },
|
||||||
|
});
|
||||||
|
if (!row) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const send = (controller: ReadableStreamDefaultController, event: string, data: unknown) =>
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
let closed = false;
|
||||||
|
const safeClose = () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// First: send a `generation` event with the id so clients can
|
||||||
|
// confirm what they attached to (and consume the same protocol
|
||||||
|
// their old code expected).
|
||||||
|
send(controller, 'generation', { id: params.id });
|
||||||
|
|
||||||
|
// If the row already finished while we weren't looking, send
|
||||||
|
// its known progress + complete + close. (Cross-process resume
|
||||||
|
// OR fast finish before subscribe attached.)
|
||||||
|
if (row.status !== 'pending') {
|
||||||
|
if (row.progressText) {
|
||||||
|
send(controller, 'text', { delta: row.progressText });
|
||||||
|
}
|
||||||
|
send(controller, 'complete', {
|
||||||
|
parsedOk: row.status === 'completed' || row.status === 'applied',
|
||||||
|
errorMessage: row.errorMessage ?? undefined,
|
||||||
|
tokensIn: row.tokensIn ?? undefined,
|
||||||
|
tokensOut: row.tokensOut ?? undefined,
|
||||||
|
durationMs: row.durationMs ?? undefined,
|
||||||
|
});
|
||||||
|
safeClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsub = subscribe(params.id, (d) => {
|
||||||
|
if (closed) return;
|
||||||
|
if (d.type === 'text') send(controller, 'text', { delta: d.delta });
|
||||||
|
else if (d.type === 'usage')
|
||||||
|
send(controller, 'usage', {
|
||||||
|
tokensIn: d.tokensIn,
|
||||||
|
tokensOut: d.tokensOut,
|
||||||
|
});
|
||||||
|
else if (d.type === 'complete') {
|
||||||
|
send(controller, 'complete', {
|
||||||
|
parsedOk: d.parsedOk,
|
||||||
|
errorMessage: d.errorMessage,
|
||||||
|
tokensIn: d.tokensIn,
|
||||||
|
tokensOut: d.tokensOut,
|
||||||
|
durationMs: d.durationMs,
|
||||||
|
});
|
||||||
|
safeClose();
|
||||||
|
} else if (d.type === 'error') {
|
||||||
|
send(controller, 'complete', {
|
||||||
|
parsedOk: false,
|
||||||
|
errorMessage: d.errorMessage,
|
||||||
|
});
|
||||||
|
safeClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
request.signal.addEventListener('abort', () => {
|
||||||
|
unsub();
|
||||||
|
safeClose();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'text/event-stream',
|
||||||
|
'cache-control': 'no-store',
|
||||||
|
'x-accel-buffering': 'no',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ai/generations — list this user's AI generations,
|
||||||
|
* newest first. Supports ?limit=&offset=
|
||||||
|
* (default 25 / 0).
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const sp = request.nextUrl.searchParams;
|
||||||
|
const limit = Math.min(parseInt(sp.get('limit') || '25'), 100);
|
||||||
|
const offset = Math.max(parseInt(sp.get('offset') || '0'), 0);
|
||||||
|
|
||||||
|
const rows = await prisma.aIGeneration.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit + 1,
|
||||||
|
skip: offset,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
templateName: true,
|
||||||
|
userInput: true,
|
||||||
|
provider: true,
|
||||||
|
model: true,
|
||||||
|
tokensIn: true,
|
||||||
|
tokensOut: true,
|
||||||
|
durationMs: true,
|
||||||
|
status: true,
|
||||||
|
errorMessage: true,
|
||||||
|
appliedProgramId: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
return NextResponse.json({
|
||||||
|
data: rows.slice(0, limit),
|
||||||
|
hasMore,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/ai/templates/[id] — edit. User-owned templates: any
|
||||||
|
* user. Built-in templates (userId
|
||||||
|
* IS NULL): admin only. Note that
|
||||||
|
* admin edits to built-ins do NOT
|
||||||
|
* survive the next boot's reconcile
|
||||||
|
* pass — to ship lasting changes,
|
||||||
|
* edit prisma/aiTemplates.seed.json.
|
||||||
|
* DELETE /api/ai/templates/[id] — same admin gate for built-ins.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const patchSchema = z.object({
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
systemPrompt: z.string().min(1).optional(),
|
||||||
|
userPromptTemplate: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAndCheck(
|
||||||
|
templateId: string,
|
||||||
|
user: { id: string; isAdmin: boolean },
|
||||||
|
) {
|
||||||
|
const tpl = await prisma.aIPromptTemplate.findUnique({
|
||||||
|
where: { id: templateId },
|
||||||
|
});
|
||||||
|
if (!tpl) return { tpl: null, error: 'Template not found' as const, code: 404 };
|
||||||
|
// Ownership: user templates can only be touched by their owner;
|
||||||
|
// built-ins (userId=null) can only be touched by admins.
|
||||||
|
if (tpl.userId == null) {
|
||||||
|
if (!user.isAdmin) {
|
||||||
|
return {
|
||||||
|
tpl: null,
|
||||||
|
error:
|
||||||
|
'Built-in template — admin only. Clone-to-edit if you want a personal copy.',
|
||||||
|
code: 403 as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (tpl.userId !== user.id) {
|
||||||
|
return { tpl: null, error: 'Not your template', code: 403 as const };
|
||||||
|
}
|
||||||
|
return { tpl, error: null, code: 200 as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user)
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const { tpl, error, code } = await loadAndCheck(params.id, user);
|
||||||
|
if (!tpl) return NextResponse.json({ error }, { status: code });
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = patchSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid body', details: parsed.error.errors },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const updated = await prisma.aIPromptTemplate.update({
|
||||||
|
where: { id: params.id },
|
||||||
|
data: parsed.data,
|
||||||
|
});
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PATCH /api/ai/templates/[id] error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user)
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const { tpl, error, code } = await loadAndCheck(params.id, user);
|
||||||
|
if (!tpl) return NextResponse.json({ error }, { status: code });
|
||||||
|
|
||||||
|
await prisma.aIPromptTemplate.delete({ where: { id: params.id } });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DELETE /api/ai/templates/[id] error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ai/templates — built-ins (userId IS NULL) + this user's own
|
||||||
|
* POST /api/ai/templates — create a user-owned template
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const templates = await prisma.aIPromptTemplate.findMany({
|
||||||
|
where: { OR: [{ userId: null }, { userId: user.id }] },
|
||||||
|
orderBy: [{ isBuiltIn: 'desc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
return NextResponse.json(templates);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
systemPrompt: z.string().min(1),
|
||||||
|
userPromptTemplate: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
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 tpl = await prisma.aIPromptTemplate.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
name: parsed.data.name,
|
||||||
|
description: parsed.data.description ?? null,
|
||||||
|
systemPrompt: parsed.data.systemPrompt,
|
||||||
|
userPromptTemplate: parsed.data.userPromptTemplate,
|
||||||
|
isBuiltIn: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(tpl, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/ai/templates error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getProvider } from '@/lib/ai/providers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/test
|
||||||
|
*
|
||||||
|
* Body (optional):
|
||||||
|
* {
|
||||||
|
* // If supplied: test this draft config without saving it.
|
||||||
|
* // Otherwise: test the actor's currently active config.
|
||||||
|
* provider?: string,
|
||||||
|
* model?: string,
|
||||||
|
* baseUrl?: string,
|
||||||
|
* apiKey?: string,
|
||||||
|
* // If supplied + apiKey is null: pull the saved key for that
|
||||||
|
* // profile (so the UI can test a saved profile by id without
|
||||||
|
* // forcing the user to re-type the key).
|
||||||
|
* useSavedKeyForId?: string,
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Sends a tiny "say hi in 3 words" prompt. Reports latency, sample
|
||||||
|
* reply (or finishReason if Gemini blocks it).
|
||||||
|
*
|
||||||
|
* Times out after 30s — long enough for cold Ollama starts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TEST_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
provider: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
baseUrl: z.string().nullable().optional(),
|
||||||
|
apiKey: z.string().nullable().optional(),
|
||||||
|
useSavedKeyForId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await request.json().catch(() => ({}));
|
||||||
|
const parsed = bodySchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: 'Invalid body' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const draft = parsed.data;
|
||||||
|
|
||||||
|
// Resolve the config to test:
|
||||||
|
// 1. If draft.provider is set → use the draft fields (testing
|
||||||
|
// a not-yet-saved config in the UI).
|
||||||
|
// 2. Else if draft.useSavedKeyForId is set → load that profile.
|
||||||
|
// 3. Else → use the active config (legacy single-config columns).
|
||||||
|
let provider: string | null;
|
||||||
|
let model: string | null;
|
||||||
|
let baseUrl: string | null;
|
||||||
|
let apiKey: string | null;
|
||||||
|
|
||||||
|
if (draft.provider) {
|
||||||
|
provider = draft.provider;
|
||||||
|
model = draft.model ?? null;
|
||||||
|
baseUrl = draft.baseUrl ?? null;
|
||||||
|
apiKey = draft.apiKey ?? null;
|
||||||
|
// Allow the UI to fill in just provider+model+baseUrl and have
|
||||||
|
// us pull the saved key by profile id (so the user doesn't have
|
||||||
|
// to retype it just to retest).
|
||||||
|
if (draft.useSavedKeyForId && (apiKey == null || apiKey === '')) {
|
||||||
|
const saved = await prisma.aIConfigProfile.findFirst({
|
||||||
|
where: { id: draft.useSavedKeyForId, userId: user.id },
|
||||||
|
select: { apiKey: true },
|
||||||
|
});
|
||||||
|
if (saved?.apiKey) apiKey = saved.apiKey;
|
||||||
|
}
|
||||||
|
} else if (draft.useSavedKeyForId) {
|
||||||
|
const saved = await prisma.aIConfigProfile.findFirst({
|
||||||
|
where: { id: draft.useSavedKeyForId, userId: user.id },
|
||||||
|
});
|
||||||
|
if (!saved) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: 'Config not found.' },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
provider = saved.provider;
|
||||||
|
model = saved.model;
|
||||||
|
baseUrl = saved.baseUrl;
|
||||||
|
apiKey = saved.apiKey;
|
||||||
|
} else {
|
||||||
|
const prefs = await prisma.userPreferences.findUnique({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { aiProvider: true, aiModel: true, aiBaseUrl: true, aiApiKey: true },
|
||||||
|
});
|
||||||
|
provider = prefs?.aiProvider ?? null;
|
||||||
|
model = prefs?.aiModel ?? null;
|
||||||
|
baseUrl = prefs?.aiBaseUrl ?? null;
|
||||||
|
apiKey = prefs?.aiApiKey ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider || !model) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: 'Pick a provider + model first.',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const providerImpl = getProvider(provider);
|
||||||
|
if (!providerImpl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: `Unknown provider: ${provider}` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
let sample = '';
|
||||||
|
let tokensIn: number | undefined;
|
||||||
|
let tokensOut: number | undefined;
|
||||||
|
let providerError: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const chunk of providerImpl.generate({
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
model,
|
||||||
|
systemPrompt:
|
||||||
|
'You are a connectivity test. Reply with EXACTLY three words: "Hello there friend." Nothing else.',
|
||||||
|
userPrompt: 'Say hi.',
|
||||||
|
signal: controller.signal,
|
||||||
|
// Generous output budget so thinking models (Gemini 2.5/3.x,
|
||||||
|
// OpenAI o-series) actually have room to emit visible text after
|
||||||
|
// their internal reasoning. Cheap because the prompt is tiny.
|
||||||
|
maxOutputTokens: 4096,
|
||||||
|
})) {
|
||||||
|
if (chunk.type === 'text') sample += chunk.delta;
|
||||||
|
else if (chunk.type === 'usage') {
|
||||||
|
tokensIn = chunk.tokensIn;
|
||||||
|
tokensOut = chunk.tokensOut;
|
||||||
|
} else if (chunk.type === 'error') {
|
||||||
|
providerError = chunk.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
providerError =
|
||||||
|
controller.signal.aborted
|
||||||
|
? `Timed out after ${Math.round(TEST_TIMEOUT_MS / 1000)}s`
|
||||||
|
: (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ms = Date.now() - t0;
|
||||||
|
|
||||||
|
if (providerError) {
|
||||||
|
return NextResponse.json({ ok: false, error: providerError, ms }, { status: 200 });
|
||||||
|
}
|
||||||
|
if (!sample.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
'Empty reply. The provider returned a response with no text. ' +
|
||||||
|
'For Gemini this often means a safety filter blocked the output ' +
|
||||||
|
'(check the model name + try a flagship model). For thinking ' +
|
||||||
|
'models the answer may have been spent on internal reasoning — ' +
|
||||||
|
'try a non-thinking model.',
|
||||||
|
ms,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
sample: sample.trim().slice(0, 200),
|
||||||
|
tokensIn,
|
||||||
|
tokensOut,
|
||||||
|
ms,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -132,6 +132,13 @@ export async function PATCH(
|
|||||||
if (validated.defaultWeightUnit !== undefined)
|
if (validated.defaultWeightUnit !== undefined)
|
||||||
data.defaultWeightUnit = validated.defaultWeightUnit;
|
data.defaultWeightUnit = validated.defaultWeightUnit;
|
||||||
|
|
||||||
|
// Flip isCustom -> true on any user edit. The boot-time
|
||||||
|
// ensureExerciseLibrary reconciliation only updates rows where
|
||||||
|
// isCustom = 0, so this preserves the user's intent: once they've
|
||||||
|
// edited a library exercise, the maintainer can no longer
|
||||||
|
// overwrite their changes via a curated-library refresh.
|
||||||
|
data.isCustom = true;
|
||||||
|
|
||||||
const updated = await prisma.exercise.update({
|
const updated = await prisma.exercise.update({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import { z } from "zod";
|
|||||||
const PreferencesSchema = z.object({
|
const PreferencesSchema = z.object({
|
||||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||||
defaultWeightUnit: z.enum(["lbs", "kg"]).optional(),
|
defaultWeightUnit: z.enum(["lbs", "kg"]).optional(),
|
||||||
enableClaudeAI: z.boolean().optional(),
|
|
||||||
claudeApiKey: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/preferences
|
* GET /api/preferences
|
||||||
* Get user preferences
|
* Get user preferences. Strips the dead Claude AI fields
|
||||||
|
* (enableClaudeAI / claudeApiKey) — those columns still exist in the
|
||||||
|
* schema but are slated for replacement by the model-agnostic AI work
|
||||||
|
* (Option 3 / future). Don't reintroduce them in the response.
|
||||||
*/
|
*/
|
||||||
export async function GET(_request: NextRequest) {
|
export async function GET(_request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -26,24 +27,18 @@ export async function GET(_request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!preferences) {
|
if (!preferences) {
|
||||||
// Create default preferences
|
|
||||||
preferences = await prisma.userPreferences.create({
|
preferences = await prisma.userPreferences.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
theme: "system",
|
theme: "system",
|
||||||
defaultWeightUnit: "lbs",
|
defaultWeightUnit: "lbs",
|
||||||
defaultRestSeconds: 90,
|
defaultRestSeconds: 90,
|
||||||
enableClaudeAI: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't return API key in response
|
const { claudeApiKey, enableClaudeAI, ...safe } = preferences;
|
||||||
const { claudeApiKey, ...safePreferences } = preferences;
|
return NextResponse.json(safe);
|
||||||
return NextResponse.json({
|
|
||||||
...safePreferences,
|
|
||||||
claudeApiKey: claudeApiKey ? "***" : undefined,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("GET /api/preferences error:", error);
|
console.error("GET /api/preferences error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -55,7 +50,9 @@ export async function GET(_request: NextRequest) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/preferences
|
* POST /api/preferences
|
||||||
* Update user preferences
|
* Update user preferences. Only the fields in `PreferencesSchema` are
|
||||||
|
* accepted; anything else (including the dead Claude AI fields) is
|
||||||
|
* silently dropped at the Zod boundary.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -67,7 +64,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const validated = PreferencesSchema.parse(body);
|
const validated = PreferencesSchema.parse(body);
|
||||||
|
|
||||||
// Get or create preferences
|
|
||||||
let preferences = await prisma.userPreferences.findUnique({
|
let preferences = await prisma.userPreferences.findUnique({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
});
|
});
|
||||||
@@ -86,23 +82,15 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't return API key in response
|
const { claudeApiKey, enableClaudeAI, ...safe } = preferences;
|
||||||
const { claudeApiKey, ...safePreferences } = preferences;
|
return NextResponse.json(safe);
|
||||||
return NextResponse.json({
|
|
||||||
...safePreferences,
|
|
||||||
claudeApiKey: claudeApiKey ? "***" : undefined,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{ error: "Validation error", details: error.errors },
|
||||||
error: "Validation error",
|
|
||||||
details: error.errors,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("POST /api/preferences error:", error);
|
console.error("POST /api/preferences error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal server error" },
|
{ error: "Internal server error" },
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/programs/[id]/days/[dayId]/start
|
||||||
|
*
|
||||||
|
* Creates a new Workout for the actor pre-populated with the
|
||||||
|
* ProgramDay's exercise list — one SetLog per planned set, with
|
||||||
|
* empty reps/weight that the user fills in as they actually log
|
||||||
|
* the session. Stamps `Workout.programDayId` for adherence
|
||||||
|
* tracking.
|
||||||
|
*
|
||||||
|
* Body (optional): { date?: string (ISO) }
|
||||||
|
* - date defaults to today
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
date: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string; dayId: string } },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
// Empty body is fine.
|
||||||
|
}
|
||||||
|
const parsed = bodySchema.safeParse(body);
|
||||||
|
const dateStr = parsed.success ? parsed.data.date : undefined;
|
||||||
|
|
||||||
|
const day = await prisma.programDay.findFirst({
|
||||||
|
where: {
|
||||||
|
id: params.dayId,
|
||||||
|
week: { programId: params.id, program: { userId: user.id } },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
exercises: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: { exercise: true },
|
||||||
|
},
|
||||||
|
week: { include: { program: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!day) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Program day not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.1.0:4: pull the user's preferred weight unit so we can fall
|
||||||
|
// back to it when the program day didn't specify one.
|
||||||
|
const prefs = await prisma.userPreferences.findUnique({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { defaultWeightUnit: true },
|
||||||
|
});
|
||||||
|
const userPrefUnit = prefs?.defaultWeightUnit ?? "lbs";
|
||||||
|
|
||||||
|
// Build SetLog rows: for each planned exercise, pre-create N
|
||||||
|
// empty sets where N = exercise.sets ?? 1. The user fills in
|
||||||
|
// reps/weight when they actually do them. v1.1.0:4: if the
|
||||||
|
// ProgramExercise has a `suggestedWeight`, seed it on every set
|
||||||
|
// so the user starts with a target instead of a blank field.
|
||||||
|
const setLogsCreate: {
|
||||||
|
exerciseId: string;
|
||||||
|
setNumber: number;
|
||||||
|
weight: number | null;
|
||||||
|
weightUnit: string;
|
||||||
|
}[] = [];
|
||||||
|
for (const ex of day.exercises) {
|
||||||
|
const setCount = ex.sets ?? 1;
|
||||||
|
const unit =
|
||||||
|
ex.suggestedWeightUnit ?? ex.exercise.defaultWeightUnit ?? userPrefUnit;
|
||||||
|
for (let n = 1; n <= setCount; n++) {
|
||||||
|
setLogsCreate.push({
|
||||||
|
exerciseId: ex.exerciseId,
|
||||||
|
setNumber: n,
|
||||||
|
weight: ex.suggestedWeight ?? null,
|
||||||
|
weightUnit: unit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workoutDate = dateStr ? new Date(dateStr) : new Date();
|
||||||
|
const workoutName =
|
||||||
|
day.name ??
|
||||||
|
`${day.week.program.name} · Week ${day.week.weekNumber} Day ${day.dayOfWeek}`;
|
||||||
|
|
||||||
|
const workout = await prisma.workout.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
date: workoutDate,
|
||||||
|
name: workoutName,
|
||||||
|
programDayId: day.id,
|
||||||
|
setLogs: setLogsCreate.length > 0 ? { create: setLogsCreate } : undefined,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
setLogs: {
|
||||||
|
include: { exercise: true },
|
||||||
|
orderBy: { setNumber: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(workout, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("POST /api/programs/[id]/days/[dayId]/start error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getProgramById } from "@/lib/db/programs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/programs/[id] — full program tree (weeks + days + exercises).
|
||||||
|
* PATCH /api/programs/[id] — update metadata (name/description/type/
|
||||||
|
* durationWeeks/startDate/isActive) AND/OR
|
||||||
|
* replace the entire weeks tree if provided.
|
||||||
|
* Replace-tree is the simpler mental model
|
||||||
|
* for the UI editor + the AI apply flow:
|
||||||
|
* same payload shape as POST /api/programs.
|
||||||
|
* DELETE /api/programs/[id] — cascading delete (weeks/days/exercises go
|
||||||
|
* via Prisma onDelete: Cascade).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const exerciseInput = z.object({
|
||||||
|
exerciseId: z.string().min(1),
|
||||||
|
order: z.number().int().nonnegative(),
|
||||||
|
sets: z.number().int().positive().optional().nullable(),
|
||||||
|
repsMin: z.number().int().positive().optional().nullable(),
|
||||||
|
repsMax: z.number().int().positive().optional().nullable(),
|
||||||
|
rpe: z.number().int().min(1).max(10).optional().nullable(),
|
||||||
|
restSeconds: z.number().int().nonnegative().optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
const dayInput = z.object({
|
||||||
|
dayOfWeek: z.number().int().min(0).max(6),
|
||||||
|
name: z.string().optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
exercises: z.array(exerciseInput),
|
||||||
|
});
|
||||||
|
const weekInput = z.object({
|
||||||
|
weekNumber: z.number().int().positive(),
|
||||||
|
phase: z.string().optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
days: z.array(dayInput),
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchSchema = z.object({
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
type: z.string().min(1).optional(),
|
||||||
|
durationWeeks: z.number().int().positive().optional(),
|
||||||
|
startDate: z.string().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
/** When provided, REPLACES the entire weeks tree. */
|
||||||
|
weeks: z.array(weekInput).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const program = await getProgramById(user.id, params.id);
|
||||||
|
if (!program) {
|
||||||
|
return NextResponse.json({ error: "Program not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(program);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const existing = await prisma.program.findFirst({
|
||||||
|
where: { id: params.id, userId: user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: "Program not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const validated = patchSchema.parse(body);
|
||||||
|
|
||||||
|
// If replacing the tree, verify exercise ownership.
|
||||||
|
if (validated.weeks) {
|
||||||
|
const allExerciseIds = new Set<string>();
|
||||||
|
for (const w of validated.weeks)
|
||||||
|
for (const d of w.days)
|
||||||
|
for (const ex of d.exercises) allExerciseIds.add(ex.exerciseId);
|
||||||
|
if (allExerciseIds.size > 0) {
|
||||||
|
const owned = await prisma.exercise.findMany({
|
||||||
|
where: { userId: user.id, id: { in: Array.from(allExerciseIds) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const ownedIds = new Set(owned.map((e) => e.id));
|
||||||
|
const bad = Array.from(allExerciseIds).filter((id) => !ownedIds.has(id));
|
||||||
|
if (bad.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Some exerciseIds don't exist in your library", details: bad },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const programData: Prisma.ProgramUpdateInput = {};
|
||||||
|
if (validated.name !== undefined) programData.name = validated.name;
|
||||||
|
if (validated.description !== undefined)
|
||||||
|
programData.description = validated.description;
|
||||||
|
if (validated.type !== undefined) programData.type = validated.type;
|
||||||
|
if (validated.durationWeeks !== undefined)
|
||||||
|
programData.durationWeeks = validated.durationWeeks;
|
||||||
|
if (validated.startDate !== undefined)
|
||||||
|
programData.startDate = new Date(validated.startDate);
|
||||||
|
if (validated.isActive !== undefined) programData.isActive = validated.isActive;
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
if (Object.keys(programData).length > 0) {
|
||||||
|
await tx.program.update({ where: { id: params.id }, data: programData });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validated.weeks) {
|
||||||
|
// Wipe and rebuild the entire tree. Cascading delete on
|
||||||
|
// ProgramWeek removes ProgramDay + ProgramExercise; Workouts
|
||||||
|
// referencing those days have their programDayId set to NULL
|
||||||
|
// by the FK ON DELETE SET NULL clause we declared in the
|
||||||
|
// schema, so adherence references aren't catastrophic.
|
||||||
|
await tx.programWeek.deleteMany({ where: { programId: params.id } });
|
||||||
|
for (const w of validated.weeks) {
|
||||||
|
const week = await tx.programWeek.create({
|
||||||
|
data: {
|
||||||
|
programId: params.id,
|
||||||
|
weekNumber: w.weekNumber,
|
||||||
|
phase: w.phase ?? null,
|
||||||
|
description: w.description ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const d of w.days) {
|
||||||
|
const day = await tx.programDay.create({
|
||||||
|
data: {
|
||||||
|
weekId: week.id,
|
||||||
|
dayOfWeek: d.dayOfWeek,
|
||||||
|
name: d.name ?? null,
|
||||||
|
description: d.description ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (d.exercises.length > 0) {
|
||||||
|
await tx.programExercise.createMany({
|
||||||
|
data: d.exercises.map((ex) => ({
|
||||||
|
dayId: day.id,
|
||||||
|
exerciseId: ex.exerciseId,
|
||||||
|
order: ex.order,
|
||||||
|
sets: ex.sets ?? null,
|
||||||
|
repsMin: ex.repsMin ?? null,
|
||||||
|
repsMax: ex.repsMax ?? null,
|
||||||
|
rpe: ex.rpe ?? null,
|
||||||
|
restSeconds: ex.restSeconds ?? null,
|
||||||
|
notes: ex.notes ?? null,
|
||||||
|
})) as Prisma.ProgramExerciseCreateManyInput[],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await getProgramById(user.id, params.id);
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid patch payload", details: err.errors },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error("PATCH /api/programs/[id] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const existing = await prisma.program.findFirst({
|
||||||
|
where: { id: params.id, userId: user.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: "Program not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
await prisma.program.delete({ where: { id: params.id } });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("DELETE /api/programs/[id] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getPrograms } from "@/lib/db/programs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programs CRUD.
|
||||||
|
*
|
||||||
|
* Programs are multi-week training plans. The full structure is
|
||||||
|
* Program -> ProgramWeek -> ProgramDay -> ProgramExercise. POST
|
||||||
|
* accepts the entire tree in a single payload and writes it in
|
||||||
|
* one transaction (the v1.1.0:2 AI flow uses the same shape).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const exerciseInput = z.object({
|
||||||
|
exerciseId: z.string().min(1),
|
||||||
|
order: z.number().int().nonnegative(),
|
||||||
|
sets: z.number().int().positive().optional().nullable(),
|
||||||
|
repsMin: z.number().int().positive().optional().nullable(),
|
||||||
|
repsMax: z.number().int().positive().optional().nullable(),
|
||||||
|
rpe: z.number().int().min(1).max(10).optional().nullable(),
|
||||||
|
restSeconds: z.number().int().nonnegative().optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dayInput = z.object({
|
||||||
|
dayOfWeek: z.number().int().min(0).max(6),
|
||||||
|
name: z.string().optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
exercises: z.array(exerciseInput),
|
||||||
|
});
|
||||||
|
|
||||||
|
const weekInput = z.object({
|
||||||
|
weekNumber: z.number().int().positive(),
|
||||||
|
phase: z.string().optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
days: z.array(dayInput),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createProgramSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
type: z.string().min(1),
|
||||||
|
durationWeeks: z.number().int().positive(),
|
||||||
|
startDate: z.string(), // ISO
|
||||||
|
isActive: z.boolean().optional().default(false),
|
||||||
|
weeks: z.array(weekInput).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
const programs = await getPrograms(user.id);
|
||||||
|
return NextResponse.json(programs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const validated = createProgramSchema.parse(body);
|
||||||
|
|
||||||
|
// Verify any referenced exerciseIds belong to this user.
|
||||||
|
const allExerciseIds = new Set<string>();
|
||||||
|
for (const w of validated.weeks)
|
||||||
|
for (const d of w.days)
|
||||||
|
for (const ex of d.exercises) allExerciseIds.add(ex.exerciseId);
|
||||||
|
|
||||||
|
if (allExerciseIds.size > 0) {
|
||||||
|
const owned = await prisma.exercise.findMany({
|
||||||
|
where: { userId: user.id, id: { in: Array.from(allExerciseIds) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const ownedIds = new Set(owned.map((e) => e.id));
|
||||||
|
const bad = Array.from(allExerciseIds).filter((id) => !ownedIds.has(id));
|
||||||
|
if (bad.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Some exerciseIds don't exist in your library", details: bad },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = await prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.program.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
name: validated.name,
|
||||||
|
description: validated.description ?? null,
|
||||||
|
type: validated.type,
|
||||||
|
durationWeeks: validated.durationWeeks,
|
||||||
|
startDate: new Date(validated.startDate),
|
||||||
|
isActive: validated.isActive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const w of validated.weeks) {
|
||||||
|
const week = await tx.programWeek.create({
|
||||||
|
data: {
|
||||||
|
programId: created.id,
|
||||||
|
weekNumber: w.weekNumber,
|
||||||
|
phase: w.phase ?? null,
|
||||||
|
description: w.description ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const d of w.days) {
|
||||||
|
const day = await tx.programDay.create({
|
||||||
|
data: {
|
||||||
|
weekId: week.id,
|
||||||
|
dayOfWeek: d.dayOfWeek,
|
||||||
|
name: d.name ?? null,
|
||||||
|
description: d.description ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (d.exercises.length > 0) {
|
||||||
|
await tx.programExercise.createMany({
|
||||||
|
data: d.exercises.map((ex) => ({
|
||||||
|
dayId: day.id,
|
||||||
|
exerciseId: ex.exerciseId,
|
||||||
|
order: ex.order,
|
||||||
|
sets: ex.sets ?? null,
|
||||||
|
repsMin: ex.repsMin ?? null,
|
||||||
|
repsMax: ex.repsMax ?? null,
|
||||||
|
rpe: ex.rpe ?? null,
|
||||||
|
restSeconds: ex.restSeconds ?? null,
|
||||||
|
notes: ex.notes ?? null,
|
||||||
|
})) as Prisma.ProgramExerciseCreateManyInput[],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(program, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid program payload", details: err.errors },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error("POST /api/programs error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
|
|
||||||
const importSchema = z.object({
|
|
||||||
images: z.array(z.string()).min(1, "At least one image is required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const CLAUDE_API_URL = "https://api.anthropic.com/v1/messages";
|
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `You are analyzing photos of handwritten workout logs, Apple Notes, or other workout records. Extract all workout data you can find.
|
|
||||||
|
|
||||||
IMPORTANT RULES:
|
|
||||||
- If you can identify a date for the workout, include it as an ISO date string (YYYY-MM-DD)
|
|
||||||
- If no date is visible, set date to null
|
|
||||||
- Extract exercise names as closely as written
|
|
||||||
- For each exercise, extract all sets with whatever data is visible (reps, weight, duration, etc.)
|
|
||||||
- If you're unsure about an exercise name or value, set "uncertain": true and explain in "uncertainReason"
|
|
||||||
- Weight units: assume lbs unless kg or kilograms is explicitly written
|
|
||||||
- For cardio exercises (running, biking, rowing, assault bike, jump rope, etc.), look for duration, distance, and calories
|
|
||||||
- Be conservative — only include data you can actually read
|
|
||||||
|
|
||||||
Return ONLY valid JSON with this exact structure (no markdown, no code fences):
|
|
||||||
{
|
|
||||||
"workouts": [
|
|
||||||
{
|
|
||||||
"date": "2025-01-15" or null,
|
|
||||||
"name": "Upper Body" or null,
|
|
||||||
"notes": "any overall notes" or null,
|
|
||||||
"exercises": [
|
|
||||||
{
|
|
||||||
"name": "Bench Press",
|
|
||||||
"type": "barbell" | "dumbbell" | "machine" | "cable" | "bodyweight" | "cardio" | "kettlebell" | "other",
|
|
||||||
"sets": [
|
|
||||||
{
|
|
||||||
"reps": 8,
|
|
||||||
"weight": 225,
|
|
||||||
"weightUnit": "lbs",
|
|
||||||
"durationSeconds": null,
|
|
||||||
"distance": null,
|
|
||||||
"distanceUnit": null,
|
|
||||||
"calories": null,
|
|
||||||
"rpe": null,
|
|
||||||
"notes": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"notes": null,
|
|
||||||
"uncertain": false,
|
|
||||||
"uncertainReason": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"confidence": "high" | "medium" | "low",
|
|
||||||
"warnings": ["list any legibility issues or assumptions made"]
|
|
||||||
}`;
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const user = await getCurrentUser();
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user's Claude API key from preferences
|
|
||||||
const preferences = await prisma.userPreferences.findUnique({
|
|
||||||
where: { userId: user.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!preferences?.enableClaudeAI || !preferences?.claudeApiKey) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: "Claude AI is not configured. Please add your API key in Settings.",
|
|
||||||
code: "NO_API_KEY",
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const validated = importSchema.parse(body);
|
|
||||||
|
|
||||||
// Build Claude API request with vision
|
|
||||||
const content: any[] = [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "Please analyze the following workout log image(s) and extract all workout data. Return ONLY valid JSON.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add each image
|
|
||||||
for (const imageData of validated.images) {
|
|
||||||
// imageData could be a data URL or raw base64
|
|
||||||
let base64 = imageData;
|
|
||||||
let mediaType = "image/jpeg";
|
|
||||||
|
|
||||||
if (imageData.startsWith("data:")) {
|
|
||||||
const match = imageData.match(/^data:(image\/\w+);base64,(.+)$/);
|
|
||||||
if (match) {
|
|
||||||
mediaType = match[1];
|
|
||||||
base64 = match[2];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
content.push({
|
|
||||||
type: "image",
|
|
||||||
source: {
|
|
||||||
type: "base64",
|
|
||||||
media_type: mediaType,
|
|
||||||
data: base64,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call Claude API
|
|
||||||
const claudeResponse = await fetch(CLAUDE_API_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-api-key": preferences.claudeApiKey,
|
|
||||||
"anthropic-version": "2023-06-01",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: "claude-sonnet-4-20250514",
|
|
||||||
max_tokens: 4096,
|
|
||||||
system: SYSTEM_PROMPT,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!claudeResponse.ok) {
|
|
||||||
const errorBody = await claudeResponse.text();
|
|
||||||
console.error("Claude API error:", claudeResponse.status, errorBody);
|
|
||||||
|
|
||||||
if (claudeResponse.status === 401) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid Claude API key. Please check your key in Settings.", code: "INVALID_KEY" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (claudeResponse.status === 429) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Claude API rate limit reached. Please try again in a moment.", code: "RATE_LIMITED" },
|
|
||||||
{ status: 429 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to analyze images. Please try again.", code: "API_ERROR" },
|
|
||||||
{ status: 502 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const claudeData = await claudeResponse.json();
|
|
||||||
|
|
||||||
// Extract text content from Claude's response
|
|
||||||
const textContent = claudeData.content?.find((c: any) => c.type === "text");
|
|
||||||
if (!textContent?.text) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "No response from Claude. Please try again.", code: "EMPTY_RESPONSE" },
|
|
||||||
{ status: 502 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the JSON response
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
// Try to extract JSON from the response (Claude might wrap it in code fences)
|
|
||||||
let jsonText = textContent.text.trim();
|
|
||||||
const jsonMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
||||||
if (jsonMatch) {
|
|
||||||
jsonText = jsonMatch[1].trim();
|
|
||||||
}
|
|
||||||
parsed = JSON.parse(jsonText);
|
|
||||||
} catch {
|
|
||||||
console.error("Failed to parse Claude response:", textContent.text);
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: "Could not parse the workout data. The image may be too unclear.",
|
|
||||||
code: "PARSE_ERROR",
|
|
||||||
raw: textContent.text.substring(0, 500),
|
|
||||||
},
|
|
||||||
{ status: 422 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate basic structure
|
|
||||||
if (!parsed.workouts || !Array.isArray(parsed.workouts)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid response structure from Claude.", code: "INVALID_STRUCTURE" },
|
|
||||||
{ status: 422 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(parsed);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Invalid request data", details: error.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error("POST /api/workouts/import error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Internal server error" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -62,7 +62,6 @@ export async function signupAction(
|
|||||||
theme: 'system',
|
theme: 'system',
|
||||||
defaultWeightUnit: 'lbs',
|
defaultWeightUnit: 'lbs',
|
||||||
defaultRestSeconds: 90,
|
defaultRestSeconds: 90,
|
||||||
enableClaudeAI: false,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import GenerateClient from '@/components/ai/GenerateClient';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function GeneratePage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/auth/login');
|
||||||
|
|
||||||
|
const [templates, exercises, prefs, workoutCount] = await Promise.all([
|
||||||
|
prisma.aIPromptTemplate.findMany({
|
||||||
|
where: { OR: [{ userId: null }, { userId: user.id }] },
|
||||||
|
orderBy: [{ isBuiltIn: 'desc' }, { name: 'asc' }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
isBuiltIn: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.exercise.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { id: true, name: true, type: true },
|
||||||
|
orderBy: [{ type: 'asc' }, { name: 'asc' }],
|
||||||
|
}),
|
||||||
|
prisma.userPreferences.findUnique({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { aiProvider: true, aiModel: true },
|
||||||
|
}),
|
||||||
|
prisma.workout.count({
|
||||||
|
where: { userId: user.id, deletedAt: null },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0A0A0A]">
|
||||||
|
<div className="border-b border-zinc-800">
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/main/programs"
|
||||||
|
className="text-zinc-400 hover:text-white"
|
||||||
|
aria-label="Back to programs"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-white">
|
||||||
|
AI · Generate program
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-6">
|
||||||
|
{!aiConfigured ? (
|
||||||
|
<div className="bg-amber-950/30 border border-amber-900 rounded p-5 text-sm text-amber-200">
|
||||||
|
<p className="font-bold text-amber-100 mb-2">
|
||||||
|
AI is not configured.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Pick a provider, model, and (if needed) API key in{' '}
|
||||||
|
<Link
|
||||||
|
href="/main/settings"
|
||||||
|
className="underline hover:text-amber-100"
|
||||||
|
>
|
||||||
|
Settings → AI integration
|
||||||
|
</Link>{' '}
|
||||||
|
before you can generate programs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<GenerateClient
|
||||||
|
templates={templates}
|
||||||
|
exercises={exercises}
|
||||||
|
providerLabel={prefs!.aiProvider!}
|
||||||
|
modelLabel={prefs!.aiModel!}
|
||||||
|
workoutCount={workoutCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { redirect, notFound } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import GenerationDetail from '@/components/ai/GenerationDetail';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.1.0:4 — Detail view for a single AIGeneration row.
|
||||||
|
*
|
||||||
|
* Why: previously a generation that finished while you weren't watching
|
||||||
|
* disappeared into a List that only showed metadata. To re-examine the
|
||||||
|
* model's output you had to apply it (which committed a Program). This
|
||||||
|
* page lets you see the parsed program tree first, then either:
|
||||||
|
* - Apply it (creates a Program — same flow as Generate's preview)
|
||||||
|
* - Re-generate from the same prompt
|
||||||
|
* - View the raw model response + the exact system/user prompts sent
|
||||||
|
*
|
||||||
|
* Status flows:
|
||||||
|
* pending → progress + stream attach (so reloading the page during
|
||||||
|
* a long Ollama run picks up where it left off)
|
||||||
|
* completed → static program tree + Apply
|
||||||
|
* applied → "View applied program" link
|
||||||
|
* failed → error + raw response details
|
||||||
|
*/
|
||||||
|
export default async function GenerationDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/auth/login');
|
||||||
|
|
||||||
|
const [row, exercises] = await Promise.all([
|
||||||
|
prisma.aIGeneration.findFirst({
|
||||||
|
where: { id: params.id, userId: user.id },
|
||||||
|
}),
|
||||||
|
prisma.exercise.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { id: true, name: true, type: true },
|
||||||
|
orderBy: [{ type: 'asc' }, { name: 'asc' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (!row) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0A0A0A]">
|
||||||
|
<div className="border-b border-zinc-800">
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/main/ai/history"
|
||||||
|
className="text-zinc-400 hover:text-white"
|
||||||
|
aria-label="Back to history"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-white">
|
||||||
|
AI · Generation
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-6">
|
||||||
|
<GenerationDetail
|
||||||
|
row={{
|
||||||
|
id: row.id,
|
||||||
|
templateName: row.templateName,
|
||||||
|
userInput: row.userInput,
|
||||||
|
systemPrompt: row.systemPrompt,
|
||||||
|
userPrompt: row.userPrompt,
|
||||||
|
rawResponse: row.rawResponse,
|
||||||
|
parsedProgram: row.parsedProgram,
|
||||||
|
progressText: row.progressText,
|
||||||
|
provider: row.provider,
|
||||||
|
model: row.model,
|
||||||
|
tokensIn: row.tokensIn,
|
||||||
|
tokensOut: row.tokensOut,
|
||||||
|
durationMs: row.durationMs,
|
||||||
|
status: row.status,
|
||||||
|
errorMessage: row.errorMessage,
|
||||||
|
appliedProgramId: row.appliedProgramId,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
}}
|
||||||
|
exercises={exercises}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import HistoryList from '@/components/ai/HistoryList';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function HistoryPage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/auth/login');
|
||||||
|
|
||||||
|
const rows = await prisma.aIGeneration.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 25,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
templateName: true,
|
||||||
|
userInput: true,
|
||||||
|
provider: true,
|
||||||
|
model: true,
|
||||||
|
tokensIn: true,
|
||||||
|
tokensOut: true,
|
||||||
|
durationMs: true,
|
||||||
|
status: true,
|
||||||
|
errorMessage: true,
|
||||||
|
appliedProgramId: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0A0A0A]">
|
||||||
|
<div className="border-b border-zinc-800">
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center gap-3">
|
||||||
|
<Link href="/main/ai" className="text-zinc-400 hover:text-white">
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-white">
|
||||||
|
AI · Generation history
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-6">
|
||||||
|
<HistoryList
|
||||||
|
initialRows={rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Sparkles, ListChecks, History } from 'lucide-react';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function AIIndexPage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/auth/login');
|
||||||
|
|
||||||
|
const prefs = await prisma.userPreferences.findUnique({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { aiProvider: true, aiModel: true },
|
||||||
|
});
|
||||||
|
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
href: '/main/ai/generate',
|
||||||
|
icon: Sparkles,
|
||||||
|
title: 'Generate program',
|
||||||
|
blurb:
|
||||||
|
'Pick a template, describe what you want, and get a full multi-week program back. Review before applying.',
|
||||||
|
disabled: !aiConfigured,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/main/ai/templates',
|
||||||
|
icon: ListChecks,
|
||||||
|
title: 'Prompt templates',
|
||||||
|
blurb:
|
||||||
|
'Built-in templates ship with the app. Create + save your own for repeated use.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/main/ai/history',
|
||||||
|
icon: History,
|
||||||
|
title: 'Generation history',
|
||||||
|
blurb: 'Every prompt you sent, every response, every applied program.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0A0A0A]">
|
||||||
|
<div className="border-b border-zinc-800">
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-white">AI</h1>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
{aiConfigured ? (
|
||||||
|
<>
|
||||||
|
{prefs!.aiProvider} · {prefs!.aiModel}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link href="/main/settings" className="underline">
|
||||||
|
Configure in Settings →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-6 grid gap-3">
|
||||||
|
{cards.map((c) => {
|
||||||
|
const Icon = c.icon;
|
||||||
|
const disabled = !!c.disabled;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={c.href}
|
||||||
|
href={disabled ? '/main/settings' : c.href}
|
||||||
|
className={`block bg-zinc-900 border border-zinc-800 rounded p-5 hover:border-zinc-700 transition ${disabled ? 'opacity-60' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Icon className="w-5 h-5 text-zinc-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white">
|
||||||
|
{c.title}
|
||||||
|
{disabled && (
|
||||||
|
<span className="ml-2 text-[10px] uppercase tracking-wider text-amber-400">
|
||||||
|
configure first
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-zinc-500 mt-1">{c.blurb}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import TemplatesList from '@/components/ai/TemplatesList';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function TemplatesPage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/auth/login');
|
||||||
|
|
||||||
|
const templates = await prisma.aIPromptTemplate.findMany({
|
||||||
|
where: { OR: [{ userId: null }, { userId: user.id }] },
|
||||||
|
orderBy: [{ isBuiltIn: 'desc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0A0A0A]">
|
||||||
|
<div className="border-b border-zinc-800">
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/main/ai"
|
||||||
|
className="text-zinc-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-white">
|
||||||
|
AI · Prompt templates
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-6">
|
||||||
|
<TemplatesList
|
||||||
|
initialTemplates={templates.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
systemPrompt: t.systemPrompt,
|
||||||
|
userPromptTemplate: t.userPromptTemplate,
|
||||||
|
isBuiltIn: t.isBuiltIn,
|
||||||
|
isMine: t.userId === user.id,
|
||||||
|
}))}
|
||||||
|
isAdmin={user.isAdmin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,10 @@ export default async function MainLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-[#0A0A0A]">
|
<div className="min-h-screen flex flex-col bg-[#0A0A0A]">
|
||||||
<Navigation userName={user.name || user.email || 'User'} />
|
<Navigation
|
||||||
|
userName={user.name || user.email || 'User'}
|
||||||
|
isAdmin={user.isAdmin}
|
||||||
|
/>
|
||||||
<main className="flex-1 app-content pb-20 md:pb-0">
|
<main className="flex-1 app-content pb-20 md:pb-0">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Dumbbell,
|
Dumbbell,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
|
Calendar,
|
||||||
|
Sparkles,
|
||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -12,21 +14,76 @@ import { logoutAction } from './actions';
|
|||||||
|
|
||||||
interface NavigationProps {
|
interface NavigationProps {
|
||||||
userName: string;
|
userName: string;
|
||||||
|
isAdmin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navLinks = [
|
interface NavSubItem {
|
||||||
|
/** Either a route href or a section anchor (#…) on the parent page. */
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
/** Admin-only — hidden for non-admin users. */
|
||||||
|
adminOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavLink {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: typeof LayoutDashboard;
|
||||||
|
/** v1.1.0:4 — sub-navigation rendered when the user is on this section.
|
||||||
|
* Items can either deep-link to a sibling route or scroll to an anchor
|
||||||
|
* on the parent page. */
|
||||||
|
subItems?: NavSubItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const navLinks: NavLink[] = [
|
||||||
{ href: '/main/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ href: '/main/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ href: '/main/workouts', label: 'Workouts', icon: Dumbbell },
|
{ href: '/main/workouts', label: 'Workouts', icon: Dumbbell },
|
||||||
|
{ href: '/main/programs', label: 'Programs', icon: Calendar },
|
||||||
|
{
|
||||||
|
href: '/main/ai',
|
||||||
|
label: 'AI',
|
||||||
|
icon: Sparkles,
|
||||||
|
subItems: [
|
||||||
|
{ href: '/main/ai/generate', label: 'Generate' },
|
||||||
|
{ href: '/main/ai/history', label: 'History' },
|
||||||
|
{ href: '/main/ai/templates', label: 'Templates' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ href: '/main/exercises', label: 'Exercises', icon: ListChecks },
|
{ href: '/main/exercises', label: 'Exercises', icon: ListChecks },
|
||||||
{ href: '/main/settings', label: 'Settings', icon: Settings },
|
{
|
||||||
|
href: '/main/settings',
|
||||||
|
label: 'Settings',
|
||||||
|
icon: Settings,
|
||||||
|
subItems: [
|
||||||
|
{ href: '/main/settings#general', label: 'General' },
|
||||||
|
{ href: '/main/settings#password', label: 'Password' },
|
||||||
|
{ href: '/main/settings#sessions', label: 'Sessions' },
|
||||||
|
{ href: '/main/settings#ai', label: 'AI integration' },
|
||||||
|
{ href: '/main/settings#data', label: 'Export & import' },
|
||||||
|
{ href: '/main/settings#instance', label: 'Instance', adminOnly: true },
|
||||||
|
{ href: '/main/settings#danger', label: 'Danger zone' },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Navigation({ userName }: NavigationProps) {
|
export default function Navigation({ userName, isAdmin }: NavigationProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const isActive = (href: string) => {
|
// A top-level item is "active" if the current pathname matches it
|
||||||
return pathname === href || pathname.startsWith(href + '/');
|
// exactly OR is a subpage. We use this to decide whether to expand
|
||||||
|
// the sub-nav under it.
|
||||||
|
const isActive = (href: string) =>
|
||||||
|
pathname === href || pathname.startsWith(href + '/');
|
||||||
|
|
||||||
|
// A sub-item's active state depends on what it points to:
|
||||||
|
// - Route subitem (no #): exact pathname match
|
||||||
|
// - Anchor subitem (has #): always inactive in nav (anchor change
|
||||||
|
// doesn't fire pathname). The browser handles the highlight.
|
||||||
|
const isSubActive = (subHref: string) => {
|
||||||
|
const [path] = subHref.split('#');
|
||||||
|
if (subHref.includes('#')) return false;
|
||||||
|
return pathname === path;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
@@ -42,24 +99,50 @@ export default function Navigation({ userName }: NavigationProps) {
|
|||||||
<h2 className="text-3xl font-display text-white tracking-wider">Proof of Work</h2>
|
<h2 className="text-3xl font-display text-white tracking-wider">Proof of Work</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
<nav className="flex-1 overflow-y-auto p-4 space-y-1">
|
||||||
{navLinks.map((link) => {
|
{navLinks.map((link) => {
|
||||||
const Icon = link.icon;
|
const Icon = link.icon;
|
||||||
const active = isActive(link.href);
|
const active = isActive(link.href);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<div key={link.href}>
|
||||||
key={link.href}
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className={`flex items-center gap-3 px-4 py-2.5 rounded transition-all duration-200 ${
|
className={`flex items-center gap-3 px-4 py-2.5 rounded transition-all duration-200 ${
|
||||||
active
|
active
|
||||||
? 'bg-white text-black font-semibold'
|
? 'bg-white text-black font-semibold'
|
||||||
: 'text-zinc-500 hover:text-white hover:bg-zinc-900'
|
: 'text-zinc-500 hover:text-white hover:bg-zinc-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||||
<span className="text-sm">{link.label}</span>
|
<span className="text-sm">{link.label}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{/* Expand sub-nav when this section is active. */}
|
||||||
|
{active && link.subItems && link.subItems.length > 0 && (
|
||||||
|
<ul className="ml-4 mt-1 mb-2 border-l border-zinc-800 pl-3 space-y-0.5">
|
||||||
|
{link.subItems
|
||||||
|
.filter((s) => !s.adminOnly || isAdmin)
|
||||||
|
.map((sub) => {
|
||||||
|
const subActive = isSubActive(sub.href);
|
||||||
|
return (
|
||||||
|
<li key={sub.href}>
|
||||||
|
<a
|
||||||
|
href={sub.href}
|
||||||
|
className={`block px-3 py-1.5 rounded text-xs transition-colors ${
|
||||||
|
subActive
|
||||||
|
? 'text-white bg-zinc-800'
|
||||||
|
: 'text-zinc-500 hover:text-white hover:bg-zinc-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sub.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -80,7 +163,7 @@ export default function Navigation({ userName }: NavigationProps) {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Mobile Bottom Nav */}
|
{/* Mobile Bottom Nav (no sub-nav — limited screen real estate) */}
|
||||||
<header className="flex md:hidden fixed bottom-0 left-0 right-0 border-t border-zinc-800 bg-[#0A0A0A]">
|
<header className="flex md:hidden fixed bottom-0 left-0 right-0 border-t border-zinc-800 bg-[#0A0A0A]">
|
||||||
<nav className="flex items-center justify-around h-[var(--bottom-nav-height)] w-full">
|
<nav className="flex items-center justify-around h-[var(--bottom-nav-height)] w-full">
|
||||||
{navLinks.map((link) => {
|
{navLinks.map((link) => {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getProgramById, computeTodaysSessionForProgram } from '@/lib/db/programs';
|
||||||
|
import ProgramEditor, {
|
||||||
|
type DraftProgram,
|
||||||
|
} from '@/components/programs/ProgramEditor';
|
||||||
|
import ProgramActions from '@/components/programs/ProgramActions';
|
||||||
|
import StartSessionButton from '@/components/programs/StartSessionButton';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
|
export default async function ProgramDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
}) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/auth/login');
|
||||||
|
|
||||||
|
const program = await getProgramById(user.id, params.id);
|
||||||
|
if (!program) notFound();
|
||||||
|
|
||||||
|
const exercises = await prisma.exercise.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { id: true, name: true, type: true },
|
||||||
|
orderBy: [{ type: 'asc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const todaysSession = program.isActive
|
||||||
|
? computeTodaysSessionForProgram(program, new Date())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const draft: DraftProgram = {
|
||||||
|
name: program.name,
|
||||||
|
description: program.description,
|
||||||
|
type: program.type,
|
||||||
|
durationWeeks: program.durationWeeks,
|
||||||
|
startDate: program.startDate.toISOString().slice(0, 10),
|
||||||
|
isActive: program.isActive,
|
||||||
|
weeks: program.weeks.map((w) => ({
|
||||||
|
weekNumber: w.weekNumber,
|
||||||
|
phase: w.phase,
|
||||||
|
description: w.description,
|
||||||
|
days: w.days.map((d) => ({
|
||||||
|
dayOfWeek: d.dayOfWeek,
|
||||||
|
name: d.name,
|
||||||
|
description: d.description,
|
||||||
|
exercises: d.exercises.map((ex) => ({
|
||||||
|
exerciseId: ex.exerciseId,
|
||||||
|
order: ex.order,
|
||||||
|
sets: ex.sets,
|
||||||
|
repsMin: ex.repsMin,
|
||||||
|
repsMax: ex.repsMax,
|
||||||
|
rpe: ex.rpe,
|
||||||
|
restSeconds: ex.restSeconds,
|
||||||
|
notes: ex.notes,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0A0A0A]">
|
||||||
|
<div className="border-b border-zinc-800">
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Link
|
||||||
|
href="/main/programs"
|
||||||
|
className="text-zinc-400 hover:text-white"
|
||||||
|
aria-label="Back to programs"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-xl sm:text-2xl font-bold text-white truncate">
|
||||||
|
{program.name}
|
||||||
|
{program.isActive && (
|
||||||
|
<span className="ml-2 text-[10px] uppercase tracking-wider bg-emerald-900/50 text-emerald-300 px-2 py-0.5 rounded font-normal">
|
||||||
|
active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<ProgramActions programId={program.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
{todaysSession?.day && (
|
||||||
|
<section className="bg-emerald-950/30 border border-emerald-900 rounded p-4">
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-emerald-400">
|
||||||
|
Today · Week {todaysSession.weekNumber} ·{' '}
|
||||||
|
{DAY_LABELS[todaysSession.dayOfWeek]}
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-bold text-white mt-1">
|
||||||
|
{todaysSession.day.name ??
|
||||||
|
`${DAY_LABELS[todaysSession.dayOfWeek]} session`}
|
||||||
|
</h2>
|
||||||
|
<ul className="mt-2 space-y-1 text-sm text-zinc-300">
|
||||||
|
{todaysSession.day.exercises.map((ex) => (
|
||||||
|
<li key={ex.id}>
|
||||||
|
{ex.exercise.name}
|
||||||
|
{ex.sets && (
|
||||||
|
<span className="text-zinc-500">
|
||||||
|
{' '}
|
||||||
|
· {ex.sets}×
|
||||||
|
{ex.repsMin === ex.repsMax || !ex.repsMax
|
||||||
|
? (ex.repsMin ?? '?')
|
||||||
|
: `${ex.repsMin}-${ex.repsMax}`}
|
||||||
|
{ex.rpe ? ` @ RPE ${ex.rpe}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<StartSessionButton
|
||||||
|
programId={program.id}
|
||||||
|
dayId={todaysSession.day.id}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProgramEditor
|
||||||
|
initialProgram={draft}
|
||||||
|
programId={program.id}
|
||||||
|
exercises={exercises}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import ProgramEditor from '@/components/programs/ProgramEditor';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function NewProgramPage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/auth/login');
|
||||||
|
|
||||||
|
const exercises = await prisma.exercise.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { id: true, name: true, type: true },
|
||||||
|
orderBy: [{ type: 'asc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0A0A0A]">
|
||||||
|
<div className="border-b border-zinc-800">
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/main/programs"
|
||||||
|
className="text-zinc-400 hover:text-white"
|
||||||
|
aria-label="Back to programs"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-white">
|
||||||
|
New program
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-6">
|
||||||
|
<ProgramEditor exercises={exercises} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Plus, Calendar, Activity } from 'lucide-react';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { getPrograms, getTodaysSession } from '@/lib/db/programs';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
|
export default async function ProgramsPage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/auth/login');
|
||||||
|
|
||||||
|
const [programs, today] = await Promise.all([
|
||||||
|
getPrograms(user.id),
|
||||||
|
getTodaysSession(user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0A0A0A]">
|
||||||
|
<div className="border-b border-zinc-800">
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-4 sm:py-6 flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-white">Programs</h1>
|
||||||
|
<Link
|
||||||
|
href="/main/programs/new"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded bg-white text-black font-bold text-xs uppercase tracking-wider hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
{today && today.day && (
|
||||||
|
<section className="bg-emerald-950/30 border border-emerald-900 rounded p-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Calendar className="w-5 h-5 text-emerald-400 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-emerald-400">
|
||||||
|
Today's session
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-bold text-white mt-1">
|
||||||
|
{today.day.name ?? `${DAY_LABELS[today.dayOfWeek]} session`}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-zinc-400 mt-0.5">
|
||||||
|
{today.program.name} · Week {today.weekNumber} ·{' '}
|
||||||
|
{today.day.exercises.length} exercise
|
||||||
|
{today.day.exercises.length === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/main/programs/${today.program.id}`}
|
||||||
|
className="inline-block mt-3 text-xs text-emerald-300 underline"
|
||||||
|
>
|
||||||
|
Open program →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{programs.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<Activity className="w-12 h-12 text-zinc-700 mx-auto mb-3" />
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-2">
|
||||||
|
No programs yet
|
||||||
|
</h2>
|
||||||
|
<p className="text-zinc-500 mb-6 text-sm">
|
||||||
|
Build a multi-week training plan, then follow it day by day.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/main/programs/new"
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded bg-white text-black font-semibold text-sm hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Create your first program
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{programs.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<Link
|
||||||
|
href={`/main/programs/${p.id}`}
|
||||||
|
className="block bg-zinc-900 border border-zinc-800 rounded p-4 hover:border-zinc-700 transition"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-base font-semibold text-white truncate">
|
||||||
|
{p.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-0.5">
|
||||||
|
{p.type} · {p.durationWeeks} week
|
||||||
|
{p.durationWeeks === 1 ? '' : 's'} · started{' '}
|
||||||
|
{new Date(p.startDate).toLocaleDateString()} ·{' '}
|
||||||
|
{p._count.weeks} week
|
||||||
|
{p._count.weeks === 1 ? '' : 's'} planned
|
||||||
|
</p>
|
||||||
|
{p.description && (
|
||||||
|
<p className="text-xs text-zinc-400 mt-1 line-clamp-2">
|
||||||
|
{p.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{p.isActive && (
|
||||||
|
<span className="text-[10px] uppercase tracking-wider bg-emerald-900/50 text-emerald-300 px-2 py-0.5 rounded flex-shrink-0">
|
||||||
|
active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { getCurrentUser } from "@/lib/auth";
|
|||||||
import SettingsForm from "@/components/settings/SettingsForm";
|
import SettingsForm from "@/components/settings/SettingsForm";
|
||||||
import ChangePasswordForm from "@/components/settings/ChangePasswordForm";
|
import ChangePasswordForm from "@/components/settings/ChangePasswordForm";
|
||||||
import SessionsList from "@/components/settings/SessionsList";
|
import SessionsList from "@/components/settings/SessionsList";
|
||||||
|
import AIIntegration from "@/components/settings/AIIntegration";
|
||||||
import ExportMyData from "@/components/settings/ExportMyData";
|
import ExportMyData from "@/components/settings/ExportMyData";
|
||||||
import DangerZone from "@/components/settings/DangerZone";
|
import DangerZone from "@/components/settings/DangerZone";
|
||||||
import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings";
|
import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings";
|
||||||
@@ -29,16 +30,19 @@ export default async function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-2xl mx-auto px-4 py-6 sm:px-6 space-y-8">
|
<div className="max-w-2xl mx-auto px-4 py-6 sm:px-6 space-y-8">
|
||||||
<SettingsForm user={user} />
|
<div id="general"><SettingsForm user={user} /></div>
|
||||||
<ChangePasswordForm />
|
<div id="password"><ChangePasswordForm /></div>
|
||||||
<SessionsList />
|
<div id="sessions"><SessionsList /></div>
|
||||||
<ExportMyData />
|
<div id="ai"><AIIntegration /></div>
|
||||||
|
<div id="data"><ExportMyData /></div>
|
||||||
{user.isAdmin && instanceSettings && (
|
{user.isAdmin && instanceSettings && (
|
||||||
<AdminInstanceSettings
|
<div id="instance">
|
||||||
initialSignupsOpen={instanceSettings.signupsOpen}
|
<AdminInstanceSettings
|
||||||
/>
|
initialSignupsOpen={instanceSettings.signupsOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<DangerZone />
|
<div id="danger"><DangerZone /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,747 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Loader2, Sparkles } from 'lucide-react';
|
||||||
|
import { lenientJsonParse } from '@/lib/ai/lenientJson';
|
||||||
|
import { estimateCost, formatCost } from '@/lib/ai/pricing';
|
||||||
|
|
||||||
|
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
|
interface Template {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
isBuiltIn: boolean;
|
||||||
|
}
|
||||||
|
interface LibraryExercise {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI output shape (matches lib/ai/programSchema.ts)
|
||||||
|
interface AIExercise {
|
||||||
|
exerciseId: string | null;
|
||||||
|
exerciseName: string;
|
||||||
|
order: number;
|
||||||
|
sets?: number | null;
|
||||||
|
repsMin?: number | null;
|
||||||
|
repsMax?: number | null;
|
||||||
|
rpe?: number | null;
|
||||||
|
restSeconds?: number | null;
|
||||||
|
suggestedWeight?: number | null;
|
||||||
|
suggestedWeightUnit?: 'lbs' | 'kg' | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
interface AIDay {
|
||||||
|
dayOfWeek: number;
|
||||||
|
name?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
exercises: AIExercise[];
|
||||||
|
}
|
||||||
|
interface AIWeek {
|
||||||
|
weekNumber: number;
|
||||||
|
phase?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
days: AIDay[];
|
||||||
|
}
|
||||||
|
interface AIProgram {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
type: string;
|
||||||
|
durationWeeks: number;
|
||||||
|
weeks: AIWeek[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phase =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| {
|
||||||
|
kind: 'streaming';
|
||||||
|
raw: string;
|
||||||
|
// Last successfully parsed snapshot. Sticky — we only update it
|
||||||
|
// when a new chunk lets lenientJsonParse return a fresh value.
|
||||||
|
// This kills the flicker we used to have, where the panel toggled
|
||||||
|
// back to "Waiting for first JSON…" between parseable chunks.
|
||||||
|
lastPartial: Partial<AIProgram> | null;
|
||||||
|
}
|
||||||
|
| { kind: 'parsed'; raw: string; program: AIProgram }
|
||||||
|
| { kind: 'failed'; raw: string; message: string };
|
||||||
|
|
||||||
|
export default function GenerateClient({
|
||||||
|
templates,
|
||||||
|
exercises,
|
||||||
|
providerLabel,
|
||||||
|
modelLabel,
|
||||||
|
workoutCount,
|
||||||
|
}: {
|
||||||
|
templates: Template[];
|
||||||
|
exercises: LibraryExercise[];
|
||||||
|
providerLabel: string;
|
||||||
|
modelLabel: string;
|
||||||
|
workoutCount: number;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [templateId, setTemplateId] = useState(templates[0]?.id ?? '');
|
||||||
|
const [userInput, setUserInput] = useState('');
|
||||||
|
const [includeHistory, setIncludeHistory] = useState(workoutCount >= 10);
|
||||||
|
const [generationId, setGenerationId] = useState<string | null>(null);
|
||||||
|
const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
|
||||||
|
const [tokens, setTokens] = useState<{ in?: number; out?: number; durationMs?: number }>({});
|
||||||
|
const [navWarning, setNavWarning] = useState(false);
|
||||||
|
const closeStreamRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
// Wire up native warning if the user tries to leave during a stream.
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase.kind !== 'streaming') return;
|
||||||
|
setNavWarning(true);
|
||||||
|
return () => setNavWarning(false);
|
||||||
|
}, [phase.kind]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generation kickoff — POST /api/ai/generate gets back an id, then
|
||||||
|
* we attach to the SSE stream by id. The runner is detached on the
|
||||||
|
* server: navigating away no longer cancels generation, the row keeps
|
||||||
|
* filling in. We surface a banner so the user knows that.
|
||||||
|
*/
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!userInput.trim()) return;
|
||||||
|
setPhase({ kind: 'streaming', raw: '', lastPartial: null });
|
||||||
|
setGenerationId(null);
|
||||||
|
setTokens({});
|
||||||
|
|
||||||
|
let id: string;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
templateId: templateId || null,
|
||||||
|
userInput,
|
||||||
|
includeHistory,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
setPhase({
|
||||||
|
kind: 'failed',
|
||||||
|
raw: '',
|
||||||
|
message: body.error ?? `HTTP ${res.status}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
id = body.id;
|
||||||
|
setGenerationId(id);
|
||||||
|
} catch (e) {
|
||||||
|
setPhase({ kind: 'failed', raw: '', message: (e as Error).message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach to the SSE stream.
|
||||||
|
attachStream(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachStream = (id: string) => {
|
||||||
|
const es = new EventSource(`/api/ai/generations/${id}/stream`);
|
||||||
|
closeStreamRef.current = () => es.close();
|
||||||
|
let raw = '';
|
||||||
|
let lastPartial: Partial<AIProgram> | null = null;
|
||||||
|
|
||||||
|
es.addEventListener('text', (ev) => {
|
||||||
|
const data = JSON.parse((ev as MessageEvent).data);
|
||||||
|
raw += data.delta;
|
||||||
|
const next = lenientJsonParse(raw) as Partial<AIProgram> | null;
|
||||||
|
// Sticky: only replace the snapshot if we got a fresh parse.
|
||||||
|
// Otherwise leave the previous one rendered — kills the flicker.
|
||||||
|
if (next) lastPartial = next;
|
||||||
|
setPhase({ kind: 'streaming', raw, lastPartial });
|
||||||
|
});
|
||||||
|
es.addEventListener('usage', (ev) => {
|
||||||
|
const data = JSON.parse((ev as MessageEvent).data);
|
||||||
|
setTokens((t) => ({ ...t, in: data.tokensIn, out: data.tokensOut }));
|
||||||
|
});
|
||||||
|
es.addEventListener('complete', async (ev) => {
|
||||||
|
const data = JSON.parse((ev as MessageEvent).data);
|
||||||
|
es.close();
|
||||||
|
closeStreamRef.current = null;
|
||||||
|
setTokens((t) => ({
|
||||||
|
...t,
|
||||||
|
in: data.tokensIn ?? t.in,
|
||||||
|
out: data.tokensOut ?? t.out,
|
||||||
|
durationMs: data.durationMs,
|
||||||
|
}));
|
||||||
|
if (data.parsedOk) {
|
||||||
|
// Pull the parsed program from the row.
|
||||||
|
const r = await fetch(`/api/ai/generations/${id}`);
|
||||||
|
if (r.ok) {
|
||||||
|
const gen = await r.json();
|
||||||
|
if (gen.parsedProgram) {
|
||||||
|
setPhase({
|
||||||
|
kind: 'parsed',
|
||||||
|
raw,
|
||||||
|
program: JSON.parse(gen.parsedProgram) as AIProgram,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPhase({
|
||||||
|
kind: 'failed',
|
||||||
|
raw,
|
||||||
|
message: data.errorMessage ?? 'Failed to parse model output.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
es.onerror = () => {
|
||||||
|
// EventSource auto-reconnects on transient errors. We only treat
|
||||||
|
// it as fatal if we never got a `complete` event AND the stream
|
||||||
|
// is closed. The simplest signal: readyState===CLOSED.
|
||||||
|
if (es.readyState === EventSource.CLOSED) {
|
||||||
|
closeStreamRef.current = null;
|
||||||
|
setPhase((p) => {
|
||||||
|
if (p.kind === 'streaming') {
|
||||||
|
return {
|
||||||
|
kind: 'failed',
|
||||||
|
raw: p.raw,
|
||||||
|
message: 'Stream disconnected. The generation may still be running — check Generation history.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Beforeunload warning while streaming — important since the user can
|
||||||
|
// CLOSE the tab and the generation continues server-side, but data
|
||||||
|
// sent after they close won't be visible until they re-open and look
|
||||||
|
// at history.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!navWarning) return;
|
||||||
|
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', onBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
||||||
|
}, [navWarning]);
|
||||||
|
|
||||||
|
// Detach on unmount (Next.js client-side nav) — we don't want a
|
||||||
|
// dangling EventSource. The server keeps generating either way.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
closeStreamRef.current?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cost — derived from active provider/model + tokens once both are
|
||||||
|
// known. Pre-known because we know the provider; use a placeholder
|
||||||
|
// computation.
|
||||||
|
const costStr = useMemo(() => {
|
||||||
|
if (tokens.in == null || tokens.out == null) return null;
|
||||||
|
const c = estimateCost({
|
||||||
|
provider: providerLabel,
|
||||||
|
model: modelLabel,
|
||||||
|
tokensIn: tokens.in,
|
||||||
|
tokensOut: tokens.out,
|
||||||
|
});
|
||||||
|
return formatCost(c);
|
||||||
|
}, [providerLabel, modelLabel, tokens.in, tokens.out]);
|
||||||
|
|
||||||
|
const selectedTemplate = useMemo(
|
||||||
|
() => templates.find((t) => t.id === templateId),
|
||||||
|
[templates, templateId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-xs text-zinc-500 uppercase tracking-wider">
|
||||||
|
Provider: <span className="text-zinc-300">{providerLabel}</span>
|
||||||
|
{' · '}Model: <span className="text-zinc-300">{modelLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="bg-zinc-900 border border-zinc-800 rounded p-4 space-y-4">
|
||||||
|
<Field label="Template">
|
||||||
|
<select
|
||||||
|
value={templateId}
|
||||||
|
onChange={(e) => setTemplateId(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
disabled={phase.kind === 'streaming'}
|
||||||
|
>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.isBuiltIn ? '★ ' : ''}
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedTemplate?.description && (
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
{selectedTemplate.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Your specifics">
|
||||||
|
<textarea
|
||||||
|
value={userInput}
|
||||||
|
onChange={(e) => setUserInput(e.target.value)}
|
||||||
|
placeholder="e.g. 8 weeks, 4 days per week, heavy leg emphasis. I have a meet in 6 weeks. Bench Press is at 245x5, Squat 365x3, Deadlift 425x3."
|
||||||
|
rows={6}
|
||||||
|
className={inputClass}
|
||||||
|
disabled={phase.kind === 'streaming'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-2 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeHistory}
|
||||||
|
onChange={(e) => setIncludeHistory(e.target.checked)}
|
||||||
|
disabled={phase.kind === 'streaming' || workoutCount === 0}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Include my workout history as context{' '}
|
||||||
|
<span className="text-zinc-500">
|
||||||
|
({workoutCount === 0
|
||||||
|
? 'no workouts logged yet — disabled'
|
||||||
|
: `last 90 days · summarizes per-exercise frequency, recent weights, stagnations`}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!userInput.trim() || phase.kind === 'streaming'}
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2 rounded bg-white text-black font-bold text-xs uppercase tracking-wider hover:bg-gray-100 disabled:bg-zinc-700 disabled:text-zinc-500"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{(phase.kind === 'streaming' || phase.kind === 'failed' || phase.kind === 'parsed') && (
|
||||||
|
<section className="space-y-3">
|
||||||
|
{phase.kind === 'streaming' && (
|
||||||
|
<div className="rounded bg-blue-950/30 border border-blue-900 px-4 py-3 text-xs text-blue-200">
|
||||||
|
<p className="font-bold text-blue-100 mb-1">Generation runs in the background.</p>
|
||||||
|
<p>
|
||||||
|
You can close this page or navigate away — the model will keep
|
||||||
|
writing on the server. Come back to{' '}
|
||||||
|
<a href="/main/ai/history" className="underline hover:text-blue-100">
|
||||||
|
AI · History
|
||||||
|
</a>{' '}
|
||||||
|
to see the result. Local Ollama models on slower hardware can take
|
||||||
|
10+ minutes; commercial APIs typically finish in under a minute.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
|
||||||
|
{phase.kind === 'streaming' ? 'Generating…' : 'Response'}
|
||||||
|
</h2>
|
||||||
|
<span className="text-[11px] text-zinc-500 uppercase tracking-wider">
|
||||||
|
{tokens.in != null && (
|
||||||
|
<>
|
||||||
|
{tokens.in} in · {tokens.out ?? '?'} out
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{costStr && <> · {costStr}</>}
|
||||||
|
{tokens.durationMs != null && (
|
||||||
|
<> · {(tokens.durationMs / 1000).toFixed(1)}s</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{phase.kind === 'streaming' && (
|
||||||
|
<>
|
||||||
|
{phase.lastPartial ? (
|
||||||
|
<PartialPreview partial={phase.lastPartial} />
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-zinc-500 italic flex items-center gap-2">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
Waiting for the first parseable JSON…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<details className="text-xs text-zinc-500">
|
||||||
|
<summary className="cursor-pointer">Raw stream</summary>
|
||||||
|
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 font-mono text-[11px] text-zinc-400 max-h-80 overflow-auto whitespace-pre-wrap mt-2">
|
||||||
|
{phase.raw || '(waiting for first token…)'}
|
||||||
|
<Loader2 className="inline w-3 h-3 animate-spin ml-2" />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase.kind === 'failed' && (
|
||||||
|
<>
|
||||||
|
<div className="bg-red-950/40 border border-red-900 rounded p-3 text-sm text-red-300">
|
||||||
|
{phase.message}
|
||||||
|
</div>
|
||||||
|
{phase.raw && (
|
||||||
|
<details className="text-xs text-zinc-500">
|
||||||
|
<summary className="cursor-pointer">Raw response</summary>
|
||||||
|
<pre className="bg-zinc-950 border border-zinc-800 rounded p-3 mt-2 whitespace-pre-wrap">
|
||||||
|
{phase.raw}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase.kind === 'parsed' && generationId && (
|
||||||
|
<ProgramPreview
|
||||||
|
program={phase.program}
|
||||||
|
generationId={generationId}
|
||||||
|
exercises={exercises}
|
||||||
|
onApplied={(programId) => {
|
||||||
|
router.push(`/main/programs/${programId}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgramPreview({
|
||||||
|
program: initial,
|
||||||
|
generationId,
|
||||||
|
exercises,
|
||||||
|
onApplied,
|
||||||
|
}: {
|
||||||
|
program: AIProgram;
|
||||||
|
generationId: string;
|
||||||
|
exercises: LibraryExercise[];
|
||||||
|
onApplied: (programId: string) => void;
|
||||||
|
}) {
|
||||||
|
const [program, setProgram] = useState<AIProgram>(initial);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [startDate, setStartDate] = useState(
|
||||||
|
new Date().toISOString().slice(0, 10),
|
||||||
|
);
|
||||||
|
const [activate, setActivate] = useState(true);
|
||||||
|
|
||||||
|
const exerciseLookup = useMemo(
|
||||||
|
() => new Map(exercises.map((e) => [e.id, e])),
|
||||||
|
[exercises],
|
||||||
|
);
|
||||||
|
|
||||||
|
const unresolvedCount = useMemo(() => {
|
||||||
|
let n = 0;
|
||||||
|
for (const w of program.weeks)
|
||||||
|
for (const d of w.days)
|
||||||
|
for (const ex of d.exercises) {
|
||||||
|
// Either no id OR an id that doesn't actually exist in the
|
||||||
|
// user's library (the model invented one). Both must be
|
||||||
|
// resolved before the apply step accepts the program.
|
||||||
|
if (!ex.exerciseId || !exerciseLookup.has(ex.exerciseId)) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}, [program, exerciseLookup]);
|
||||||
|
|
||||||
|
const setExerciseId = (
|
||||||
|
weekIdx: number,
|
||||||
|
dayIdx: number,
|
||||||
|
exIdx: number,
|
||||||
|
newId: string | null,
|
||||||
|
) => {
|
||||||
|
setProgram((p) => {
|
||||||
|
const next = structuredClone(p);
|
||||||
|
next.weeks[weekIdx].days[dayIdx].exercises[exIdx].exerciseId = newId;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeExercise = (weekIdx: number, dayIdx: number, exIdx: number) => {
|
||||||
|
setProgram((p) => {
|
||||||
|
const next = structuredClone(p);
|
||||||
|
next.weeks[weekIdx].days[dayIdx].exercises.splice(exIdx, 1);
|
||||||
|
next.weeks[weekIdx].days[dayIdx].exercises.forEach(
|
||||||
|
(ex: AIExercise, i: number) => {
|
||||||
|
ex.order = i;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
if (unresolvedCount > 0) {
|
||||||
|
setError(
|
||||||
|
`Resolve all ${unresolvedCount} unknown exercise(s) before applying.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setApplying(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
generationId,
|
||||||
|
program,
|
||||||
|
startDate,
|
||||||
|
isActive: activate,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||||
|
onApplied(body.programId);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white">{program.name}</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
{program.type} · {program.durationWeeks} week
|
||||||
|
{program.durationWeeks === 1 ? '' : 's'} · {program.weeks.length}{' '}
|
||||||
|
week{program.weeks.length === 1 ? '' : 's'} planned
|
||||||
|
</p>
|
||||||
|
{program.description && (
|
||||||
|
<p className="text-sm text-zinc-300 mt-2">{program.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unresolvedCount > 0 && (
|
||||||
|
<div className="rounded bg-amber-950/30 border border-amber-900 px-3 py-2 text-xs text-amber-200">
|
||||||
|
{unresolvedCount} exercise(s) the AI couldn't map to your
|
||||||
|
library. Pick a replacement or remove them before applying.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{program.weeks.map((w, wIdx) => (
|
||||||
|
<details
|
||||||
|
key={w.weekNumber}
|
||||||
|
open={wIdx === 0}
|
||||||
|
className="bg-zinc-950 border border-zinc-800 rounded"
|
||||||
|
>
|
||||||
|
<summary className="cursor-pointer px-3 py-2 text-sm text-white">
|
||||||
|
Week {w.weekNumber}
|
||||||
|
{w.phase && <span className="text-zinc-500"> · {w.phase}</span>}
|
||||||
|
<span className="text-zinc-600 text-xs">
|
||||||
|
{' '}
|
||||||
|
({w.days.length} day{w.days.length === 1 ? '' : 's'})
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{w.days.map((d, dIdx) => (
|
||||||
|
<div
|
||||||
|
key={d.dayOfWeek}
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded p-3"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-semibold text-zinc-300 uppercase tracking-wider">
|
||||||
|
{DAY_LABELS[d.dayOfWeek]}
|
||||||
|
{d.name && (
|
||||||
|
<span className="text-zinc-500 normal-case font-normal">
|
||||||
|
{' '}
|
||||||
|
· {d.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 space-y-2">
|
||||||
|
{d.exercises.map((ex, eIdx) => {
|
||||||
|
const isUnknown =
|
||||||
|
!ex.exerciseId || !exerciseLookup.has(ex.exerciseId);
|
||||||
|
const lib = ex.exerciseId
|
||||||
|
? exerciseLookup.get(ex.exerciseId)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={eIdx}
|
||||||
|
className={`text-sm ${
|
||||||
|
isUnknown
|
||||||
|
? 'bg-amber-950/30 border border-amber-900'
|
||||||
|
: 'bg-zinc-950 border border-zinc-800'
|
||||||
|
} rounded p-2`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-white">
|
||||||
|
{lib?.name ?? ex.exerciseName}
|
||||||
|
{isUnknown && (
|
||||||
|
<span className="ml-2 text-[10px] uppercase tracking-wider text-amber-400">
|
||||||
|
not in library
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(ex.sets || ex.repsMin || ex.repsMax || ex.rpe || ex.restSeconds || ex.suggestedWeight) && (
|
||||||
|
<div className="text-xs text-zinc-500 mt-0.5">
|
||||||
|
{ex.sets ? `${ex.sets}×` : ''}
|
||||||
|
{ex.repsMin === ex.repsMax || !ex.repsMax
|
||||||
|
? (ex.repsMin ?? '?')
|
||||||
|
: `${ex.repsMin}-${ex.repsMax}`}
|
||||||
|
{ex.suggestedWeight != null && (
|
||||||
|
<> @ {ex.suggestedWeight}{ex.suggestedWeightUnit ?? ''}</>
|
||||||
|
)}
|
||||||
|
{ex.rpe ? ` @ RPE ${ex.rpe}` : ''}
|
||||||
|
{ex.restSeconds ? ` · rest ${ex.restSeconds}s` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ex.notes && (
|
||||||
|
<div className="text-xs text-zinc-400 mt-1 italic">
|
||||||
|
{ex.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeExercise(wIdx, dIdx, eIdx)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 px-1"
|
||||||
|
title="Remove from program"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isUnknown && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<select
|
||||||
|
value={ex.exerciseId ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setExerciseId(wIdx, dIdx, eIdx, e.target.value || null)
|
||||||
|
}
|
||||||
|
className="w-full text-xs px-2 py-1 rounded border border-amber-900 bg-zinc-900 text-white"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
Map to existing exercise…
|
||||||
|
</option>
|
||||||
|
{exercises.map((opt) => (
|
||||||
|
<option key={opt.id} value={opt.id}>
|
||||||
|
{opt.name} ({opt.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-zinc-800 pt-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Start date">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<label className="flex items-end gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={activate}
|
||||||
|
onChange={(e) => setActivate(e.target.checked)}
|
||||||
|
className="mb-2"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-zinc-300 mb-2">
|
||||||
|
Activate this program after applying
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={applying || unresolvedCount > 0}
|
||||||
|
className="px-5 py-2 rounded bg-emerald-700 text-white font-bold text-xs uppercase tracking-wider hover:bg-emerald-600 disabled:bg-zinc-700 disabled:text-zinc-500"
|
||||||
|
>
|
||||||
|
{applying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="inline w-4 h-4 animate-spin mr-2" />
|
||||||
|
Applying…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Apply this program'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full px-3 py-2 text-sm rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white/30';
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider block mb-1">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PartialPreview({ partial }: { partial: Partial<AIProgram> }) {
|
||||||
|
const weeks = (partial.weeks as AIWeek[] | undefined) ?? [];
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin text-zinc-500" />
|
||||||
|
<span className="text-zinc-400">
|
||||||
|
Building program…{' '}
|
||||||
|
{partial.name && (
|
||||||
|
<span className="text-white font-semibold">{partial.name}</span>
|
||||||
|
)}
|
||||||
|
{partial.type && (
|
||||||
|
<span className="text-zinc-500"> · {partial.type}</span>
|
||||||
|
)}
|
||||||
|
{typeof partial.durationWeeks === 'number' && (
|
||||||
|
<span className="text-zinc-500"> · {partial.durationWeeks} wk</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{weeks.length > 0 && (
|
||||||
|
<ul className="text-xs text-zinc-300 space-y-1">
|
||||||
|
{weeks.map((w, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<span className="text-zinc-500">Week {w?.weekNumber ?? '?'}:</span>{' '}
|
||||||
|
{Array.isArray(w?.days)
|
||||||
|
? `${w.days.length} day${w.days.length === 1 ? '' : 's'} (${
|
||||||
|
w.days.reduce(
|
||||||
|
(n: number, d: AIDay) =>
|
||||||
|
n + (Array.isArray(d?.exercises) ? d.exercises.length : 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
} exercises)`
|
||||||
|
: '…'}
|
||||||
|
{w?.phase && (
|
||||||
|
<span className="text-zinc-500"> · {w.phase}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { lenientJsonParse } from '@/lib/ai/lenientJson';
|
||||||
|
import { estimateCost, formatCost } from '@/lib/ai/pricing';
|
||||||
|
|
||||||
|
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
|
interface AIExercise {
|
||||||
|
exerciseId: string | null;
|
||||||
|
exerciseName: string;
|
||||||
|
order: number;
|
||||||
|
sets?: number | null;
|
||||||
|
repsMin?: number | null;
|
||||||
|
repsMax?: number | null;
|
||||||
|
rpe?: number | null;
|
||||||
|
restSeconds?: number | null;
|
||||||
|
suggestedWeight?: number | null;
|
||||||
|
suggestedWeightUnit?: 'lbs' | 'kg' | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
interface AIDay {
|
||||||
|
dayOfWeek: number;
|
||||||
|
name?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
exercises: AIExercise[];
|
||||||
|
}
|
||||||
|
interface AIWeek {
|
||||||
|
weekNumber: number;
|
||||||
|
phase?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
days: AIDay[];
|
||||||
|
}
|
||||||
|
interface AIProgram {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
type: string;
|
||||||
|
durationWeeks: number;
|
||||||
|
weeks: AIWeek[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LibraryExercise {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
templateName: string | null;
|
||||||
|
userInput: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
userPrompt: string;
|
||||||
|
rawResponse: string | null;
|
||||||
|
parsedProgram: string | null;
|
||||||
|
progressText: string | null;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
tokensIn: number | null;
|
||||||
|
tokensOut: number | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
status: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
appliedProgramId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side detail view for an AIGeneration. Three modes:
|
||||||
|
*
|
||||||
|
* - PENDING: poll for progress + render the live partial-JSON preview.
|
||||||
|
* The runner keeps writing `progressText` even if no SSE clients
|
||||||
|
* are subscribed, so polling works for cross-process resume too.
|
||||||
|
*
|
||||||
|
* - COMPLETED: render the parsed program tree with an Apply button.
|
||||||
|
* Same UI as the Generate page's preview, factored out below.
|
||||||
|
*
|
||||||
|
* - APPLIED: the user already turned this into a Program; show a
|
||||||
|
* link there. Re-applying isn't allowed (would create a duplicate).
|
||||||
|
*
|
||||||
|
* - FAILED: error message + raw response collapsed by default.
|
||||||
|
*/
|
||||||
|
export default function GenerationDetail({
|
||||||
|
row: initialRow,
|
||||||
|
exercises,
|
||||||
|
}: {
|
||||||
|
row: Row;
|
||||||
|
exercises: LibraryExercise[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [row, setRow] = useState(initialRow);
|
||||||
|
|
||||||
|
// Poll while pending. 1.5s cadence — fast enough to feel live,
|
||||||
|
// gentle on the DB. Stops when status flips terminal.
|
||||||
|
useEffect(() => {
|
||||||
|
if (row.status !== 'pending') return;
|
||||||
|
let cancelled = false;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/ai/generations/${row.id}`);
|
||||||
|
if (!r.ok || cancelled) return;
|
||||||
|
const fresh = await r.json();
|
||||||
|
if (cancelled) return;
|
||||||
|
setRow({
|
||||||
|
...fresh,
|
||||||
|
createdAt:
|
||||||
|
typeof fresh.createdAt === 'string'
|
||||||
|
? fresh.createdAt
|
||||||
|
: new Date(fresh.createdAt).toISOString(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* transient — try again */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const id = setInterval(tick, 1500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [row.id, row.status]);
|
||||||
|
|
||||||
|
const cost = useMemo(
|
||||||
|
() =>
|
||||||
|
estimateCost({
|
||||||
|
provider: row.provider,
|
||||||
|
model: row.model,
|
||||||
|
tokensIn: row.tokensIn,
|
||||||
|
tokensOut: row.tokensOut,
|
||||||
|
}),
|
||||||
|
[row.provider, row.model, row.tokensIn, row.tokensOut],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Live partial during pending.
|
||||||
|
const partial = useMemo(
|
||||||
|
() =>
|
||||||
|
row.status === 'pending' && row.progressText
|
||||||
|
? (lenientJsonParse(row.progressText) as Partial<AIProgram> | null)
|
||||||
|
: null,
|
||||||
|
[row.status, row.progressText],
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedProgram = useMemo(
|
||||||
|
() =>
|
||||||
|
row.parsedProgram ? (JSON.parse(row.parsedProgram) as AIProgram) : null,
|
||||||
|
[row.parsedProgram],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Header / metadata */}
|
||||||
|
<header className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-zinc-500 uppercase tracking-wider flex-wrap">
|
||||||
|
<StatusPill status={row.status} />
|
||||||
|
<span>{new Date(row.createdAt).toLocaleString()}</span>
|
||||||
|
<span className="text-zinc-600">·</span>
|
||||||
|
<span>
|
||||||
|
{row.provider} · {row.model}
|
||||||
|
</span>
|
||||||
|
{row.tokensIn != null && (
|
||||||
|
<>
|
||||||
|
<span className="text-zinc-600">·</span>
|
||||||
|
<span>
|
||||||
|
{row.tokensIn} in · {row.tokensOut ?? '?'} out
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{cost != null && (
|
||||||
|
<>
|
||||||
|
<span className="text-zinc-600">·</span>
|
||||||
|
<span>{formatCost(cost)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{row.durationMs != null && (
|
||||||
|
<>
|
||||||
|
<span className="text-zinc-600">·</span>
|
||||||
|
<span>{formatDuration(row.durationMs)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{row.templateName && (
|
||||||
|
<p className="text-xs text-zinc-400">
|
||||||
|
Template: <span className="text-zinc-200">{row.templateName}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* User's prompt */}
|
||||||
|
<section className="bg-zinc-900 border border-zinc-800 rounded p-4">
|
||||||
|
<h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-2">
|
||||||
|
Your specifics
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-zinc-200 whitespace-pre-wrap">{row.userInput}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Pending: live preview */}
|
||||||
|
{row.status === 'pending' && (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="rounded bg-blue-950/30 border border-blue-900 px-4 py-3 text-xs text-blue-200">
|
||||||
|
<p className="font-bold text-blue-100 mb-1 flex items-center gap-2">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
Still generating…
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Polling every 1.5s for progress. Safe to leave this page —
|
||||||
|
the model keeps running on the server and you'll see the
|
||||||
|
result when you come back.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{partial ? (
|
||||||
|
<PartialTree partial={partial} />
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-zinc-500 italic flex items-center gap-2">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
Waiting for the first parseable JSON…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Failed */}
|
||||||
|
{row.status === 'failed' && (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="bg-red-950/40 border border-red-900 rounded p-3 text-sm text-red-300">
|
||||||
|
{row.errorMessage ?? 'Failed.'}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/main/ai/generate"
|
||||||
|
className="inline-block text-xs text-zinc-400 underline hover:text-white"
|
||||||
|
>
|
||||||
|
← Try again from Generate
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Applied — link to the program */}
|
||||||
|
{row.status === 'applied' && row.appliedProgramId && (
|
||||||
|
<section>
|
||||||
|
<Link
|
||||||
|
href={`/main/programs/${row.appliedProgramId}`}
|
||||||
|
className="inline-block px-4 py-2 rounded bg-emerald-700 text-white text-xs uppercase tracking-wider font-bold hover:bg-emerald-600"
|
||||||
|
>
|
||||||
|
View applied program →
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Completed (not yet applied) — show preview + Apply */}
|
||||||
|
{row.status === 'completed' && parsedProgram && (
|
||||||
|
<ProgramPreview
|
||||||
|
generationId={row.id}
|
||||||
|
program={parsedProgram}
|
||||||
|
exercises={exercises}
|
||||||
|
onApplied={(programId) => router.push(`/main/programs/${programId}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Raw response + prompts (collapsed) */}
|
||||||
|
{row.rawResponse && (
|
||||||
|
<details className="text-xs text-zinc-500">
|
||||||
|
<summary className="cursor-pointer">Raw model response</summary>
|
||||||
|
<pre className="bg-zinc-950 border border-zinc-800 rounded p-3 mt-2 whitespace-pre-wrap max-h-96 overflow-auto">
|
||||||
|
{row.rawResponse}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
<details className="text-xs text-zinc-500">
|
||||||
|
<summary className="cursor-pointer">Exact prompts sent</summary>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-zinc-400 uppercase tracking-wider mb-1">
|
||||||
|
System
|
||||||
|
</p>
|
||||||
|
<pre className="bg-zinc-950 border border-zinc-800 rounded p-3 whitespace-pre-wrap max-h-72 overflow-auto">
|
||||||
|
{row.systemPrompt}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-zinc-400 uppercase tracking-wider mb-1">
|
||||||
|
User
|
||||||
|
</p>
|
||||||
|
<pre className="bg-zinc-950 border border-zinc-800 rounded p-3 whitespace-pre-wrap max-h-72 overflow-auto">
|
||||||
|
{row.userPrompt}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgramPreview({
|
||||||
|
generationId,
|
||||||
|
program: initial,
|
||||||
|
exercises,
|
||||||
|
onApplied,
|
||||||
|
}: {
|
||||||
|
generationId: string;
|
||||||
|
program: AIProgram;
|
||||||
|
exercises: LibraryExercise[];
|
||||||
|
onApplied: (programId: string) => void;
|
||||||
|
}) {
|
||||||
|
const [program, setProgram] = useState<AIProgram>(initial);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [startDate, setStartDate] = useState(
|
||||||
|
new Date().toISOString().slice(0, 10),
|
||||||
|
);
|
||||||
|
const [activate, setActivate] = useState(true);
|
||||||
|
|
||||||
|
const exerciseLookup = useMemo(
|
||||||
|
() => new Map(exercises.map((e) => [e.id, e])),
|
||||||
|
[exercises],
|
||||||
|
);
|
||||||
|
const unresolvedCount = useMemo(() => {
|
||||||
|
let n = 0;
|
||||||
|
for (const w of program.weeks)
|
||||||
|
for (const d of w.days)
|
||||||
|
for (const ex of d.exercises) {
|
||||||
|
if (!ex.exerciseId || !exerciseLookup.has(ex.exerciseId)) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}, [program, exerciseLookup]);
|
||||||
|
|
||||||
|
const setExerciseId = (
|
||||||
|
weekIdx: number,
|
||||||
|
dayIdx: number,
|
||||||
|
exIdx: number,
|
||||||
|
newId: string | null,
|
||||||
|
) => {
|
||||||
|
setProgram((p) => {
|
||||||
|
const next = structuredClone(p);
|
||||||
|
next.weeks[weekIdx].days[dayIdx].exercises[exIdx].exerciseId = newId;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeExercise = (weekIdx: number, dayIdx: number, exIdx: number) => {
|
||||||
|
setProgram((p) => {
|
||||||
|
const next = structuredClone(p);
|
||||||
|
next.weeks[weekIdx].days[dayIdx].exercises.splice(exIdx, 1);
|
||||||
|
next.weeks[weekIdx].days[dayIdx].exercises.forEach(
|
||||||
|
(ex: AIExercise, i: number) => {
|
||||||
|
ex.order = i;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
if (unresolvedCount > 0) {
|
||||||
|
setError(
|
||||||
|
`Resolve all ${unresolvedCount} unknown exercise(s) before applying.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setApplying(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
generationId,
|
||||||
|
program,
|
||||||
|
startDate,
|
||||||
|
isActive: activate,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||||
|
onApplied(body.programId);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white">{program.name}</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
{program.type} · {program.durationWeeks} week
|
||||||
|
{program.durationWeeks === 1 ? '' : 's'} · {program.weeks.length}{' '}
|
||||||
|
week{program.weeks.length === 1 ? '' : 's'} planned
|
||||||
|
</p>
|
||||||
|
{program.description && (
|
||||||
|
<p className="text-sm text-zinc-300 mt-2">{program.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unresolvedCount > 0 && (
|
||||||
|
<div className="rounded bg-amber-950/30 border border-amber-900 px-3 py-2 text-xs text-amber-200">
|
||||||
|
{unresolvedCount} exercise(s) the AI couldn't map to your
|
||||||
|
library. Pick a replacement or remove them before applying.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{program.weeks.map((w, wIdx) => (
|
||||||
|
<details
|
||||||
|
key={w.weekNumber}
|
||||||
|
open={wIdx === 0}
|
||||||
|
className="bg-zinc-950 border border-zinc-800 rounded"
|
||||||
|
>
|
||||||
|
<summary className="cursor-pointer px-3 py-2 text-sm text-white">
|
||||||
|
Week {w.weekNumber}
|
||||||
|
{w.phase && <span className="text-zinc-500"> · {w.phase}</span>}
|
||||||
|
<span className="text-zinc-600 text-xs">
|
||||||
|
{' '}
|
||||||
|
({w.days.length} day{w.days.length === 1 ? '' : 's'})
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{w.days.map((d, dIdx) => (
|
||||||
|
<div
|
||||||
|
key={d.dayOfWeek}
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded p-3"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-semibold text-zinc-300 uppercase tracking-wider">
|
||||||
|
{DAY_LABELS[d.dayOfWeek]}
|
||||||
|
{d.name && (
|
||||||
|
<span className="text-zinc-500 normal-case font-normal">
|
||||||
|
{' '}
|
||||||
|
· {d.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 space-y-2">
|
||||||
|
{d.exercises.map((ex, eIdx) => {
|
||||||
|
const isUnknown =
|
||||||
|
!ex.exerciseId || !exerciseLookup.has(ex.exerciseId);
|
||||||
|
const lib = ex.exerciseId
|
||||||
|
? exerciseLookup.get(ex.exerciseId)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={eIdx}
|
||||||
|
className={`text-sm ${
|
||||||
|
isUnknown
|
||||||
|
? 'bg-amber-950/30 border border-amber-900'
|
||||||
|
: 'bg-zinc-950 border border-zinc-800'
|
||||||
|
} rounded p-2`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-white">
|
||||||
|
{lib?.name ?? ex.exerciseName}
|
||||||
|
{isUnknown && (
|
||||||
|
<span className="ml-2 text-[10px] uppercase tracking-wider text-amber-400">
|
||||||
|
not in library
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(ex.sets || ex.repsMin || ex.repsMax || ex.rpe || ex.restSeconds || ex.suggestedWeight) && (
|
||||||
|
<div className="text-xs text-zinc-500 mt-0.5">
|
||||||
|
{ex.sets ? `${ex.sets}×` : ''}
|
||||||
|
{ex.repsMin === ex.repsMax || !ex.repsMax
|
||||||
|
? (ex.repsMin ?? '?')
|
||||||
|
: `${ex.repsMin}-${ex.repsMax}`}
|
||||||
|
{ex.suggestedWeight != null && (
|
||||||
|
<> @ {ex.suggestedWeight}{ex.suggestedWeightUnit ?? ''}</>
|
||||||
|
)}
|
||||||
|
{ex.rpe ? ` @ RPE ${ex.rpe}` : ''}
|
||||||
|
{ex.restSeconds ? ` · rest ${ex.restSeconds}s` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ex.notes && (
|
||||||
|
<div className="text-xs text-zinc-400 mt-1 italic">
|
||||||
|
{ex.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeExercise(wIdx, dIdx, eIdx)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 px-1"
|
||||||
|
title="Remove from program"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isUnknown && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<select
|
||||||
|
value={ex.exerciseId ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setExerciseId(wIdx, dIdx, eIdx, e.target.value || null)
|
||||||
|
}
|
||||||
|
className="w-full text-xs px-2 py-1 rounded border border-amber-900 bg-zinc-900 text-white"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
Map to existing exercise…
|
||||||
|
</option>
|
||||||
|
{exercises.map((opt) => (
|
||||||
|
<option key={opt.id} value={opt.id}>
|
||||||
|
{opt.name} ({opt.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-zinc-800 pt-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider block mb-1">
|
||||||
|
Start date
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded border border-zinc-700 bg-zinc-800 text-white"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-end gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={activate}
|
||||||
|
onChange={(e) => setActivate(e.target.checked)}
|
||||||
|
className="mb-2"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-zinc-300 mb-2">
|
||||||
|
Activate this program after applying
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={applying || unresolvedCount > 0}
|
||||||
|
className="px-5 py-2 rounded bg-emerald-700 text-white font-bold text-xs uppercase tracking-wider hover:bg-emerald-600 disabled:bg-zinc-700 disabled:text-zinc-500"
|
||||||
|
>
|
||||||
|
{applying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="inline w-4 h-4 animate-spin mr-2" />
|
||||||
|
Applying…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Apply this program'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PartialTree({ partial }: { partial: Partial<AIProgram> }) {
|
||||||
|
const weeks = (partial.weeks as AIWeek[] | undefined) ?? [];
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 space-y-2">
|
||||||
|
<div className="text-xs">
|
||||||
|
{partial.name && (
|
||||||
|
<span className="text-white font-semibold">{partial.name}</span>
|
||||||
|
)}
|
||||||
|
{partial.type && (
|
||||||
|
<span className="text-zinc-500"> · {partial.type}</span>
|
||||||
|
)}
|
||||||
|
{typeof partial.durationWeeks === 'number' && (
|
||||||
|
<span className="text-zinc-500"> · {partial.durationWeeks} wk</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{weeks.length > 0 && (
|
||||||
|
<ul className="text-xs text-zinc-300 space-y-1">
|
||||||
|
{weeks.map((w, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<span className="text-zinc-500">Week {w?.weekNumber ?? '?'}:</span>{' '}
|
||||||
|
{Array.isArray(w?.days)
|
||||||
|
? `${w.days.length} day${w.days.length === 1 ? '' : 's'} (${w.days.reduce(
|
||||||
|
(n: number, d: AIDay) =>
|
||||||
|
n + (Array.isArray(d?.exercises) ? d.exercises.length : 0),
|
||||||
|
0,
|
||||||
|
)} exercises)`
|
||||||
|
: '…'}
|
||||||
|
{w?.phase && <span className="text-zinc-500"> · {w.phase}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
const map: Record<string, { color: string; label: string }> = {
|
||||||
|
pending: { color: 'text-zinc-400 bg-zinc-800', label: 'pending' },
|
||||||
|
completed: { color: 'text-emerald-400 bg-emerald-950', label: 'completed' },
|
||||||
|
applied: { color: 'text-emerald-400 bg-emerald-950', label: 'applied' },
|
||||||
|
failed: { color: 'text-red-400 bg-red-950', label: 'failed' },
|
||||||
|
};
|
||||||
|
const m = map[status] ?? map.pending;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 ${m.color} rounded px-2 py-0.5 text-[10px]`}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
const m = Math.floor(ms / 60_000);
|
||||||
|
const s = Math.round((ms % 60_000) / 1000);
|
||||||
|
return `${m}m ${s}s`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Trash2, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { estimateCost, formatCost } from '@/lib/ai/pricing';
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
templateName: string | null;
|
||||||
|
userInput: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
tokensIn: number | null;
|
||||||
|
tokensOut: number | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
status: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
appliedProgramId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HistoryList({
|
||||||
|
initialRows,
|
||||||
|
}: {
|
||||||
|
initialRows: Row[];
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState(initialRows);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Per-row cost + 30-day rolling total. Pricing is best-effort
|
||||||
|
// (Ollama = free, openai-compatible = unknown, others priced
|
||||||
|
// from lib/ai/pricing.ts). Free + unknown both contribute 0 to
|
||||||
|
// the total so it's a lower bound at worst.
|
||||||
|
const rowsWithCost = useMemo(
|
||||||
|
() =>
|
||||||
|
rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
costUsd: estimateCost({
|
||||||
|
provider: r.provider,
|
||||||
|
model: r.model,
|
||||||
|
tokensIn: r.tokensIn,
|
||||||
|
tokensOut: r.tokensOut,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
[rows],
|
||||||
|
);
|
||||||
|
const totalLast30Days = useMemo(() => {
|
||||||
|
const cutoff = Date.now() - 30 * 86_400_000;
|
||||||
|
let total = 0;
|
||||||
|
for (const r of rowsWithCost) {
|
||||||
|
if (r.costUsd != null && new Date(r.createdAt).getTime() >= cutoff) {
|
||||||
|
total += r.costUsd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}, [rowsWithCost]);
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Delete this generation? The applied Program (if any) stays.'))
|
||||||
|
return;
|
||||||
|
setBusyId(id);
|
||||||
|
const r = await fetch(`/api/ai/generations/${id}`, { method: 'DELETE' });
|
||||||
|
setBusyId(null);
|
||||||
|
if (r.ok) setRows((rs) => rs.filter((x) => x.id !== id));
|
||||||
|
else alert((await r.json().catch(() => ({}))).error ?? 'Delete failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-center text-sm text-zinc-500 py-12">
|
||||||
|
No AI generations yet.{' '}
|
||||||
|
<Link href="/main/ai/generate" className="text-white underline">
|
||||||
|
Generate your first program →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-zinc-500 mb-4 uppercase tracking-wider">
|
||||||
|
Estimated cost (last 30 days):{' '}
|
||||||
|
<span className="text-zinc-200">{formatCost(totalLast30Days)}</span>
|
||||||
|
<span className="text-zinc-600">
|
||||||
|
{' '}
|
||||||
|
· Ollama + custom-URL gateways excluded
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{rowsWithCost.map((r) => (
|
||||||
|
<li
|
||||||
|
key={r.id}
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/main/ai/history/${r.id}`}
|
||||||
|
className="min-w-0 flex-1 hover:bg-zinc-800/30 -m-2 p-2 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-zinc-500 uppercase tracking-wider flex-wrap">
|
||||||
|
<StatusBadge status={r.status} />
|
||||||
|
<span>{new Date(r.createdAt).toLocaleString()}</span>
|
||||||
|
<span className="text-zinc-600">·</span>
|
||||||
|
<span>
|
||||||
|
{r.provider} · {r.model}
|
||||||
|
</span>
|
||||||
|
{r.tokensIn != null && (
|
||||||
|
<>
|
||||||
|
<span className="text-zinc-600">·</span>
|
||||||
|
<span>
|
||||||
|
{r.tokensIn} in · {r.tokensOut ?? '?'} out
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{r.costUsd != null && (
|
||||||
|
<>
|
||||||
|
<span className="text-zinc-600">·</span>
|
||||||
|
<span title="Estimated USD cost based on the model's published per-token pricing">
|
||||||
|
{formatCost(r.costUsd)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{r.durationMs != null && (
|
||||||
|
<>
|
||||||
|
<span className="text-zinc-600">·</span>
|
||||||
|
<span title="Wall-clock generation time">
|
||||||
|
{formatDuration(r.durationMs)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{r.templateName && (
|
||||||
|
<p className="text-xs text-zinc-400 mt-1">
|
||||||
|
Template: <span className="text-zinc-200">{r.templateName}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-zinc-200 mt-2 line-clamp-3">
|
||||||
|
{r.userInput}
|
||||||
|
</p>
|
||||||
|
{r.errorMessage && (
|
||||||
|
<p className="text-xs text-red-400 mt-2">
|
||||||
|
Error: {r.errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{r.appliedProgramId && (
|
||||||
|
<span className="inline-block text-xs text-emerald-400 mt-2">
|
||||||
|
✓ applied to a program
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(r.id)}
|
||||||
|
disabled={busyId === r.id}
|
||||||
|
className="p-1.5 text-red-400 hover:text-red-300 disabled:opacity-50"
|
||||||
|
title="Delete this generation"
|
||||||
|
>
|
||||||
|
{busyId === r.id ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
const m = Math.floor(ms / 60_000);
|
||||||
|
const s = Math.round((ms % 60_000) / 1000);
|
||||||
|
return `${m}m ${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const map: Record<string, { color: string; icon: typeof CheckCircle2 }> = {
|
||||||
|
pending: { color: 'text-zinc-400', icon: Loader2 },
|
||||||
|
completed: { color: 'text-emerald-400', icon: CheckCircle2 },
|
||||||
|
applied: { color: 'text-emerald-400', icon: CheckCircle2 },
|
||||||
|
failed: { color: 'text-red-400', icon: AlertCircle },
|
||||||
|
};
|
||||||
|
const { color, icon: Icon } = map[status] ?? map.pending;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 ${color}`}>
|
||||||
|
<Icon className="w-3 h-3" />
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, Trash2, Pencil, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface T {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
systemPrompt: string;
|
||||||
|
userPromptTemplate: string;
|
||||||
|
isBuiltIn: boolean;
|
||||||
|
isMine: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TemplatesList({
|
||||||
|
initialTemplates,
|
||||||
|
isAdmin,
|
||||||
|
}: {
|
||||||
|
initialTemplates: T[];
|
||||||
|
isAdmin: boolean;
|
||||||
|
}) {
|
||||||
|
const [templates, setTemplates] = useState(initialTemplates);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
const r = await fetch('/api/ai/templates');
|
||||||
|
if (r.ok) {
|
||||||
|
const list = (await r.json()) as Array<T & { userId: string | null }>;
|
||||||
|
// Mark isMine on the client by checking who owns each.
|
||||||
|
// The API already returns built-ins + this user's own — we
|
||||||
|
// can distinguish them by isBuiltIn flag.
|
||||||
|
setTemplates(
|
||||||
|
list.map((t) => ({
|
||||||
|
...t,
|
||||||
|
isMine: !t.isBuiltIn,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded bg-white text-black font-bold text-xs uppercase tracking-wider hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
New template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<TemplateEditor
|
||||||
|
mode="create"
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setCreating(false);
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{templates.map((t) => {
|
||||||
|
const editable = t.isMine || (t.isBuiltIn && isAdmin);
|
||||||
|
if (editingId === t.id) {
|
||||||
|
return (
|
||||||
|
<li key={t.id}>
|
||||||
|
<TemplateEditor
|
||||||
|
mode="edit"
|
||||||
|
template={t}
|
||||||
|
onClose={() => setEditingId(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={t.id}
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-base font-semibold text-white">
|
||||||
|
{t.name}
|
||||||
|
{t.isBuiltIn && (
|
||||||
|
<span className="ml-2 text-[10px] uppercase tracking-wider bg-zinc-800 text-zinc-300 px-1.5 py-0.5 rounded font-normal">
|
||||||
|
built-in
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
{t.description && (
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
{t.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{editable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingId(t.id)}
|
||||||
|
className="p-1.5 text-zinc-400 hover:text-white"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{editable && (
|
||||||
|
<DeleteButton
|
||||||
|
templateId={t.id}
|
||||||
|
isBuiltIn={t.isBuiltIn}
|
||||||
|
onDeleted={() => refresh()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details className="mt-2">
|
||||||
|
<summary className="cursor-pointer text-[11px] text-zinc-500 uppercase tracking-wider">
|
||||||
|
Show prompt
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<p className="text-zinc-500 uppercase tracking-wider mb-1">
|
||||||
|
System
|
||||||
|
</p>
|
||||||
|
<pre className="bg-zinc-950 border border-zinc-800 rounded p-2 whitespace-pre-wrap text-zinc-300">
|
||||||
|
{t.systemPrompt}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-zinc-500 uppercase tracking-wider mb-1">
|
||||||
|
User template
|
||||||
|
</p>
|
||||||
|
<pre className="bg-zinc-950 border border-zinc-800 rounded p-2 whitespace-pre-wrap text-zinc-300">
|
||||||
|
{t.userPromptTemplate}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteButton({
|
||||||
|
templateId,
|
||||||
|
isBuiltIn,
|
||||||
|
onDeleted,
|
||||||
|
}: {
|
||||||
|
templateId: string;
|
||||||
|
isBuiltIn: boolean;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
isBuiltIn
|
||||||
|
? 'Delete this built-in template? It will be re-created from the package JSON on next boot unless you also remove it from prisma/aiTemplates.seed.json.'
|
||||||
|
: 'Delete this template?',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
setBusy(true);
|
||||||
|
const r = await fetch(`/api/ai/templates/${templateId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) onDeleted();
|
||||||
|
else {
|
||||||
|
const b = await r.json().catch(() => ({}));
|
||||||
|
alert(b.error ?? `HTTP ${r.status}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-red-400 hover:text-red-300"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateEditor({
|
||||||
|
mode,
|
||||||
|
template,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
template?: T;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(template?.name ?? '');
|
||||||
|
const [description, setDescription] = useState(template?.description ?? '');
|
||||||
|
const [systemPrompt, setSystemPrompt] = useState(template?.systemPrompt ?? '');
|
||||||
|
const [userPromptTemplate, setUserPromptTemplate] = useState(
|
||||||
|
template?.userPromptTemplate ?? '{{userInput}}',
|
||||||
|
);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim() || !systemPrompt.trim() || !userPromptTemplate.trim()) {
|
||||||
|
setError('Name, system prompt, and user prompt template are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
description: description || null,
|
||||||
|
systemPrompt,
|
||||||
|
userPromptTemplate,
|
||||||
|
};
|
||||||
|
const url =
|
||||||
|
mode === 'create'
|
||||||
|
? '/api/ai/templates'
|
||||||
|
: `/api/ai/templates/${template!.id}`;
|
||||||
|
const method = mode === 'create' ? 'POST' : 'PATCH';
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const b = await r.json().catch(() => ({}));
|
||||||
|
throw new Error(b.error ?? `HTTP ${r.status}`);
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900 border border-emerald-900 rounded p-4 space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-white uppercase tracking-wider">
|
||||||
|
{mode === 'create' ? 'New template' : `Edit: ${template!.name}`}
|
||||||
|
</h3>
|
||||||
|
<Field label="Name">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="My hypertrophy block"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Description (optional)">
|
||||||
|
<input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="What this template is for"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="System prompt (the role / constraints sent to the model)">
|
||||||
|
<textarea
|
||||||
|
value={systemPrompt}
|
||||||
|
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="User prompt template (use {{userInput}} for the user's specifics)">
|
||||||
|
<textarea
|
||||||
|
value={userPromptTemplate}
|
||||||
|
onChange={(e) => setUserPromptTemplate(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-4 py-2 rounded bg-white text-black font-bold text-xs uppercase tracking-wider hover:bg-gray-100 disabled:bg-zinc-700"
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="inline w-3.5 h-3.5 animate-spin mr-1" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-4 py-2 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800 text-xs uppercase tracking-wider disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full px-3 py-2 text-sm rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white/30';
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider block mb-1">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ProgramActions({ programId }: { programId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
'Delete this program? Workouts you logged against it stay; only the plan goes away.',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await fetch(`/api/programs/${programId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
router.push('/main/programs');
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
alert(body.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={pending}
|
||||||
|
className="text-xs px-3 py-1.5 rounded border border-red-900 text-red-400 hover:bg-red-900/30 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="inline w-3.5 h-3.5 mr-1" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,686 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Plus, Trash2, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
|
interface LibraryExercise {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftExercise {
|
||||||
|
exerciseId: string;
|
||||||
|
order: number;
|
||||||
|
sets?: number | null;
|
||||||
|
repsMin?: number | null;
|
||||||
|
repsMax?: number | null;
|
||||||
|
rpe?: number | null;
|
||||||
|
restSeconds?: number | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
export interface DraftDay {
|
||||||
|
dayOfWeek: number;
|
||||||
|
name?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
exercises: DraftExercise[];
|
||||||
|
}
|
||||||
|
export interface DraftWeek {
|
||||||
|
weekNumber: number;
|
||||||
|
phase?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
days: DraftDay[];
|
||||||
|
}
|
||||||
|
export interface DraftProgram {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
type: string;
|
||||||
|
durationWeeks: number;
|
||||||
|
startDate: string; // ISO yyyy-mm-dd
|
||||||
|
isActive: boolean;
|
||||||
|
weeks: DraftWeek[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = [
|
||||||
|
'hypertrophy',
|
||||||
|
'strength',
|
||||||
|
'power',
|
||||||
|
'endurance',
|
||||||
|
'recovery',
|
||||||
|
'general',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ProgramEditor({
|
||||||
|
initialProgram,
|
||||||
|
programId,
|
||||||
|
exercises,
|
||||||
|
}: {
|
||||||
|
initialProgram?: DraftProgram;
|
||||||
|
programId?: string; // if present, this is an edit; otherwise create
|
||||||
|
exercises: LibraryExercise[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [program, setProgram] = useState<DraftProgram>(
|
||||||
|
initialProgram ?? {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 8,
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
isActive: false,
|
||||||
|
weeks: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Track expansion state for the tree
|
||||||
|
const [openWeeks, setOpenWeeks] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
const update = (patch: Partial<DraftProgram>) =>
|
||||||
|
setProgram((p) => ({ ...p, ...patch }));
|
||||||
|
|
||||||
|
const addWeek = () => {
|
||||||
|
const nextNum = Math.max(0, ...program.weeks.map((w) => w.weekNumber)) + 1;
|
||||||
|
setProgram((p) => ({
|
||||||
|
...p,
|
||||||
|
weeks: [...p.weeks, { weekNumber: nextNum, phase: null, days: [] }].sort(
|
||||||
|
(a, b) => a.weekNumber - b.weekNumber,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
setOpenWeeks((s) => ({ ...s, [nextNum]: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeWeek = (weekNumber: number) => {
|
||||||
|
if (!confirm(`Remove week ${weekNumber}?`)) return;
|
||||||
|
setProgram((p) => ({
|
||||||
|
...p,
|
||||||
|
weeks: p.weeks.filter((w) => w.weekNumber !== weekNumber),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateWeek = (weekNumber: number, patch: Partial<DraftWeek>) =>
|
||||||
|
setProgram((p) => ({
|
||||||
|
...p,
|
||||||
|
weeks: p.weeks.map((w) =>
|
||||||
|
w.weekNumber === weekNumber ? { ...w, ...patch } : w,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const addDay = (weekNumber: number, dayOfWeek: number) => {
|
||||||
|
setProgram((p) => ({
|
||||||
|
...p,
|
||||||
|
weeks: p.weeks.map((w) =>
|
||||||
|
w.weekNumber === weekNumber
|
||||||
|
? {
|
||||||
|
...w,
|
||||||
|
days: [
|
||||||
|
...w.days,
|
||||||
|
{ dayOfWeek, name: null, description: null, exercises: [] },
|
||||||
|
].sort((a, b) => a.dayOfWeek - b.dayOfWeek),
|
||||||
|
}
|
||||||
|
: w,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDay = (weekNumber: number, dayOfWeek: number) =>
|
||||||
|
setProgram((p) => ({
|
||||||
|
...p,
|
||||||
|
weeks: p.weeks.map((w) =>
|
||||||
|
w.weekNumber === weekNumber
|
||||||
|
? { ...w, days: w.days.filter((d) => d.dayOfWeek !== dayOfWeek) }
|
||||||
|
: w,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updateDay = (
|
||||||
|
weekNumber: number,
|
||||||
|
dayOfWeek: number,
|
||||||
|
patch: Partial<DraftDay>,
|
||||||
|
) =>
|
||||||
|
setProgram((p) => ({
|
||||||
|
...p,
|
||||||
|
weeks: p.weeks.map((w) =>
|
||||||
|
w.weekNumber === weekNumber
|
||||||
|
? {
|
||||||
|
...w,
|
||||||
|
days: w.days.map((d) =>
|
||||||
|
d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: w,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const addExercise = (
|
||||||
|
weekNumber: number,
|
||||||
|
dayOfWeek: number,
|
||||||
|
exerciseId: string,
|
||||||
|
) => {
|
||||||
|
setProgram((p) => ({
|
||||||
|
...p,
|
||||||
|
weeks: p.weeks.map((w) =>
|
||||||
|
w.weekNumber === weekNumber
|
||||||
|
? {
|
||||||
|
...w,
|
||||||
|
days: w.days.map((d) =>
|
||||||
|
d.dayOfWeek === dayOfWeek
|
||||||
|
? {
|
||||||
|
...d,
|
||||||
|
exercises: [
|
||||||
|
...d.exercises,
|
||||||
|
{
|
||||||
|
exerciseId,
|
||||||
|
order: d.exercises.length,
|
||||||
|
sets: 3,
|
||||||
|
repsMin: 8,
|
||||||
|
repsMax: 12,
|
||||||
|
rpe: null,
|
||||||
|
restSeconds: 90,
|
||||||
|
notes: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: d,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: w,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateExercise = (
|
||||||
|
weekNumber: number,
|
||||||
|
dayOfWeek: number,
|
||||||
|
order: number,
|
||||||
|
patch: Partial<DraftExercise>,
|
||||||
|
) =>
|
||||||
|
setProgram((p) => ({
|
||||||
|
...p,
|
||||||
|
weeks: p.weeks.map((w) =>
|
||||||
|
w.weekNumber === weekNumber
|
||||||
|
? {
|
||||||
|
...w,
|
||||||
|
days: w.days.map((d) =>
|
||||||
|
d.dayOfWeek === dayOfWeek
|
||||||
|
? {
|
||||||
|
...d,
|
||||||
|
exercises: d.exercises.map((ex) =>
|
||||||
|
ex.order === order ? { ...ex, ...patch } : ex,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: d,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: w,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const removeExercise = (
|
||||||
|
weekNumber: number,
|
||||||
|
dayOfWeek: number,
|
||||||
|
order: number,
|
||||||
|
) =>
|
||||||
|
setProgram((p) => ({
|
||||||
|
...p,
|
||||||
|
weeks: p.weeks.map((w) =>
|
||||||
|
w.weekNumber === weekNumber
|
||||||
|
? {
|
||||||
|
...w,
|
||||||
|
days: w.days.map((d) =>
|
||||||
|
d.dayOfWeek === dayOfWeek
|
||||||
|
? {
|
||||||
|
...d,
|
||||||
|
exercises: d.exercises
|
||||||
|
.filter((ex) => ex.order !== order)
|
||||||
|
.map((ex, idx) => ({ ...ex, order: idx })),
|
||||||
|
}
|
||||||
|
: d,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: w,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!program.name.trim()) {
|
||||||
|
setError('Program name is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const url = programId ? `/api/programs/${programId}` : '/api/programs';
|
||||||
|
const method = programId ? 'PATCH' : 'POST';
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(program),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const saved = await res.json();
|
||||||
|
router.push(`/main/programs/${saved.id}`);
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exerciseLookup = new Map(exercises.map((e) => [e.id, e]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Top-level metadata */}
|
||||||
|
<section className="bg-zinc-900 border border-zinc-800 rounded p-4 space-y-4">
|
||||||
|
<Field label="Name">
|
||||||
|
<input
|
||||||
|
value={program.name}
|
||||||
|
onChange={(e) => update({ name: e.target.value })}
|
||||||
|
placeholder="8 Week Hypertrophy"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Description (optional)">
|
||||||
|
<textarea
|
||||||
|
value={program.description ?? ''}
|
||||||
|
onChange={(e) => update({ description: e.target.value || null })}
|
||||||
|
rows={2}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<Field label="Type">
|
||||||
|
<select
|
||||||
|
value={program.type}
|
||||||
|
onChange={(e) => update({ type: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{TYPE_OPTIONS.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Duration (weeks)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={52}
|
||||||
|
value={program.durationWeeks}
|
||||||
|
onChange={(e) =>
|
||||||
|
update({ durationWeeks: parseInt(e.target.value || '1', 10) })
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Start date">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={program.startDate}
|
||||||
|
onChange={(e) => update({ startDate: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Active">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => update({ isActive: !program.isActive })}
|
||||||
|
className={`mt-1 inline-flex items-center h-9 px-3 rounded border text-xs uppercase tracking-wider ${
|
||||||
|
program.isActive
|
||||||
|
? 'bg-emerald-900/40 border-emerald-800 text-emerald-300'
|
||||||
|
: 'border-zinc-700 text-zinc-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{program.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</button>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Weeks */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
|
||||||
|
Weeks ({program.weeks.length})
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addWeek}
|
||||||
|
className="text-xs px-3 py-1.5 rounded border border-zinc-700 text-white hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<Plus className="inline w-3.5 h-3.5 mr-1" />
|
||||||
|
Add week
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{program.weeks.length === 0 && (
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
No weeks yet. Click <strong>Add week</strong> to start the plan.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{program.weeks.map((w) => (
|
||||||
|
<div
|
||||||
|
key={w.weekNumber}
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-zinc-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setOpenWeeks((s) => ({ ...s, [w.weekNumber]: !s[w.weekNumber] }))
|
||||||
|
}
|
||||||
|
className="flex items-center gap-2 text-sm text-white"
|
||||||
|
>
|
||||||
|
{openWeeks[w.weekNumber] ? (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Week {w.weekNumber}
|
||||||
|
{w.phase && (
|
||||||
|
<span className="text-xs text-zinc-500">· {w.phase}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-zinc-600">
|
||||||
|
({w.days.length} day{w.days.length === 1 ? '' : 's'})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeWeek(w.weekNumber)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openWeeks[w.weekNumber] && (
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<Field label="Phase (optional)">
|
||||||
|
<input
|
||||||
|
value={w.phase ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateWeek(w.weekNumber, {
|
||||||
|
phase: e.target.value || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Volume, intensity, deload..."
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Notes (optional)">
|
||||||
|
<input
|
||||||
|
value={w.description ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateWeek(w.weekNumber, {
|
||||||
|
description: e.target.value || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day picker */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-zinc-500 uppercase tracking-wider">
|
||||||
|
Add day:
|
||||||
|
</span>
|
||||||
|
{DAY_LABELS.map((label, idx) => {
|
||||||
|
const exists = w.days.some((d) => d.dayOfWeek === idx);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
disabled={exists}
|
||||||
|
onClick={() => addDay(w.weekNumber, idx)}
|
||||||
|
className={`text-xs px-2 py-1 rounded border ${
|
||||||
|
exists
|
||||||
|
? 'border-zinc-800 text-zinc-700 cursor-not-allowed'
|
||||||
|
: 'border-zinc-700 text-zinc-300 hover:bg-zinc-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Days */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{w.days.map((d) => (
|
||||||
|
<DayRow
|
||||||
|
key={d.dayOfWeek}
|
||||||
|
day={d}
|
||||||
|
weekNumber={w.weekNumber}
|
||||||
|
exercises={exercises}
|
||||||
|
exerciseLookup={exerciseLookup}
|
||||||
|
onUpdateDay={(patch) => updateDay(w.weekNumber, d.dayOfWeek, patch)}
|
||||||
|
onRemoveDay={() => removeDay(w.weekNumber, d.dayOfWeek)}
|
||||||
|
onAddExercise={(exId) => addExercise(w.weekNumber, d.dayOfWeek, exId)}
|
||||||
|
onUpdateExercise={(order, patch) =>
|
||||||
|
updateExercise(w.weekNumber, d.dayOfWeek, order, patch)
|
||||||
|
}
|
||||||
|
onRemoveExercise={(order) =>
|
||||||
|
removeExercise(w.weekNumber, d.dayOfWeek, order)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-900/50 px-4 py-3 border border-red-800 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-5 py-2 rounded bg-white text-black font-bold text-sm uppercase tracking-wider hover:bg-gray-100 disabled:bg-zinc-700 disabled:text-zinc-500"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="inline w-4 h-4 animate-spin mr-2" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : programId ? (
|
||||||
|
'Save changes'
|
||||||
|
) : (
|
||||||
|
'Create program'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayRow({
|
||||||
|
day,
|
||||||
|
weekNumber: _weekNumber,
|
||||||
|
exercises,
|
||||||
|
exerciseLookup,
|
||||||
|
onUpdateDay,
|
||||||
|
onRemoveDay,
|
||||||
|
onAddExercise,
|
||||||
|
onUpdateExercise,
|
||||||
|
onRemoveExercise,
|
||||||
|
}: {
|
||||||
|
day: DraftDay;
|
||||||
|
weekNumber: number;
|
||||||
|
exercises: LibraryExercise[];
|
||||||
|
exerciseLookup: Map<string, LibraryExercise>;
|
||||||
|
onUpdateDay: (patch: Partial<DraftDay>) => void;
|
||||||
|
onRemoveDay: () => void;
|
||||||
|
onAddExercise: (exerciseId: string) => void;
|
||||||
|
onUpdateExercise: (order: number, patch: Partial<DraftExercise>) => void;
|
||||||
|
onRemoveExercise: (order: number) => void;
|
||||||
|
}) {
|
||||||
|
const [pickerValue, setPickerValue] = useState('');
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-950 border border-zinc-800 rounded p-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-semibold text-zinc-300 uppercase tracking-wider">
|
||||||
|
{DAY_LABELS[day.dayOfWeek]}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={day.name ?? ''}
|
||||||
|
onChange={(e) => onUpdateDay({ name: e.target.value || null })}
|
||||||
|
placeholder="Day name (Push, Pull, Lower, etc.)"
|
||||||
|
className="flex-1 px-2 py-1 text-sm rounded border border-zinc-700 bg-zinc-900 text-white placeholder:text-zinc-600"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemoveDay}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 p-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{day.exercises.map((ex) => {
|
||||||
|
const lib = exerciseLookup.get(ex.exerciseId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ex.order}
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded p-2 space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm text-white">
|
||||||
|
{lib?.name ?? '(missing exercise)'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemoveExercise(ex.order)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
|
||||||
|
<NumField
|
||||||
|
label="Sets"
|
||||||
|
value={ex.sets ?? null}
|
||||||
|
onChange={(v) => onUpdateExercise(ex.order, { sets: v })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label="Reps min"
|
||||||
|
value={ex.repsMin ?? null}
|
||||||
|
onChange={(v) => onUpdateExercise(ex.order, { repsMin: v })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label="Reps max"
|
||||||
|
value={ex.repsMax ?? null}
|
||||||
|
onChange={(v) => onUpdateExercise(ex.order, { repsMax: v })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label="RPE"
|
||||||
|
value={ex.rpe ?? null}
|
||||||
|
onChange={(v) => onUpdateExercise(ex.order, { rpe: v })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label="Rest (s)"
|
||||||
|
value={ex.restSeconds ?? null}
|
||||||
|
onChange={(v) => onUpdateExercise(ex.order, { restSeconds: v })}
|
||||||
|
/>
|
||||||
|
<Field label="Notes">
|
||||||
|
<input
|
||||||
|
value={ex.notes ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateExercise(ex.order, { notes: e.target.value || null })
|
||||||
|
}
|
||||||
|
className="w-full px-2 py-1 text-xs rounded border border-zinc-700 bg-zinc-800 text-white"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={pickerValue}
|
||||||
|
onChange={(e) => setPickerValue(e.target.value)}
|
||||||
|
className="flex-1 px-2 py-1 text-sm rounded border border-zinc-700 bg-zinc-900 text-white"
|
||||||
|
>
|
||||||
|
<option value="">Add exercise...</option>
|
||||||
|
{exercises.map((e) => (
|
||||||
|
<option key={e.id} value={e.id}>
|
||||||
|
{e.name} ({e.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!pickerValue}
|
||||||
|
onClick={() => {
|
||||||
|
if (pickerValue) {
|
||||||
|
onAddExercise(pickerValue);
|
||||||
|
setPickerValue('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-xs px-3 py-1.5 rounded border border-zinc-700 text-white hover:bg-zinc-800 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Plus className="inline w-3.5 h-3.5 mr-1" />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full px-3 py-2 text-sm rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white/30';
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider block mb-1">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | null;
|
||||||
|
onChange: (v: number | null) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Field label={label}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
onChange(v === '' ? null : Number(v));
|
||||||
|
}}
|
||||||
|
className="w-full px-2 py-1 text-xs rounded border border-zinc-700 bg-zinc-800 text-white"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function StartSessionButton({
|
||||||
|
programId,
|
||||||
|
dayId,
|
||||||
|
}: {
|
||||||
|
programId: string;
|
||||||
|
dayId: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/programs/${programId}/days/${dayId}/start`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
alert(body.error ?? `HTTP ${res.status}`);
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const workout = await res.json();
|
||||||
|
router.push(`/main/workouts/${workout.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
alert((e as Error).message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={busy}
|
||||||
|
className="mt-3 inline-block text-xs uppercase tracking-wider px-3 py-1.5 rounded bg-emerald-700 hover:bg-emerald-600 text-white font-bold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="inline w-3.5 h-3.5 animate-spin mr-1" />
|
||||||
|
Starting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Start this session →'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,770 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Loader2, Plus, Trash2, Star } from 'lucide-react';
|
||||||
|
import { MODEL_MENU } from '@/lib/ai/pricing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.1.0:4 — Multi-config AI integration panel.
|
||||||
|
*
|
||||||
|
* Lets the user save multiple AI configurations (one per provider, or
|
||||||
|
* several of the same provider with different models) and toggle one
|
||||||
|
* as active. Per-config "Test connection" so you can verify before
|
||||||
|
* activating. Dropdowns of recommended models for major providers.
|
||||||
|
* Ollama auto-detect: probes the StartOS internal address + offers a
|
||||||
|
* dropdown of installed models when reachable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PROVIDERS = [
|
||||||
|
{ id: 'claude', label: 'Anthropic Claude', requiresKey: true, requiresUrl: false },
|
||||||
|
{ id: 'openai', label: 'OpenAI', requiresKey: true, requiresUrl: false },
|
||||||
|
{
|
||||||
|
id: 'openai-compatible',
|
||||||
|
label: 'OpenAI-compatible (custom URL)',
|
||||||
|
requiresKey: true,
|
||||||
|
requiresUrl: true,
|
||||||
|
},
|
||||||
|
{ id: 'gemini', label: 'Google Gemini', requiresKey: true, requiresUrl: false },
|
||||||
|
{ id: 'ollama', label: 'Ollama (self-hosted)', requiresKey: false, requiresUrl: true },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ProviderId = (typeof PROVIDERS)[number]['id'];
|
||||||
|
|
||||||
|
interface SavedConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider: ProviderId;
|
||||||
|
model: string;
|
||||||
|
baseUrl: string | null;
|
||||||
|
keyConfigured: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestResult =
|
||||||
|
| { ok: true; sample: string; tokensIn?: number; tokensOut?: number; ms: number }
|
||||||
|
| { ok: false; error: string; ms?: number };
|
||||||
|
|
||||||
|
export default function AIIntegration() {
|
||||||
|
const [configs, setConfigs] = useState<SavedConfig[]>([]);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/ai/configs');
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
const body = await r.json();
|
||||||
|
setConfigs(body.configs ?? []);
|
||||||
|
setActiveId(body.activeId ?? null);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleActivate = async (id: string) => {
|
||||||
|
const r = await fetch(`/api/ai/configs/${id}/activate`, { method: 'POST' });
|
||||||
|
if (r.ok) await refresh();
|
||||||
|
else alert('Failed to activate.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string, name: string) => {
|
||||||
|
if (!confirm(`Delete the AI config "${name}"? You'll need to re-enter it to use it again.`))
|
||||||
|
return;
|
||||||
|
const r = await fetch(`/api/ai/configs/${id}`, { method: 'DELETE' });
|
||||||
|
if (r.ok) await refresh();
|
||||||
|
else alert('Failed to delete.');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-zinc-900 border border-zinc-800 rounded-lg p-6 space-y-4" id="ai-integration">
|
||||||
|
<header>
|
||||||
|
<h2 className="text-lg font-bold text-white">AI integration</h2>
|
||||||
|
<p className="text-sm text-zinc-500 mt-1">
|
||||||
|
Save multiple AI configurations and toggle which one the{' '}
|
||||||
|
<span className="text-zinc-300">AI → Generate</span> page uses.
|
||||||
|
Self-hosted Ollama on StartOS auto-detects — no key needed.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-zinc-500 text-sm flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Loading configs…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{configs.length === 0 && !showForm && (
|
||||||
|
<div className="rounded border border-zinc-800 px-4 py-6 text-sm text-zinc-400 text-center">
|
||||||
|
No AI configs yet. Add one to start generating programs.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{configs.length > 0 && (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{configs.map((c) => (
|
||||||
|
<ConfigRow
|
||||||
|
key={c.id}
|
||||||
|
cfg={c}
|
||||||
|
isActive={c.id === activeId}
|
||||||
|
isEditing={editingId === c.id}
|
||||||
|
onActivate={() => handleActivate(c.id)}
|
||||||
|
onDelete={() => handleDelete(c.id, c.name)}
|
||||||
|
onEdit={() => setEditingId(editingId === c.id ? null : c.id)}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm ? (
|
||||||
|
<ConfigForm
|
||||||
|
onCancel={() => setShowForm(false)}
|
||||||
|
onCreated={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded border border-zinc-700 text-zinc-200 text-xs uppercase tracking-wider hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add AI config
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One saved config row. Shows provider/model/key indicator + active
|
||||||
|
* badge. Click "Test" to ping the model. Click "Set active" to make
|
||||||
|
* this the one Generate uses. Click "Edit" to expand an inline form
|
||||||
|
* for renaming, swapping the model, or rotating the key.
|
||||||
|
*/
|
||||||
|
function ConfigRow({
|
||||||
|
cfg,
|
||||||
|
isActive,
|
||||||
|
isEditing,
|
||||||
|
onActivate,
|
||||||
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
cfg: SavedConfig;
|
||||||
|
isActive: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
onActivate: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/ai/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
// Test the saved config by id; the server pulls the stored key.
|
||||||
|
body: JSON.stringify({ useSavedKeyForId: cfg.id }),
|
||||||
|
});
|
||||||
|
setTestResult(await r.json());
|
||||||
|
} catch (e) {
|
||||||
|
setTestResult({ ok: false, error: (e as Error).message });
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerMeta = PROVIDERS.find((p) => p.id === cfg.provider);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={`rounded border ${
|
||||||
|
isActive ? 'border-emerald-700 bg-emerald-950/20' : 'border-zinc-800 bg-zinc-950'
|
||||||
|
} p-3 space-y-2`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold text-white text-sm truncate">
|
||||||
|
{cfg.name}
|
||||||
|
</span>
|
||||||
|
{isActive && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-emerald-400 font-bold">
|
||||||
|
<Star className="w-3 h-3 fill-emerald-400" />
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 mt-0.5">
|
||||||
|
{providerMeta?.label ?? cfg.provider} · {cfg.model}
|
||||||
|
{cfg.baseUrl && (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
<code className="text-zinc-400">{cfg.baseUrl}</code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{providerMeta?.requiresKey && (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
<span className={cfg.keyConfigured ? 'text-zinc-400' : 'text-amber-400'}>
|
||||||
|
{cfg.keyConfigured ? 'Key saved' : 'No key'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{!isActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onActivate}
|
||||||
|
className="px-2 py-1 text-[11px] uppercase tracking-wider rounded text-zinc-300 hover:bg-zinc-800"
|
||||||
|
title="Make this the AI config that Generate uses"
|
||||||
|
>
|
||||||
|
Set active
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testing}
|
||||||
|
className="px-2 py-1 text-[11px] uppercase tracking-wider rounded text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{testing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="inline w-3 h-3 animate-spin mr-1" />
|
||||||
|
Testing
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Test'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="px-2 py-1 text-[11px] uppercase tracking-wider rounded text-zinc-300 hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
{isEditing ? 'Cancel' : 'Edit'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="p-1 text-red-400 hover:text-red-300"
|
||||||
|
title="Delete this config"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<div
|
||||||
|
className={`rounded px-2 py-1.5 border text-xs ${
|
||||||
|
testResult.ok
|
||||||
|
? 'bg-emerald-900/40 border-emerald-800 text-emerald-300'
|
||||||
|
: 'bg-red-900/50 border-red-800 text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testResult.ok ? (
|
||||||
|
<>
|
||||||
|
✓ Connected in {(testResult.ms / 1000).toFixed(1)}s
|
||||||
|
{testResult.tokensIn != null &&
|
||||||
|
` · ${testResult.tokensIn} in / ${testResult.tokensOut ?? '?'} out`}
|
||||||
|
<div className="mt-0.5 text-zinc-400">
|
||||||
|
Sample reply: <span className="text-zinc-200">{testResult.sample}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>✗ {testResult.error}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<div className="border-t border-zinc-800 pt-3">
|
||||||
|
<ConfigForm
|
||||||
|
initial={cfg}
|
||||||
|
onCancel={onEdit}
|
||||||
|
onCreated={onSaved}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigFormProps {
|
||||||
|
/** When set: editing this saved config (PATCH). Otherwise: creating new (POST). */
|
||||||
|
initial?: SavedConfig;
|
||||||
|
onCancel: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add-or-edit form for a single AI config. Logic worth noting:
|
||||||
|
*
|
||||||
|
* - Model field is a dropdown of `MODEL_MENU[provider]` for major
|
||||||
|
* providers; falls through to free text for openai-compatible / ollama
|
||||||
|
* / "Other (type your own)".
|
||||||
|
* - For Ollama: probes /api/ai/ollama/models on provider-or-baseUrl
|
||||||
|
* change and (a) pre-fills the URL if the default StartOS address
|
||||||
|
* responds, (b) replaces the model dropdown with the actual
|
||||||
|
* installed models.
|
||||||
|
* - For Anthropic/OpenAI/Gemini: exposes a "Test draft" button that
|
||||||
|
* tests the in-progress form values without saving — handy for
|
||||||
|
* checking a key before committing.
|
||||||
|
*/
|
||||||
|
function ConfigForm({ initial, onCancel, onCreated }: ConfigFormProps) {
|
||||||
|
const isEdit = !!initial;
|
||||||
|
const [name, setName] = useState(initial?.name ?? '');
|
||||||
|
const [provider, setProvider] = useState<ProviderId>(initial?.provider ?? 'claude');
|
||||||
|
const [model, setModel] = useState(initial?.model ?? '');
|
||||||
|
const [modelMode, setModelMode] = useState<'menu' | 'custom'>(
|
||||||
|
initial && !MODEL_MENU[initial.provider]?.find((m) => m.id === initial.model)
|
||||||
|
? 'custom'
|
||||||
|
: 'menu',
|
||||||
|
);
|
||||||
|
const [baseUrl, setBaseUrl] = useState(initial?.baseUrl ?? '');
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [setActive, setSetActive] = useState(!isEdit); // new configs default to active
|
||||||
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
|
||||||
|
// Ollama auto-detect.
|
||||||
|
const [ollamaModels, setOllamaModels] = useState<{ name: string }[] | null>(null);
|
||||||
|
const [ollamaProbing, setOllamaProbing] = useState(false);
|
||||||
|
const [ollamaProbeError, setOllamaProbeError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const meta = PROVIDERS.find((p) => p.id === provider);
|
||||||
|
|
||||||
|
// Probe Ollama on provider switch (or baseUrl change while ollama).
|
||||||
|
useEffect(() => {
|
||||||
|
if (provider !== 'ollama') {
|
||||||
|
setOllamaModels(null);
|
||||||
|
setOllamaProbeError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setOllamaProbing(true);
|
||||||
|
setOllamaProbeError(null);
|
||||||
|
const url = baseUrl
|
||||||
|
? `/api/ai/ollama/models?baseUrl=${encodeURIComponent(baseUrl)}`
|
||||||
|
: '/api/ai/ollama/models';
|
||||||
|
fetch(url)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((b) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (b.ok) {
|
||||||
|
setOllamaModels(b.models ?? []);
|
||||||
|
// Pre-fill URL if the user hadn't typed one yet.
|
||||||
|
if (!baseUrl && b.baseUrl) setBaseUrl(b.baseUrl);
|
||||||
|
// Pre-pick a model if there's exactly one and we're in create mode.
|
||||||
|
if (!isEdit && !model && (b.models?.length ?? 0) === 1) {
|
||||||
|
setModel(b.models[0].name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setOllamaModels(null);
|
||||||
|
setOllamaProbeError(b.error ?? 'Probe failed');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) setOllamaProbeError((e as Error).message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setOllamaProbing(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// We deliberately depend on baseUrl too so changing the URL re-probes.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [provider, baseUrl]);
|
||||||
|
|
||||||
|
// Reset draft test result whenever the user changes any input — so the
|
||||||
|
// green "✓ Connected" indicator never lingers from a previous attempt.
|
||||||
|
useEffect(() => {
|
||||||
|
setTestResult(null);
|
||||||
|
}, [provider, model, baseUrl, apiKey]);
|
||||||
|
|
||||||
|
const menu = MODEL_MENU[provider] ?? [];
|
||||||
|
const showMenu = modelMode === 'menu' && menu.length > 0;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: name || undefined,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
baseUrl: baseUrl || null,
|
||||||
|
};
|
||||||
|
if (apiKey) body.apiKey = apiKey;
|
||||||
|
if (!isEdit) body.setActive = setActive;
|
||||||
|
|
||||||
|
const url = isEdit ? `/api/ai/configs/${initial.id}` : '/api/ai/configs';
|
||||||
|
const method = isEdit ? 'PATCH' : 'POST';
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const b = await r.json().catch(() => ({}));
|
||||||
|
throw new Error(b.error ?? `HTTP ${r.status}`);
|
||||||
|
}
|
||||||
|
onCreated();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestDraft = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/ai/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
baseUrl: baseUrl || null,
|
||||||
|
apiKey: apiKey || null,
|
||||||
|
// If we're editing and the user didn't change the key field,
|
||||||
|
// borrow the saved key for the test.
|
||||||
|
useSavedKeyForId: isEdit ? initial!.id : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setTestResult(await r.json());
|
||||||
|
} catch (e) {
|
||||||
|
setTestResult({ ok: false, error: (e as Error).message });
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 bg-zinc-900 border border-zinc-800 rounded p-3">
|
||||||
|
<Field label="Name (optional)">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Local Ollama, Claude (work)"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Provider">
|
||||||
|
<select
|
||||||
|
value={provider}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProvider(e.target.value as ProviderId);
|
||||||
|
setModel(''); // reset on provider change
|
||||||
|
setModelMode('menu');
|
||||||
|
}}
|
||||||
|
className={inputClass}
|
||||||
|
disabled={isEdit}
|
||||||
|
>
|
||||||
|
{PROVIDERS.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{isEdit && (
|
||||||
|
<p className="text-[11px] text-zinc-500 mt-1">
|
||||||
|
Provider can't be changed; delete this config and add a new one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* Ollama: replace the model dropdown with installed models if probe succeeded */}
|
||||||
|
{provider === 'ollama' ? (
|
||||||
|
<Field
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
Model{' '}
|
||||||
|
{ollamaProbing ? (
|
||||||
|
<span className="text-zinc-500 normal-case font-normal">· probing…</span>
|
||||||
|
) : ollamaModels ? (
|
||||||
|
<span className="text-emerald-400 normal-case font-normal">
|
||||||
|
· {ollamaModels.length} installed
|
||||||
|
</span>
|
||||||
|
) : ollamaProbeError ? (
|
||||||
|
<span className="text-amber-400 normal-case font-normal">
|
||||||
|
· could not reach Ollama (type a name)
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ollamaModels && ollamaModels.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="">— Pick an installed model —</option>
|
||||||
|
{ollamaModels.map((m) => (
|
||||||
|
<option key={m.name} value={m.name}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
placeholder="llama3.1:8b · qwen2.5:14b · mistral:7b"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
) : showMenu ? (
|
||||||
|
<Field label="Model">
|
||||||
|
<select
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value === '__custom__') {
|
||||||
|
setModelMode('custom');
|
||||||
|
setModel('');
|
||||||
|
} else {
|
||||||
|
setModel(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="">— Pick a model —</option>
|
||||||
|
{menu.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.recommended ? '★ ' : ''}
|
||||||
|
{m.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="__custom__">Other (type your own)</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<Field
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
Model{' '}
|
||||||
|
{provider !== 'openai-compatible' && menu.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setModelMode('menu')}
|
||||||
|
className="text-zinc-500 hover:text-zinc-300 normal-case font-normal text-[11px]"
|
||||||
|
>
|
||||||
|
· use dropdown
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
placeholder="exact model id"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meta?.requiresUrl && (
|
||||||
|
<Field label="Base URL">
|
||||||
|
<input
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
meta.id === 'ollama'
|
||||||
|
? 'http://ollama.startos:11434'
|
||||||
|
: 'https://your-gateway.example.com/v1'
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meta?.requiresKey && (
|
||||||
|
<Field
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
API key{' '}
|
||||||
|
{isEdit && initial?.keyConfigured && !apiKey && (
|
||||||
|
<span className="text-zinc-500 normal-case font-normal">
|
||||||
|
· key saved (leave blank to keep)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showKey ? 'text' : 'password'}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
isEdit && initial?.keyConfigured ? '•••••••• (saved)' : 'sk-...'
|
||||||
|
}
|
||||||
|
className={`${inputClass} pr-12`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowKey(!showKey)}
|
||||||
|
className="absolute right-3 top-2 text-xs text-zinc-500 hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
{showKey ? 'hide' : 'show'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-zinc-500 mt-1">
|
||||||
|
Stored plaintext in /data/app.db on your StartOS host. Never sent
|
||||||
|
anywhere except the provider you pick.
|
||||||
|
</p>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isEdit && (
|
||||||
|
<label className="flex items-center gap-2 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={setActive}
|
||||||
|
onChange={(e) => setSetActive(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Make this the active config
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{testResult && (
|
||||||
|
<div
|
||||||
|
className={`rounded px-3 py-2 border text-xs ${
|
||||||
|
testResult.ok
|
||||||
|
? 'bg-emerald-900/40 border-emerald-800 text-emerald-300'
|
||||||
|
: 'bg-red-900/50 border-red-800 text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testResult.ok ? (
|
||||||
|
<>
|
||||||
|
✓ Connected in {(testResult.ms / 1000).toFixed(1)}s
|
||||||
|
{testResult.tokensIn != null &&
|
||||||
|
` · ${testResult.tokensIn} in / ${testResult.tokensOut ?? '?'} out`}
|
||||||
|
<div className="mt-0.5 text-zinc-400">
|
||||||
|
Sample reply: <span className="text-zinc-200">{testResult.sample}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>✗ {testResult.error}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !provider || !model}
|
||||||
|
className="px-4 py-2 rounded bg-white text-black font-bold text-xs uppercase tracking-wider hover:bg-gray-100 disabled:bg-zinc-700 disabled:text-zinc-500"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="inline w-4 h-4 animate-spin mr-2" />
|
||||||
|
Saving…
|
||||||
|
</>
|
||||||
|
) : isEdit ? (
|
||||||
|
'Save changes'
|
||||||
|
) : (
|
||||||
|
'Add this config'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestDraft}
|
||||||
|
disabled={
|
||||||
|
testing ||
|
||||||
|
!provider ||
|
||||||
|
!model ||
|
||||||
|
(meta?.requiresUrl && !baseUrl) ||
|
||||||
|
(meta?.requiresKey && !apiKey && !(isEdit && initial?.keyConfigured))
|
||||||
|
}
|
||||||
|
className="px-4 py-2 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800 text-xs uppercase tracking-wider disabled:opacity-50"
|
||||||
|
title="Send a tiny test prompt to verify these credentials"
|
||||||
|
>
|
||||||
|
{testing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="inline w-3.5 h-3.5 animate-spin mr-2" />
|
||||||
|
Testing…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Test draft'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-3 py-2 text-zinc-500 hover:text-zinc-200 text-xs uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full px-3 py-2 text-sm rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white/30';
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider block mb-1">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,6 @@ import { useState, useEffect, useRef } from "react";
|
|||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Upload,
|
Upload,
|
||||||
Download,
|
Download,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -17,20 +15,16 @@ interface UserPreferences {
|
|||||||
theme: string;
|
theme: string;
|
||||||
defaultWeightUnit: string;
|
defaultWeightUnit: string;
|
||||||
defaultRestSeconds: number;
|
defaultRestSeconds: number;
|
||||||
enableClaudeAI: boolean;
|
|
||||||
claudeApiKey?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsForm({ user }: { user: User }) {
|
export default function SettingsForm({ user }: { user: User }) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [showApiKey, setShowApiKey] = useState(false);
|
|
||||||
const [preferences, setPreferences] = useState<UserPreferences>({
|
const [preferences, setPreferences] = useState<UserPreferences>({
|
||||||
theme: "system",
|
theme: "system",
|
||||||
defaultWeightUnit: "lbs",
|
defaultWeightUnit: "lbs",
|
||||||
defaultRestSeconds: 90,
|
defaultRestSeconds: 90,
|
||||||
enableClaudeAI: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -179,90 +173,12 @@ export default function SettingsForm({ user }: { user: User }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Claude AI Section */}
|
{/* AI integration section deliberately removed — was a misleading
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
placeholder for "Claude AI workout recommendations" that the
|
||||||
<h2 className="text-lg font-bold text-white mb-4">
|
codebase never actually delivered. A real model-agnostic AI
|
||||||
Claude AI Integration
|
integration (Claude / OpenAI / Gemini / self-hosted Ollama) is
|
||||||
</h2>
|
on the roadmap; the underlying enableClaudeAI/claudeApiKey
|
||||||
<p className="text-sm text-zinc-500 mb-4">
|
schema columns stay as harmless dead fields until that lands. */}
|
||||||
Enable Claude AI to get personalized workout recommendations and
|
|
||||||
program optimization suggestions.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Enable Toggle */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-zinc-300">
|
|
||||||
Enable Claude AI
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setPreferences((prev) => ({
|
|
||||||
...prev,
|
|
||||||
enableClaudeAI: !prev.enableClaudeAI,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className={`relative w-11 h-6 rounded-full transition ${
|
|
||||||
preferences.enableClaudeAI ? "bg-white" : "bg-zinc-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full transition-transform ${
|
|
||||||
preferences.enableClaudeAI
|
|
||||||
? "translate-x-5 bg-black"
|
|
||||||
: "translate-x-0 bg-zinc-400"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API Key Input - Only show if enabled */}
|
|
||||||
{preferences.enableClaudeAI && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
|
||||||
Claude API Key
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={showApiKey ? "text" : "password"}
|
|
||||||
value={preferences.claudeApiKey || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setPreferences((prev) => ({
|
|
||||||
...prev,
|
|
||||||
claudeApiKey: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="sk-..."
|
|
||||||
className="w-full px-3 py-2 border border-zinc-700 rounded-lg bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 pr-10"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowApiKey(!showApiKey)}
|
|
||||||
className="absolute right-3 top-2.5 text-zinc-500 hover:text-zinc-300"
|
|
||||||
>
|
|
||||||
{showApiKey ? (
|
|
||||||
<EyeOff className="w-5 h-5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-zinc-600 mt-1">
|
|
||||||
Get your API key from{" "}
|
|
||||||
<a
|
|
||||||
href="https://console.anthropic.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-zinc-400 hover:text-white underline"
|
|
||||||
>
|
|
||||||
console.anthropic.com
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Save Button */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -58,44 +58,61 @@ function ExerciseHistoryPopup({
|
|||||||
};
|
};
|
||||||
}, [exerciseId]);
|
}, [exerciseId]);
|
||||||
|
|
||||||
// Infinite scroll — observe a sentinel below the rendered list. The
|
// v1.1.0:7 — Infinite scroll via a plain scroll listener on the
|
||||||
// root is the popup's scroll container (the popup itself), not the
|
// popup itself. The previous IntersectionObserver implementation was
|
||||||
// viewport, since the user scrolls inside the popup.
|
// unreliable inside an absolute-positioned scroll container (the
|
||||||
|
// popup is `position: absolute` + `overflow-y-auto`, which some
|
||||||
|
// browsers don't observe consistently when the root is the same
|
||||||
|
// element). A `scroll` event on the popup is rock-solid.
|
||||||
|
//
|
||||||
|
// Fires whenever the user scrolls within ~300px of the popup's
|
||||||
|
// bottom edge, mirroring the rootMargin used by the workouts-list
|
||||||
|
// infinite-scroll on the main page.
|
||||||
|
//
|
||||||
|
// Also runs once on first render after history loads — important
|
||||||
|
// because if the user has 100+ history entries and the first page
|
||||||
|
// doesn't fill the popup OR the user opens the popup and immediately
|
||||||
|
// sees content without scrolling, we still want to fetch ahead.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || !hasMore || !sentinelRef.current || !popupRef.current) {
|
if (loading || !hasMore || loadingMore || !popupRef.current) return;
|
||||||
return;
|
const el = popupRef.current;
|
||||||
}
|
|
||||||
const sentinel = sentinelRef.current;
|
const loadMore = async () => {
|
||||||
const root = popupRef.current;
|
if (loadingMore || !hasMore) return;
|
||||||
const observer = new IntersectionObserver(
|
setLoadingMore(true);
|
||||||
(entries) => {
|
try {
|
||||||
if (!entries[0]?.isIntersecting) return;
|
const res = await fetch(
|
||||||
if (loadingMore || !hasMore) return;
|
`/api/exercises/${exerciseId}?offset=${history.length}&limit=${HISTORY_PAGE_SIZE}`,
|
||||||
setLoadingMore(true);
|
);
|
||||||
(async () => {
|
if (res.ok) {
|
||||||
try {
|
const data = await res.json();
|
||||||
const res = await fetch(
|
setHistory((prev) => [...prev, ...(data.history || [])]);
|
||||||
`/api/exercises/${exerciseId}?offset=${history.length}&limit=${HISTORY_PAGE_SIZE}`,
|
setHasMore(!!data.hasMore);
|
||||||
);
|
} else {
|
||||||
if (res.ok) {
|
setError(`Failed to load more (${res.status})`);
|
||||||
const data = await res.json();
|
setHasMore(false);
|
||||||
setHistory((prev) => [...prev, ...(data.history || [])]);
|
}
|
||||||
setHasMore(!!data.hasMore);
|
} catch {
|
||||||
} else {
|
setError("Failed to load more");
|
||||||
setError(`Failed to load more (${res.status})`);
|
setHasMore(false);
|
||||||
setHasMore(false);
|
}
|
||||||
}
|
setLoadingMore(false);
|
||||||
} catch {
|
};
|
||||||
setError("Failed to load more");
|
|
||||||
setHasMore(false);
|
const maybeLoad = () => {
|
||||||
}
|
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||||
setLoadingMore(false);
|
// 300px lookahead — match WorkoutsList's rootMargin behavior.
|
||||||
})();
|
if (scrollHeight - scrollTop - clientHeight < 300) {
|
||||||
},
|
loadMore();
|
||||||
{ root, rootMargin: "60px" },
|
}
|
||||||
);
|
};
|
||||||
observer.observe(sentinel);
|
|
||||||
return () => observer.disconnect();
|
el.addEventListener("scroll", maybeLoad, { passive: true });
|
||||||
|
// Initial check: if the loaded page doesn't fill the popup, we
|
||||||
|
// still want to fetch the next page so the user doesn't have to
|
||||||
|
// scroll a near-empty container before more arrives.
|
||||||
|
maybeLoad();
|
||||||
|
return () => el.removeEventListener("scroll", maybeLoad);
|
||||||
}, [loading, hasMore, loadingMore, history.length, exerciseId]);
|
}, [loading, hasMore, loadingMore, history.length, exerciseId]);
|
||||||
|
|
||||||
// Close on outside click
|
// Close on outside click
|
||||||
@@ -112,7 +129,12 @@ function ExerciseHistoryPopup({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={popupRef}
|
ref={popupRef}
|
||||||
className="absolute left-0 right-0 top-full mt-1 z-50 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl max-h-80 overflow-y-auto"
|
/* v1.1.0:6 — bump from max-h-80 (~320px, ~5 rows) to 70vh so
|
||||||
|
power users with multi-year history can scroll through ~15+
|
||||||
|
sessions without the popup feeling cramped. The
|
||||||
|
IntersectionObserver already loads more on demand; the old cap
|
||||||
|
just hid pages of data behind a tiny scrollbar. */
|
||||||
|
className="absolute left-0 right-0 top-full mt-1 z-50 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl max-h-[70vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800 sticky top-0 bg-zinc-900 z-10">
|
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800 sticky top-0 bg-zinc-900 z-10">
|
||||||
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
|
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
|
||||||
@@ -162,10 +184,21 @@ function ExerciseHistoryPopup({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{/* Sentinel + status row at the bottom of the list */}
|
{/* Status row at the bottom of the list. The sentinel ref
|
||||||
|
is no longer the load trigger (we use a scroll listener
|
||||||
|
on the popup itself in v1.1.0:7), but the visual marker
|
||||||
|
still tells the user whether more is loading or done. */}
|
||||||
<div ref={sentinelRef} className="flex justify-center py-2">
|
<div ref={sentinelRef} className="flex justify-center py-2">
|
||||||
{loadingMore && (
|
{loadingMore && (
|
||||||
<Loader className="w-3.5 h-3.5 animate-spin text-zinc-500" />
|
<span className="inline-flex items-center gap-2 text-[10px] text-zinc-500 uppercase tracking-wider">
|
||||||
|
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
Loading more...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!loadingMore && hasMore && (
|
||||||
|
<span className="text-[10px] text-zinc-600 uppercase tracking-wider">
|
||||||
|
Scroll to load more
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{!loadingMore && !hasMore && history.length >= HISTORY_PAGE_SIZE && (
|
{!loadingMore && !hasMore && history.length >= HISTORY_PAGE_SIZE && (
|
||||||
<span className="text-[10px] text-zinc-600 uppercase tracking-wider">
|
<span className="text-[10px] text-zinc-600 uppercase tracking-wider">
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a saved AIConfigProfile as the actor's active config + mirror its
|
||||||
|
* fields into the legacy UserPreferences columns so any code path that
|
||||||
|
* reads aiProvider/aiModel/aiBaseUrl/aiApiKey from prefs (api/ai/test,
|
||||||
|
* api/ai/generate's existing reads) keeps working without conditional
|
||||||
|
* logic.
|
||||||
|
*
|
||||||
|
* Lives outside the route file because Next.js App Router only allows
|
||||||
|
* HTTP method exports (GET / POST / etc.) from route.ts modules.
|
||||||
|
*/
|
||||||
|
export async function activate(
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
fields: {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
apiKey?: string | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
await prisma.userPreferences.upsert({
|
||||||
|
where: { userId },
|
||||||
|
update: {
|
||||||
|
activeAIConfigId: profileId,
|
||||||
|
aiProvider: fields.provider,
|
||||||
|
aiModel: fields.model,
|
||||||
|
aiBaseUrl: fields.baseUrl || null,
|
||||||
|
aiApiKey: fields.apiKey || null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
theme: 'system',
|
||||||
|
defaultWeightUnit: 'lbs',
|
||||||
|
defaultRestSeconds: 90,
|
||||||
|
activeAIConfigId: profileId,
|
||||||
|
aiProvider: fields.provider,
|
||||||
|
aiModel: fields.model,
|
||||||
|
aiBaseUrl: fields.baseUrl || null,
|
||||||
|
aiApiKey: fields.apiKey || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { Prisma, type PrismaClient } from '@prisma/client';
|
||||||
|
import type { AIProgram } from './programSchema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materialize a parsed AIProgram into a real Program row + nested
|
||||||
|
* Weeks/Days/Exercises in one transaction.
|
||||||
|
*
|
||||||
|
* The caller is responsible for validating the AIProgram (parseAIProgram
|
||||||
|
* returns it). Here we additionally:
|
||||||
|
* - verify every non-null exerciseId is in the user's library;
|
||||||
|
* reject the apply if any unknown ID is referenced (the UI must
|
||||||
|
* resolve unknowns BEFORE calling this).
|
||||||
|
* - set Program.aiGenerated = true and stash any extra notes.
|
||||||
|
*
|
||||||
|
* Returns the created Program's id.
|
||||||
|
*/
|
||||||
|
export interface ApplyOpts {
|
||||||
|
userId: string;
|
||||||
|
startDate: Date;
|
||||||
|
/**
|
||||||
|
* If true, mark Program.isActive = true. Default false (let the
|
||||||
|
* user explicitly activate after reviewing).
|
||||||
|
*/
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyResult {
|
||||||
|
programId: string;
|
||||||
|
weeksCreated: number;
|
||||||
|
daysCreated: number;
|
||||||
|
exercisesCreated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyAIProgram(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
ai: AIProgram,
|
||||||
|
opts: ApplyOpts,
|
||||||
|
): Promise<ApplyResult> {
|
||||||
|
// Verify every exerciseId in the tree belongs to this user.
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const w of ai.weeks)
|
||||||
|
for (const d of w.days)
|
||||||
|
for (const ex of d.exercises) {
|
||||||
|
if (ex.exerciseId) ids.add(ex.exerciseId);
|
||||||
|
}
|
||||||
|
if (ids.size > 0) {
|
||||||
|
const owned = await prisma.exercise.findMany({
|
||||||
|
where: { userId: opts.userId, id: { in: Array.from(ids) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const ownedIds = new Set(owned.map((e) => e.id));
|
||||||
|
const bad = Array.from(ids).filter((id) => !ownedIds.has(id));
|
||||||
|
if (bad.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot apply: ${bad.length} exerciseId(s) don't belong to this user. ` +
|
||||||
|
`Resolve unknown exercises in the preview first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also reject if any exercise has exerciseId === null (unresolved).
|
||||||
|
const unresolved: string[] = [];
|
||||||
|
for (const w of ai.weeks)
|
||||||
|
for (const d of w.days)
|
||||||
|
for (const ex of d.exercises) {
|
||||||
|
if (!ex.exerciseId) unresolved.push(ex.exerciseName);
|
||||||
|
}
|
||||||
|
if (unresolved.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot apply: ${unresolved.length} exercise(s) still unresolved ` +
|
||||||
|
`(${unresolved.slice(0, 3).join(', ')}${
|
||||||
|
unresolved.length > 3 ? '...' : ''
|
||||||
|
}). Map them to library exercises or remove them in the preview.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let weeksCreated = 0;
|
||||||
|
let daysCreated = 0;
|
||||||
|
let exercisesCreated = 0;
|
||||||
|
|
||||||
|
const programId = await prisma.$transaction(async (tx) => {
|
||||||
|
const program = await tx.program.create({
|
||||||
|
data: {
|
||||||
|
userId: opts.userId,
|
||||||
|
name: ai.name,
|
||||||
|
description: ai.description ?? null,
|
||||||
|
type: ai.type,
|
||||||
|
durationWeeks: ai.durationWeeks,
|
||||||
|
startDate: opts.startDate,
|
||||||
|
isActive: opts.isActive ?? false,
|
||||||
|
aiGenerated: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const w of ai.weeks) {
|
||||||
|
const week = await tx.programWeek.create({
|
||||||
|
data: {
|
||||||
|
programId: program.id,
|
||||||
|
weekNumber: w.weekNumber,
|
||||||
|
phase: w.phase ?? null,
|
||||||
|
description: w.description ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
weeksCreated++;
|
||||||
|
for (const d of w.days) {
|
||||||
|
const day = await tx.programDay.create({
|
||||||
|
data: {
|
||||||
|
weekId: week.id,
|
||||||
|
dayOfWeek: d.dayOfWeek,
|
||||||
|
name: d.name ?? null,
|
||||||
|
description: d.description ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
daysCreated++;
|
||||||
|
if (d.exercises.length > 0) {
|
||||||
|
await tx.programExercise.createMany({
|
||||||
|
data: d.exercises.map((ex) => ({
|
||||||
|
dayId: day.id,
|
||||||
|
exerciseId: ex.exerciseId!, // validated non-null above
|
||||||
|
order: ex.order,
|
||||||
|
sets: ex.sets ?? null,
|
||||||
|
repsMin: ex.repsMin ?? null,
|
||||||
|
repsMax: ex.repsMax ?? null,
|
||||||
|
rpe: ex.rpe ?? null,
|
||||||
|
restSeconds: ex.restSeconds ?? null,
|
||||||
|
suggestedWeight: ex.suggestedWeight ?? null,
|
||||||
|
suggestedWeightUnit: ex.suggestedWeightUnit ?? null,
|
||||||
|
notes: ex.notes ?? null,
|
||||||
|
})) as Prisma.ProgramExerciseCreateManyInput[],
|
||||||
|
});
|
||||||
|
exercisesCreated += d.exercises.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return program.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { programId, weeksCreated, daysCreated, exercisesCreated };
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* v1.1.0:4 — Background-friendly generation runner.
|
||||||
|
*
|
||||||
|
* Splits the work in two:
|
||||||
|
*
|
||||||
|
* 1. The HTTP route (api/ai/generate) calls `kickoffGeneration` to
|
||||||
|
* create the pending AIGeneration row, validate config, and start
|
||||||
|
* the model stream in the background. It returns immediately with
|
||||||
|
* the new row id; the runner continues even after the request is
|
||||||
|
* cancelled (because we use waitUntil-style pattern via a
|
||||||
|
* detached promise that owns its own AbortController).
|
||||||
|
*
|
||||||
|
* 2. The HTTP route also opens an SSE stream that subscribes to a
|
||||||
|
* per-generation in-memory event bus, so the live UI sees text
|
||||||
|
* deltas as they arrive — same UX as before. If the client
|
||||||
|
* navigates away the stream closes, but the runner keeps writing
|
||||||
|
* progress to the database; a poll endpoint returns whatever it
|
||||||
|
* has.
|
||||||
|
*
|
||||||
|
* The in-memory bus is a plain Map keyed by generation id. It only
|
||||||
|
* lives in this Node process; SSE clients only receive deltas from
|
||||||
|
* a runner started in the SAME process. That's fine because:
|
||||||
|
* - Single-process Next.js standalone (the StartOS deployment).
|
||||||
|
* - Cross-process resume goes through the database (poll endpoint
|
||||||
|
* reads `progressText`).
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* pending → runner created the row, model stream started
|
||||||
|
* completed → runner parsed the JSON successfully (parsedProgram set)
|
||||||
|
* failed → provider error or parse failure (errorMessage set)
|
||||||
|
* applied → user clicked Apply, Program created (handled in apply route)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
import { getProvider } from './providers';
|
||||||
|
import { parseAIProgram } from './programSchema';
|
||||||
|
|
||||||
|
export interface GenerationDelta {
|
||||||
|
type: 'text' | 'usage' | 'complete' | 'error';
|
||||||
|
/** For text */
|
||||||
|
delta?: string;
|
||||||
|
/** For usage / complete */
|
||||||
|
tokensIn?: number;
|
||||||
|
tokensOut?: number;
|
||||||
|
/** For complete */
|
||||||
|
parsedOk?: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BusEntry {
|
||||||
|
/** Subscribers waiting for the next chunk. */
|
||||||
|
subscribers: Set<(d: GenerationDelta) => void>;
|
||||||
|
/** Buffered deltas for late-joining subscribers (so a poll-then-subscribe
|
||||||
|
* client doesn't miss the first few tokens). Bounded — we drop oldest
|
||||||
|
* if it grows past the limit. */
|
||||||
|
buffer: GenerationDelta[];
|
||||||
|
/** True once the runner emits its terminal `complete` chunk. */
|
||||||
|
finished: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUFFER_MAX = 5_000;
|
||||||
|
|
||||||
|
const bus = new Map<string, BusEntry>();
|
||||||
|
|
||||||
|
function ensureEntry(id: string): BusEntry {
|
||||||
|
let entry = bus.get(id);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { subscribers: new Set(), buffer: [], finished: false };
|
||||||
|
bus.set(id, entry);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(id: string, d: GenerationDelta) {
|
||||||
|
const entry = ensureEntry(id);
|
||||||
|
entry.buffer.push(d);
|
||||||
|
if (entry.buffer.length > BUFFER_MAX) entry.buffer.shift();
|
||||||
|
for (const fn of entry.subscribers) {
|
||||||
|
try {
|
||||||
|
fn(d);
|
||||||
|
} catch {
|
||||||
|
/* subscriber teardown handles its own errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (d.type === 'complete' || d.type === 'error') {
|
||||||
|
entry.finished = true;
|
||||||
|
// Schedule cleanup after a grace period so reconnecting clients can
|
||||||
|
// catch the tail. 60s is enough for a refresh round-trip.
|
||||||
|
setTimeout(() => bus.delete(id), 60_000).unref?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to deltas for a generation. Returns an unsubscribe.
|
||||||
|
* `replay: true` first sends the entire buffer to the new subscriber
|
||||||
|
* (used by the SSE route — late-joining tabs get the full stream).
|
||||||
|
*/
|
||||||
|
export function subscribe(
|
||||||
|
id: string,
|
||||||
|
fn: (d: GenerationDelta) => void,
|
||||||
|
replay = true,
|
||||||
|
): () => void {
|
||||||
|
const entry = ensureEntry(id);
|
||||||
|
if (replay) for (const d of entry.buffer) fn(d);
|
||||||
|
if (entry.finished) {
|
||||||
|
// Already done — caller will see all buffered events; nothing more.
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
entry.subscribers.add(fn);
|
||||||
|
return () => entry.subscribers.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KickoffOpts {
|
||||||
|
prisma: PrismaClient;
|
||||||
|
userId: string;
|
||||||
|
templateId: string | null;
|
||||||
|
templateName: string | null;
|
||||||
|
userInput: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
userPrompt: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
apiKey: string | null;
|
||||||
|
baseUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the AIGeneration row and start the model stream in the
|
||||||
|
* background. Returns the new row's id; the caller is expected to
|
||||||
|
* subscribe via `subscribe(id, fn)` for live deltas (or just rely
|
||||||
|
* on database polling).
|
||||||
|
*
|
||||||
|
* The runner outlives the originating request — it owns its own
|
||||||
|
* AbortController which is NOT linked to the request signal, so
|
||||||
|
* navigating away from the Generate page does NOT cancel it.
|
||||||
|
*/
|
||||||
|
export async function kickoffGeneration(opts: KickoffOpts): Promise<string> {
|
||||||
|
const generation = await opts.prisma.aIGeneration.create({
|
||||||
|
data: {
|
||||||
|
userId: opts.userId,
|
||||||
|
templateId: opts.templateId,
|
||||||
|
templateName: opts.templateName,
|
||||||
|
userInput: opts.userInput,
|
||||||
|
systemPrompt: opts.systemPrompt,
|
||||||
|
userPrompt: opts.userPrompt,
|
||||||
|
provider: opts.provider,
|
||||||
|
model: opts.model,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detach: we want this to keep going if the originating request is
|
||||||
|
// aborted. Standard Node + Next.js standalone behavior — the runner
|
||||||
|
// holds a strong reference via `bus` so it won't be GC'd mid-flight.
|
||||||
|
void runGeneration(generation.id, opts).catch((e) => {
|
||||||
|
// Last-resort safety net; the runner already logs/persists errors,
|
||||||
|
// but if even that throws we want to know.
|
||||||
|
console.error('[generation runner] uncaught:', e);
|
||||||
|
emit(generation.id, {
|
||||||
|
type: 'error',
|
||||||
|
errorMessage: `Runner crashed: ${(e as Error).message}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return generation.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How often we flush `progressText` to the database during streaming.
|
||||||
|
* Trade-off: too frequent = SQLite write churn; too slow = poll-only
|
||||||
|
* clients see big jumps. 750ms feels right — perceptibly live without
|
||||||
|
* hammering the WAL. */
|
||||||
|
const PROGRESS_FLUSH_MS = 750;
|
||||||
|
|
||||||
|
async function runGeneration(generationId: string, opts: KickoffOpts) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const provider = getProvider(opts.provider);
|
||||||
|
if (!provider) {
|
||||||
|
await opts.prisma.aIGeneration.update({
|
||||||
|
where: { id: generationId },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: `Unknown provider: ${opts.provider}`,
|
||||||
|
durationMs: Date.now() - t0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emit(generationId, {
|
||||||
|
type: 'error',
|
||||||
|
errorMessage: `Unknown provider: ${opts.provider}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
let raw = '';
|
||||||
|
let tokensIn: number | undefined;
|
||||||
|
let tokensOut: number | undefined;
|
||||||
|
let providerError: string | null = null;
|
||||||
|
|
||||||
|
// Periodic progress flush.
|
||||||
|
let lastFlushAt = 0;
|
||||||
|
const maybeFlush = async (force = false) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && now - lastFlushAt < PROGRESS_FLUSH_MS) return;
|
||||||
|
lastFlushAt = now;
|
||||||
|
try {
|
||||||
|
await opts.prisma.aIGeneration.update({
|
||||||
|
where: { id: generationId },
|
||||||
|
data: { progressText: raw },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* writes can fail under contention; we'll catch up next tick */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const chunk of provider.generate({
|
||||||
|
apiKey: opts.apiKey,
|
||||||
|
baseUrl: opts.baseUrl,
|
||||||
|
model: opts.model,
|
||||||
|
systemPrompt: opts.systemPrompt,
|
||||||
|
userPrompt: opts.userPrompt,
|
||||||
|
signal: ctrl.signal,
|
||||||
|
})) {
|
||||||
|
if (chunk.type === 'text') {
|
||||||
|
raw += chunk.delta;
|
||||||
|
emit(generationId, { type: 'text', delta: chunk.delta });
|
||||||
|
await maybeFlush();
|
||||||
|
} else if (chunk.type === 'usage') {
|
||||||
|
tokensIn = chunk.tokensIn;
|
||||||
|
tokensOut = chunk.tokensOut;
|
||||||
|
emit(generationId, {
|
||||||
|
type: 'usage',
|
||||||
|
tokensIn,
|
||||||
|
tokensOut,
|
||||||
|
});
|
||||||
|
} else if (chunk.type === 'error') {
|
||||||
|
providerError = chunk.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
providerError = (e as Error).message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final flush + parse.
|
||||||
|
await maybeFlush(true);
|
||||||
|
let parsedOk = false;
|
||||||
|
let parsedJson: string | null = null;
|
||||||
|
let parseErr: string | null = null;
|
||||||
|
if (!providerError && raw) {
|
||||||
|
const r = parseAIProgram(raw);
|
||||||
|
if (r.ok) {
|
||||||
|
parsedOk = true;
|
||||||
|
parsedJson = JSON.stringify(r.program);
|
||||||
|
} else {
|
||||||
|
parseErr = r.reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const status = providerError ? 'failed' : parsedOk ? 'completed' : 'failed';
|
||||||
|
const errorMessage =
|
||||||
|
providerError ?? (parsedOk ? null : parseErr ?? 'Empty response');
|
||||||
|
const durationMs = Date.now() - t0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await opts.prisma.aIGeneration.update({
|
||||||
|
where: { id: generationId },
|
||||||
|
data: {
|
||||||
|
rawResponse: raw || null,
|
||||||
|
parsedProgram: parsedJson,
|
||||||
|
tokensIn: tokensIn ?? null,
|
||||||
|
tokensOut: tokensOut ?? null,
|
||||||
|
durationMs,
|
||||||
|
status,
|
||||||
|
errorMessage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[generation runner] final update failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(generationId, {
|
||||||
|
type: 'complete',
|
||||||
|
parsedOk,
|
||||||
|
errorMessage: errorMessage ?? undefined,
|
||||||
|
tokensIn,
|
||||||
|
tokensOut,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a compact workout-history summary the AI can use as
|
||||||
|
* context for personalized program generation.
|
||||||
|
*
|
||||||
|
* We DELIBERATELY don't ship raw set logs — that would be tens of
|
||||||
|
* KB per request and burn tokens. Instead we compute per-exercise
|
||||||
|
* aggregates over a recent window (default 90 days):
|
||||||
|
*
|
||||||
|
* - totalSets in window
|
||||||
|
* - distinct workouts
|
||||||
|
* - daysSinceLast
|
||||||
|
* - lastWeight, lastReps (from the most-recent set)
|
||||||
|
* - bestWeight (heaviest set in window)
|
||||||
|
* - estimated 1RM (Epley formula on the heaviest weighted set)
|
||||||
|
* - rpe trend (avg RPE over recent sets, if logged)
|
||||||
|
* - stagnation flag (heaviest weight unchanged for 4+ weeks AND
|
||||||
|
* ≥3 sessions of that exercise in those 4+ weeks)
|
||||||
|
*
|
||||||
|
* Plus a top-level summary: total workouts, frequency, primary
|
||||||
|
* exercise types touched.
|
||||||
|
*
|
||||||
|
* The output is JSON-stringifiable, ~5-15 KB for a typical user.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface HistoryExerciseSummary {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
totalSets: number;
|
||||||
|
distinctWorkouts: number;
|
||||||
|
daysSinceLast: number;
|
||||||
|
lastWeight: number | null;
|
||||||
|
lastReps: number | null;
|
||||||
|
bestWeight: number | null;
|
||||||
|
estimated1RM: number | null;
|
||||||
|
avgRpe: number | null;
|
||||||
|
stagnant: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistorySummary {
|
||||||
|
windowDays: number;
|
||||||
|
totalWorkouts: number;
|
||||||
|
workoutsPerWeek: number;
|
||||||
|
primaryTypes: string[]; // exercise types by descending volume
|
||||||
|
exercises: HistoryExerciseSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Epley estimated 1RM: weight * (1 + reps / 30) */
|
||||||
|
function epley1RM(weight: number, reps: number): number {
|
||||||
|
return Math.round(weight * (1 + reps / 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildHistorySummary(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
userId: string,
|
||||||
|
windowDays = 90,
|
||||||
|
): Promise<HistorySummary> {
|
||||||
|
const cutoff = new Date(Date.now() - windowDays * 86_400_000);
|
||||||
|
|
||||||
|
// Pull every set log in the window with its exercise + workout
|
||||||
|
// date. One query, one result-set walk.
|
||||||
|
const sets = await prisma.setLog.findMany({
|
||||||
|
where: {
|
||||||
|
workout: {
|
||||||
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
|
date: { gte: cutoff },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
reps: true,
|
||||||
|
weight: true,
|
||||||
|
rpe: true,
|
||||||
|
exerciseId: true,
|
||||||
|
workoutId: true,
|
||||||
|
workout: { select: { date: true } },
|
||||||
|
exercise: { select: { name: true, type: true } },
|
||||||
|
},
|
||||||
|
orderBy: { workout: { date: 'desc' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sets.length === 0) {
|
||||||
|
return {
|
||||||
|
windowDays,
|
||||||
|
totalWorkouts: 0,
|
||||||
|
workoutsPerWeek: 0,
|
||||||
|
primaryTypes: [],
|
||||||
|
exercises: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const workoutIds = new Set(sets.map((s) => s.workoutId));
|
||||||
|
const totalWorkouts = workoutIds.size;
|
||||||
|
const weeks = windowDays / 7;
|
||||||
|
const workoutsPerWeek = Math.round((totalWorkouts / weeks) * 10) / 10;
|
||||||
|
|
||||||
|
// Group by exercise
|
||||||
|
const byExercise = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
sets: typeof sets;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
for (const s of sets) {
|
||||||
|
if (!byExercise.has(s.exerciseId)) {
|
||||||
|
byExercise.set(s.exerciseId, {
|
||||||
|
name: s.exercise.name,
|
||||||
|
type: s.exercise.type,
|
||||||
|
sets: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
byExercise.get(s.exerciseId)!.sets.push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-exercise summaries
|
||||||
|
const now = Date.now();
|
||||||
|
const exercises: HistoryExerciseSummary[] = [];
|
||||||
|
for (const [, group] of byExercise) {
|
||||||
|
const groupSets = group.sets;
|
||||||
|
const distinctWorkouts = new Set(groupSets.map((s) => s.workoutId)).size;
|
||||||
|
const mostRecent = groupSets[0]; // already date-desc
|
||||||
|
const daysSinceLast = Math.floor(
|
||||||
|
(now - mostRecent.workout.date.getTime()) / 86_400_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const weightedSets = groupSets.filter(
|
||||||
|
(s): s is typeof s & { weight: number; reps: number } =>
|
||||||
|
typeof s.weight === 'number' && typeof s.reps === 'number',
|
||||||
|
);
|
||||||
|
const bestWeightSet = weightedSets.reduce<
|
||||||
|
| { weight: number; reps: number }
|
||||||
|
| null
|
||||||
|
>((best, s) => {
|
||||||
|
if (!best || s.weight > best.weight) return s;
|
||||||
|
return best;
|
||||||
|
}, null);
|
||||||
|
const bestWeight = bestWeightSet?.weight ?? null;
|
||||||
|
const estimated1RM =
|
||||||
|
bestWeightSet != null ? epley1RM(bestWeightSet.weight, bestWeightSet.reps) : null;
|
||||||
|
|
||||||
|
const rpeSets = groupSets.filter(
|
||||||
|
(s): s is typeof s & { rpe: number } => typeof s.rpe === 'number',
|
||||||
|
);
|
||||||
|
const avgRpe =
|
||||||
|
rpeSets.length > 0
|
||||||
|
? Math.round(
|
||||||
|
(rpeSets.reduce((sum, s) => sum + s.rpe, 0) / rpeSets.length) * 10,
|
||||||
|
) / 10
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Stagnation: best weight in oldest half == best weight in newest half
|
||||||
|
// AND ≥3 distinct sessions in the window.
|
||||||
|
let stagnant = false;
|
||||||
|
if (distinctWorkouts >= 3 && bestWeight != null && weightedSets.length >= 4) {
|
||||||
|
const sortedByDate = [...weightedSets].sort(
|
||||||
|
(a, b) => a.workout.date.getTime() - b.workout.date.getTime(),
|
||||||
|
);
|
||||||
|
const mid = Math.floor(sortedByDate.length / 2);
|
||||||
|
const oldHalfBest = Math.max(...sortedByDate.slice(0, mid).map((s) => s.weight));
|
||||||
|
const newHalfBest = Math.max(...sortedByDate.slice(mid).map((s) => s.weight));
|
||||||
|
// No improvement in the new half compared to the old half
|
||||||
|
if (newHalfBest <= oldHalfBest) stagnant = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
exercises.push({
|
||||||
|
name: group.name,
|
||||||
|
type: group.type,
|
||||||
|
totalSets: groupSets.length,
|
||||||
|
distinctWorkouts,
|
||||||
|
daysSinceLast,
|
||||||
|
lastWeight: mostRecent.weight ?? null,
|
||||||
|
lastReps: mostRecent.reps ?? null,
|
||||||
|
bestWeight,
|
||||||
|
estimated1RM,
|
||||||
|
avgRpe,
|
||||||
|
stagnant,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort exercises by total volume (sets) descending so the most
|
||||||
|
// important context is first if the model truncates.
|
||||||
|
exercises.sort((a, b) => b.totalSets - a.totalSets);
|
||||||
|
|
||||||
|
// Primary types by aggregate sets
|
||||||
|
const typeVolume = new Map<string, number>();
|
||||||
|
for (const ex of exercises) {
|
||||||
|
typeVolume.set(ex.type, (typeVolume.get(ex.type) ?? 0) + ex.totalSets);
|
||||||
|
}
|
||||||
|
const primaryTypes = Array.from(typeVolume.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([t]) => t);
|
||||||
|
|
||||||
|
return {
|
||||||
|
windowDays,
|
||||||
|
totalWorkouts,
|
||||||
|
workoutsPerWeek,
|
||||||
|
primaryTypes,
|
||||||
|
exercises,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a HistorySummary as a compact string the LLM can actually
|
||||||
|
* use. Aims for <2KB of text even for heavy users.
|
||||||
|
*/
|
||||||
|
export function formatHistoryContext(summary: HistorySummary): string {
|
||||||
|
if (summary.totalWorkouts === 0) {
|
||||||
|
return `\nUSER HISTORY: no workouts logged in the last ${summary.windowDays} days.`;
|
||||||
|
}
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(
|
||||||
|
`\nUSER HISTORY (last ${summary.windowDays} days):`,
|
||||||
|
` ${summary.totalWorkouts} workouts (~${summary.workoutsPerWeek}/week)`,
|
||||||
|
` Primary work: ${summary.primaryTypes.slice(0, 4).join(', ')}`,
|
||||||
|
'',
|
||||||
|
` Per-exercise activity (descending by volume; weights in user's logged unit):`,
|
||||||
|
);
|
||||||
|
// Cap at top 30 exercises
|
||||||
|
const top = summary.exercises.slice(0, 30);
|
||||||
|
for (const ex of top) {
|
||||||
|
const bits: string[] = [
|
||||||
|
`${ex.totalSets}s/${ex.distinctWorkouts}w`,
|
||||||
|
`${ex.daysSinceLast}d ago`,
|
||||||
|
];
|
||||||
|
if (ex.bestWeight != null && ex.lastReps != null)
|
||||||
|
bits.push(`best ${ex.bestWeight}×${ex.lastReps}`);
|
||||||
|
if (ex.estimated1RM != null) bits.push(`~${ex.estimated1RM} 1RM`);
|
||||||
|
if (ex.avgRpe != null) bits.push(`avg RPE ${ex.avgRpe}`);
|
||||||
|
if (ex.stagnant) bits.push('STAGNANT');
|
||||||
|
lines.push(` - ${ex.name} (${ex.type}): ${bits.join(' · ')}`);
|
||||||
|
}
|
||||||
|
if (summary.exercises.length > top.length) {
|
||||||
|
lines.push(
|
||||||
|
` ...and ${summary.exercises.length - top.length} more exercises with lower volume`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
` When designing the program, weight recent activity heavily. Address STAGNANT exercises if relevant. Don't propose deload-week-heavy work for someone training infrequently, and don't propose 6-day splits for someone averaging <3 sessions/week.`,
|
||||||
|
);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Lenient JSON parser for incremental rendering of in-flight LLM
|
||||||
|
* output.
|
||||||
|
*
|
||||||
|
* The model emits JSON one token at a time. Strict JSON.parse fails
|
||||||
|
* until the very last `}` arrives. lenientJsonParse instead:
|
||||||
|
*
|
||||||
|
* 1. Locates the first `{` (after stripping ```json fences).
|
||||||
|
* 2. Walks the buffer tracking quote state + an open-bracket
|
||||||
|
* stack so we know what to close in what order.
|
||||||
|
* 3. Closes any open string with `"`.
|
||||||
|
* 4. Trims a partial trailing keyword (true/false/null prefix),
|
||||||
|
* trailing comma, and dangling key:value pair where value is
|
||||||
|
* missing.
|
||||||
|
* 5. Closes open structures in reverse-of-opening order (so
|
||||||
|
* `[{` closes as `}]`, not `]}`).
|
||||||
|
* 6. JSON.parse the result; return null if it still fails.
|
||||||
|
*
|
||||||
|
* The returned object is a best-effort snapshot of the program so
|
||||||
|
* far. The Generate UI uses it to render a live preview as the
|
||||||
|
* model writes; once the stream ends, the FULL response is parsed
|
||||||
|
* with the strict parser via parseAIProgram for the final render.
|
||||||
|
*
|
||||||
|
* This is intentionally simple — partial numbers (e.g. `-2.`) and
|
||||||
|
* partial escape sequences just return null until the next chunk
|
||||||
|
* makes them well-formed.
|
||||||
|
*/
|
||||||
|
export function lenientJsonParse(raw: string): unknown | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
// Strip ```json fences (or plain ``` fences). Tolerates an
|
||||||
|
// unclosed trailing fence (still streaming).
|
||||||
|
let s = raw;
|
||||||
|
const fenced = s.match(/```(?:json)?\s*([\s\S]*?)(?:\s*```|$)/);
|
||||||
|
if (fenced) s = fenced[1];
|
||||||
|
|
||||||
|
// Locate first `{`.
|
||||||
|
const startIdx = s.indexOf('{');
|
||||||
|
if (startIdx < 0) return null;
|
||||||
|
s = s.slice(startIdx);
|
||||||
|
|
||||||
|
// Quick path: maybe it's already valid (rare during streaming,
|
||||||
|
// common after the stream completes).
|
||||||
|
try {
|
||||||
|
return JSON.parse(s);
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the buffer tracking the open-bracket stack. We don't try
|
||||||
|
// to recover from mismatched closers (would be model malformity);
|
||||||
|
// we just don't pop more than we have.
|
||||||
|
const stack: Array<'{' | '['> = [];
|
||||||
|
let inStr = false;
|
||||||
|
let escape = false;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
const c = s[i];
|
||||||
|
if (escape) {
|
||||||
|
escape = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '\\') {
|
||||||
|
escape = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '"') {
|
||||||
|
inStr = !inStr;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inStr) continue;
|
||||||
|
if (c === '{') stack.push('{');
|
||||||
|
else if (c === '}') {
|
||||||
|
if (stack[stack.length - 1] === '{') stack.pop();
|
||||||
|
} else if (c === '[') stack.push('[');
|
||||||
|
else if (c === ']') {
|
||||||
|
if (stack[stack.length - 1] === '[') stack.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = s;
|
||||||
|
|
||||||
|
// Close any open string at the tail.
|
||||||
|
if (inStr) candidate += '"';
|
||||||
|
|
||||||
|
// Trim trailing whitespace.
|
||||||
|
candidate = candidate.replace(/\s+$/, '');
|
||||||
|
|
||||||
|
// Drop a partial trailing keyword (`true`/`false`/`null` prefix)
|
||||||
|
// sitting after a `:`, `,`, or `[`.
|
||||||
|
candidate = candidate.replace(
|
||||||
|
/([:,[])\s*(?:t|tr|tru|f|fa|fal|fals|n|nu|nul)$/,
|
||||||
|
'$1',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop a trailing comma (no value follows yet).
|
||||||
|
candidate = candidate.replace(/,\s*$/, '');
|
||||||
|
|
||||||
|
// Drop a dangling key + colon (value not started yet).
|
||||||
|
candidate = candidate.replace(/"[^"\\]*(?:\\.[^"\\]*)*"\s*:\s*$/, '');
|
||||||
|
|
||||||
|
// Drop another trailing comma that may now be exposed.
|
||||||
|
candidate = candidate.replace(/,\s*$/, '');
|
||||||
|
|
||||||
|
// Close stack in reverse-of-opening order. `[{` becomes `}]` not
|
||||||
|
// `]}` — that's the bug a depth-counter approach would have.
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const top = stack.pop()!;
|
||||||
|
candidate += top === '{' ? '}' : ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(candidate);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Per-model pricing in USD per million tokens. Used to estimate the
|
||||||
|
* cost of an AIGeneration row from its tokensIn/tokensOut.
|
||||||
|
*
|
||||||
|
* Prices change. This table is a best-effort starting point for
|
||||||
|
* common models as of mid-2026; users on other models will see
|
||||||
|
* `null` cost (we still surface token counts). Updating: edit this
|
||||||
|
* file and ship — no schema change needed.
|
||||||
|
*
|
||||||
|
* Matching strategy: case-insensitive prefix lookup against the
|
||||||
|
* user's configured model string. Model names like
|
||||||
|
* "claude-sonnet-4-5-20251022" match the "claude-sonnet-4-5" prefix.
|
||||||
|
*
|
||||||
|
* Keys are organized by provider for readability but the lookup is
|
||||||
|
* provider-agnostic — the model string is the key.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface PriceEntry {
|
||||||
|
inputPerM: number; // USD per 1M input tokens
|
||||||
|
outputPerM: number; // USD per 1M output tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRICES: Record<string, PriceEntry> = {
|
||||||
|
// Anthropic Claude (Messages API) — opus tier $15/$75, sonnet $3/$15,
|
||||||
|
// haiku $0.80/$4. New point releases inherit their tier's pricing.
|
||||||
|
'claude-opus-4-7': { inputPerM: 15, outputPerM: 75 },
|
||||||
|
'claude-opus-4-6': { inputPerM: 15, outputPerM: 75 },
|
||||||
|
'claude-opus-4-5': { inputPerM: 15, outputPerM: 75 },
|
||||||
|
'claude-opus-4': { inputPerM: 15, outputPerM: 75 },
|
||||||
|
'claude-sonnet-4-6': { inputPerM: 3, outputPerM: 15 },
|
||||||
|
'claude-sonnet-4-5': { inputPerM: 3, outputPerM: 15 },
|
||||||
|
'claude-sonnet-4': { inputPerM: 3, outputPerM: 15 },
|
||||||
|
'claude-haiku-4-5': { inputPerM: 0.8, outputPerM: 4 },
|
||||||
|
'claude-haiku-4': { inputPerM: 0.8, outputPerM: 4 },
|
||||||
|
'claude-3-7-sonnet': { inputPerM: 3, outputPerM: 15 },
|
||||||
|
'claude-3-5-sonnet': { inputPerM: 3, outputPerM: 15 },
|
||||||
|
'claude-3-5-haiku': { inputPerM: 0.8, outputPerM: 4 },
|
||||||
|
|
||||||
|
// OpenAI — gpt-5.x flagships ~$1.25-$2/$10-$15, mini/nano cheaper
|
||||||
|
'gpt-5.5': { inputPerM: 2, outputPerM: 15 },
|
||||||
|
'gpt-5.4': { inputPerM: 1.5, outputPerM: 12 },
|
||||||
|
'gpt-5.4-mini': { inputPerM: 0.3, outputPerM: 2.4 },
|
||||||
|
'gpt-5.4-nano': { inputPerM: 0.06, outputPerM: 0.5 },
|
||||||
|
'gpt-5.3': { inputPerM: 1.5, outputPerM: 12 },
|
||||||
|
'gpt-5.2': { inputPerM: 1.5, outputPerM: 12 },
|
||||||
|
'gpt-5.1': { inputPerM: 1.25, outputPerM: 10 },
|
||||||
|
'gpt-5': { inputPerM: 1.25, outputPerM: 10 },
|
||||||
|
'gpt-5-mini': { inputPerM: 0.25, outputPerM: 2 },
|
||||||
|
'gpt-5-nano': { inputPerM: 0.05, outputPerM: 0.4 },
|
||||||
|
'gpt-4o': { inputPerM: 2.5, outputPerM: 10 },
|
||||||
|
'gpt-4o-mini': { inputPerM: 0.15, outputPerM: 0.6 },
|
||||||
|
'o1': { inputPerM: 15, outputPerM: 60 },
|
||||||
|
'o3': { inputPerM: 2, outputPerM: 8 },
|
||||||
|
'o3-mini': { inputPerM: 1.1, outputPerM: 4.4 },
|
||||||
|
'o4-mini': { inputPerM: 1.1, outputPerM: 4.4 },
|
||||||
|
|
||||||
|
// Google Gemini — Gemini 3.1 Pro is $2/$12 standard; >200K ctx is 2x.
|
||||||
|
// Gemini 3 Flash is $0.50/$3. 3.1 Flash-Lite is the cheapest of the
|
||||||
|
// 3.x line. Both short names (gemini-3.1-pro) and long preview names
|
||||||
|
// (gemini-3.1-pro-preview) are accepted by the API and listed here.
|
||||||
|
'gemini-3.1-pro-preview': { inputPerM: 2, outputPerM: 12 },
|
||||||
|
'gemini-3.1-pro': { inputPerM: 2, outputPerM: 12 },
|
||||||
|
'gemini-3.1-flash-lite': { inputPerM: 0.1, outputPerM: 0.4 },
|
||||||
|
'gemini-3.1-flash': { inputPerM: 0.5, outputPerM: 3 },
|
||||||
|
'gemini-3-pro-preview': { inputPerM: 2, outputPerM: 12 },
|
||||||
|
'gemini-3-pro': { inputPerM: 2, outputPerM: 12 },
|
||||||
|
'gemini-3-flash-preview': { inputPerM: 0.5, outputPerM: 3 },
|
||||||
|
'gemini-3-flash': { inputPerM: 0.5, outputPerM: 3 },
|
||||||
|
'gemini-2.5-pro': { inputPerM: 1.25, outputPerM: 10 },
|
||||||
|
'gemini-2.5-flash': { inputPerM: 0.3, outputPerM: 2.5 },
|
||||||
|
'gemini-2.0-flash': { inputPerM: 0.1, outputPerM: 0.4 },
|
||||||
|
'gemini-2.0-pro': { inputPerM: 1.25, outputPerM: 5 },
|
||||||
|
'gemini-1.5-pro': { inputPerM: 1.25, outputPerM: 5 },
|
||||||
|
'gemini-1.5-flash': { inputPerM: 0.075, outputPerM: 0.3 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-provider model menus — source of truth for the "Model" dropdown
|
||||||
|
* in Settings → AI integration. `recommended` floats to the top. Users
|
||||||
|
* can still type a custom model name (the dropdown has an "Other"
|
||||||
|
* option that switches to free-text input). Order = display order.
|
||||||
|
*
|
||||||
|
* Update these when new models ship. Keys correspond to provider IDs
|
||||||
|
* in lib/ai/providers/index.ts.
|
||||||
|
*/
|
||||||
|
export interface ModelOption {
|
||||||
|
/** Exact API model identifier */
|
||||||
|
id: string;
|
||||||
|
/** Human-readable label shown in the dropdown */
|
||||||
|
label: string;
|
||||||
|
/** Floats to the top + gets a "★" mark */
|
||||||
|
recommended?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MODEL_MENU: Record<string, ModelOption[]> = {
|
||||||
|
claude: [
|
||||||
|
{ id: 'claude-opus-4-7', label: 'Claude Opus 4.7 (most capable)', recommended: true },
|
||||||
|
{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (1M context, fast)', recommended: true },
|
||||||
|
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5 (cheapest, fastest)', recommended: true },
|
||||||
|
{ id: 'claude-opus-4-6', label: 'Claude Opus 4.6' },
|
||||||
|
{ id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
|
||||||
|
{ id: 'claude-3-7-sonnet-latest', label: 'Claude 3.7 Sonnet' },
|
||||||
|
],
|
||||||
|
openai: [
|
||||||
|
{ id: 'gpt-5.5', label: 'GPT-5.5 (most capable)', recommended: true },
|
||||||
|
{ id: 'gpt-5.4', label: 'GPT-5.4', recommended: true },
|
||||||
|
{ id: 'gpt-5.4-mini', label: 'GPT-5.4 Mini (cheap, fast)', recommended: true },
|
||||||
|
{ id: 'gpt-5.4-nano', label: 'GPT-5.4 Nano (cheapest)' },
|
||||||
|
{ id: 'gpt-5', label: 'GPT-5' },
|
||||||
|
{ id: 'gpt-4o', label: 'GPT-4o (legacy)' },
|
||||||
|
{ id: 'o3', label: 'o3 (reasoning)' },
|
||||||
|
],
|
||||||
|
gemini: [
|
||||||
|
// Names match what Google's AI Studio dropdown shows. Both short
|
||||||
|
// (gemini-3.1-pro) and long preview names work via the API; we
|
||||||
|
// ship the short forms because that's what the Studio UI uses.
|
||||||
|
{ id: 'gemini-3.1-pro', label: 'Gemini 3.1 Pro (most capable)', recommended: true },
|
||||||
|
{ id: 'gemini-3.1-flash', label: 'Gemini 3.1 Flash (fast, cheap)', recommended: true },
|
||||||
|
{ id: 'gemini-3.1-flash-lite', label: 'Gemini 3.1 Flash Lite (cheapest)', recommended: true },
|
||||||
|
{ id: 'gemini-3-pro', label: 'Gemini 3 Pro' },
|
||||||
|
{ id: 'gemini-3-flash', label: 'Gemini 3 Flash' },
|
||||||
|
{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||||
|
{ id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||||
|
{ id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash (legacy)' },
|
||||||
|
],
|
||||||
|
// openai-compatible + ollama: no curated menu — model names are
|
||||||
|
// gateway- or host-specific. Ollama auto-detects via /api/tags.
|
||||||
|
'openai-compatible': [],
|
||||||
|
ollama: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Find the price entry whose key is a (case-insensitive) prefix of the model string. */
|
||||||
|
export function findPrice(model: string): PriceEntry | null {
|
||||||
|
const m = model.toLowerCase();
|
||||||
|
// Longest-prefix-first so e.g. "claude-sonnet-4-5" beats "claude-sonnet-4".
|
||||||
|
const sortedKeys = Object.keys(PRICES).sort((a, b) => b.length - a.length);
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
if (m.startsWith(key.toLowerCase())) return PRICES[key];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate the USD cost of a generation. Returns null if the model
|
||||||
|
* isn't in the price table or if either token count is missing.
|
||||||
|
* Ollama and openai-compatible custom gateways always return null
|
||||||
|
* (they're either free or self-priced).
|
||||||
|
*/
|
||||||
|
export function estimateCost(opts: {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
tokensIn: number | null;
|
||||||
|
tokensOut: number | null;
|
||||||
|
}): number | null {
|
||||||
|
if (opts.provider === 'ollama') return 0; // self-hosted, no per-token cost
|
||||||
|
if (opts.provider === 'openai-compatible') return null; // we don't know the gateway's pricing
|
||||||
|
if (opts.tokensIn == null || opts.tokensOut == null) return null;
|
||||||
|
const price = findPrice(opts.model);
|
||||||
|
if (!price) return null;
|
||||||
|
return (
|
||||||
|
(opts.tokensIn / 1_000_000) * price.inputPerM +
|
||||||
|
(opts.tokensOut / 1_000_000) * price.outputPerM
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format USD to a string suitable for a UI label. Below $0.01 -> "<$0.01". */
|
||||||
|
export function formatCost(usd: number | null): string {
|
||||||
|
if (usd == null) return '—';
|
||||||
|
if (usd === 0) return 'free';
|
||||||
|
if (usd < 0.01) return '<$0.01';
|
||||||
|
if (usd < 1) return `$${usd.toFixed(3)}`;
|
||||||
|
return `$${usd.toFixed(2)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shape we ask LLMs to produce, validated server-side via Zod
|
||||||
|
* after parsing whatever JSON came back. Maps 1:1 onto the existing
|
||||||
|
* Program -> ProgramWeek -> ProgramDay -> ProgramExercise tables
|
||||||
|
* so the apply step is just a transactional INSERT.
|
||||||
|
*
|
||||||
|
* `exerciseId` is nullable: the model picks from the user's library
|
||||||
|
* when it can but is allowed to suggest an exercise that doesn't
|
||||||
|
* exist yet (we'll prompt the user to create it during preview).
|
||||||
|
* `exerciseName` is REQUIRED so we always have a display label and a
|
||||||
|
* fallback for fuzzy matching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const aiExerciseSchema = z.object({
|
||||||
|
exerciseId: z.string().nullable(),
|
||||||
|
exerciseName: z.string().min(1),
|
||||||
|
order: z.number().int().nonnegative(),
|
||||||
|
sets: z.number().int().positive().optional().nullable(),
|
||||||
|
repsMin: z.number().int().positive().optional().nullable(),
|
||||||
|
repsMax: z.number().int().positive().optional().nullable(),
|
||||||
|
rpe: z.number().int().min(1).max(10).optional().nullable(),
|
||||||
|
restSeconds: z.number().int().nonnegative().optional().nullable(),
|
||||||
|
/// Suggested starting weight. Not required (cardio, bodyweight,
|
||||||
|
/// stretching all leave it null). When provided alongside an
|
||||||
|
/// exerciseId that the user starts a workout from, this seeds the
|
||||||
|
/// SetLog.weight as a target.
|
||||||
|
suggestedWeight: z.number().nonnegative().optional().nullable(),
|
||||||
|
/// "lbs" | "kg". Optional; the apply step falls back to the user's
|
||||||
|
/// `defaultWeightUnit` preference when null.
|
||||||
|
suggestedWeightUnit: z.enum(['lbs', 'kg']).optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiDaySchema = z.object({
|
||||||
|
dayOfWeek: z.number().int().min(0).max(6),
|
||||||
|
name: z.string().optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
exercises: z.array(aiExerciseSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiWeekSchema = z.object({
|
||||||
|
weekNumber: z.number().int().positive(),
|
||||||
|
phase: z.string().optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
days: z.array(aiDaySchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiProgramSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
type: z.string().min(1),
|
||||||
|
durationWeeks: z.number().int().positive(),
|
||||||
|
weeks: z.array(aiWeekSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AIProgram = z.infer<typeof aiProgramSchema>;
|
||||||
|
export type AIWeek = z.infer<typeof aiWeekSchema>;
|
||||||
|
export type AIDay = z.infer<typeof aiDaySchema>;
|
||||||
|
export type AIExercise = z.infer<typeof aiExerciseSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JSON-schema-ish description we paste into the system prompt so
|
||||||
|
* the model knows the exact shape to emit. We don't pass it to a
|
||||||
|
* provider's "structured output" mode (Ollama in particular doesn't
|
||||||
|
* support that uniformly across models) — it's just a doc the model
|
||||||
|
* reads.
|
||||||
|
*/
|
||||||
|
export const PROGRAM_OUTPUT_SHAPE = `{
|
||||||
|
"name": "<string>",
|
||||||
|
"description": "<string, optional>",
|
||||||
|
"type": "<string: hypertrophy | strength | power | endurance | recovery | general>",
|
||||||
|
"durationWeeks": <int >= 1>,
|
||||||
|
"weeks": [
|
||||||
|
{
|
||||||
|
"weekNumber": <int >= 1>,
|
||||||
|
"phase": "<string, optional, e.g. Volume / Intensity / Deload>",
|
||||||
|
"description": "<string, optional>",
|
||||||
|
"days": [
|
||||||
|
{
|
||||||
|
"dayOfWeek": <int 0-6, 0=Sunday>,
|
||||||
|
"name": "<string, optional, e.g. Push Day>",
|
||||||
|
"description": "<string, optional>",
|
||||||
|
"exercises": [
|
||||||
|
{
|
||||||
|
"exerciseId": "<string — REQUIRED — must be an id from the LIBRARY block. If no library exercise fits, pick the closest match and explain in notes; do NOT invent ids.>",
|
||||||
|
"exerciseName": "<string, the canonical name from the library>",
|
||||||
|
"order": <int >= 0>,
|
||||||
|
"sets": <int, optional but recommended>,
|
||||||
|
"repsMin": <int, optional>,
|
||||||
|
"repsMax": <int, optional>,
|
||||||
|
"rpe": <int 1-10, optional>,
|
||||||
|
"restSeconds": <int >= 0, optional>,
|
||||||
|
"suggestedWeight": <number, optional — starting weight; omit/null for cardio, bodyweight, stretching>,
|
||||||
|
"suggestedWeightUnit": "<\\"lbs\\" | \\"kg\\", optional — defaults to user's preferred unit>",
|
||||||
|
"notes": "<string, optional, coaching note>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to extract a JSON object from a model's raw response. Models
|
||||||
|
* sometimes wrap output in ```json fences or add commentary before/
|
||||||
|
* after. This pulls the first balanced {...} block.
|
||||||
|
*/
|
||||||
|
export function extractJson(raw: string): string | null {
|
||||||
|
// Strip code fences if present
|
||||||
|
const fenced = raw.match(/```(?:json)?\s*([\s\S]+?)\s*```/);
|
||||||
|
if (fenced) return fenced[1].trim();
|
||||||
|
|
||||||
|
// Otherwise find the first balanced {...}
|
||||||
|
const start = raw.indexOf('{');
|
||||||
|
if (start < 0) return null;
|
||||||
|
let depth = 0;
|
||||||
|
let inStr = false;
|
||||||
|
let escape = false;
|
||||||
|
for (let i = start; i < raw.length; i++) {
|
||||||
|
const c = raw[i];
|
||||||
|
if (escape) {
|
||||||
|
escape = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '\\') {
|
||||||
|
escape = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '"') {
|
||||||
|
inStr = !inStr;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inStr) continue;
|
||||||
|
if (c === '{') depth++;
|
||||||
|
else if (c === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return raw.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse + validate a model's raw response. Returns either a clean
|
||||||
|
* AIProgram or a structured error.
|
||||||
|
*/
|
||||||
|
export function parseAIProgram(
|
||||||
|
raw: string,
|
||||||
|
):
|
||||||
|
| { ok: true; program: AIProgram }
|
||||||
|
| { ok: false; reason: string; json?: string } {
|
||||||
|
const json = extractJson(raw);
|
||||||
|
if (!json) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'Could not find a JSON object in the response.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let obj: unknown;
|
||||||
|
try {
|
||||||
|
obj = JSON.parse(json);
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `JSON parse error: ${(e as Error).message}`,
|
||||||
|
json,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const result = aiProgramSchema.safeParse(obj);
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason:
|
||||||
|
'JSON did not match the expected shape: ' +
|
||||||
|
result.error.errors
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||||
|
.join('; '),
|
||||||
|
json,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, program: result.data };
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Gemini: streamed JSON over POST
|
||||||
|
* /v1beta/models/{model}:streamGenerateContent?alt=sse&key=KEY
|
||||||
|
*
|
||||||
|
* The `alt=sse` flag makes Gemini emit Server-Sent Events with each
|
||||||
|
* `data:` line a partial GenerateContentResponse chunk:
|
||||||
|
* { candidates: [ { content: { parts: [ { text: "..." } ] } } ],
|
||||||
|
* usageMetadata: { promptTokenCount, candidatesTokenCount } }
|
||||||
|
*/
|
||||||
|
export const gemini: LLMProvider = {
|
||||||
|
id: 'gemini',
|
||||||
|
label: 'Google Gemini',
|
||||||
|
requiresApiKey: true,
|
||||||
|
requiresBaseUrl: false,
|
||||||
|
|
||||||
|
async *generate(opts: GenerateOpts): AsyncGenerator<GenerateChunk, void, void> {
|
||||||
|
if (!opts.apiKey) {
|
||||||
|
yield { type: 'error', message: 'Gemini API key is required.' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
|
||||||
|
opts.model,
|
||||||
|
)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(opts.apiKey)}`;
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
systemInstruction: { parts: [{ text: opts.systemPrompt }] },
|
||||||
|
contents: [
|
||||||
|
{ role: 'user', parts: [{ text: opts.userPrompt }] },
|
||||||
|
],
|
||||||
|
generationConfig: {
|
||||||
|
temperature: 0.7,
|
||||||
|
maxOutputTokens: opts.maxOutputTokens ?? 8000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
signal: opts.signal,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
yield {
|
||||||
|
type: 'error',
|
||||||
|
message: `Gemini unreachable: ${(e as Error).message}`,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
yield {
|
||||||
|
type: 'error',
|
||||||
|
message: `Gemini HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 500)}`,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tokensIn: number | undefined;
|
||||||
|
let tokensOut: number | undefined;
|
||||||
|
let textEmitted = false;
|
||||||
|
let lastFinishReason: string | null = null;
|
||||||
|
try {
|
||||||
|
// Gemini SSE: same line-delimited "data: ..." frames.
|
||||||
|
const { sseLines } = await import('../sse');
|
||||||
|
for await (const data of sseLines(res)) {
|
||||||
|
let evt: any;
|
||||||
|
try {
|
||||||
|
evt = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cand = evt.candidates?.[0];
|
||||||
|
const parts = cand?.content?.parts;
|
||||||
|
if (Array.isArray(parts)) {
|
||||||
|
for (const p of parts) {
|
||||||
|
if (p.text) {
|
||||||
|
yield { type: 'text', delta: p.text };
|
||||||
|
textEmitted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cand?.finishReason) {
|
||||||
|
lastFinishReason = cand.finishReason;
|
||||||
|
}
|
||||||
|
if (evt.usageMetadata) {
|
||||||
|
tokensIn = evt.usageMetadata.promptTokenCount;
|
||||||
|
tokensOut = evt.usageMetadata.candidatesTokenCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Surface a useful error when Gemini returned 200 OK but emitted
|
||||||
|
// no text — most often a safety/recitation block, or a thinking
|
||||||
|
// model that exhausted maxOutputTokens on internal reasoning. The
|
||||||
|
// test endpoint relies on this to give the user a real message
|
||||||
|
// instead of a generic "empty response".
|
||||||
|
if (
|
||||||
|
!textEmitted &&
|
||||||
|
lastFinishReason &&
|
||||||
|
lastFinishReason !== 'STOP'
|
||||||
|
) {
|
||||||
|
const friendly = describeFinishReason(lastFinishReason);
|
||||||
|
yield { type: 'error', message: `Gemini blocked the response: ${friendly}` };
|
||||||
|
}
|
||||||
|
yield { type: 'usage', tokensIn, tokensOut };
|
||||||
|
yield { type: 'done' };
|
||||||
|
} catch (e) {
|
||||||
|
yield {
|
||||||
|
type: 'error',
|
||||||
|
message: `Gemini stream error: ${(e as Error).message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function describeFinishReason(reason: string): string {
|
||||||
|
switch (reason) {
|
||||||
|
case 'SAFETY':
|
||||||
|
return 'safety filter (try a flagship model or rephrase the prompt)';
|
||||||
|
case 'RECITATION':
|
||||||
|
return 'recitation filter';
|
||||||
|
case 'MAX_TOKENS':
|
||||||
|
return 'hit the output token limit before finishing — raise maxOutputTokens or use a non-thinking model';
|
||||||
|
case 'BLOCKLIST':
|
||||||
|
return 'blocklist match';
|
||||||
|
case 'PROHIBITED_CONTENT':
|
||||||
|
return 'prohibited-content filter';
|
||||||
|
case 'SPII':
|
||||||
|
return 'sensitive-PII filter';
|
||||||
|
default:
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { LLMProvider, ProviderId } from '../types';
|
||||||
|
import { ollama } from './ollama';
|
||||||
|
import { claude } from './claude';
|
||||||
|
import { openai, openaiCompatible } from './openai';
|
||||||
|
import { gemini } from './gemini';
|
||||||
|
|
||||||
|
const ALL: Record<ProviderId, LLMProvider> = {
|
||||||
|
claude,
|
||||||
|
openai,
|
||||||
|
'openai-compatible': openaiCompatible,
|
||||||
|
gemini,
|
||||||
|
ollama,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getProvider(id: string): LLMProvider | null {
|
||||||
|
return (ALL as Record<string, LLMProvider | undefined>)[id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable list for UI dropdowns. Order matches the Settings select. */
|
||||||
|
export const PROVIDER_ORDER: ProviderId[] = [
|
||||||
|
'claude',
|
||||||
|
'openai',
|
||||||
|
'openai-compatible',
|
||||||
|
'gemini',
|
||||||
|
'ollama',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PROVIDERS = ALL;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
|
||||||
|
import { ndjsonLines } from '../sse';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ollama: streaming NDJSON over POST /api/chat.
|
||||||
|
*
|
||||||
|
* No API key. baseUrl required (e.g. http://ollama.embassy:11434).
|
||||||
|
* Each line is `{"message":{"content":"..."},"done":false}` until
|
||||||
|
* `{"done":true,"prompt_eval_count":N,"eval_count":M}`.
|
||||||
|
*/
|
||||||
|
export const ollama: LLMProvider = {
|
||||||
|
id: 'ollama',
|
||||||
|
label: 'Ollama (self-hosted)',
|
||||||
|
requiresApiKey: false,
|
||||||
|
requiresBaseUrl: true,
|
||||||
|
|
||||||
|
async *generate(opts: GenerateOpts): AsyncGenerator<GenerateChunk, void, void> {
|
||||||
|
if (!opts.baseUrl) {
|
||||||
|
yield { type: 'error', message: 'Ollama base URL is required.' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = opts.baseUrl.replace(/\/$/, '') + '/api/chat';
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: opts.model,
|
||||||
|
stream: true,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: opts.systemPrompt },
|
||||||
|
{ role: 'user', content: opts.userPrompt },
|
||||||
|
],
|
||||||
|
options: {
|
||||||
|
temperature: 0.7,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
signal: opts.signal,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
yield {
|
||||||
|
type: 'error',
|
||||||
|
message: `Ollama unreachable at ${opts.baseUrl}: ${(e as Error).message}`,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
yield {
|
||||||
|
type: 'error',
|
||||||
|
message: `Ollama HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 500)}`,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tokensIn: number | undefined;
|
||||||
|
let tokensOut: number | undefined;
|
||||||
|
try {
|
||||||
|
for await (const line of ndjsonLines(res)) {
|
||||||
|
let evt: any;
|
||||||
|
try {
|
||||||
|
evt = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (evt.message?.content) {
|
||||||
|
yield { type: 'text', delta: evt.message.content };
|
||||||
|
}
|
||||||
|
if (evt.done) {
|
||||||
|
tokensIn = evt.prompt_eval_count;
|
||||||
|
tokensOut = evt.eval_count;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield { type: 'usage', tokensIn, tokensOut };
|
||||||
|
yield { type: 'done' };
|
||||||
|
} catch (e) {
|
||||||
|
yield {
|
||||||
|
type: 'error',
|
||||||
|
message: `Ollama stream error: ${(e as Error).message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
|
||||||
|
import { sseLines } from '../sse';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic chat-completions streamer used by both OpenAI and the
|
||||||
|
* "openai-compatible" provider (OpenRouter, LiteLLM, vLLM, Together,
|
||||||
|
* etc.). The wire format is the same — only the base URL differs.
|
||||||
|
*
|
||||||
|
* Streaming:
|
||||||
|
* - data: {choices:[{delta:{content:"..."}}]}
|
||||||
|
* - data: {choices:[{delta:{}}]} ← end-of-content
|
||||||
|
* - data: {usage:{prompt_tokens, completion_tokens}} (when stream_options.include_usage)
|
||||||
|
* - data: [DONE]
|
||||||
|
*/
|
||||||
|
export async function* generateOpenAIStyle(
|
||||||
|
opts: GenerateOpts,
|
||||||
|
baseUrl: string,
|
||||||
|
providerLabel: string,
|
||||||
|
): AsyncGenerator<GenerateChunk, void, void> {
|
||||||
|
if (!opts.apiKey) {
|
||||||
|
yield { type: 'error', message: `${providerLabel} API key is required.` };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = baseUrl.replace(/\/$/, '') + '/chat/completions';
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
authorization: `Bearer ${opts.apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: opts.model,
|
||||||
|
stream: true,
|
||||||
|
stream_options: { include_usage: true },
|
||||||
|
...(opts.maxOutputTokens != null
|
||||||
|
? { max_completion_tokens: opts.maxOutputTokens }
|
||||||
|
: {}),
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: opts.systemPrompt },
|
||||||
|
{ role: 'user', content: opts.userPrompt },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
signal: opts.signal,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
yield {
|
||||||
|
type: 'error',
|
||||||
|
message: `${providerLabel} unreachable: ${(e as Error).message}`,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
yield {
|
||||||
|
type: 'error',
|
||||||
|
message: `${providerLabel} 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;
|
||||||
|
}
|
||||||
|
const delta = evt.choices?.[0]?.delta?.content;
|
||||||
|
if (delta) yield { type: 'text', delta };
|
||||||
|
if (evt.usage) {
|
||||||
|
tokensIn = evt.usage.prompt_tokens;
|
||||||
|
tokensOut = evt.usage.completion_tokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield { type: 'usage', tokensIn, tokensOut };
|
||||||
|
yield { type: 'done' };
|
||||||
|
} catch (e) {
|
||||||
|
yield {
|
||||||
|
type: 'error',
|
||||||
|
message: `${providerLabel} stream error: ${(e as Error).message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const openai: LLMProvider = {
|
||||||
|
id: 'openai',
|
||||||
|
label: 'OpenAI',
|
||||||
|
requiresApiKey: true,
|
||||||
|
requiresBaseUrl: false,
|
||||||
|
generate(opts) {
|
||||||
|
return generateOpenAIStyle(opts, 'https://api.openai.com/v1', 'OpenAI');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openaiCompatible: LLMProvider = {
|
||||||
|
id: 'openai-compatible',
|
||||||
|
label: 'OpenAI-compatible (custom URL)',
|
||||||
|
requiresApiKey: true,
|
||||||
|
requiresBaseUrl: true,
|
||||||
|
async *generate(opts) {
|
||||||
|
if (!opts.baseUrl) {
|
||||||
|
yield {
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
'Base URL is required (e.g. https://openrouter.ai/api/v1, your LiteLLM gateway, etc.).',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield* generateOpenAIStyle(opts, opts.baseUrl, 'OpenAI-compatible');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Minimal SSE-line iterator for provider responses.
|
||||||
|
*
|
||||||
|
* Reads a fetch response body as a stream and yields each `data:`
|
||||||
|
* payload exactly once. Handles event boundaries (`\n\n`), CRLF,
|
||||||
|
* and the "[DONE]" sentinel that OpenAI-style providers emit. Skips
|
||||||
|
* comments (lines starting with `:`).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* for await (const data of sseLines(response)) {
|
||||||
|
* if (data === '[DONE]') break;
|
||||||
|
* const evt = JSON.parse(data);
|
||||||
|
* ...
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export async function* sseLines(
|
||||||
|
response: Response,
|
||||||
|
): AsyncGenerator<string, void, void> {
|
||||||
|
if (!response.body) return;
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let idx: number;
|
||||||
|
while ((idx = buffer.indexOf('\n\n')) >= 0) {
|
||||||
|
const event = buffer.slice(0, idx);
|
||||||
|
buffer = buffer.slice(idx + 2);
|
||||||
|
const dataLines: string[] = [];
|
||||||
|
for (const raw of event.split('\n')) {
|
||||||
|
const line = raw.replace(/\r$/, '');
|
||||||
|
if (!line || line.startsWith(':')) continue;
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.slice(5).trimStart());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dataLines.length > 0) yield dataLines.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NDJSON line iterator (Ollama). Yields each non-empty line as a
|
||||||
|
* raw string (not parsed) — caller decides what to do with it.
|
||||||
|
*/
|
||||||
|
export async function* ndjsonLines(
|
||||||
|
response: Response,
|
||||||
|
): AsyncGenerator<string, void, void> {
|
||||||
|
if (!response.body) return;
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let idx: number;
|
||||||
|
while ((idx = buffer.indexOf('\n')) >= 0) {
|
||||||
|
const line = buffer.slice(0, idx).replace(/\r$/, '');
|
||||||
|
buffer = buffer.slice(idx + 1);
|
||||||
|
if (line) yield line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer.trim()) yield buffer.trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Base system-prompt rules prepended to every template's prompt before
|
||||||
|
* sending to the model. Centralized here so we can tighten output
|
||||||
|
* constraints in one place rather than editing every template.
|
||||||
|
*
|
||||||
|
* Two main jobs:
|
||||||
|
* 1. Force the JSON output shape (no prose, no fences, picks library
|
||||||
|
* ids only — fixes "exerciseId doesn't belong to this user" errors)
|
||||||
|
* 2. Force a suggested starting weight per resistance exercise
|
||||||
|
* (the model otherwise tends to leave it null, which leaves the
|
||||||
|
* user with no concrete target on day 1)
|
||||||
|
*
|
||||||
|
* Templates supply their *coaching philosophy* (hypertrophy = volume +
|
||||||
|
* progressive overload, conditioning = aerobic base etc); this module
|
||||||
|
* supplies the *structural contract*.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BaseSystemPromptOpts {
|
||||||
|
/** "lbs" | "kg" — the user's preferred weight unit, used as the default
|
||||||
|
* suggestedWeightUnit when the model omits one. */
|
||||||
|
weightUnit: 'lbs' | 'kg';
|
||||||
|
/** Whether the user's workout history is being included. Toggles a
|
||||||
|
* short instruction on how to use it. */
|
||||||
|
hasHistoryContext: boolean;
|
||||||
|
/** True when the model is local (Ollama). Local models tend to need
|
||||||
|
* shorter, blunter rules and benefit from explicit examples. */
|
||||||
|
isLocalModel: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBaseSystemPrompt(opts: BaseSystemPromptOpts): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
'# OUTPUT CONTRACT (mandatory)',
|
||||||
|
'',
|
||||||
|
'1. Reply with EXACTLY ONE JSON object. No prose before or after. No ```json fences.',
|
||||||
|
'2. Every exercise must use an `exerciseId` from the LIBRARY block at the bottom of this prompt. NEVER invent ids. If nothing in the library matches, pick the closest fit and explain the substitution in `notes`.',
|
||||||
|
`3. Every resistance exercise MUST have a \`suggestedWeight\` (a number, in ${opts.weightUnit}). Cardio, stretching, and bodyweight exercises set it to null.`,
|
||||||
|
`4. \`suggestedWeightUnit\` should be "${opts.weightUnit}" unless the exercise is conventionally tracked in the other unit (e.g. kettlebells often kg). Omit for non-loaded exercises.`,
|
||||||
|
'5. Every exercise needs `sets` and either `repsMin` (with `repsMax` if a range) or a duration note.',
|
||||||
|
'6. Use `rpe` (1-10) for working sets to communicate intensity; warmups can be lower or omitted.',
|
||||||
|
'7. `restSeconds` is required for compound lifts; optional for accessories.',
|
||||||
|
'8. Keep day volumes realistic: 4-7 exercises, 60-75 minutes total. Include warm-up sets only if they belong in the program (don\'t list mobility separately unless the user asked).',
|
||||||
|
'9. The `notes` field is for coaching cues, tempo, technique reminders — keep it short, one sentence.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.hasHistoryContext) {
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'# USING THE HISTORY BLOCK',
|
||||||
|
'',
|
||||||
|
'The HISTORY block below summarizes the user\'s last 90 days. Use it to:',
|
||||||
|
'- Pick `suggestedWeight` near their current working weights, NOT round numbers from nowhere.',
|
||||||
|
'- Address any STAGNANT lifts: deload, change rep ranges, swap variations, or work at a different RPE.',
|
||||||
|
'- Respect their training frequency (don\'t prescribe 5x/week if they\'ve been training 3x).',
|
||||||
|
'- Stay in their movement vocabulary unless they asked for variety.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'# WEIGHT GUIDANCE WITHOUT HISTORY',
|
||||||
|
'',
|
||||||
|
`Without prior performance data, set conservative \`suggestedWeight\` values: 50-65% of typical 1RM for the lift at the user's stated experience level. Use round increments common in commercial gyms (5${opts.weightUnit} jumps; 2.5${opts.weightUnit} for small accessories). Always add a coaching note like "adjust to leave 2-3 reps in reserve" so the user knows it's a starting estimate.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.isLocalModel) {
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'# LOCAL MODEL REMINDER',
|
||||||
|
'',
|
||||||
|
'You are running locally with limited reasoning. Stick to the simplest valid program that matches the request. Do not overthink. JSON only.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Provider-agnostic LLM streaming abstraction.
|
||||||
|
*
|
||||||
|
* Each provider implements `generate(opts)` returning an
|
||||||
|
* AsyncIterable that yields text deltas as the model produces them,
|
||||||
|
* then a final usage chunk + done. This normalizes the wildly
|
||||||
|
* different streaming formats (Anthropic SSE / OpenAI SSE / Gemini
|
||||||
|
* streaming JSON / Ollama NDJSON) behind one shape.
|
||||||
|
*
|
||||||
|
* The system prompt + user prompt + a strict-JSON instruction is
|
||||||
|
* what every provider receives. We DON'T use provider-specific
|
||||||
|
* "JSON mode" / "structured output" features because they aren't
|
||||||
|
* uniformly available (notably Ollama varies by model). Instead the
|
||||||
|
* prompt itself instructs the model to emit ONLY valid JSON; the
|
||||||
|
* server validates with Zod after.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ProviderId =
|
||||||
|
| 'claude'
|
||||||
|
| 'openai'
|
||||||
|
| 'openai-compatible'
|
||||||
|
| 'gemini'
|
||||||
|
| 'ollama';
|
||||||
|
|
||||||
|
export interface GenerateOpts {
|
||||||
|
/** API key. Null/undefined for ollama on a trusted LAN. */
|
||||||
|
apiKey?: string | null;
|
||||||
|
/**
|
||||||
|
* Provider base URL.
|
||||||
|
* - openai-compatible: required (e.g. https://openrouter.ai/api/v1)
|
||||||
|
* - ollama: required (e.g. http://ollama.embassy:11434)
|
||||||
|
* - others: ignored, provider hits its hardcoded endpoint
|
||||||
|
*/
|
||||||
|
baseUrl?: string | null;
|
||||||
|
/** Model identifier as the provider expects it. */
|
||||||
|
model: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
userPrompt: string;
|
||||||
|
/** AbortSignal for cancellation; the implementation must respect it. */
|
||||||
|
signal?: AbortSignal;
|
||||||
|
/**
|
||||||
|
* v1.1.0:4: explicit max output token budget. Providers honor this
|
||||||
|
* differently — used to make small "test connection" calls survive
|
||||||
|
* thinking models (Gemini 2.5+, OpenAI o-series) that may spend
|
||||||
|
* their default budget on internal reasoning before emitting visible
|
||||||
|
* text. Default per-provider when omitted.
|
||||||
|
*/
|
||||||
|
maxOutputTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GenerateChunk =
|
||||||
|
| { type: 'text'; delta: string }
|
||||||
|
| { type: 'usage'; tokensIn?: number; tokensOut?: number }
|
||||||
|
| { type: 'done' }
|
||||||
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
|
export interface LLMProvider {
|
||||||
|
id: ProviderId;
|
||||||
|
/** Display label for the Settings UI dropdown. */
|
||||||
|
label: string;
|
||||||
|
/** Whether the provider needs an API key. */
|
||||||
|
requiresApiKey: boolean;
|
||||||
|
/** Whether the provider needs a custom base URL. */
|
||||||
|
requiresBaseUrl: boolean;
|
||||||
|
/** Async stream of chunks. */
|
||||||
|
generate(opts: GenerateOpts): AsyncIterable<GenerateChunk>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { prisma } from "../prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side helpers for the Programs feature (v1.1.0:1).
|
||||||
|
*
|
||||||
|
* A "program" is a multi-week training plan: Program -> ProgramWeek
|
||||||
|
* -> ProgramDay -> ProgramExercise. The user follows it day-by-day,
|
||||||
|
* logging actual workouts that get tagged with `Workout.programDayId`
|
||||||
|
* so we can later compute adherence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function getPrograms(userId: string) {
|
||||||
|
return prisma.program.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: [{ isActive: "desc" }, { createdAt: "desc" }],
|
||||||
|
include: {
|
||||||
|
_count: { select: { weeks: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProgramById(userId: string, programId: string) {
|
||||||
|
return prisma.program.findFirst({
|
||||||
|
where: { id: programId, userId },
|
||||||
|
include: {
|
||||||
|
weeks: {
|
||||||
|
orderBy: { weekNumber: "asc" },
|
||||||
|
include: {
|
||||||
|
days: {
|
||||||
|
orderBy: { dayOfWeek: "asc" },
|
||||||
|
include: {
|
||||||
|
exercises: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: { exercise: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActivePrograms(userId: string) {
|
||||||
|
return prisma.program.findMany({
|
||||||
|
where: { userId, isActive: true },
|
||||||
|
include: {
|
||||||
|
weeks: {
|
||||||
|
orderBy: { weekNumber: "asc" },
|
||||||
|
include: {
|
||||||
|
days: {
|
||||||
|
orderBy: { dayOfWeek: "asc" },
|
||||||
|
include: {
|
||||||
|
exercises: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: { exercise: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For an active program + a target date, compute which planned
|
||||||
|
* ProgramDay (if any) is "today's session." Algorithm:
|
||||||
|
*
|
||||||
|
* daysSinceStart = floor((today - startDate) / 1 day)
|
||||||
|
* weekNumber = floor(daysSinceStart / 7) + 1
|
||||||
|
* dayOfWeek = today.getDay() (0=Sun..6=Sat)
|
||||||
|
*
|
||||||
|
* Then look up the ProgramDay matching (weekNumber, dayOfWeek). If
|
||||||
|
* the program has no day for today's dayOfWeek, returns null (rest
|
||||||
|
* day or program doesn't cover this slot).
|
||||||
|
*
|
||||||
|
* Returns null if:
|
||||||
|
* - the date is before the program's startDate
|
||||||
|
* - the date is past durationWeeks * 7 days from the start
|
||||||
|
* - the program has no matching day for that (weekNumber, dayOfWeek)
|
||||||
|
*
|
||||||
|
* The date comparison is anchored to UTC midnight on both sides so
|
||||||
|
* timezone fuzz doesn't shift you across day boundaries.
|
||||||
|
*/
|
||||||
|
export interface TodaysSession {
|
||||||
|
program: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
durationWeeks: number;
|
||||||
|
startDate: Date;
|
||||||
|
};
|
||||||
|
weekNumber: number;
|
||||||
|
dayOfWeek: number;
|
||||||
|
day: NonNullable<
|
||||||
|
Awaited<ReturnType<typeof getProgramById>>
|
||||||
|
>["weeks"][number]["days"][number] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeTodaysSessionForProgram(
|
||||||
|
program: NonNullable<Awaited<ReturnType<typeof getProgramById>>>,
|
||||||
|
today: Date,
|
||||||
|
): TodaysSession | null {
|
||||||
|
const startUtc = Date.UTC(
|
||||||
|
program.startDate.getUTCFullYear(),
|
||||||
|
program.startDate.getUTCMonth(),
|
||||||
|
program.startDate.getUTCDate(),
|
||||||
|
);
|
||||||
|
const todayUtc = Date.UTC(
|
||||||
|
today.getUTCFullYear(),
|
||||||
|
today.getUTCMonth(),
|
||||||
|
today.getUTCDate(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dayMs = 24 * 60 * 60 * 1000;
|
||||||
|
const daysSinceStart = Math.floor((todayUtc - startUtc) / dayMs);
|
||||||
|
|
||||||
|
if (daysSinceStart < 0) return null; // hasn't started yet
|
||||||
|
if (daysSinceStart >= program.durationWeeks * 7) return null; // program over
|
||||||
|
|
||||||
|
const weekNumber = Math.floor(daysSinceStart / 7) + 1;
|
||||||
|
const dayOfWeek = today.getUTCDay();
|
||||||
|
|
||||||
|
const week = program.weeks.find((w) => w.weekNumber === weekNumber);
|
||||||
|
const day = week?.days.find((d) => d.dayOfWeek === dayOfWeek) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
program: {
|
||||||
|
id: program.id,
|
||||||
|
name: program.name,
|
||||||
|
type: program.type,
|
||||||
|
durationWeeks: program.durationWeeks,
|
||||||
|
startDate: program.startDate,
|
||||||
|
},
|
||||||
|
weekNumber,
|
||||||
|
dayOfWeek,
|
||||||
|
day,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the user's "today's session" across all active programs.
|
||||||
|
* If multiple programs are active and have a session today, returns
|
||||||
|
* the most-recently-started one (only the first match in practice
|
||||||
|
* since active programs are usually unique).
|
||||||
|
*/
|
||||||
|
export async function getTodaysSession(
|
||||||
|
userId: string,
|
||||||
|
today: Date = new Date(),
|
||||||
|
): Promise<TodaysSession | null> {
|
||||||
|
const programs = await getActivePrograms(userId);
|
||||||
|
for (const p of programs.sort(
|
||||||
|
(a, b) => b.startDate.getTime() - a.startDate.getTime(),
|
||||||
|
)) {
|
||||||
|
const session = computeTodaysSessionForProgram(p, today);
|
||||||
|
if (session?.day) return session;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Hypertrophy block",
|
||||||
|
"description": "Volume-focused muscle-building program. Push/Pull/Legs split, RPE-based progression.",
|
||||||
|
"systemPrompt": "You are a strength and conditioning coach designing a hypertrophy block. Use a Push/Pull/Legs/Rest split rotated through the week. 4-6 working sets per major muscle per session, 6-12 rep range, RPE 7-9. Compound lifts first, isolation/accessory work after. Include progression notes per week (volume up, then deload in the final week). Keep sessions to 60-75 minutes, 5-7 exercises. Pick exercises from the user's library; if you need a movement they don't have, set exerciseId to null and propose a name.",
|
||||||
|
"userPromptTemplate": "Build me a hypertrophy program with the following specifics:\n\n{{userInput}}\n\nDefault to 8 weeks if I didn't specify duration."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Strength block",
|
||||||
|
"description": "Periodized strength work centered on the big compound lifts.",
|
||||||
|
"systemPrompt": "You are a strength coach designing a periodized strength program. Squat/Bench/Deadlift (or close variations from the library) anchor each session, accessories support the main lift. Rep ranges 3-6 for primaries, 5-10 for accessories. Use linear or wave-loaded periodization across weeks: week 1 introductory, weeks 2-N progressive, final week deload. Sessions 4-6 exercises, 60-90 minutes. Include rest periods (3-5 min for primaries, 90-180s for accessories). Pick from the user's library; null exerciseId if a needed lift isn't there.",
|
||||||
|
"userPromptTemplate": "Design a strength block for me:\n\n{{userInput}}\n\nDefault to 6 weeks, 4 sessions per week if I didn't specify."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Endurance / running block",
|
||||||
|
"description": "Running-focused training: easy / tempo / interval / long-run progression.",
|
||||||
|
"systemPrompt": "You are an endurance coach designing a running-focused program. Each week has: 2-3 easy aerobic runs, 1 tempo or threshold session, 1 interval/speed session, 1 long run, 1-2 rest or cross-training days. Use Running, Cycling, Rowing as primary cardio. Include 1-2 strength accessory sessions per week (single-leg work, core, mobility). Distance-based exercises: log distance + duration + calories. Progress weekly volume by ~10%, deload every 4th week. Pick from the user's library; null exerciseId for missing items.",
|
||||||
|
"userPromptTemplate": "Design a running block:\n\n{{userInput}}\n\nDefault to 12 weeks, building toward the goal event if I mentioned one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Recovery / deload week",
|
||||||
|
"description": "Single low-volume recovery week structured around what you've recently been doing.",
|
||||||
|
"systemPrompt": "You are designing a single deload week. Reduce volume by 40-50% from a normal block. Movement patterns stay the same but: lower sets, lower reps per set, lower intensity (RPE 5-7). Add mobility and easy aerobic work. Sessions 30-45 minutes. Output durationWeeks: 1, with a single week containing 4-5 days of light work + 2-3 rest days. Pick familiar exercises from the user's library.",
|
||||||
|
"userPromptTemplate": "Build me a deload week:\n\n{{userInput}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Custom",
|
||||||
|
"description": "Open-ended template — you describe everything in the prompt.",
|
||||||
|
"systemPrompt": "You are a strength and conditioning coach. The user describes the program they want; design it as JSON matching the OUTPUT SHAPE. Use exercises from the user's library (set exerciseId to null for movements they don't have). Be specific about sets, reps, RPE, and rest periods. Add coaching notes per exercise where relevant.",
|
||||||
|
"userPromptTemplate": "{{userInput}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,26 +1,33 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* ensureExerciseLibrary — runs at every container boot from
|
* ensureExerciseLibrary — runs at every container boot from
|
||||||
* docker_entrypoint.sh. Inserts every exercise from
|
* docker_entrypoint.sh. Reconciles every existing user's exercise
|
||||||
* /app/prisma/exercises.seed.json into the Exercise table for every
|
* library with the curated /app/prisma/exercises.seed.json:
|
||||||
* existing user, using `INSERT OR IGNORE` keyed on (userId, name).
|
|
||||||
*
|
*
|
||||||
* Properties:
|
* - INSERT new library entries (rows that don't exist yet for this
|
||||||
* - Multi-user-aware. Iterates all rows in `User` so every user on the
|
* user) — same as previous behavior.
|
||||||
* instance gets the same curated library.
|
* - UPDATE existing rows where `isCustom = 0` to match the curated
|
||||||
* - Idempotent. Re-running is a no-op for exercises that already exist.
|
* values (description, type, muscleGroups, inputFields,
|
||||||
* - Additive only. Never deletes or updates existing rows. Users keep
|
* defaultWeightUnit). This propagates maintainer-side fixes
|
||||||
* their own custom exercises (isCustom=true) untouched, and existing
|
* (e.g. correcting cardio inputFields) to existing installs
|
||||||
* library entries are not reshaped if the maintainer changed the
|
* without overwriting user-customized rows.
|
||||||
* description/inputFields/etc. for them downstream.
|
* - SKIP rows where `isCustom = 1` — user customizations win, end
|
||||||
* - Cheap. Wrapped in a single transaction; ~164 rows x N users runs in
|
* of story. PATCH /api/exercises/[id] flips isCustom to 1 on
|
||||||
* well under a second.
|
* any user edit so this rule is honored automatically.
|
||||||
|
*
|
||||||
|
* Multi-user-aware: iterates all rows in `User`. Idempotent.
|
||||||
|
* Additive only — exercises removed from the curated JSON are NOT
|
||||||
|
* deleted from existing installs (users may have logged sets
|
||||||
|
* against them).
|
||||||
|
*
|
||||||
|
* Cheap: a single transaction, ~164 rows × N users runs in well
|
||||||
|
* under a second.
|
||||||
*
|
*
|
||||||
* Invoked from docker_entrypoint.sh after the schema-compat ALTERs:
|
* Invoked from docker_entrypoint.sh after the schema-compat ALTERs:
|
||||||
* node /app/prisma/ensureExerciseLibrary.cjs --db /data/app.db --json /app/prisma/exercises.seed.json
|
* node /app/prisma/ensureExerciseLibrary.cjs --db /data/app.db --json /app/prisma/exercises.seed.json
|
||||||
*
|
*
|
||||||
* Uses the sqlite3 CLI (already installed in the runner image) instead of a
|
* Uses the sqlite3 CLI (already in the runner image) instead of a
|
||||||
* Node SQLite binding so we don't have to ship a native dep.
|
* Node SQLite binding so we don't ship a native dep.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -55,28 +62,24 @@ if (!Array.isArray(library) || library.length === 0) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all user ids
|
|
||||||
const usersRaw = execFileSync('sqlite3', [dbPath, 'SELECT id FROM User;'], {
|
const usersRaw = execFileSync('sqlite3', [dbPath, 'SELECT id FROM User;'], {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
});
|
});
|
||||||
const userIds = usersRaw.split('\n').map((s) => s.trim()).filter(Boolean);
|
const userIds = usersRaw.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
if (userIds.length === 0) {
|
if (userIds.length === 0) {
|
||||||
console.error('[ensure-library] no users yet; skipping (will run on next boot after a user exists)');
|
console.error('[ensure-library] no users yet; skipping');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quote a string for safe use as a SQLite single-quoted literal.
|
|
||||||
const q = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
const q = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
||||||
// Generate a 25-char id with a "c" prefix to roughly match cuid shape.
|
|
||||||
// Uniqueness comes from 12 random bytes; collision probability is negligible.
|
|
||||||
const newId = () => 'c' + crypto.randomBytes(12).toString('hex');
|
const newId = () => 'c' + crypto.randomBytes(12).toString('hex');
|
||||||
|
|
||||||
const stmts = ['BEGIN;'];
|
const stmts = ['BEGIN;'];
|
||||||
let inserts = 0;
|
let upserts = 0;
|
||||||
for (const userId of userIds) {
|
for (const userId of userIds) {
|
||||||
for (const ex of library) {
|
for (const ex of library) {
|
||||||
inserts++;
|
upserts++;
|
||||||
const muscleGroups = q(JSON.stringify(ex.muscleGroups || []));
|
const muscleGroups = q(JSON.stringify(ex.muscleGroups || []));
|
||||||
const inputFields = q(
|
const inputFields = q(
|
||||||
JSON.stringify(ex.inputFields || ['sets', 'reps', 'weight']),
|
JSON.stringify(ex.inputFields || ['sets', 'reps', 'weight']),
|
||||||
@@ -85,12 +88,23 @@ for (const userId of userIds) {
|
|||||||
ex.defaultWeightUnit == null ? 'NULL' : q(ex.defaultWeightUnit);
|
ex.defaultWeightUnit == null ? 'NULL' : q(ex.defaultWeightUnit);
|
||||||
const description = ex.description == null ? 'NULL' : q(ex.description);
|
const description = ex.description == null ? 'NULL' : q(ex.description);
|
||||||
|
|
||||||
|
// INSERT a new row for this (userId, name) pair, or UPDATE the
|
||||||
|
// existing one IF it's not user-customized. The WHERE clause on
|
||||||
|
// the DO UPDATE side is the key piece — skips rows the user has
|
||||||
|
// edited (isCustom=1). SQLite supports this since 3.24 (2018).
|
||||||
stmts.push(
|
stmts.push(
|
||||||
`INSERT OR IGNORE INTO Exercise ` +
|
`INSERT INTO Exercise ` +
|
||||||
`(id, userId, name, description, muscleGroups, type, inputFields, defaultWeightUnit, isCustom, createdAt) ` +
|
`(id, userId, name, description, muscleGroups, type, inputFields, defaultWeightUnit, isCustom, createdAt) ` +
|
||||||
`VALUES (${q(newId())}, ${q(userId)}, ${q(ex.name)}, ${description}, ` +
|
`VALUES (${q(newId())}, ${q(userId)}, ${q(ex.name)}, ${description}, ` +
|
||||||
`${muscleGroups}, ${q(ex.type)}, ${inputFields}, ${defaultWeightUnit}, ` +
|
`${muscleGroups}, ${q(ex.type)}, ${inputFields}, ${defaultWeightUnit}, ` +
|
||||||
`0, CURRENT_TIMESTAMP);`,
|
`0, CURRENT_TIMESTAMP) ` +
|
||||||
|
`ON CONFLICT(userId, name) DO UPDATE SET ` +
|
||||||
|
`description = excluded.description, ` +
|
||||||
|
`muscleGroups = excluded.muscleGroups, ` +
|
||||||
|
`type = excluded.type, ` +
|
||||||
|
`inputFields = excluded.inputFields, ` +
|
||||||
|
`defaultWeightUnit = excluded.defaultWeightUnit ` +
|
||||||
|
`WHERE Exercise.isCustom = 0;`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,6 +112,7 @@ stmts.push('COMMIT;');
|
|||||||
|
|
||||||
execFileSync('sqlite3', [dbPath], { input: stmts.join('\n') });
|
execFileSync('sqlite3', [dbPath], { input: stmts.join('\n') });
|
||||||
console.error(
|
console.error(
|
||||||
`[ensure-library] processed ${userIds.length} user(s) x ${library.length} exercise(s) ` +
|
`[ensure-library] reconciled ${userIds.length} user(s) x ${library.length} ` +
|
||||||
`(${inserts} INSERT OR IGNORE statements)`,
|
`exercise(s) (${upserts} INSERT-OR-UPDATE statements; user-customized ` +
|
||||||
|
`rows skipped)`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* ensurePromptTemplates — runs at every container boot from
|
||||||
|
* docker_entrypoint.sh. Reconciles the curated built-in AI prompt
|
||||||
|
* templates from /app/prisma/aiTemplates.seed.json into the
|
||||||
|
* AIPromptTemplate table. Built-ins have userId=NULL.
|
||||||
|
*
|
||||||
|
* - INSERT new built-in templates that don't exist by name yet.
|
||||||
|
* - UPDATE existing built-ins (userId IS NULL) by name to match
|
||||||
|
* the curated systemPrompt + userPromptTemplate. Maintainer-side
|
||||||
|
* fixes propagate to existing installs.
|
||||||
|
* - User-created templates (userId IS NOT NULL) are never touched
|
||||||
|
* here, even if a user happens to have created a template with
|
||||||
|
* the same name as a built-in.
|
||||||
|
*
|
||||||
|
* Idempotent. Cheap (5 templates × 1 transaction).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { execFileSync } = require('child_process');
|
||||||
|
|
||||||
|
function arg(name) {
|
||||||
|
const i = process.argv.indexOf(name);
|
||||||
|
return i >= 0 ? process.argv[i + 1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = arg('--db');
|
||||||
|
const jsonPath = arg('--json');
|
||||||
|
if (!dbPath || !jsonPath) {
|
||||||
|
console.error(
|
||||||
|
'usage: ensurePromptTemplates.cjs --db <path> --json <path>',
|
||||||
|
);
|
||||||
|
process.exit(64);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(dbPath)) {
|
||||||
|
console.error(`[ensure-templates] db not found: ${dbPath}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(jsonPath)) {
|
||||||
|
console.error(`[ensure-templates] templates json not found: ${jsonPath}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defensive: only run if the table exists (compat ALTER may not have
|
||||||
|
// run yet on a brand-new install where seed.ts ran instead).
|
||||||
|
const hasTable = execFileSync(
|
||||||
|
'sqlite3',
|
||||||
|
[
|
||||||
|
dbPath,
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='AIPromptTemplate';",
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
).trim();
|
||||||
|
if (!hasTable) {
|
||||||
|
console.error('[ensure-templates] AIPromptTemplate table not found; skipping');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||||
|
if (!Array.isArray(templates) || templates.length === 0) {
|
||||||
|
console.error('[ensure-templates] templates array is empty; nothing to do');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
||||||
|
const newId = () => 'c' + crypto.randomBytes(12).toString('hex');
|
||||||
|
|
||||||
|
// SQLite doesn't have a partial unique index easy to install via
|
||||||
|
// schema, so we manually upsert: SELECT-by-name-where-builtin first,
|
||||||
|
// then INSERT or UPDATE.
|
||||||
|
const stmts = ['BEGIN;'];
|
||||||
|
for (const t of templates) {
|
||||||
|
// Find existing built-in with this name. We use COALESCE-by-rowid
|
||||||
|
// to make this single-statement-friendly with INSERT OR REPLACE.
|
||||||
|
// Easier: do explicit lookup + branch in JS.
|
||||||
|
const existingRows = execFileSync(
|
||||||
|
'sqlite3',
|
||||||
|
[
|
||||||
|
dbPath,
|
||||||
|
`SELECT id FROM AIPromptTemplate WHERE userId IS NULL AND name = ${q(t.name)};`,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
)
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean);
|
||||||
|
const existingId = existingRows[0];
|
||||||
|
|
||||||
|
if (existingId) {
|
||||||
|
stmts.push(
|
||||||
|
`UPDATE AIPromptTemplate SET ` +
|
||||||
|
`description = ${t.description ? q(t.description) : 'NULL'}, ` +
|
||||||
|
`systemPrompt = ${q(t.systemPrompt)}, ` +
|
||||||
|
`userPromptTemplate = ${q(t.userPromptTemplate)}, ` +
|
||||||
|
`isBuiltIn = 1, ` +
|
||||||
|
`updatedAt = CURRENT_TIMESTAMP ` +
|
||||||
|
`WHERE id = ${q(existingId)};`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
stmts.push(
|
||||||
|
`INSERT INTO AIPromptTemplate ` +
|
||||||
|
`(id, userId, name, description, systemPrompt, userPromptTemplate, isBuiltIn, createdAt, updatedAt) ` +
|
||||||
|
`VALUES (${q(newId())}, NULL, ${q(t.name)}, ` +
|
||||||
|
`${t.description ? q(t.description) : 'NULL'}, ` +
|
||||||
|
`${q(t.systemPrompt)}, ${q(t.userPromptTemplate)}, ` +
|
||||||
|
`1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stmts.push('COMMIT;');
|
||||||
|
|
||||||
|
execFileSync('sqlite3', [dbPath], { input: stmts.join('\n') });
|
||||||
|
console.error(
|
||||||
|
`[ensure-templates] reconciled ${templates.length} built-in template(s)`,
|
||||||
|
);
|
||||||
@@ -487,7 +487,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Core",
|
"name": "Core",
|
||||||
"description": null,
|
"description": "Generic core circuit — note specific exercises (crunches, bicycle, decline situps, etc.) in the notes field.",
|
||||||
"type": "bodyweight",
|
"type": "bodyweight",
|
||||||
"muscleGroups": [],
|
"muscleGroups": [],
|
||||||
"inputFields": [
|
"inputFields": [
|
||||||
@@ -679,7 +679,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Plank",
|
"name": "Plank",
|
||||||
"description": "Bodyweight plank for core stability",
|
"description": "Front plank — log hold duration; add weight for plate-loaded variations.",
|
||||||
"type": "bodyweight",
|
"type": "bodyweight",
|
||||||
"muscleGroups": [
|
"muscleGroups": [
|
||||||
"core",
|
"core",
|
||||||
@@ -687,7 +687,7 @@
|
|||||||
],
|
],
|
||||||
"inputFields": [
|
"inputFields": [
|
||||||
"sets",
|
"sets",
|
||||||
"reps",
|
"duration",
|
||||||
"weight"
|
"weight"
|
||||||
],
|
],
|
||||||
"defaultWeightUnit": null
|
"defaultWeightUnit": null
|
||||||
@@ -725,7 +725,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Resistance Band",
|
"name": "Resistance Band",
|
||||||
"description": null,
|
"description": "Generic resistance band exercise — log specific motion (pull-apart, face pull, etc.) in notes.",
|
||||||
"type": "bodyweight",
|
"type": "bodyweight",
|
||||||
"muscleGroups": [],
|
"muscleGroups": [],
|
||||||
"inputFields": [
|
"inputFields": [
|
||||||
@@ -1023,83 +1023,81 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Cycling",
|
"name": "Cycling",
|
||||||
"description": "Cycling for lower body cardio",
|
"description": "Stationary or outdoor cycling — log distance + duration as a single set.",
|
||||||
"type": "cardio",
|
"type": "cardio",
|
||||||
"muscleGroups": [
|
"muscleGroups": [
|
||||||
"cardio"
|
"cardio"
|
||||||
],
|
],
|
||||||
"inputFields": [
|
"inputFields": [
|
||||||
"sets",
|
"sets",
|
||||||
"reps",
|
"duration",
|
||||||
"weight"
|
"distance",
|
||||||
|
"calories"
|
||||||
],
|
],
|
||||||
"defaultWeightUnit": null
|
"defaultWeightUnit": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Headstand",
|
"name": "Headstand",
|
||||||
"description": null,
|
"description": "Inverted headstand hold — log duration per set.",
|
||||||
"type": "cardio",
|
"type": "bodyweight",
|
||||||
"muscleGroups": [],
|
"muscleGroups": [],
|
||||||
"inputFields": [
|
"inputFields": [
|
||||||
"sets",
|
"sets",
|
||||||
"reps",
|
"duration"
|
||||||
"duration",
|
|
||||||
"notes"
|
|
||||||
],
|
],
|
||||||
"defaultWeightUnit": null
|
"defaultWeightUnit": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Hip Extension",
|
"name": "Hip Extension",
|
||||||
"description": null,
|
"description": "Glute hip extension on bench or GHD — log reps per set.",
|
||||||
"type": "cardio",
|
"type": "bodyweight",
|
||||||
"muscleGroups": [],
|
"muscleGroups": [],
|
||||||
"inputFields": [
|
"inputFields": [
|
||||||
"sets",
|
"sets",
|
||||||
"reps",
|
"reps"
|
||||||
"duration",
|
|
||||||
"notes"
|
|
||||||
],
|
],
|
||||||
"defaultWeightUnit": null
|
"defaultWeightUnit": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Jump Rope",
|
"name": "Jump Rope",
|
||||||
"description": "Jump rope for cardio and footwork",
|
"description": "Jump rope intervals — log duration per set.",
|
||||||
"type": "cardio",
|
"type": "cardio",
|
||||||
"muscleGroups": [
|
"muscleGroups": [
|
||||||
"cardio"
|
"cardio"
|
||||||
],
|
],
|
||||||
"inputFields": [
|
"inputFields": [
|
||||||
"sets",
|
"sets",
|
||||||
"reps",
|
"duration"
|
||||||
"weight"
|
|
||||||
],
|
],
|
||||||
"defaultWeightUnit": null
|
"defaultWeightUnit": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rowing",
|
"name": "Rowing",
|
||||||
"description": "Rowing machine for full-body cardio",
|
"description": "Rowing erg — log distance, duration, calories per piece.",
|
||||||
"type": "cardio",
|
"type": "cardio",
|
||||||
"muscleGroups": [
|
"muscleGroups": [
|
||||||
"cardio"
|
"cardio"
|
||||||
],
|
],
|
||||||
"inputFields": [
|
"inputFields": [
|
||||||
"sets",
|
"sets",
|
||||||
"reps",
|
"duration",
|
||||||
"weight"
|
"distance",
|
||||||
|
"calories"
|
||||||
],
|
],
|
||||||
"defaultWeightUnit": null
|
"defaultWeightUnit": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Running",
|
"name": "Running",
|
||||||
"description": "Running for cardiovascular endurance",
|
"description": "Running outdoor or treadmill — log distance + duration per piece.",
|
||||||
"type": "cardio",
|
"type": "cardio",
|
||||||
"muscleGroups": [
|
"muscleGroups": [
|
||||||
"cardio"
|
"cardio"
|
||||||
],
|
],
|
||||||
"inputFields": [
|
"inputFields": [
|
||||||
"sets",
|
"sets",
|
||||||
"reps",
|
"duration",
|
||||||
"weight"
|
"distance",
|
||||||
|
"calories"
|
||||||
],
|
],
|
||||||
"defaultWeightUnit": null
|
"defaultWeightUnit": null
|
||||||
},
|
},
|
||||||
@@ -1121,27 +1119,24 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Walking Lunge",
|
"name": "Walking Lunge",
|
||||||
"description": null,
|
"description": "Alternating walking lunges — bodyweight or with dumbbells in hand.",
|
||||||
"type": "cardio",
|
"type": "bodyweight",
|
||||||
"muscleGroups": [],
|
"muscleGroups": [],
|
||||||
"inputFields": [
|
"inputFields": [
|
||||||
"sets",
|
"sets",
|
||||||
"reps",
|
"reps",
|
||||||
"weight",
|
"weight"
|
||||||
"duration",
|
|
||||||
"notes"
|
|
||||||
],
|
],
|
||||||
"defaultWeightUnit": null
|
"defaultWeightUnit": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Wall Sit",
|
"name": "Wall Sit",
|
||||||
"description": null,
|
"description": "Isometric wall sit — log hold duration per set.",
|
||||||
"type": "cardio",
|
"type": "bodyweight",
|
||||||
"muscleGroups": [],
|
"muscleGroups": [],
|
||||||
"inputFields": [
|
"inputFields": [
|
||||||
"sets",
|
"sets",
|
||||||
"duration",
|
"duration"
|
||||||
"notes"
|
|
||||||
],
|
],
|
||||||
"defaultWeightUnit": null
|
"defaultWeightUnit": null
|
||||||
},
|
},
|
||||||
@@ -1566,7 +1561,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Stir the pot",
|
"name": "Stir the pot",
|
||||||
"description": null,
|
"description": "Stability-ball plank with circular forearm motion (\"stirring the pot\").",
|
||||||
"type": "exercise ball",
|
"type": "exercise ball",
|
||||||
"muscleGroups": [
|
"muscleGroups": [
|
||||||
"abs"
|
"abs"
|
||||||
@@ -1579,7 +1574,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Captains of Crush",
|
"name": "Captains of Crush",
|
||||||
"description": null,
|
"description": "Hand gripper for forearm/grip strength — record gripper level (1, 1.5, 2, etc.) in notes.",
|
||||||
"type": "grippers",
|
"type": "grippers",
|
||||||
"muscleGroups": [
|
"muscleGroups": [
|
||||||
"forearms"
|
"forearms"
|
||||||
@@ -1769,7 +1764,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "TGU",
|
"name": "TGU",
|
||||||
"description": null,
|
"description": "Turkish Get-Up — full ground-to-standing kettlebell movement; log kettlebell weight.",
|
||||||
"type": "kettlebell",
|
"type": "kettlebell",
|
||||||
"muscleGroups": [
|
"muscleGroups": [
|
||||||
"full body",
|
"full body",
|
||||||
@@ -1800,14 +1795,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Mace warmup",
|
"name": "Mace warmup",
|
||||||
"description": null,
|
"description": "Steel mace warm-up flow — 360s, figure-8s, etc. Log mace weight.",
|
||||||
"type": "mace bar",
|
"type": "mace bar",
|
||||||
"muscleGroups": [
|
"muscleGroups": [
|
||||||
"shoulders",
|
"shoulders",
|
||||||
"core"
|
"core"
|
||||||
],
|
],
|
||||||
"inputFields": [
|
"inputFields": [
|
||||||
"sets"
|
"sets",
|
||||||
|
"reps",
|
||||||
|
"weight"
|
||||||
],
|
],
|
||||||
"defaultWeightUnit": null
|
"defaultWeightUnit": null
|
||||||
},
|
},
|
||||||
@@ -2030,11 +2027,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Hollow Body Landmine",
|
"name": "Hollow Body Landmine",
|
||||||
"description": null,
|
"description": "Landmine press performed in hollow body position — log reps + barbell weight.",
|
||||||
"type": "other",
|
"type": "other",
|
||||||
"muscleGroups": [],
|
"muscleGroups": [],
|
||||||
"inputFields": [
|
"inputFields": [
|
||||||
"sets",
|
"sets",
|
||||||
|
"reps",
|
||||||
"weight"
|
"weight"
|
||||||
],
|
],
|
||||||
"defaultWeightUnit": null
|
"defaultWeightUnit": null
|
||||||
@@ -2090,7 +2088,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Neck Circuit",
|
"name": "Neck Circuit",
|
||||||
"description": null,
|
"description": "Series of neck flexion/extension/lateral isometric movements.",
|
||||||
"type": "other",
|
"type": "other",
|
||||||
"muscleGroups": [
|
"muscleGroups": [
|
||||||
"neck"
|
"neck"
|
||||||
@@ -2177,7 +2175,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Slide Board",
|
"name": "Slide Board",
|
||||||
"description": null,
|
"description": "Sliding lateral skating-style movement on a slick slide board.",
|
||||||
"type": "other",
|
"type": "other",
|
||||||
"muscleGroups": [],
|
"muscleGroups": [],
|
||||||
"inputFields": [
|
"inputFields": [
|
||||||
@@ -2188,15 +2186,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Soccer",
|
"name": "Soccer",
|
||||||
"description": null,
|
"description": "Pickup or training soccer — log overall duration + optional distance.",
|
||||||
"type": "other",
|
"type": "other",
|
||||||
"muscleGroups": [
|
"muscleGroups": [
|
||||||
"cardio"
|
"cardio"
|
||||||
],
|
],
|
||||||
"inputFields": [
|
"inputFields": [
|
||||||
"calories",
|
"sets",
|
||||||
|
"duration",
|
||||||
"distance",
|
"distance",
|
||||||
"duration"
|
"calories"
|
||||||
],
|
],
|
||||||
"defaultWeightUnit": null
|
"defaultWeightUnit": null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ model User {
|
|||||||
equipment Equipment[]
|
equipment Equipment[]
|
||||||
contentItems ContentItem[]
|
contentItems ContentItem[]
|
||||||
aiSuggestions AISuggestion[]
|
aiSuggestions AISuggestion[]
|
||||||
|
aiPromptTemplates AIPromptTemplate[]
|
||||||
|
aiGenerations AIGeneration[]
|
||||||
|
aiConfigProfiles AIConfigProfile[]
|
||||||
userPreferences UserPreferences?
|
userPreferences UserPreferences?
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@ -82,17 +85,23 @@ model Workout {
|
|||||||
durationMinutes Int?
|
durationMinutes Int?
|
||||||
difficulty Int? // 1-10 scale
|
difficulty Int? // 1-10 scale
|
||||||
caloriesBurned Int?
|
caloriesBurned Int?
|
||||||
|
/// Optional: which planned ProgramDay this workout was logged
|
||||||
|
/// against. Set when the user clicks "Start workout from program
|
||||||
|
/// day" so we can later compute adherence vs plan.
|
||||||
|
programDayId String?
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
setLogs SetLog[]
|
setLogs SetLog[]
|
||||||
|
programDay ProgramDay? @relation(fields: [programDayId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([date])
|
@@index([date])
|
||||||
@@index([deletedAt])
|
@@index([deletedAt])
|
||||||
|
@@index([programDayId])
|
||||||
// Hot path: workout list page is `where userId AND deletedAt IS NULL
|
// Hot path: workout list page is `where userId AND deletedAt IS NULL
|
||||||
// ORDER BY date DESC LIMIT N`. Composite index lets SQLite walk it
|
// ORDER BY date DESC LIMIT N`. Composite index lets SQLite walk it
|
||||||
// straight off the index without sorting.
|
// straight off the index without sorting.
|
||||||
@@ -178,6 +187,7 @@ model ProgramDay {
|
|||||||
// Relations
|
// Relations
|
||||||
week ProgramWeek @relation(fields: [weekId], references: [id], onDelete: Cascade)
|
week ProgramWeek @relation(fields: [weekId], references: [id], onDelete: Cascade)
|
||||||
exercises ProgramExercise[]
|
exercises ProgramExercise[]
|
||||||
|
workouts Workout[] // sessions logged against this planned day
|
||||||
|
|
||||||
@@unique([weekId, dayOfWeek])
|
@@unique([weekId, dayOfWeek])
|
||||||
@@index([weekId])
|
@@index([weekId])
|
||||||
@@ -194,6 +204,12 @@ model ProgramExercise {
|
|||||||
rpe Int?
|
rpe Int?
|
||||||
restSeconds Int?
|
restSeconds Int?
|
||||||
notes String?
|
notes String?
|
||||||
|
/// v1.1.0:4 — AI-suggested starting weight (or coach-prescribed
|
||||||
|
/// for manual programs). When you "Start workout from program day"
|
||||||
|
/// this pre-populates SetLog.weight so the user has a target. Null
|
||||||
|
/// = no suggestion, fall back to whatever they did last time.
|
||||||
|
suggestedWeight Float?
|
||||||
|
suggestedWeightUnit String? // "lbs" | "kg"; null = use user pref
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
@@ -295,8 +311,33 @@ model UserPreferences {
|
|||||||
theme String @default("system") // light, dark, system
|
theme String @default("system") // light, dark, system
|
||||||
defaultWeightUnit String @default("lbs")
|
defaultWeightUnit String @default("lbs")
|
||||||
defaultRestSeconds Int @default(90)
|
defaultRestSeconds Int @default(90)
|
||||||
|
// ─── Dead fields, retained for back-compat ────────────────────
|
||||||
|
// enableClaudeAI / claudeApiKey were the v1.0.0:1-6 single-provider
|
||||||
|
// toggles. v1.0.0:7 removed the photo-import feature that used them
|
||||||
|
// and stopped reading/writing them. Kept as columns to avoid a
|
||||||
|
// destructive ALTER on existing data; new code uses the aiProvider/
|
||||||
|
// aiModel/aiBaseUrl/aiApiKey block below.
|
||||||
enableClaudeAI Boolean @default(false)
|
enableClaudeAI Boolean @default(false)
|
||||||
claudeApiKey String?
|
claudeApiKey String?
|
||||||
|
// ─── v1.1.0:2 model-agnostic AI configuration ─────────────────
|
||||||
|
// aiProvider: 'claude' | 'openai' | 'openai-compatible' | 'gemini'
|
||||||
|
// | 'ollama' | null (disabled)
|
||||||
|
// aiBaseUrl: required for openai-compatible + ollama; ignored
|
||||||
|
// otherwise. Use http://ollama.embassy:11434 for the
|
||||||
|
// StartOS sister service if it's installed.
|
||||||
|
// aiApiKey: plaintext (consistent with all other secrets in
|
||||||
|
// /data; the host-level threat model assumes /data
|
||||||
|
// is owned by the operator). Null for ollama on a
|
||||||
|
// trusted LAN.
|
||||||
|
aiProvider String?
|
||||||
|
aiModel String?
|
||||||
|
aiBaseUrl String?
|
||||||
|
aiApiKey String?
|
||||||
|
// ─── v1.1.0:4 multi-config: which AIConfigProfile is active ───
|
||||||
|
// Null = fall back to the legacy single-config columns above (which
|
||||||
|
// we keep populated as a mirror of the active profile for backwards-
|
||||||
|
// compat with any code path that still reads them).
|
||||||
|
activeAIConfigId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -305,3 +346,103 @@ model UserPreferences {
|
|||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v1.1.0:4 — A single saved AI provider configuration. Users can
|
||||||
|
/// have many (one per provider, or several of the same provider with
|
||||||
|
/// different models/keys) and toggle one as active. The active one is
|
||||||
|
/// what /api/ai/generate and /api/ai/test use.
|
||||||
|
///
|
||||||
|
/// We mirror the active profile back into UserPreferences.aiProvider/
|
||||||
|
/// aiModel/aiBaseUrl/aiApiKey on every "set active" so any old code
|
||||||
|
/// path that reads from prefs keeps working without conditional logic.
|
||||||
|
model AIConfigProfile {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
/// User-chosen label, e.g. "Local Ollama", "Claude Sonnet (work)".
|
||||||
|
/// Defaults to a generated name on create if not provided.
|
||||||
|
name String
|
||||||
|
provider String // 'claude' | 'openai' | 'openai-compatible' | 'gemini' | 'ollama'
|
||||||
|
model String
|
||||||
|
baseUrl String? // for openai-compatible + ollama
|
||||||
|
apiKey String? // plaintext, same threat model as the rest of /data
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-defined or shipped prompt templates for AI program generation.
|
||||||
|
/// `userId = null` means the template ships with the package (built-in,
|
||||||
|
/// reconciled per-boot from prisma/aiTemplates.seed.json). `userId =
|
||||||
|
/// <id>` means a user-created template, fully owned by them.
|
||||||
|
///
|
||||||
|
/// Built-in templates are read-only in the UI for non-admins (they
|
||||||
|
/// can clone-to-edit). Admins can edit built-ins via the same API but
|
||||||
|
/// edits won't survive the next boot's reconcile pass — to ship
|
||||||
|
/// changes, edit the JSON in the repo and bump a version.
|
||||||
|
model AIPromptTemplate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String?
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
/// Role + constraints sent as the system message to the LLM.
|
||||||
|
systemPrompt String
|
||||||
|
/// Body of the user message; supports {{userInput}} interpolation
|
||||||
|
/// so the user's specifics (e.g. "4 weeks heavy leg emphasis") get
|
||||||
|
/// stitched into the template.
|
||||||
|
userPromptTemplate String
|
||||||
|
isBuiltIn Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([isBuiltIn])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row per AI generation request. Stores the exact prompts sent
|
||||||
|
/// (so the user can see why the model produced what it did), the raw
|
||||||
|
/// model output, the parsed program structure if parsing succeeded,
|
||||||
|
/// and a status that tracks the lifecycle:
|
||||||
|
///
|
||||||
|
/// pending → request in flight
|
||||||
|
/// completed → got a response, parsed it; preview available
|
||||||
|
/// failed → got an error or parse failure (errorMessage set)
|
||||||
|
/// applied → user clicked Apply, Program created (appliedProgramId set)
|
||||||
|
model AIGeneration {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
templateId String?
|
||||||
|
templateName String?
|
||||||
|
userInput String
|
||||||
|
systemPrompt String
|
||||||
|
userPrompt String
|
||||||
|
/// Streamed-so-far text. Updated periodically by the background
|
||||||
|
/// generator so navigating-away clients can resume display via
|
||||||
|
/// polling. Final value matches `rawResponse` once status flips
|
||||||
|
/// to 'completed' or 'failed'.
|
||||||
|
progressText String?
|
||||||
|
rawResponse String?
|
||||||
|
parsedProgram String? // JSON.stringify of the parsed structure
|
||||||
|
provider String
|
||||||
|
model String
|
||||||
|
tokensIn Int?
|
||||||
|
tokensOut Int?
|
||||||
|
/// Wall-clock duration in milliseconds from request start to final
|
||||||
|
/// status flip. Useful for the "this took 10 minutes" stat in the UI.
|
||||||
|
durationMs Int?
|
||||||
|
status String @default("pending")
|
||||||
|
errorMessage String?
|
||||||
|
appliedProgramId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@index([status])
|
||||||
|
@@index([appliedProgramId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { applyAIProgram } from '@/lib/ai/apply';
|
||||||
|
import type { AIProgram } from '@/lib/ai/programSchema';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.session.deleteMany();
|
||||||
|
await prisma.setLog.deleteMany();
|
||||||
|
await prisma.workout.deleteMany();
|
||||||
|
await prisma.programExercise.deleteMany();
|
||||||
|
await prisma.programDay.deleteMany();
|
||||||
|
await prisma.programWeek.deleteMany();
|
||||||
|
await prisma.program.deleteMany();
|
||||||
|
await prisma.exercise.deleteMany();
|
||||||
|
await prisma.aIGeneration.deleteMany();
|
||||||
|
await prisma.aIPromptTemplate.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
await prisma.instanceSettings.deleteMany();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email: 'a@x', passwordHash: 'fake' },
|
||||||
|
});
|
||||||
|
const bench = await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
name: 'Bench Press',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const squat = await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
name: 'Squat',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { user, bench, squat };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('applyAIProgram', () => {
|
||||||
|
it('materializes a valid AIProgram into Program + nested rows + flips aiGenerated', async () => {
|
||||||
|
const { user, bench, squat } = await setup();
|
||||||
|
const ai: AIProgram = {
|
||||||
|
name: 'AI 4-week',
|
||||||
|
description: 'Test',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 4,
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
phase: 'Volume',
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
name: 'Push',
|
||||||
|
exercises: [
|
||||||
|
{
|
||||||
|
exerciseId: bench.id,
|
||||||
|
exerciseName: 'Bench Press',
|
||||||
|
order: 0,
|
||||||
|
sets: 4,
|
||||||
|
repsMin: 6,
|
||||||
|
repsMax: 10,
|
||||||
|
rpe: 8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
weekNumber: 2,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 2,
|
||||||
|
exercises: [
|
||||||
|
{
|
||||||
|
exerciseId: squat.id,
|
||||||
|
exerciseName: 'Squat',
|
||||||
|
order: 0,
|
||||||
|
sets: 5,
|
||||||
|
repsMin: 5,
|
||||||
|
repsMax: 5,
|
||||||
|
rpe: 9,
|
||||||
|
restSeconds: 180,
|
||||||
|
notes: 'Pause for 1 second',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
name: null,
|
||||||
|
description: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
phase: null,
|
||||||
|
description: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await applyAIProgram(prisma, ai, {
|
||||||
|
userId: user.id,
|
||||||
|
startDate: new Date('2026-05-10'),
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
expect(result.weeksCreated).toBe(2);
|
||||||
|
expect(result.daysCreated).toBe(2);
|
||||||
|
expect(result.exercisesCreated).toBe(2);
|
||||||
|
|
||||||
|
const program = await prisma.program.findUnique({
|
||||||
|
where: { id: result.programId },
|
||||||
|
include: {
|
||||||
|
weeks: {
|
||||||
|
include: { days: { include: { exercises: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(program?.aiGenerated).toBe(true);
|
||||||
|
expect(program?.weeks).toHaveLength(2);
|
||||||
|
expect(program?.weeks[1].days[0].exercises[0].notes).toBe(
|
||||||
|
'Pause for 1 second',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when an exerciseId references another user', async () => {
|
||||||
|
const { user } = await setup();
|
||||||
|
const otherUser = await prisma.user.create({
|
||||||
|
data: { email: 'b@x', passwordHash: 'fake' },
|
||||||
|
});
|
||||||
|
const otherEx = await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: otherUser.id,
|
||||||
|
name: 'Other ex',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ai: AIProgram = {
|
||||||
|
name: 'X',
|
||||||
|
type: 'h',
|
||||||
|
durationWeeks: 1,
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
exercises: [
|
||||||
|
{
|
||||||
|
exerciseId: otherEx.id,
|
||||||
|
exerciseName: 'Other ex',
|
||||||
|
order: 0,
|
||||||
|
sets: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
applyAIProgram(prisma, ai, {
|
||||||
|
userId: user.id,
|
||||||
|
startDate: new Date(),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/don't belong to this user/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when any exercise still has null exerciseId (unresolved)', async () => {
|
||||||
|
const { user, bench } = await setup();
|
||||||
|
const ai: AIProgram = {
|
||||||
|
name: 'X',
|
||||||
|
type: 'h',
|
||||||
|
durationWeeks: 1,
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
exercises: [
|
||||||
|
{
|
||||||
|
exerciseId: bench.id,
|
||||||
|
exerciseName: 'Bench Press',
|
||||||
|
order: 0,
|
||||||
|
sets: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseId: null,
|
||||||
|
exerciseName: 'Bulgarian Split Squat',
|
||||||
|
order: 1,
|
||||||
|
sets: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
applyAIProgram(prisma, ai, {
|
||||||
|
userId: user.id,
|
||||||
|
startDate: new Date(),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/unresolved/);
|
||||||
|
// Nothing should have been written.
|
||||||
|
expect(await prisma.program.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors isActive flag', async () => {
|
||||||
|
const { user, bench } = await setup();
|
||||||
|
const ai: AIProgram = {
|
||||||
|
name: 'Active program',
|
||||||
|
type: 'h',
|
||||||
|
durationWeeks: 1,
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
exercises: [
|
||||||
|
{
|
||||||
|
exerciseId: bench.id,
|
||||||
|
exerciseName: 'Bench Press',
|
||||||
|
order: 0,
|
||||||
|
sets: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = await applyAIProgram(prisma, ai, {
|
||||||
|
userId: user.id,
|
||||||
|
startDate: new Date(),
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
const p = await prisma.program.findUnique({
|
||||||
|
where: { id: result.programId },
|
||||||
|
});
|
||||||
|
expect(p?.isActive).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the in-memory bus inside lib/ai/generationRunner.ts.
|
||||||
|
*
|
||||||
|
* The runner itself touches the database + provider implementations,
|
||||||
|
* which we don't want to spin up here. The interesting logic worth
|
||||||
|
* testing is the pub/sub:
|
||||||
|
* - late-joining subscribers replay the buffered chunks
|
||||||
|
* - terminal events (complete/error) flip `finished` and stop accepting
|
||||||
|
* new subscribers
|
||||||
|
* - bounded buffer (we don't accumulate forever on a chatty model)
|
||||||
|
*
|
||||||
|
* To exercise it without spinning up the runner we directly drive the
|
||||||
|
* bus through a non-exported `emit` ... but it isn't exported, so we
|
||||||
|
* instead hit it through the (also not exported) bus map. Vitest
|
||||||
|
* lets us re-import the module's internals via dynamic import + module
|
||||||
|
* cache reset so we can assert on the public `subscribe` contract by
|
||||||
|
* spying on the subscriber callback under controlled emit ordering.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// We test the public API; the internals (`bus`, `emit`) aren't reachable
|
||||||
|
// without monkey-patching, so the strategy is: import + call subscribe,
|
||||||
|
// and observe what the subscriber receives. We synthesize the writer-side
|
||||||
|
// by calling the runner's internal flush via... actually the cleanest way
|
||||||
|
// is to require the module and exploit Node's CJS interop to grab the
|
||||||
|
// non-exported module-internal map. Instead of fragile reflection, we
|
||||||
|
// just rebuild a tiny mirror of the bus shape locally and assert the
|
||||||
|
// contract documented in the module header.
|
||||||
|
|
||||||
|
describe('generationRunner module surface', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports kickoffGeneration + subscribe', async () => {
|
||||||
|
const mod = await import('@/lib/ai/generationRunner');
|
||||||
|
expect(typeof mod.kickoffGeneration).toBe('function');
|
||||||
|
expect(typeof mod.subscribe).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subscribe to an unknown id returns a no-op unsubscribe (no throw)', async () => {
|
||||||
|
const { subscribe } = await import('@/lib/ai/generationRunner');
|
||||||
|
const unsub = subscribe('nonexistent-id', () => {});
|
||||||
|
expect(typeof unsub).toBe('function');
|
||||||
|
expect(() => unsub()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replay=false on a fresh entry receives no events from buffer', async () => {
|
||||||
|
const { subscribe } = await import('@/lib/ai/generationRunner');
|
||||||
|
const seen: unknown[] = [];
|
||||||
|
const unsub = subscribe('fresh-id', (d) => seen.push(d), false);
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test the contract Generate UI relies on: an EventSource attaches
|
||||||
|
* AFTER the first text chunk has streamed, and we still receive that
|
||||||
|
* earlier chunk because `subscribe(id, fn, replay=true)` (the default)
|
||||||
|
* walks the buffer first.
|
||||||
|
*
|
||||||
|
* We can't exercise the real runner without provider mocking — that's
|
||||||
|
* covered indirectly by the SSE attach route's behavior (see
|
||||||
|
* tests/routes-ai-templates.test.ts pattern). Here we assert the simple
|
||||||
|
* fact that `subscribe`'s signature has the replay default.
|
||||||
|
*/
|
||||||
|
describe('generationRunner.subscribe replay defaulting', () => {
|
||||||
|
it('replay defaults to true (third arg optional)', async () => {
|
||||||
|
const { subscribe } = await import('@/lib/ai/generationRunner');
|
||||||
|
// No throw on omitted third arg.
|
||||||
|
expect(() => {
|
||||||
|
const unsub = subscribe('id', () => {});
|
||||||
|
unsub();
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import {
|
||||||
|
buildHistorySummary,
|
||||||
|
formatHistoryContext,
|
||||||
|
} from '@/lib/ai/historyContext';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.session.deleteMany();
|
||||||
|
await prisma.setLog.deleteMany();
|
||||||
|
await prisma.workout.deleteMany();
|
||||||
|
await prisma.programExercise.deleteMany();
|
||||||
|
await prisma.programDay.deleteMany();
|
||||||
|
await prisma.programWeek.deleteMany();
|
||||||
|
await prisma.program.deleteMany();
|
||||||
|
await prisma.exercise.deleteMany();
|
||||||
|
await prisma.aIGeneration.deleteMany();
|
||||||
|
await prisma.aIPromptTemplate.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
await prisma.instanceSettings.deleteMany();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email: 'a@x', passwordHash: 'fake' },
|
||||||
|
});
|
||||||
|
const bench = await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
name: 'Bench Press',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const squat = await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
name: 'Squat',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { user, bench, squat };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logWorkout(
|
||||||
|
userId: string,
|
||||||
|
daysAgo: number,
|
||||||
|
sets: Array<{ exerciseId: string; reps: number; weight: number; rpe?: number }>,
|
||||||
|
) {
|
||||||
|
const date = new Date(Date.now() - daysAgo * 86_400_000);
|
||||||
|
return prisma.workout.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
date,
|
||||||
|
setLogs: {
|
||||||
|
create: sets.map((s, i) => ({
|
||||||
|
exerciseId: s.exerciseId,
|
||||||
|
setNumber: i + 1,
|
||||||
|
reps: s.reps,
|
||||||
|
weight: s.weight,
|
||||||
|
rpe: s.rpe,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildHistorySummary', () => {
|
||||||
|
it('returns empty summary for a user with no workouts', async () => {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email: 'a@x', passwordHash: 'fake' },
|
||||||
|
});
|
||||||
|
const s = await buildHistorySummary(prisma, user.id);
|
||||||
|
expect(s.totalWorkouts).toBe(0);
|
||||||
|
expect(s.exercises).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarizes a single user\'s recent activity', async () => {
|
||||||
|
const { user, bench, squat } = await setup();
|
||||||
|
// 3 bench sessions, 2 squat sessions in last 30 days
|
||||||
|
await logWorkout(user.id, 1, [
|
||||||
|
{ exerciseId: bench.id, reps: 5, weight: 225 },
|
||||||
|
{ exerciseId: bench.id, reps: 5, weight: 225 },
|
||||||
|
]);
|
||||||
|
await logWorkout(user.id, 4, [
|
||||||
|
{ exerciseId: bench.id, reps: 5, weight: 235 },
|
||||||
|
{ exerciseId: bench.id, reps: 5, weight: 235 },
|
||||||
|
]);
|
||||||
|
await logWorkout(user.id, 7, [
|
||||||
|
{ exerciseId: bench.id, reps: 5, weight: 215 },
|
||||||
|
{ exerciseId: squat.id, reps: 5, weight: 315 },
|
||||||
|
]);
|
||||||
|
await logWorkout(user.id, 14, [
|
||||||
|
{ exerciseId: squat.id, reps: 5, weight: 305 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const s = await buildHistorySummary(prisma, user.id);
|
||||||
|
expect(s.totalWorkouts).toBe(4);
|
||||||
|
expect(s.workoutsPerWeek).toBeGreaterThan(0);
|
||||||
|
expect(s.exercises).toHaveLength(2);
|
||||||
|
|
||||||
|
const benchSummary = s.exercises.find((e) => e.name === 'Bench Press');
|
||||||
|
expect(benchSummary).toBeTruthy();
|
||||||
|
expect(benchSummary!.totalSets).toBe(5);
|
||||||
|
expect(benchSummary!.distinctWorkouts).toBe(3);
|
||||||
|
expect(benchSummary!.bestWeight).toBe(235);
|
||||||
|
expect(benchSummary!.daysSinceLast).toBeLessThanOrEqual(2); // logged 1 day ago
|
||||||
|
|
||||||
|
// Epley(235, 5) = 235 * (1 + 5/30) = 274.17 → 274
|
||||||
|
expect(benchSummary!.estimated1RM).toBe(274);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags stagnation on a stuck exercise', async () => {
|
||||||
|
const { user, bench } = await setup();
|
||||||
|
// 6 sessions all at the same weight
|
||||||
|
for (let d = 0; d < 6; d++) {
|
||||||
|
await logWorkout(user.id, d * 5, [
|
||||||
|
{ exerciseId: bench.id, reps: 5, weight: 225 },
|
||||||
|
{ exerciseId: bench.id, reps: 5, weight: 225 },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const s = await buildHistorySummary(prisma, user.id);
|
||||||
|
const bs = s.exercises.find((e) => e.name === 'Bench Press');
|
||||||
|
expect(bs?.stagnant).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT flag stagnation on a progressing exercise', async () => {
|
||||||
|
const { user, bench } = await setup();
|
||||||
|
// 6 sessions with progressive weight
|
||||||
|
for (let d = 0; d < 6; d++) {
|
||||||
|
await logWorkout(user.id, (5 - d) * 7, [
|
||||||
|
{ exerciseId: bench.id, reps: 5, weight: 200 + d * 10 },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const s = await buildHistorySummary(prisma, user.id);
|
||||||
|
const bs = s.exercises.find((e) => e.name === 'Bench Press');
|
||||||
|
expect(bs?.stagnant).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes workouts outside the window', async () => {
|
||||||
|
const { user, bench } = await setup();
|
||||||
|
await logWorkout(user.id, 5, [{ exerciseId: bench.id, reps: 5, weight: 225 }]);
|
||||||
|
await logWorkout(user.id, 200, [{ exerciseId: bench.id, reps: 5, weight: 200 }]);
|
||||||
|
const s = await buildHistorySummary(prisma, user.id, 90);
|
||||||
|
expect(s.totalWorkouts).toBe(1);
|
||||||
|
expect(s.exercises[0].totalSets).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes soft-deleted workouts', async () => {
|
||||||
|
const { user, bench } = await setup();
|
||||||
|
const w = await logWorkout(user.id, 3, [
|
||||||
|
{ exerciseId: bench.id, reps: 5, weight: 225 },
|
||||||
|
]);
|
||||||
|
await prisma.workout.update({
|
||||||
|
where: { id: w.id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
const s = await buildHistorySummary(prisma, user.id);
|
||||||
|
expect(s.totalWorkouts).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates per-user data (does not bleed across users)', async () => {
|
||||||
|
const { user, bench } = await setup();
|
||||||
|
const otherUser = await prisma.user.create({
|
||||||
|
data: { email: 'b@x', passwordHash: 'fake' },
|
||||||
|
});
|
||||||
|
const otherBench = await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: otherUser.id,
|
||||||
|
name: 'Bench Press',
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await logWorkout(user.id, 1, [{ exerciseId: bench.id, reps: 5, weight: 225 }]);
|
||||||
|
await logWorkout(otherUser.id, 1, [
|
||||||
|
{ exerciseId: otherBench.id, reps: 100, weight: 999 },
|
||||||
|
]);
|
||||||
|
const s = await buildHistorySummary(prisma, user.id);
|
||||||
|
expect(s.totalWorkouts).toBe(1);
|
||||||
|
expect(s.exercises[0].bestWeight).toBe(225); // not 999
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatHistoryContext', () => {
|
||||||
|
it('emits a friendly message on empty history', () => {
|
||||||
|
const out = formatHistoryContext({
|
||||||
|
windowDays: 90,
|
||||||
|
totalWorkouts: 0,
|
||||||
|
workoutsPerWeek: 0,
|
||||||
|
primaryTypes: [],
|
||||||
|
exercises: [],
|
||||||
|
});
|
||||||
|
expect(out).toMatch(/no workouts/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats a populated summary into a compact block', () => {
|
||||||
|
const out = formatHistoryContext({
|
||||||
|
windowDays: 90,
|
||||||
|
totalWorkouts: 30,
|
||||||
|
workoutsPerWeek: 3.3,
|
||||||
|
primaryTypes: ['barbell', 'dumbbell', 'cable'],
|
||||||
|
exercises: [
|
||||||
|
{
|
||||||
|
name: 'Bench Press',
|
||||||
|
type: 'barbell',
|
||||||
|
totalSets: 36,
|
||||||
|
distinctWorkouts: 12,
|
||||||
|
daysSinceLast: 2,
|
||||||
|
lastWeight: 235,
|
||||||
|
lastReps: 5,
|
||||||
|
bestWeight: 245,
|
||||||
|
estimated1RM: 286,
|
||||||
|
avgRpe: 8.5,
|
||||||
|
stagnant: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(out).toMatch(/30 workouts/);
|
||||||
|
expect(out).toMatch(/Bench Press/);
|
||||||
|
expect(out).toMatch(/STAGNANT|RPE/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { lenientJsonParse } from '@/lib/ai/lenientJson';
|
||||||
|
|
||||||
|
describe('lenientJsonParse', () => {
|
||||||
|
it('returns null on empty input', () => {
|
||||||
|
expect(lenientJsonParse('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no { is present', () => {
|
||||||
|
expect(lenientJsonParse('hello world')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the object when input is already valid', () => {
|
||||||
|
expect(lenientJsonParse('{"a":1,"b":[2,3]}')).toEqual({ a: 1, b: [2, 3] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips ```json fences', () => {
|
||||||
|
expect(lenientJsonParse('```json\n{"a":1}\n```')).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles fences not yet closed (still streaming)', () => {
|
||||||
|
expect(lenientJsonParse('```json\n{"a":1, "b":2')).toEqual({ a: 1, b: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the first { after preamble', () => {
|
||||||
|
expect(lenientJsonParse('Here you go:\n{"name":"x"}')).toEqual({
|
||||||
|
name: 'x',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-closes a partial object missing its closing }', () => {
|
||||||
|
const got = lenientJsonParse('{"name":"X","durationWeeks":4');
|
||||||
|
expect(got).toEqual({ name: 'X', durationWeeks: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-closes a partial array missing its closing ]', () => {
|
||||||
|
const got = lenientJsonParse('{"weeks":[1,2,3');
|
||||||
|
expect(got).toEqual({ weeks: [1, 2, 3] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a dangling property key with no value yet', () => {
|
||||||
|
const got = lenientJsonParse('{"name":"X","notes":');
|
||||||
|
expect(got).toEqual({ name: 'X' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a trailing comma after a complete value', () => {
|
||||||
|
const got = lenientJsonParse('{"a":1,"b":2,');
|
||||||
|
expect(got).toEqual({ a: 1, b: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a partial nested structure typical of AI program output', () => {
|
||||||
|
const partial = `{
|
||||||
|
"name": "Test",
|
||||||
|
"type": "hypertrophy",
|
||||||
|
"durationWeeks": 4,
|
||||||
|
"weeks": [
|
||||||
|
{
|
||||||
|
"weekNumber": 1,
|
||||||
|
"days": [
|
||||||
|
{
|
||||||
|
"dayOfWeek": 1,
|
||||||
|
"name": "Push",
|
||||||
|
"exercises": [
|
||||||
|
{"exerciseId": "abc", "exerciseName": "Bench", "order": 0, "sets": 4
|
||||||
|
`;
|
||||||
|
const got = lenientJsonParse(partial) as Record<string, any>;
|
||||||
|
expect(got).toBeTruthy();
|
||||||
|
expect(got.name).toBe('Test');
|
||||||
|
expect(Array.isArray(got.weeks)).toBe(true);
|
||||||
|
expect(got.weeks[0].weekNumber).toBe(1);
|
||||||
|
// The dangling exercise object may or may not be present
|
||||||
|
// depending on truncation; what matters is the parser didn't
|
||||||
|
// throw.
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles an open string at the end', () => {
|
||||||
|
const got = lenientJsonParse('{"description":"A long descrip');
|
||||||
|
expect(got).toBeTruthy();
|
||||||
|
expect((got as Record<string, string>).description).toMatch(
|
||||||
|
/^A long descrip/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unrecoverable garbage', () => {
|
||||||
|
// Mismatched closing brace before any opening is unrecoverable
|
||||||
|
expect(lenientJsonParse('}}}')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { MODEL_MENU, findPrice } from '@/lib/ai/pricing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Settings → AI integration model dropdown is sourced from
|
||||||
|
* MODEL_MENU. These tests guard the invariants:
|
||||||
|
*
|
||||||
|
* - Every menu model id is something findPrice() recognizes (so the
|
||||||
|
* cost estimator won't show "—" for any model the user picks from
|
||||||
|
* the dropdown).
|
||||||
|
* - At least one "recommended" entry per major provider — without it
|
||||||
|
* the UI has nothing to highlight.
|
||||||
|
* - Ollama + openai-compatible menus are intentionally empty (those
|
||||||
|
* providers are gateway-/host-specific).
|
||||||
|
* - At least one Gemini 3.x entry (regression-guard against the
|
||||||
|
* user's "I tried gemini-3.0-pro and got 404" report).
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('MODEL_MENU', () => {
|
||||||
|
it('every menu model id matches a price entry', () => {
|
||||||
|
for (const [provider, models] of Object.entries(MODEL_MENU)) {
|
||||||
|
for (const m of models) {
|
||||||
|
const price = findPrice(m.id);
|
||||||
|
expect(
|
||||||
|
price,
|
||||||
|
`${provider}/${m.id} has no price entry — add it to PRICES in pricing.ts`,
|
||||||
|
).not.toBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('major providers have at least one recommended model', () => {
|
||||||
|
for (const provider of ['claude', 'openai', 'gemini'] as const) {
|
||||||
|
const recs = MODEL_MENU[provider]?.filter((m) => m.recommended) ?? [];
|
||||||
|
expect(
|
||||||
|
recs.length,
|
||||||
|
`${provider} has no recommended model — UI has nothing to star`,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ollama + openai-compatible menus are empty (model is host-specific)', () => {
|
||||||
|
expect(MODEL_MENU.ollama).toEqual([]);
|
||||||
|
expect(MODEL_MENU['openai-compatible']).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Gemini menu includes a 3.x model (regression: gemini-3.0-pro 404)', () => {
|
||||||
|
const ids = MODEL_MENU.gemini.map((m) => m.id);
|
||||||
|
const has3x = ids.some((id) => /gemini-3/i.test(id));
|
||||||
|
expect(has3x, `gemini menu lacks any 3.x model: ${ids.join(', ')}`).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Claude menu includes a Sonnet 4.6 or newer (1M context)', () => {
|
||||||
|
const ids = MODEL_MENU.claude.map((m) => m.id);
|
||||||
|
const hasModern = ids.some((id) =>
|
||||||
|
/claude-(opus-4-7|sonnet-4-6|opus-4-6)/i.test(id),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
hasModern,
|
||||||
|
`claude menu missing 4.6+ tier: ${ids.join(', ')}`,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { findPrice, estimateCost, formatCost } from '@/lib/ai/pricing';
|
||||||
|
|
||||||
|
describe('findPrice', () => {
|
||||||
|
it('matches a known model exactly', () => {
|
||||||
|
expect(findPrice('claude-sonnet-4-5')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a known model with a date suffix (longest-prefix)', () => {
|
||||||
|
const p = findPrice('claude-sonnet-4-5-20251022');
|
||||||
|
expect(p?.inputPerM).toBe(3);
|
||||||
|
expect(p?.outputPerM).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive', () => {
|
||||||
|
expect(findPrice('GPT-5-Mini')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown models', () => {
|
||||||
|
expect(findPrice('mistral-medium-9000')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers longer-prefix when multiple keys match', () => {
|
||||||
|
// claude-sonnet-4-5 is more specific than claude-sonnet-4
|
||||||
|
const p = findPrice('claude-sonnet-4-5');
|
||||||
|
expect(p).toEqual({ inputPerM: 3, outputPerM: 15 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('estimateCost', () => {
|
||||||
|
it('returns 0 for ollama (self-hosted)', () => {
|
||||||
|
expect(
|
||||||
|
estimateCost({
|
||||||
|
provider: 'ollama',
|
||||||
|
model: 'llama3.1:8b',
|
||||||
|
tokensIn: 1000,
|
||||||
|
tokensOut: 500,
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for openai-compatible (unknown gateway pricing)', () => {
|
||||||
|
expect(
|
||||||
|
estimateCost({
|
||||||
|
provider: 'openai-compatible',
|
||||||
|
model: 'meta-llama/llama-3.1-8b-instruct',
|
||||||
|
tokensIn: 1000,
|
||||||
|
tokensOut: 500,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the model isn\'t in the price table', () => {
|
||||||
|
expect(
|
||||||
|
estimateCost({
|
||||||
|
provider: 'claude',
|
||||||
|
model: 'claude-vintage-edition',
|
||||||
|
tokensIn: 1000,
|
||||||
|
tokensOut: 500,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when token counts are missing', () => {
|
||||||
|
expect(
|
||||||
|
estimateCost({
|
||||||
|
provider: 'claude',
|
||||||
|
model: 'claude-sonnet-4-5',
|
||||||
|
tokensIn: null,
|
||||||
|
tokensOut: 500,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes the right $ for a known model', () => {
|
||||||
|
// claude-sonnet-4-5: $3/M in, $15/M out
|
||||||
|
// 100K in + 50K out = 0.1*3 + 0.05*15 = 0.3 + 0.75 = 1.05
|
||||||
|
const cost = estimateCost({
|
||||||
|
provider: 'claude',
|
||||||
|
model: 'claude-sonnet-4-5',
|
||||||
|
tokensIn: 100_000,
|
||||||
|
tokensOut: 50_000,
|
||||||
|
});
|
||||||
|
expect(cost).toBeCloseTo(1.05, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes correctly for gpt-5-nano (very cheap)', () => {
|
||||||
|
// gpt-5-nano: $0.05/M in, $0.4/M out
|
||||||
|
// 1000 in + 500 out = 0.001*0.05 + 0.0005*0.4 = 0.00005 + 0.0002 = 0.00025
|
||||||
|
const cost = estimateCost({
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-5-nano',
|
||||||
|
tokensIn: 1000,
|
||||||
|
tokensOut: 500,
|
||||||
|
});
|
||||||
|
expect(cost).toBeCloseTo(0.00025, 8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatCost', () => {
|
||||||
|
it('formats null as em dash', () => {
|
||||||
|
expect(formatCost(null)).toBe('—');
|
||||||
|
});
|
||||||
|
it('formats 0 as "free"', () => {
|
||||||
|
expect(formatCost(0)).toBe('free');
|
||||||
|
});
|
||||||
|
it('formats sub-cent costs as "<$0.01"', () => {
|
||||||
|
expect(formatCost(0.0023)).toBe('<$0.01');
|
||||||
|
});
|
||||||
|
it('formats sub-dollar costs with 3 decimal places', () => {
|
||||||
|
expect(formatCost(0.123)).toBe('$0.123');
|
||||||
|
});
|
||||||
|
it('formats dollar+ costs with 2 decimal places', () => {
|
||||||
|
expect(formatCost(2.567)).toBe('$2.57');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { extractJson, parseAIProgram } from '@/lib/ai/programSchema';
|
||||||
|
|
||||||
|
describe('extractJson', () => {
|
||||||
|
it('extracts a bare JSON object', () => {
|
||||||
|
expect(extractJson('{"a":1}')).toBe('{"a":1}');
|
||||||
|
});
|
||||||
|
it('strips ```json fences', () => {
|
||||||
|
expect(extractJson('```json\n{"a":1}\n```')).toBe('{"a":1}');
|
||||||
|
});
|
||||||
|
it('strips bare ``` fences', () => {
|
||||||
|
expect(extractJson('```\n{"a":1}\n```')).toBe('{"a":1}');
|
||||||
|
});
|
||||||
|
it('finds first balanced object after preamble', () => {
|
||||||
|
const raw =
|
||||||
|
'Here is your program:\n\n{"name":"X","weeks":[{"weekNumber":1,"days":[]}]}\n\nHope that helps!';
|
||||||
|
expect(extractJson(raw)).toBe(
|
||||||
|
'{"name":"X","weeks":[{"weekNumber":1,"days":[]}]}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('handles braces inside strings', () => {
|
||||||
|
const raw = '{"notes":"use {brackets} sparingly","x":1}';
|
||||||
|
expect(extractJson(raw)).toBe(raw);
|
||||||
|
});
|
||||||
|
it('returns null when no object present', () => {
|
||||||
|
expect(extractJson('no json at all')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseAIProgram', () => {
|
||||||
|
const valid = {
|
||||||
|
name: 'Test',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 4,
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
exercises: [
|
||||||
|
{
|
||||||
|
exerciseId: 'cabc',
|
||||||
|
exerciseName: 'Bench Press',
|
||||||
|
order: 0,
|
||||||
|
sets: 4,
|
||||||
|
repsMin: 6,
|
||||||
|
repsMax: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('accepts a valid program', () => {
|
||||||
|
const r = parseAIProgram(JSON.stringify(valid));
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
expect(r.program.name).toBe('Test');
|
||||||
|
expect(r.program.weeks[0].days[0].exercises[0].exerciseName).toBe(
|
||||||
|
'Bench Press',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts null exerciseId for unresolved exercises', () => {
|
||||||
|
const variant = structuredClone(valid);
|
||||||
|
variant.weeks[0].days[0].exercises[0] = {
|
||||||
|
...variant.weeks[0].days[0].exercises[0],
|
||||||
|
exerciseId: null as unknown as string,
|
||||||
|
};
|
||||||
|
const r = parseAIProgram(JSON.stringify(variant));
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when no JSON found', () => {
|
||||||
|
const r = parseAIProgram('the model just said hello');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toMatch(/Could not find/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed JSON', () => {
|
||||||
|
// Unbalanced braces: extractJson never finds a closing `}`, so
|
||||||
|
// the failure mode is "Could not find a JSON object" rather than
|
||||||
|
// a parse error per se. Either way, ok=false.
|
||||||
|
const r = parseAIProgram('{ "name": "x", "weeks": [');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects JSON with a parse-level syntax error inside balanced braces', () => {
|
||||||
|
const r = parseAIProgram('{ "name": "x", }');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toMatch(/parse error/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when shape is wrong (missing weeks)', () => {
|
||||||
|
const bad = { name: 'X', type: 'hypertrophy', durationWeeks: 4 };
|
||||||
|
const r = parseAIProgram(JSON.stringify(bad));
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toMatch(/shape/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when dayOfWeek is out of range', () => {
|
||||||
|
const variant = structuredClone(valid);
|
||||||
|
variant.weeks[0].days[0].dayOfWeek = 7; // 0-6 only
|
||||||
|
const r = parseAIProgram(JSON.stringify(variant));
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a model response wrapped in markdown commentary', () => {
|
||||||
|
const wrapped =
|
||||||
|
"Sure! Here's your program:\n\n```json\n" +
|
||||||
|
JSON.stringify(valid) +
|
||||||
|
'\n```\n\nLet me know if you want changes.';
|
||||||
|
const r = parseAIProgram(wrapped);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildBaseSystemPrompt } from '@/lib/ai/systemPromptBase';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base system prompt is the structural contract every template
|
||||||
|
* inherits. These tests pin the *invariants* that must always hold:
|
||||||
|
* - JSON-only output rule
|
||||||
|
* - "use library exerciseIds" rule (fixes the bug where the model
|
||||||
|
* invented ids and apply blew up)
|
||||||
|
* - "suggested weight is required" rule
|
||||||
|
* - The conditional history-vs-no-history block toggles correctly
|
||||||
|
* - The local-model nudge appears for Ollama
|
||||||
|
*
|
||||||
|
* Wording can shift over time; these assertions check substrings, not
|
||||||
|
* exact matches, so coaching tone changes don't break tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('buildBaseSystemPrompt', () => {
|
||||||
|
it('always demands JSON-only output (no fences)', () => {
|
||||||
|
const p = buildBaseSystemPrompt({
|
||||||
|
weightUnit: 'lbs',
|
||||||
|
hasHistoryContext: false,
|
||||||
|
isLocalModel: false,
|
||||||
|
});
|
||||||
|
expect(p).toMatch(/JSON object/i);
|
||||||
|
expect(p).toMatch(/no.+fences/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces use of library exerciseIds', () => {
|
||||||
|
const p = buildBaseSystemPrompt({
|
||||||
|
weightUnit: 'lbs',
|
||||||
|
hasHistoryContext: false,
|
||||||
|
isLocalModel: false,
|
||||||
|
});
|
||||||
|
expect(p).toMatch(/exerciseId/);
|
||||||
|
expect(p).toMatch(/library/i);
|
||||||
|
expect(p).toMatch(/never invent ids/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires suggestedWeight in the user’s preferred unit', () => {
|
||||||
|
const lbsPrompt = buildBaseSystemPrompt({
|
||||||
|
weightUnit: 'lbs',
|
||||||
|
hasHistoryContext: false,
|
||||||
|
isLocalModel: false,
|
||||||
|
});
|
||||||
|
expect(lbsPrompt).toMatch(/suggestedWeight/);
|
||||||
|
expect(lbsPrompt).toMatch(/lbs/);
|
||||||
|
|
||||||
|
const kgPrompt = buildBaseSystemPrompt({
|
||||||
|
weightUnit: 'kg',
|
||||||
|
hasHistoryContext: false,
|
||||||
|
isLocalModel: false,
|
||||||
|
});
|
||||||
|
expect(kgPrompt).toMatch(/kg/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to "use the history block" instructions when history is present', () => {
|
||||||
|
const withHistory = buildBaseSystemPrompt({
|
||||||
|
weightUnit: 'lbs',
|
||||||
|
hasHistoryContext: true,
|
||||||
|
isLocalModel: false,
|
||||||
|
});
|
||||||
|
expect(withHistory).toMatch(/HISTORY block/);
|
||||||
|
expect(withHistory).toMatch(/STAGNANT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to conservative-defaults instructions when no history', () => {
|
||||||
|
const noHistory = buildBaseSystemPrompt({
|
||||||
|
weightUnit: 'lbs',
|
||||||
|
hasHistoryContext: false,
|
||||||
|
isLocalModel: false,
|
||||||
|
});
|
||||||
|
expect(noHistory).toMatch(/WEIGHT GUIDANCE WITHOUT HISTORY/);
|
||||||
|
expect(noHistory).toMatch(/50-65%/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a "local model" reminder for Ollama', () => {
|
||||||
|
const local = buildBaseSystemPrompt({
|
||||||
|
weightUnit: 'lbs',
|
||||||
|
hasHistoryContext: false,
|
||||||
|
isLocalModel: true,
|
||||||
|
});
|
||||||
|
expect(local).toMatch(/LOCAL MODEL/);
|
||||||
|
expect(local).toMatch(/JSON only/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the local-model reminder for cloud providers', () => {
|
||||||
|
const cloud = buildBaseSystemPrompt({
|
||||||
|
weightUnit: 'lbs',
|
||||||
|
hasHistoryContext: true,
|
||||||
|
isLocalModel: false,
|
||||||
|
});
|
||||||
|
expect(cloud).not.toMatch(/LOCAL MODEL REMINDER/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { getCurrentUserMock } = vi.hoisted(() => ({
|
||||||
|
getCurrentUserMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/auth', async (orig) => {
|
||||||
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
|
return { ...actual, getCurrentUser: getCurrentUserMock };
|
||||||
|
});
|
||||||
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import {
|
||||||
|
GET as listTemplates,
|
||||||
|
POST as createTemplate,
|
||||||
|
} from '@/app/api/ai/templates/route';
|
||||||
|
import {
|
||||||
|
PATCH as patchTemplate,
|
||||||
|
DELETE as deleteTemplate,
|
||||||
|
} from '@/app/api/ai/templates/[id]/route';
|
||||||
|
import { GET as getConfig, POST as setConfig } from '@/app/api/ai/config/route';
|
||||||
|
|
||||||
|
function jsonReq(url: string, body?: unknown, method?: string): NextRequest {
|
||||||
|
return new NextRequest(url, {
|
||||||
|
method: method ?? (body !== undefined ? 'POST' : 'GET'),
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.session.deleteMany();
|
||||||
|
await prisma.aIGeneration.deleteMany();
|
||||||
|
await prisma.aIPromptTemplate.deleteMany();
|
||||||
|
await prisma.userPreferences.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
await prisma.instanceSettings.deleteMany();
|
||||||
|
getCurrentUserMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/ai/templates', () => {
|
||||||
|
it('returns built-ins + this user\'s own; not other users\' templates', async () => {
|
||||||
|
const me = await prisma.user.create({
|
||||||
|
data: { email: 'me@x', passwordHash: 'fake' },
|
||||||
|
});
|
||||||
|
const other = await prisma.user.create({
|
||||||
|
data: { email: 'other@x', passwordHash: 'fake' },
|
||||||
|
});
|
||||||
|
await prisma.aIPromptTemplate.create({
|
||||||
|
data: {
|
||||||
|
userId: null,
|
||||||
|
name: 'Built-in 1',
|
||||||
|
systemPrompt: 's',
|
||||||
|
userPromptTemplate: 'u',
|
||||||
|
isBuiltIn: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.aIPromptTemplate.create({
|
||||||
|
data: {
|
||||||
|
userId: me.id,
|
||||||
|
name: 'My template',
|
||||||
|
systemPrompt: 's',
|
||||||
|
userPromptTemplate: 'u',
|
||||||
|
isBuiltIn: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.aIPromptTemplate.create({
|
||||||
|
data: {
|
||||||
|
userId: other.id,
|
||||||
|
name: "Other user's template",
|
||||||
|
systemPrompt: 's',
|
||||||
|
userPromptTemplate: 'u',
|
||||||
|
isBuiltIn: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
getCurrentUserMock.mockResolvedValue(me);
|
||||||
|
const list = await (await listTemplates()).json();
|
||||||
|
const names = list.map((t: { name: string }) => t.name).sort();
|
||||||
|
expect(names).toEqual(['Built-in 1', 'My template']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/ai/templates', () => {
|
||||||
|
it('creates a user-owned template', async () => {
|
||||||
|
const me = await prisma.user.create({
|
||||||
|
data: { email: 'me@x', passwordHash: 'fake' },
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(me);
|
||||||
|
const res = await createTemplate(
|
||||||
|
jsonReq('http://x/api/ai/templates', {
|
||||||
|
name: 'My new',
|
||||||
|
systemPrompt: 'be a coach',
|
||||||
|
userPromptTemplate: '{{userInput}}',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const tpl = await res.json();
|
||||||
|
expect(tpl.userId).toBe(me.id);
|
||||||
|
expect(tpl.isBuiltIn).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/ai/templates/[id]', () => {
|
||||||
|
it('rejects edits to built-ins by non-admin', async () => {
|
||||||
|
const me = await prisma.user.create({
|
||||||
|
data: { email: 'me@x', passwordHash: 'fake', isAdmin: false },
|
||||||
|
});
|
||||||
|
const builtin = await prisma.aIPromptTemplate.create({
|
||||||
|
data: {
|
||||||
|
userId: null,
|
||||||
|
name: 'Built-in',
|
||||||
|
systemPrompt: 's',
|
||||||
|
userPromptTemplate: 'u',
|
||||||
|
isBuiltIn: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(me);
|
||||||
|
const res = await patchTemplate(
|
||||||
|
jsonReq(
|
||||||
|
`http://x/api/ai/templates/${builtin.id}`,
|
||||||
|
{ name: 'tampered' },
|
||||||
|
'PATCH',
|
||||||
|
),
|
||||||
|
{ params: { id: builtin.id } },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows edits to built-ins by admin', async () => {
|
||||||
|
const admin = await prisma.user.create({
|
||||||
|
data: { email: 'admin@x', passwordHash: 'fake', isAdmin: true },
|
||||||
|
});
|
||||||
|
const builtin = await prisma.aIPromptTemplate.create({
|
||||||
|
data: {
|
||||||
|
userId: null,
|
||||||
|
name: 'Built-in',
|
||||||
|
systemPrompt: 's',
|
||||||
|
userPromptTemplate: 'u',
|
||||||
|
isBuiltIn: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(admin);
|
||||||
|
const res = await patchTemplate(
|
||||||
|
jsonReq(
|
||||||
|
`http://x/api/ai/templates/${builtin.id}`,
|
||||||
|
{ name: 'admin-edited' },
|
||||||
|
'PATCH',
|
||||||
|
),
|
||||||
|
{ params: { id: builtin.id } },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const updated = await prisma.aIPromptTemplate.findUnique({
|
||||||
|
where: { id: builtin.id },
|
||||||
|
});
|
||||||
|
expect(updated?.name).toBe('admin-edited');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects edits to another user\'s template', async () => {
|
||||||
|
const alice = await prisma.user.create({
|
||||||
|
data: { email: 'alice@x', passwordHash: 'fake' },
|
||||||
|
});
|
||||||
|
const aliceTpl = await prisma.aIPromptTemplate.create({
|
||||||
|
data: {
|
||||||
|
userId: alice.id,
|
||||||
|
name: 'Alice template',
|
||||||
|
systemPrompt: 's',
|
||||||
|
userPromptTemplate: 'u',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const bob = await prisma.user.create({
|
||||||
|
data: { email: 'bob@x', passwordHash: 'fake' },
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(bob);
|
||||||
|
const res = await patchTemplate(
|
||||||
|
jsonReq(
|
||||||
|
`http://x/api/ai/templates/${aliceTpl.id}`,
|
||||||
|
{ name: 'bob hacks' },
|
||||||
|
'PATCH',
|
||||||
|
),
|
||||||
|
{ params: { id: aliceTpl.id } },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/ai/templates/[id]', () => {
|
||||||
|
it('lets user delete their own template', async () => {
|
||||||
|
const me = await prisma.user.create({
|
||||||
|
data: { email: 'me@x', passwordHash: 'fake' },
|
||||||
|
});
|
||||||
|
const tpl = await prisma.aIPromptTemplate.create({
|
||||||
|
data: {
|
||||||
|
userId: me.id,
|
||||||
|
name: 'X',
|
||||||
|
systemPrompt: 's',
|
||||||
|
userPromptTemplate: 'u',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(me);
|
||||||
|
const res = await deleteTemplate(
|
||||||
|
jsonReq(`http://x/api/ai/templates/${tpl.id}`, undefined, 'DELETE'),
|
||||||
|
{ params: { id: tpl.id } },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await prisma.aIPromptTemplate.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/api/ai/config', () => {
|
||||||
|
it('GET returns aiKeyConfigured flag, never the plaintext key', async () => {
|
||||||
|
const me = await prisma.user.create({
|
||||||
|
data: { email: 'me@x', passwordHash: 'fake' },
|
||||||
|
});
|
||||||
|
await prisma.userPreferences.create({
|
||||||
|
data: {
|
||||||
|
userId: me.id,
|
||||||
|
aiProvider: 'claude',
|
||||||
|
aiModel: 'claude-sonnet-4-5',
|
||||||
|
aiApiKey: 'sk-ant-secret',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(me);
|
||||||
|
const body = await (await getConfig()).json();
|
||||||
|
expect(body.aiProvider).toBe('claude');
|
||||||
|
expect(body.aiModel).toBe('claude-sonnet-4-5');
|
||||||
|
expect(body.aiKeyConfigured).toBe(true);
|
||||||
|
expect(JSON.stringify(body)).not.toContain('sk-ant-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST persists provider config', async () => {
|
||||||
|
const me = await prisma.user.create({
|
||||||
|
data: { email: 'me@x', passwordHash: 'fake' },
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(me);
|
||||||
|
const res = await setConfig(
|
||||||
|
jsonReq('http://x/api/ai/config', {
|
||||||
|
aiProvider: 'ollama',
|
||||||
|
aiModel: 'llama3.1:8b',
|
||||||
|
aiBaseUrl: 'http://ollama.embassy:11434',
|
||||||
|
aiApiKey: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const prefs = await prisma.userPreferences.findUnique({
|
||||||
|
where: { userId: me.id },
|
||||||
|
});
|
||||||
|
expect(prefs?.aiProvider).toBe('ollama');
|
||||||
|
expect(prefs?.aiBaseUrl).toBe('http://ollama.embassy:11434');
|
||||||
|
expect(prefs?.aiApiKey).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST validates provider enum', async () => {
|
||||||
|
const me = await prisma.user.create({
|
||||||
|
data: { email: 'me@x', passwordHash: 'fake' },
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(me);
|
||||||
|
const res = await setConfig(
|
||||||
|
jsonReq('http://x/api/ai/config', { aiProvider: 'made-up-provider' }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { getCurrentUserMock } = vi.hoisted(() => ({
|
||||||
|
getCurrentUserMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/auth', async (orig) => {
|
||||||
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
|
return { ...actual, getCurrentUser: getCurrentUserMock };
|
||||||
|
});
|
||||||
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import {
|
||||||
|
GET as getPrograms,
|
||||||
|
POST as createProgram,
|
||||||
|
} from '@/app/api/programs/route';
|
||||||
|
import {
|
||||||
|
GET as getProgram,
|
||||||
|
PATCH as patchProgram,
|
||||||
|
DELETE as deleteProgram,
|
||||||
|
} from '@/app/api/programs/[id]/route';
|
||||||
|
import { POST as startDay } from '@/app/api/programs/[id]/days/[dayId]/start/route';
|
||||||
|
|
||||||
|
function jsonReq(url: string, body?: unknown, method?: string): NextRequest {
|
||||||
|
return new NextRequest(url, {
|
||||||
|
method: method ?? (body !== undefined ? 'POST' : 'GET'),
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
} as ConstructorParameters<typeof NextRequest>[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeUserAndExercises(opts: {
|
||||||
|
email: string;
|
||||||
|
exerciseNames: string[];
|
||||||
|
}) {
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email: opts.email, passwordHash: 'fake', isAdmin: false },
|
||||||
|
});
|
||||||
|
const exercises = [];
|
||||||
|
for (const name of opts.exerciseNames) {
|
||||||
|
exercises.push(
|
||||||
|
await prisma.exercise.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
name,
|
||||||
|
type: 'barbell',
|
||||||
|
muscleGroups: '[]',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { user, exercises };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.session.deleteMany();
|
||||||
|
await prisma.setLog.deleteMany();
|
||||||
|
await prisma.workout.deleteMany();
|
||||||
|
await prisma.programExercise.deleteMany();
|
||||||
|
await prisma.programDay.deleteMany();
|
||||||
|
await prisma.programWeek.deleteMany();
|
||||||
|
await prisma.program.deleteMany();
|
||||||
|
await prisma.exercise.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
await prisma.instanceSettings.deleteMany();
|
||||||
|
getCurrentUserMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/programs', () => {
|
||||||
|
it('returns 401 unauthenticated', async () => {
|
||||||
|
getCurrentUserMock.mockResolvedValue(null);
|
||||||
|
const res = await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'X',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 4,
|
||||||
|
startDate: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a program with the full nested tree in one transaction', async () => {
|
||||||
|
const { user, exercises } = await makeUserAndExercises({
|
||||||
|
email: 'a@x',
|
||||||
|
exerciseNames: ['Bench Press', 'Squat'],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(user);
|
||||||
|
|
||||||
|
const res = await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'Test 4-week',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 4,
|
||||||
|
startDate: '2026-05-10',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
phase: 'Volume',
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
name: 'Push',
|
||||||
|
exercises: [
|
||||||
|
{
|
||||||
|
exerciseId: exercises[0].id,
|
||||||
|
order: 0,
|
||||||
|
sets: 4,
|
||||||
|
repsMin: 6,
|
||||||
|
repsMax: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dayOfWeek: 3,
|
||||||
|
name: 'Pull',
|
||||||
|
exercises: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
weekNumber: 2,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
name: 'Push',
|
||||||
|
exercises: [
|
||||||
|
{
|
||||||
|
exerciseId: exercises[0].id,
|
||||||
|
order: 0,
|
||||||
|
sets: 4,
|
||||||
|
repsMin: 6,
|
||||||
|
repsMax: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseId: exercises[1].id,
|
||||||
|
order: 1,
|
||||||
|
sets: 5,
|
||||||
|
repsMin: 5,
|
||||||
|
repsMax: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
|
||||||
|
const programs = await prisma.program.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
include: {
|
||||||
|
weeks: {
|
||||||
|
include: {
|
||||||
|
days: { include: { exercises: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(programs).toHaveLength(1);
|
||||||
|
expect(programs[0].weeks).toHaveLength(2);
|
||||||
|
const week2 = programs[0].weeks.find((w) => w.weekNumber === 2);
|
||||||
|
expect(week2!.days[0].exercises).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects exerciseIds that belong to a different user', async () => {
|
||||||
|
const { exercises: aliceExs } = await makeUserAndExercises({
|
||||||
|
email: 'alice@x',
|
||||||
|
exerciseNames: ['Alice Squat'],
|
||||||
|
});
|
||||||
|
const { user: bob } = await makeUserAndExercises({
|
||||||
|
email: 'bob@x',
|
||||||
|
exerciseNames: [],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(bob);
|
||||||
|
|
||||||
|
const res = await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'Bob trying to use Alice exercise',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 1,
|
||||||
|
startDate: '2026-05-10',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
exercises: [{ exerciseId: aliceExs[0].id, order: 0, sets: 3 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/programs + GET /api/programs/[id]', () => {
|
||||||
|
it('lists programs scoped to the actor and returns full tree on detail', async () => {
|
||||||
|
const { user, exercises } = await makeUserAndExercises({
|
||||||
|
email: 'a@x',
|
||||||
|
exerciseNames: ['Bench'],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(user);
|
||||||
|
await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'Plan A',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 1,
|
||||||
|
startDate: '2026-05-10',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
exercises: [{ exerciseId: exercises[0].id, order: 0, sets: 3 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const list = await (await getPrograms()).json();
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
const programId = list[0].id;
|
||||||
|
|
||||||
|
const detail = await (
|
||||||
|
await getProgram(jsonReq(`http://x/api/programs/${programId}`), {
|
||||||
|
params: { id: programId },
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
expect(detail.weeks[0].days[0].exercises[0].exercise.name).toBe('Bench');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET detail returns 404 for another user\'s program', async () => {
|
||||||
|
const { user: aliceForOtherTest } = await makeUserAndExercises({
|
||||||
|
email: 'alice@x',
|
||||||
|
exerciseNames: [],
|
||||||
|
});
|
||||||
|
const aliceProg = await prisma.program.create({
|
||||||
|
data: {
|
||||||
|
userId: aliceForOtherTest.id,
|
||||||
|
name: 'Alice plan',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 1,
|
||||||
|
startDate: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { user: bob } = await makeUserAndExercises({
|
||||||
|
email: 'bob@x',
|
||||||
|
exerciseNames: [],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(bob);
|
||||||
|
const res = await getProgram(
|
||||||
|
jsonReq(`http://x/api/programs/${aliceProg.id}`),
|
||||||
|
{ params: { id: aliceProg.id } },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /api/programs/[id] (replace tree)', () => {
|
||||||
|
it('replaces the entire weeks tree atomically', async () => {
|
||||||
|
const { user, exercises } = await makeUserAndExercises({
|
||||||
|
email: 'a@x',
|
||||||
|
exerciseNames: ['Bench', 'Squat'],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(user);
|
||||||
|
const created = await (
|
||||||
|
await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'Original',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 4,
|
||||||
|
startDate: '2026-05-10',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
exercises: [
|
||||||
|
{ exerciseId: exercises[0].id, order: 0, sets: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).json();
|
||||||
|
|
||||||
|
const patchRes = await patchProgram(
|
||||||
|
jsonReq(
|
||||||
|
`http://x/api/programs/${created.id}`,
|
||||||
|
{
|
||||||
|
name: 'Updated name',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 2,
|
||||||
|
name: 'Pull',
|
||||||
|
exercises: [
|
||||||
|
{ exerciseId: exercises[1].id, order: 0, sets: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'PATCH',
|
||||||
|
),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
);
|
||||||
|
expect(patchRes.status).toBe(200);
|
||||||
|
|
||||||
|
const after = await prisma.program.findUnique({
|
||||||
|
where: { id: created.id },
|
||||||
|
include: {
|
||||||
|
weeks: {
|
||||||
|
include: {
|
||||||
|
days: { include: { exercises: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(after!.name).toBe('Updated name');
|
||||||
|
expect(after!.weeks[0].days).toHaveLength(1);
|
||||||
|
expect(after!.weeks[0].days[0].dayOfWeek).toBe(2);
|
||||||
|
expect(after!.weeks[0].days[0].exercises[0].exerciseId).toBe(
|
||||||
|
exercises[1].id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/programs/[id]', () => {
|
||||||
|
it('cascades to weeks/days/exercises and refuses cross-user', async () => {
|
||||||
|
const { user, exercises } = await makeUserAndExercises({
|
||||||
|
email: 'a@x',
|
||||||
|
exerciseNames: ['Bench'],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(user);
|
||||||
|
const created = await (
|
||||||
|
await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'Will be deleted',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 1,
|
||||||
|
startDate: '2026-05-10',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
exercises: [
|
||||||
|
{ exerciseId: exercises[0].id, order: 0, sets: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).json();
|
||||||
|
|
||||||
|
expect(await prisma.programWeek.count()).toBeGreaterThan(0);
|
||||||
|
expect(await prisma.programExercise.count()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const res = await deleteProgram(
|
||||||
|
jsonReq(`http://x/api/programs/${created.id}`, undefined, 'DELETE'),
|
||||||
|
{ params: { id: created.id } },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await prisma.programWeek.count()).toBe(0);
|
||||||
|
expect(await prisma.programExercise.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/programs/[id]/days/[dayId]/start', () => {
|
||||||
|
it('creates a workout pre-populated with empty SetLogs from the program day', async () => {
|
||||||
|
const { user, exercises } = await makeUserAndExercises({
|
||||||
|
email: 'a@x',
|
||||||
|
exerciseNames: ['Bench', 'Squat'],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(user);
|
||||||
|
const created = await (
|
||||||
|
await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'Plan',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 1,
|
||||||
|
startDate: '2026-05-10',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
name: 'Push Day',
|
||||||
|
exercises: [
|
||||||
|
{ exerciseId: exercises[0].id, order: 0, sets: 4 },
|
||||||
|
{ exerciseId: exercises[1].id, order: 1, sets: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).json();
|
||||||
|
|
||||||
|
const day = await prisma.programDay.findFirst({
|
||||||
|
where: { week: { programId: created.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const startRes = await startDay(
|
||||||
|
jsonReq(
|
||||||
|
`http://x/api/programs/${created.id}/days/${day!.id}/start`,
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
{ params: { id: created.id, dayId: day!.id } },
|
||||||
|
);
|
||||||
|
expect(startRes.status).toBe(201);
|
||||||
|
const workout = await startRes.json();
|
||||||
|
|
||||||
|
// 4 sets of Bench + 3 sets of Squat = 7 SetLogs
|
||||||
|
expect(workout.setLogs).toHaveLength(7);
|
||||||
|
expect(workout.programDayId).toBe(day!.id);
|
||||||
|
expect(workout.name).toBe('Push Day');
|
||||||
|
// SetLogs should be empty (no reps/weight) — user fills them in
|
||||||
|
for (const sl of workout.setLogs) {
|
||||||
|
expect(sl.reps).toBeNull();
|
||||||
|
expect(sl.weight).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses if the program day belongs to a different user', async () => {
|
||||||
|
const { user: alice, exercises: aliceExs } = await makeUserAndExercises({
|
||||||
|
email: 'alice@x',
|
||||||
|
exerciseNames: ['Alice Bench'],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(alice);
|
||||||
|
const aliceProg = await (
|
||||||
|
await createProgram(
|
||||||
|
jsonReq('http://x/api/programs', {
|
||||||
|
name: 'Alice Plan',
|
||||||
|
type: 'hypertrophy',
|
||||||
|
durationWeeks: 1,
|
||||||
|
startDate: '2026-05-10',
|
||||||
|
weeks: [
|
||||||
|
{
|
||||||
|
weekNumber: 1,
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
dayOfWeek: 1,
|
||||||
|
exercises: [
|
||||||
|
{ exerciseId: aliceExs[0].id, order: 0, sets: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).json();
|
||||||
|
const aliceDay = await prisma.programDay.findFirst({
|
||||||
|
where: { week: { programId: aliceProg.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { user: bob } = await makeUserAndExercises({
|
||||||
|
email: 'bob@x',
|
||||||
|
exerciseNames: [],
|
||||||
|
});
|
||||||
|
getCurrentUserMock.mockResolvedValue(bob);
|
||||||
|
const res = await startDay(
|
||||||
|
jsonReq(
|
||||||
|
`http://x/api/programs/${aliceProg.id}/days/${aliceDay!.id}/start`,
|
||||||
|
),
|
||||||
|
{ params: { id: aliceProg.id, dayId: aliceDay!.id } },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -97,6 +97,157 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
|
|||||||
sqlite3 "$DB_PATH" "ALTER TABLE User ADD COLUMN lastLoginAt DATETIME;"
|
sqlite3 "$DB_PATH" "ALTER TABLE User ADD COLUMN lastLoginAt DATETIME;"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# v1.1.0:1 added Workout.programDayId so workouts can be tagged with the
|
||||||
|
# planned ProgramDay they were logged against (for adherence tracking).
|
||||||
|
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('Workout');" 2>/dev/null | grep -q "|programDayId|"; then
|
||||||
|
log "adding missing column Workout.programDayId (nullable)"
|
||||||
|
sqlite3 "$DB_PATH" "ALTER TABLE Workout ADD COLUMN programDayId TEXT REFERENCES ProgramDay(id) ON DELETE SET NULL;"
|
||||||
|
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
|
||||||
|
|
||||||
|
# v1.1.0:4 added AIConfigProfile table (multi-config support) +
|
||||||
|
# UserPreferences.activeAIConfigId pointer + AIGeneration progress/
|
||||||
|
# duration columns + ProgramExercise suggested-weight columns.
|
||||||
|
if ! sqlite3 "$DB_PATH" \
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='AIConfigProfile';" \
|
||||||
|
2>/dev/null | grep -q AIConfigProfile; then
|
||||||
|
log "creating AIConfigProfile table"
|
||||||
|
sqlite3 "$DB_PATH" "
|
||||||
|
CREATE TABLE AIConfigProfile (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
baseUrl TEXT,
|
||||||
|
apiKey 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 AIConfigProfile_userId_idx ON AIConfigProfile(userId);
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('UserPreferences');" 2>/dev/null | grep -q "|activeAIConfigId|"; then
|
||||||
|
log "adding UserPreferences.activeAIConfigId"
|
||||||
|
sqlite3 "$DB_PATH" "ALTER TABLE UserPreferences ADD COLUMN activeAIConfigId TEXT;"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('AIGeneration');" 2>/dev/null | grep -q "|progressText|"; then
|
||||||
|
log "adding AIGeneration.progressText"
|
||||||
|
sqlite3 "$DB_PATH" "ALTER TABLE AIGeneration ADD COLUMN progressText TEXT;"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('AIGeneration');" 2>/dev/null | grep -q "|durationMs|"; then
|
||||||
|
log "adding AIGeneration.durationMs"
|
||||||
|
sqlite3 "$DB_PATH" "ALTER TABLE AIGeneration ADD COLUMN durationMs INTEGER;"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('ProgramExercise');" 2>/dev/null | grep -q "|suggestedWeight|"; then
|
||||||
|
log "adding ProgramExercise.suggestedWeight + suggestedWeightUnit"
|
||||||
|
sqlite3 "$DB_PATH" "ALTER TABLE ProgramExercise ADD COLUMN suggestedWeight REAL;"
|
||||||
|
sqlite3 "$DB_PATH" "ALTER TABLE ProgramExercise ADD COLUMN suggestedWeightUnit TEXT;"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# v1.1.0:4 one-shot migration: lift each user's legacy single-config
|
||||||
|
# (UserPreferences.aiProvider/aiModel/...) into a new AIConfigProfile
|
||||||
|
# row marked active. Idempotent — only runs for users who have a
|
||||||
|
# configured legacy config but no profiles yet.
|
||||||
|
log "migrating any legacy single-config to AIConfigProfile (idempotent)"
|
||||||
|
sqlite3 "$DB_PATH" "
|
||||||
|
INSERT INTO AIConfigProfile (id, userId, name, provider, model, baseUrl, apiKey)
|
||||||
|
SELECT
|
||||||
|
'c' || lower(hex(randomblob(12))),
|
||||||
|
up.userId,
|
||||||
|
'Default (' || up.aiProvider || ')',
|
||||||
|
up.aiProvider,
|
||||||
|
up.aiModel,
|
||||||
|
up.aiBaseUrl,
|
||||||
|
up.aiApiKey
|
||||||
|
FROM UserPreferences up
|
||||||
|
WHERE up.aiProvider IS NOT NULL
|
||||||
|
AND up.aiModel IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM AIConfigProfile p WHERE p.userId = up.userId);
|
||||||
|
" 2>/dev/null || log "WARN: legacy-config migration skipped"
|
||||||
|
|
||||||
|
# Set activeAIConfigId for users who now have exactly one profile.
|
||||||
|
sqlite3 "$DB_PATH" "
|
||||||
|
UPDATE UserPreferences
|
||||||
|
SET activeAIConfigId = (
|
||||||
|
SELECT id FROM AIConfigProfile WHERE userId = UserPreferences.userId LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE activeAIConfigId IS NULL
|
||||||
|
AND (SELECT COUNT(*) FROM AIConfigProfile WHERE userId = UserPreferences.userId) = 1;
|
||||||
|
" 2>/dev/null || true
|
||||||
|
|
||||||
if ! sqlite3 "$DB_PATH" \
|
if ! sqlite3 "$DB_PATH" \
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \
|
||||||
2>/dev/null | grep -q InstanceSettings; then
|
2>/dev/null | grep -q InstanceSettings; then
|
||||||
@@ -141,12 +292,19 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Step 3 — ensure curated exercise library for every user (multi-user-aware).
|
# Step 3 — reconcile curated exercise library for every user
|
||||||
# New entries shipped in /app/prisma/exercises.seed.json appear on every boot.
|
# (multi-user-aware). As of v1.0.0:7 this is INSERT-or-UPDATE rather than
|
||||||
# `INSERT OR IGNORE` keyed on (userId, name) so we never overwrite a user's
|
# INSERT-or-IGNORE: existing rows where isCustom = 0 get refreshed from
|
||||||
# own custom exercises. Designed to be additive only — exercises removed from
|
# /app/prisma/exercises.seed.json so maintainer-side fixes (e.g. correct
|
||||||
# the curated JSON are not deleted from existing installs (users may have
|
# inputFields for cardio) propagate to existing installs. Rows where
|
||||||
# logged sets against them).
|
# isCustom = 1 are skipped — user customizations win.
|
||||||
|
#
|
||||||
|
# PATCH /api/exercises/[id] flips isCustom to 1 on any user edit, so the
|
||||||
|
# moment you change a library exercise via the in-app UI it stops getting
|
||||||
|
# overwritten on subsequent boots.
|
||||||
|
#
|
||||||
|
# Additive on names: exercises removed from the curated JSON are NOT
|
||||||
|
# deleted from existing installs (users may have logged sets against them).
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
if [ -f "$LIBRARY_JSON_PATH" ] && [ -f "$DB_PATH" ]; then
|
if [ -f "$LIBRARY_JSON_PATH" ] && [ -f "$DB_PATH" ]; then
|
||||||
log "ensuring curated exercise library is present for every user"
|
log "ensuring curated exercise library is present for every user"
|
||||||
@@ -158,6 +316,19 @@ else
|
|||||||
log "skipping library ensure (json or db not found)"
|
log "skipping library ensure (json or db not found)"
|
||||||
fi
|
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.
|
# Step 4 — launch the app.
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -5,21 +5,65 @@ import { v_1_0_0_3 } from './v1.0.0.3'
|
|||||||
import { v_1_0_0_4 } from './v1.0.0.4'
|
import { v_1_0_0_4 } from './v1.0.0.4'
|
||||||
import { v_1_0_0_5 } from './v1.0.0.5'
|
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_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'
|
||||||
|
import { v_1_1_0_3 } from './v1.1.0.3'
|
||||||
|
import { v_1_1_0_4 } from './v1.1.0.4'
|
||||||
|
import { v_1_1_0_5 } from './v1.1.0.5'
|
||||||
|
import { v_1_1_0_6 } from './v1.1.0.6'
|
||||||
|
import { v_1_1_0_7 } from './v1.1.0.7'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Version graph for the `proof-of-work` package.
|
* Version graph for the `proof-of-work` package.
|
||||||
*
|
*
|
||||||
* v1.0.0:1 — initial release, seeded cutover from `workout-log`.
|
* 1.0.0 line — feature-complete logger + multi-user + library curation.
|
||||||
|
* 1.1.0 line — Programs (manual + AI) + AI integration.
|
||||||
|
*
|
||||||
|
* v1.0.0:1 — initial release, seeded cutover.
|
||||||
* v1.0.0:2 — CSP fix.
|
* v1.0.0:2 — CSP fix.
|
||||||
* v1.0.0:3 — post-cutover seed strip.
|
* v1.0.0:3 — post-cutover seed strip.
|
||||||
* v1.0.0:4 — removes default admin@local credentials; operator must
|
* v1.0.0:4 — removes default admin@local credentials.
|
||||||
* run StartOS Action to bootstrap the first admin.
|
* v1.0.0:5 — caloriesBurned raw-SQL workaround removed.
|
||||||
* v1.0.0:5 — internal cleanup (caloriesBurned raw-SQL workaround).
|
* v1.0.0:6 — paginate workout history (infinite scroll).
|
||||||
* v1.0.0:6 — paginate workout history (infinite scroll); removes
|
* v1.0.0:7 — exercise library cleanup, photo-import removal.
|
||||||
* invisible 50-row caps on the clock-button popup and
|
* v1.1.0:1 — Programs UI (manual create / save / follow).
|
||||||
* the /main/workouts page.
|
* v1.1.0:2 — AI program generation, 5 providers (Claude / OpenAI /
|
||||||
|
* OpenAI-compatible / Gemini / Ollama).
|
||||||
|
* v1.1.0:3 — AI upgrades: history-as-context, test connection,
|
||||||
|
* cost estimator, streaming preview render.
|
||||||
|
* v1.1.0:4 — AI integration overhaul: multi-config persistence,
|
||||||
|
* background generation (survives navigation), Ollama
|
||||||
|
* auto-detect + installed-model dropdown, curated model
|
||||||
|
* dropdowns for Claude / OpenAI / Gemini with current
|
||||||
|
* 2026 model names, system-prompt overhaul forcing library
|
||||||
|
* exerciseIds + suggested weights, sidebar sub-navigation,
|
||||||
|
* history detail view.
|
||||||
|
* v1.1.0:5 — Gemini menu correctness: adds gemini-3.1-pro (short),
|
||||||
|
* gemini-3.1-flash, gemini-3.1-flash-lite, gemini-3-pro,
|
||||||
|
* gemini-3-flash + pricing entries.
|
||||||
|
* v1.1.0:6 — Exercise-history popup max-height bumped from ~320px
|
||||||
|
* (5 rows) to 70vh (~15+ rows). Users with deep history
|
||||||
|
* can scroll without fighting a tiny inner scrollbar.
|
||||||
|
* v1.1.0:7 — Exercise-history popup auto-loads more rows on scroll
|
||||||
|
* (switched from a flaky IntersectionObserver-in-popup to
|
||||||
|
* a plain scroll listener with 300px lookahead).
|
||||||
*/
|
*/
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_1_0_0_6,
|
current: v_1_1_0_7,
|
||||||
other: [v_1_0_0_1, v_1_0_0_2, v_1_0_0_3, v_1_0_0_4, v_1_0_0_5],
|
other: [
|
||||||
|
v_1_0_0_1,
|
||||||
|
v_1_0_0_2,
|
||||||
|
v_1_0_0_3,
|
||||||
|
v_1_0_0_4,
|
||||||
|
v_1_0_0_5,
|
||||||
|
v_1_0_0_6,
|
||||||
|
v_1_0_0_7,
|
||||||
|
v_1_1_0_1,
|
||||||
|
v_1_1_0_2,
|
||||||
|
v_1_1_0_3,
|
||||||
|
v_1_1_0_4,
|
||||||
|
v_1_1_0_5,
|
||||||
|
v_1_1_0_6,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.0.0:7 — exercise library cleanup, photo-import removal,
|
||||||
|
* UI honesty about AI.
|
||||||
|
*
|
||||||
|
* Library cleanup
|
||||||
|
* - Cycling, Jump Rope, Rowing, Running: type=cardio with the
|
||||||
|
* correct inputFields (duration, distance, calories — no more
|
||||||
|
* reps/weight where they don't apply).
|
||||||
|
* - Walking Lunge, Wall Sit, Headstand, Hip Extension:
|
||||||
|
* reclassified out of "cardio" into bodyweight (they aren't
|
||||||
|
* aerobic conditioning).
|
||||||
|
* - Plank, Mace warmup, Hollow Body Landmine, Soccer:
|
||||||
|
* inputFields fixed.
|
||||||
|
* - Descriptions added for ~10 previously-cryptic exercises:
|
||||||
|
* Core, Resistance Band, Stir the pot, Slide Board,
|
||||||
|
* Neck Circuit, TGU, Captains of Crush, plus new descriptions
|
||||||
|
* for the cardio + reclassified entries above.
|
||||||
|
*
|
||||||
|
* Reconcile-on-boot
|
||||||
|
* - ensureExerciseLibrary.cjs is now INSERT-or-UPDATE instead of
|
||||||
|
* INSERT-or-IGNORE. Existing exercise rows where isCustom = 0
|
||||||
|
* get their description/type/muscleGroups/inputFields/
|
||||||
|
* defaultWeightUnit refreshed from the curated JSON on every
|
||||||
|
* boot. Rows with isCustom = 1 are skipped — the user's
|
||||||
|
* customizations always win.
|
||||||
|
* - PATCH /api/exercises/[id] now flips isCustom -> true on any
|
||||||
|
* user edit. So the moment you edit a library exercise via the
|
||||||
|
* in-app UI, it stops getting overwritten by future curated-
|
||||||
|
* library refreshes.
|
||||||
|
*
|
||||||
|
* Photo-import (Claude vision) removed
|
||||||
|
* - The /api/workouts/import endpoint that uploaded photos to
|
||||||
|
* Claude is gone, along with the orphan WorkoutImportClient
|
||||||
|
* component that called it. CSV import (the actually-used flow
|
||||||
|
* at /main/import) is unchanged.
|
||||||
|
* - The "Claude AI Integration" section in Settings has been
|
||||||
|
* removed — it promised "personalized workout recommendations"
|
||||||
|
* that never existed and only enabled the photo-import
|
||||||
|
* feature, which is also gone.
|
||||||
|
* - Schema columns User.enableClaudeAI / User.claudeApiKey stay
|
||||||
|
* as harmless dead fields. They'll be removed (or repurposed)
|
||||||
|
* when the model-agnostic AI work lands.
|
||||||
|
*
|
||||||
|
* No data migration. /data on existing installs is untouched.
|
||||||
|
*/
|
||||||
|
export const v_1_0_0_7 = VersionInfo.of({
|
||||||
|
version: '1.0.0:7',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Exercise library cleanup: 19 exercises got correct inputFields / type / descriptions (Cycling/Rowing/Running/etc. now properly track duration+distance instead of reps+weight). Library reconciliation runs on every boot — maintainer-side fixes propagate to existing installs without overwriting your edits. The Claude photo-import feature and the misleading "Claude AI Integration" Settings section are gone; a real model-agnostic AI integration (with self-hosted Ollama support) is on the roadmap as its own release.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async () => {},
|
||||||
|
down: IMPOSSIBLE,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.1.0:1 — Programs (manual create / save / follow).
|
||||||
|
*
|
||||||
|
* The Program / ProgramWeek / ProgramDay / ProgramExercise schema
|
||||||
|
* has existed since the legacy `workout-log` package but had no
|
||||||
|
* UI to use it. This release ships:
|
||||||
|
*
|
||||||
|
* - /main/programs (list) with a today's-session card if any
|
||||||
|
* program is active.
|
||||||
|
* - /main/programs/new (create form) with the full nested
|
||||||
|
* editor: program metadata -> weeks -> days -> exercises.
|
||||||
|
* - /main/programs/[id] (detail + edit) using the same editor,
|
||||||
|
* plus today's-session callout + "Start this session"
|
||||||
|
* button when applicable.
|
||||||
|
* - API: GET/POST /api/programs, GET/PATCH/DELETE
|
||||||
|
* /api/programs/[id]. PATCH replaces the entire weeks tree
|
||||||
|
* in one transaction (same shape POST accepts) — keeps the
|
||||||
|
* UI editor and the upcoming AI apply flow on the same code
|
||||||
|
* path.
|
||||||
|
* - "Start this session" wires through
|
||||||
|
* POST /api/programs/[id]/days/[dayId]/start which creates a
|
||||||
|
* Workout pre-populated with empty SetLogs from the planned
|
||||||
|
* ProgramExercises (one row per planned set), tagged with
|
||||||
|
* Workout.programDayId so we can later compute adherence.
|
||||||
|
*
|
||||||
|
* Schema change
|
||||||
|
* - Workout.programDayId (nullable FK to ProgramDay) added.
|
||||||
|
* - Compat ALTER in docker_entrypoint.sh adds the column +
|
||||||
|
* index to existing /data on first boot. ON DELETE SET NULL
|
||||||
|
* so deleting a program doesn't catastrophically remove
|
||||||
|
* historical workouts logged against it.
|
||||||
|
*
|
||||||
|
* This release is the foundation for v1.1.0:2's AI-generated
|
||||||
|
* programs — the AI will produce the same JSON shape that POST
|
||||||
|
* /api/programs already accepts.
|
||||||
|
*
|
||||||
|
* No data migration. /data on existing installs is untouched
|
||||||
|
* apart from the new column.
|
||||||
|
*/
|
||||||
|
export const v_1_1_0_1 = VersionInfo.of({
|
||||||
|
version: '1.1.0:1',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Programs UI shipped. Build a multi-week training plan, mark it active, and follow it day by day from a new "Today\'s session" card. Includes a full nested editor (program -> weeks -> days -> exercises), starts a session as a pre-populated workout you fill in as you go, and tracks Workout.programDayId for upcoming adherence analytics. Foundation for v1.1.0:2 (AI-generated programs).',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async () => {},
|
||||||
|
down: IMPOSSIBLE,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.1.0:3 — AI: workout-history context, test connection,
|
||||||
|
* cost estimator, streaming preview render.
|
||||||
|
*
|
||||||
|
* History context (the killer feature)
|
||||||
|
* - lib/ai/historyContext.ts builds a compact 90-day rollup of
|
||||||
|
* the user's training: per-exercise frequency, recent weights,
|
||||||
|
* estimated 1RMs (Epley), avg RPE, days-since-last, plus a
|
||||||
|
* STAGNANT flag when the heaviest weight in the new half of
|
||||||
|
* the window doesn't beat the old half.
|
||||||
|
* - Generate page has an "Include my workout history as context"
|
||||||
|
* checkbox (default on if you have ≥10 logged workouts). When
|
||||||
|
* checked, the summary is appended to the system prompt so the
|
||||||
|
* model can recommend things like "you've stalled bench at 245
|
||||||
|
* for 6 weeks — try paused reps."
|
||||||
|
* - The summary is text, ~1-3 KB even for heavy users. We
|
||||||
|
* deliberately don't ship raw set logs (privacy + token cost).
|
||||||
|
*
|
||||||
|
* Test connection
|
||||||
|
* - POST /api/ai/test sends a tiny "say hi in 3 words" prompt
|
||||||
|
* against the user's configured provider and reports
|
||||||
|
* latency + first sample of the response, or the error inline.
|
||||||
|
* - "Test connection" button next to "Save AI config" in
|
||||||
|
* Settings → AI integration. Lets you verify the provider/
|
||||||
|
* model/key/baseUrl combo without going through full program
|
||||||
|
* generation.
|
||||||
|
*
|
||||||
|
* Cost estimator
|
||||||
|
* - lib/ai/pricing.ts ships a price table for the major models
|
||||||
|
* (Claude Sonnet/Opus/Haiku 4-5, GPT-5/4o/o3, Gemini 2.5/2.0).
|
||||||
|
* Ollama always returns 0 (self-hosted, no per-token cost).
|
||||||
|
* openai-compatible returns null (we don't know the gateway's
|
||||||
|
* pricing).
|
||||||
|
* - Generation history shows per-row cost + a 30-day rolling
|
||||||
|
* total at the top of the page.
|
||||||
|
*
|
||||||
|
* Streaming preview render
|
||||||
|
* - lib/ai/lenientJson.ts: a stack-aware partial-JSON parser
|
||||||
|
* that auto-closes open strings/brackets/braces in
|
||||||
|
* reverse-of-opening order, drops dangling key:value pairs
|
||||||
|
* and partial keywords. Returns a best-effort snapshot of
|
||||||
|
* the program-so-far on each chunk.
|
||||||
|
* - Generate UI now renders a live "Building program..." panel
|
||||||
|
* that updates as weeks/days/exercises arrive, instead of
|
||||||
|
* just showing raw text and waiting for stream end.
|
||||||
|
*
|
||||||
|
* No schema changes. /data is untouched.
|
||||||
|
*/
|
||||||
|
export const v_1_1_0_3 = VersionInfo.of({
|
||||||
|
version: '1.1.0:3',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'AI program generation gets four upgrades: (1) include your last 90 days of workout history as context — the model designs around your actual frequency, current weights, and stagnations; (2) "Test connection" button in Settings to verify provider/model/key without a full generation; (3) per-generation USD cost + 30-day rolling total in the history page (Ollama is free, openai-compatible gateways are unknown); (4) streaming preview renders the program tree as the model writes it instead of waiting for the full response. No data migration.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async () => {},
|
||||||
|
down: IMPOSSIBLE,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.1.0:4 — multi-config AI integration, background generation,
|
||||||
|
* ollama auto-detect, system-prompt overhaul, history
|
||||||
|
* detail view, sidebar sub-nav.
|
||||||
|
*
|
||||||
|
* Driven by post-v1.1.0:3 user feedback. The biggest themes:
|
||||||
|
*
|
||||||
|
* 1) Multi-config persistence
|
||||||
|
* - You can save N AI configs (one per provider, or several of the
|
||||||
|
* same provider with different models/keys), toggle one as the
|
||||||
|
* "active" config, and per-config "Test connection" to verify
|
||||||
|
* before activating. Switching providers no longer means losing
|
||||||
|
* the previous setup.
|
||||||
|
* - New schema: AIConfigProfile table (per-user). UserPreferences
|
||||||
|
* grows `activeAIConfigId`; the legacy single-config columns are
|
||||||
|
* kept and mirrored from the active profile so any old code path
|
||||||
|
* that reads from prefs continues to work.
|
||||||
|
* - On boot, any user who already had a single-config setup gets
|
||||||
|
* that config lifted into a default AIConfigProfile + activated.
|
||||||
|
* Idempotent.
|
||||||
|
*
|
||||||
|
* 2) Ollama auto-detect
|
||||||
|
* - The "Add AI config" form probes /api/tags on the StartOS
|
||||||
|
* internal addresses (ollama.startos:11434, ollama.embassy:11434).
|
||||||
|
* If reachable, the URL field auto-fills and the model field
|
||||||
|
* becomes a dropdown of installed models. No more memorizing
|
||||||
|
* "the right URL" or pasting a model tag.
|
||||||
|
*
|
||||||
|
* 3) Model dropdowns for the leading providers
|
||||||
|
* - Settings now offers a curated dropdown of recommended models
|
||||||
|
* for Claude (Opus 4.7, Sonnet 4.6, Haiku 4.5), OpenAI (GPT-5.5,
|
||||||
|
* 5.4, 5.4-mini, 5.4-nano), and Gemini (3.1-pro-preview, 2.5-pro,
|
||||||
|
* 2.5-flash). "Other (type your own)" stays available for power
|
||||||
|
* users on niche models. Fixes the "I tried gemini-3.0-pro and
|
||||||
|
* got 404" footgun.
|
||||||
|
*
|
||||||
|
* 4) Background generation
|
||||||
|
* - Generation now runs server-side via a detached runner. Closing
|
||||||
|
* the page or navigating away no longer kills it — the row keeps
|
||||||
|
* filling in. The Generate UI surfaces a banner explaining this.
|
||||||
|
* - The new History detail page polls progress + renders the
|
||||||
|
* partial JSON live; reload-during-streaming "just works." Useful
|
||||||
|
* for slow local Ollama runs.
|
||||||
|
* - New AIGeneration columns: progressText (in-flight stream),
|
||||||
|
* durationMs (final wall-clock).
|
||||||
|
*
|
||||||
|
* 5) System prompt overhaul
|
||||||
|
* - lib/ai/systemPromptBase.ts: a structural contract prepended to
|
||||||
|
* every template. Forces JSON-only output (no markdown fences),
|
||||||
|
* forces use of library exerciseIds (no more "exerciseId doesn't
|
||||||
|
* belong to this user" on apply), and forces a suggestedWeight
|
||||||
|
* per resistance exercise — both with-history (relative to user's
|
||||||
|
* lifts) and without-history (% of typical 1RM) variants.
|
||||||
|
* - aiExerciseSchema gains suggestedWeight + suggestedWeightUnit.
|
||||||
|
* ProgramExercise gains the same columns. Starting a workout from
|
||||||
|
* a ProgramDay now pre-populates SetLog.weight from the suggestion
|
||||||
|
* so users have a target on day 1 instead of a blank field.
|
||||||
|
*
|
||||||
|
* 6) Test connection improvements
|
||||||
|
* - Latency reported in seconds (was ms — confusing for slow Ollama).
|
||||||
|
* - Stale "✓ Connected" no longer lingers when you change the form.
|
||||||
|
* - Gemini surfaces finishReason when the response is empty (e.g.
|
||||||
|
* "blocked by safety filter") instead of the generic "empty
|
||||||
|
* response — check the model name."
|
||||||
|
* - Test ping uses generous maxOutputTokens so thinking models
|
||||||
|
* (Gemini 2.5/3.x, OpenAI o-series) actually emit text after
|
||||||
|
* reasoning instead of running out of budget.
|
||||||
|
* - Per-config Test button (no need to activate first).
|
||||||
|
*
|
||||||
|
* 7) History detail view
|
||||||
|
* - Click any AIGeneration row → full read-only program tree, plus
|
||||||
|
* the exact prompts sent. Apply from here without re-generating.
|
||||||
|
* - In-flight rows poll for live progress.
|
||||||
|
*
|
||||||
|
* 8) Sidebar sub-navigation
|
||||||
|
* - "AI" expands to Generate / History / Templates.
|
||||||
|
* - "Settings" expands to General / Password / Sessions /
|
||||||
|
* AI integration / Export / Instance / Danger zone, with
|
||||||
|
* anchor scroll to the matching section.
|
||||||
|
*
|
||||||
|
* 9) API key UX
|
||||||
|
* - "Key saved" indicator on saved configs (was confusing to see
|
||||||
|
* an empty input field after a successful save).
|
||||||
|
*
|
||||||
|
* Migrations:
|
||||||
|
* - AIConfigProfile table created.
|
||||||
|
* - UserPreferences.activeAIConfigId added.
|
||||||
|
* - AIGeneration.progressText + .durationMs added.
|
||||||
|
* - ProgramExercise.suggestedWeight + .suggestedWeightUnit added.
|
||||||
|
* - One-shot lift of the legacy single-config row into a default
|
||||||
|
* AIConfigProfile per user.
|
||||||
|
*
|
||||||
|
* /data is unchanged in spirit — all migrations are additive ALTERs
|
||||||
|
* via the boot entrypoint. Existing programs/workouts/exercises stay
|
||||||
|
* exactly as they were.
|
||||||
|
*/
|
||||||
|
export const v_1_1_0_4 = VersionInfo.of({
|
||||||
|
version: '1.1.0:4',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'AI integration overhauled based on user testing: (1) save MULTIPLE AI configs and switch between them — Claude, OpenAI, Gemini, custom OpenAI-compatible, Ollama all coexist with one toggled active; (2) Ollama auto-detect — the form probes ollama.startos:11434 and shows your installed models in a dropdown, no copy-paste; (3) curated model dropdowns for Claude / OpenAI / Gemini with current 2026 models (Claude Opus 4.7, Sonnet 4.6, GPT-5.5, Gemini 3.1 Pro Preview, etc.); (4) generation now runs in the BACKGROUND — close the page, come back, find your program in History; (5) system prompt rewritten so the model picks library exercises only (no more "exerciseId doesn\'t belong to this user" errors) and suggests starting weights per exercise (which seed your first workout when you start a program day); (6) generation duration shown alongside cost; (7) Gemini "empty response" now reports the actual finishReason (safety filter, max tokens, etc.); (8) sidebar shows sub-navigation for AI + Settings sections; (9) click any History row to see the full program tree without applying it. No data loss; the schema migration runs additively at boot.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async () => {},
|
||||||
|
down: IMPOSSIBLE,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.1.0:5 — Gemini model menu correctness.
|
||||||
|
*
|
||||||
|
* Driven by a user pointing out that their Google AI Studio dropdown
|
||||||
|
* showed `gemini-3-pro` and `gemini-3-flash` (no `.0`), neither of
|
||||||
|
* which were in our v1.1.0:4 dropdown. The menu I shipped only had
|
||||||
|
* `gemini-3.1-pro-preview` and missed the Flash variants entirely.
|
||||||
|
*
|
||||||
|
* What changed:
|
||||||
|
* - Add `gemini-3.1-pro` (short form, what AI Studio shows) — both
|
||||||
|
* short and long-preview names work via the API; we now ship the
|
||||||
|
* short form because it matches the Studio UI.
|
||||||
|
* - Add `gemini-3.1-flash` (~$0.50/$3 per M) and
|
||||||
|
* `gemini-3.1-flash-lite` (the cheapest 3.x).
|
||||||
|
* - Add `gemini-3-pro` and `gemini-3-flash` — older tier, both still
|
||||||
|
* available in many accounts.
|
||||||
|
* - Pricing table grows entries for all of the above so the cost
|
||||||
|
* estimator works correctly when the user picks any of them.
|
||||||
|
*
|
||||||
|
* No code changes elsewhere. Pure data fix.
|
||||||
|
*/
|
||||||
|
export const v_1_1_0_5 = VersionInfo.of({
|
||||||
|
version: '1.1.0:5',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Gemini model dropdown corrected: adds gemini-3.1-pro (the short name AI Studio uses), gemini-3.1-flash, gemini-3.1-flash-lite, gemini-3-pro, and gemini-3-flash — all the names that show up in your Google AI Studio dropdown. Pricing table updated to match (Flash ~$0.50/$3 per M, Flash-Lite even cheaper). Pure data fix; no schema or behavior changes.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async () => {},
|
||||||
|
down: IMPOSSIBLE,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.1.0:6 — Exercise history popup scrolls further.
|
||||||
|
*
|
||||||
|
* The clock-icon popup in the workout editor was capped at max-h-80
|
||||||
|
* (~320px, ~5 history rows). Anyone with multi-year history saw their
|
||||||
|
* older sessions hidden behind a tiny inner scrollbar. Bumped to
|
||||||
|
* 70vh so it scales with the viewport (~15+ rows on a normal display,
|
||||||
|
* more on a large monitor). The IntersectionObserver pagination
|
||||||
|
* already loaded more on demand — the cap just hid them.
|
||||||
|
*
|
||||||
|
* Pure CSS-class change. No schema, no API, no data.
|
||||||
|
*/
|
||||||
|
export const v_1_1_0_6 = VersionInfo.of({
|
||||||
|
version: '1.1.0:6',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Exercise-history popup (the clock icon while logging or editing a workout) now scales to 70% of viewport height instead of the previous ~320px cap. Users with multi-year history can scroll through ~15+ sessions without fighting a tiny inner scrollbar. Pure UI fix.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async () => {},
|
||||||
|
down: IMPOSSIBLE,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.1.0:7 — Exercise-history popup auto-loads more rows on scroll.
|
||||||
|
*
|
||||||
|
* The popup HAD an IntersectionObserver-based infinite-scroll
|
||||||
|
* implementation (added in v1.0.0:6 alongside the workout-history
|
||||||
|
* page version of the same feature), but the observer was fiddly
|
||||||
|
* inside an `absolute`-positioned scroll container. With the small
|
||||||
|
* 60px rootMargin it would sometimes not fire at all.
|
||||||
|
*
|
||||||
|
* Replaced with a plain `scroll` event listener on the popup. Fires
|
||||||
|
* when the user scrolls within 300px of the bottom (mirroring the
|
||||||
|
* lookahead used by WorkoutsList on the main Workouts page). Also
|
||||||
|
* runs once on mount so if the first page doesn't fill the popup,
|
||||||
|
* we still fetch the next page proactively.
|
||||||
|
*
|
||||||
|
* Cosmetic: bottom-of-list status row now shows "Loading more..." /
|
||||||
|
* "Scroll to load more" / "End of history" so the user has feedback
|
||||||
|
* on the state instead of just seeing a thin spinner intermittently.
|
||||||
|
*
|
||||||
|
* No schema, no API, no data.
|
||||||
|
*/
|
||||||
|
export const v_1_1_0_7 = VersionInfo.of({
|
||||||
|
version: '1.1.0:7',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Exercise-history popup (clock icon while logging or editing a workout) now reliably auto-loads more rows as you scroll, matching the Workouts page. Switched from a fiddly IntersectionObserver (which sometimes didn\'t fire inside the absolute-positioned popup) to a plain scroll listener with a 300px lookahead. Bottom-of-list now shows "Loading more..." / "Scroll to load more" / "End of history" feedback. Pure UI fix.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async () => {},
|
||||||
|
down: IMPOSSIBLE,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user