Compare commits
34 Commits
cc9b83ef84
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d4557304a5 | |||
| 891bf09d7e | |||
| 794070a1d8 | |||
| 91b5b04d97 | |||
| b2587d767b | |||
| 7fda9ceb7e | |||
| a36ca12318 | |||
| 2b0abad68e | |||
| 0401a831b7 | |||
| d1bc895e5e | |||
| 184382f75c | |||
| 38503436e1 | |||
| 4be489d6d3 | |||
| ef3d079ca2 | |||
| 486dcb3773 | |||
| 390aaf556e | |||
| 4d1f9126b0 | |||
| f540a473ef | |||
| 00a4b704e8 | |||
| 0178f8f5cc | |||
| 56963ab4fd | |||
| c02892e178 | |||
| f487204b73 | |||
| 96d8431de9 | |||
| 3f22ef7600 | |||
| 988a3cca9a | |||
| 09eeef249d | |||
| 0ed41765da | |||
| 29b9d2437c | |||
| 1a77a0bfc2 | |||
| 01529204cb | |||
| 35539a9341 | |||
| 7a62690a4a | |||
| dba478aa23 |
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../docs/guides/ai-subsystem.md
|
||||||
+11
@@ -21,10 +21,13 @@ logs/
|
|||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
.env.local
|
.env.local
|
||||||
.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
|
||||||
@@ -48,3 +51,11 @@ proof-of-work/prisma/data/*.db
|
|||||||
start9/*/seed/data/*.db
|
start9/*/seed/data/*.db
|
||||||
start9/*/seed/data/*.db.bak
|
start9/*/seed/data/*.db.bak
|
||||||
start9/*/seed/data/*.bak
|
start9/*/seed/data/*.bak
|
||||||
|
|
||||||
|
# Claude Code — deny by default, allow-list shared wiring (see standards/portability.md)
|
||||||
|
.claude/*
|
||||||
|
!.claude/rules/
|
||||||
|
!.claude/agents/
|
||||||
|
!.claude/commands/
|
||||||
|
!.claude/skills/
|
||||||
|
!.claude/settings.json
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
> **Inbox check:** At session start, if `~/Projects/standards/INBOX.md` exists, scan it for
|
||||||
|
> items tagged `(proof-of-work)` and surface them before proposing next steps; triage with `/triage`.
|
||||||
|
|
||||||
|
> **Design:** before building or changing any user-facing UI, read `design/DESIGN.md` and
|
||||||
|
> `design/tokens.tokens.json` and conform to them.
|
||||||
|
|
||||||
|
## Stack (versions that matter)
|
||||||
|
|
||||||
|
- **Next.js 15** (App Router, server components + server actions, SSE streaming) — dynamic request APIs are async (see Conventions)
|
||||||
|
- **React 19**, **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 sparkcontrol.ts + index.ts (getProvider; openai.ts exports both openai + openai-compatible = 6 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)
|
||||||
|
design/ ← UI design contract: DESIGN.md + tokens.tokens.json (read before UI work); brand/ inspiration/
|
||||||
|
~/.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)
|
||||||
|
npm run db:seed # seed InstanceSettings singleton (NO users/library — see below)
|
||||||
|
npm run create-admin -- you@example.com pw "Name" # create first admin locally (--force resets existing)
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Verify on-box state read-only via `start-cli`** (the same host config) instead of punting to the StartOS web UI — used this way to confirm the 1.2.0:4/:5 ALTERs and a persisted set:
|
||||||
|
- `start-cli package installed-version proof-of-work` — what version the box actually runs.
|
||||||
|
- `start-cli package logs proof-of-work --limit N | grep -iE "adding missing column|as nextjs|error"` — confirms boot ALTERs ran (each logs once) and the non-root launch.
|
||||||
|
- `start-cli package attach proof-of-work -- sqlite3 /data/app.db "<SELECT …>"` — read-only query of the live app DB (e.g. confirm a new column exists / a value persisted). **SELECT only**; never mutate prod data this way.
|
||||||
|
|
||||||
|
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 **only the `InstanceSettings` singleton** — deliberately NO users and NO curated library (the library attaches at admin-creation and via the boot-time ensure). It is **live, not dead** — invoked at Docker image-build time (`start9/0.4/Dockerfile`) and the local-dev first-run path. `npm run create-admin` (= `tsx scripts/create-admin.ts`) is the local-dev equivalent of the StartOS "Set admin credentials" action: creates the first admin + seeds their library; `--force` to reset/promote an existing account. 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.
|
||||||
|
- **Adding a first-class numeric set metric** (precedent: `watts`, 1.2.0:4): mirror `calories` end-to-end — `schema.prisma` column + `prisma generate`; guarded additive `ALTER` in `docker_entrypoint.sh`; zod field + insert in all **5 set-write paths** (`workouts` POST, `workouts/[id]` PATCH, `workouts/[id]/sets`, `workouts/import/save`, `me/import`); `SetRow.tsx` (`show*`/state/`emitUpdate`/`buildSummary`/`firstField`/input) + `WorkoutForm.tsx` (set interfaces, `buildPayload`, `handleUpdateSet`, `initial*` prop, has-data checks); read summary in `app/main/workouts/[id]/page.tsx` + edit mapping in `app/main/workouts/new/page.tsx`; CSV export/parse + `page-csv` payload; field-option label lists (`lib/exerciseOptions.ts`, `app/main/exercises/[id]/page.tsx`, `ExercisePicker.tsx`). The `inputFields` token == the column name; the human label lives in those option lists (token `watts` → "Avg. watts"). `me/export` rides the 1:1 Prisma dump automatically. Add a round-trip test in `tests/routes-crud.test.ts`.
|
||||||
|
- **Logged-set effort is Gear or RPE, by cardio-ness** (1.2.0:5): the effort select is always shown (not an `inputFields` token). Cardio exercises log breathing **Gear** (`SetLog.gear`, 1–5); everything else logs **RPE** (`SetLog.rpe`, 6–10). The switch is `isCardioExercise(exercise)` (`lib/exerciseOptions.ts`): `type === "cardio"` OR `muscleGroups` contains "cardio". `SetRow` takes an `isCardio` prop (from `WorkoutForm`) and renders one; both are always emitted (the hidden one stays empty). Distinct from program/AI **target**-RPE (`ProgramExercise.rpe`), which is unrelated and unaffected.
|
||||||
|
- **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`.
|
||||||
|
- **Authorization tiers**: whole-instance routes (`settings/{export,import}-db`) are **admin-only** (`!user.isAdmin → 403`); per-user data routes scope by `user.id`. Custom-URL AI providers (Ollama, OpenAI-compatible — anything with `requiresBaseUrl`) are **admin-only** (SSRF surface); fixed-URL cloud providers (claude/openai/gemini) stay per-user. Gate the server route AND hide the control in the UI.
|
||||||
|
- **Cross-user id ownership**: any route writing `SetLog`s or `ProgramExercise`s from a **client-supplied `exerciseId`** must validate it via `findUnownedExerciseIds(userId, ids)` (`lib/exerciseOwnership.ts`) → `400 { error, details: bad }`. Unknown vs. foreign ids are deliberately indistinguishable (no existence oracle). Applied to all workout-write routes (create/PATCH/add-sets/import-save) + both program routes. (Server-*derived* ids, e.g. program-day `start`, are already owned — no check needed.)
|
||||||
|
- **Login must not leak account existence**: both login paths (`app/auth/login/actions.ts`, `app/api/auth/route.ts`) call `verifyPasswordOrDummy` (`lib/auth.ts`) so an unknown email spends the same bcrypt as a real one. Never reintroduce an early `if (!user) return` *before* the compare — that's the timing oracle.
|
||||||
|
- **Malformed JSON body must return 400, not 500.** Routes whose catch maps `instanceof z.ZodError → 400` parse via `readJsonBody(request)` (`lib/http.ts` — throws a `ZodError` on bad JSON, so the existing branch handles it with no catch change). `safeParse`-style routes (`me/import`, `admin/signups`) wrap `request.json()` in an explicit `try/catch → 400`. (AI/admin routes using `.catch(() => ({}))` are a third, pre-existing pattern — unify if you touch them.)
|
||||||
|
- **Next 15 dynamic APIs are async — `await` them.** Route-handler context `params`, page/layout `params` + `searchParams`, and `cookies()`/`headers()` are all Promises. Established idiom (keeps handler bodies untouched): `[id]` routes take `context: { params: Promise<{…}> }` then `const params = await context.params`; server pages take `props` then `const params = await props.params` / `const searchParams = await props.searchParams`. Route tests pass `params: Promise.resolve({…})`. All routes are dynamic, so the Next 15 "uncached by default" change is a no-op here.
|
||||||
|
- **The container runs the Node server as non-root.** `docker_entrypoint.sh` runs as root only to prep `/data` (seed, ALTERs, library reconcile), then `chown -R nextjs:nodejs "$DATA_DIR"` and `exec su-exec nextjs:nodejs node /app/server.js` (su-exec added in the Dockerfile runner stage). Any new entrypoint step that needs root must run *before* that final line.
|
||||||
|
- **Auth forms retry their server action once on a transport failure**: `LoginForm`/`SignupForm` wrap the action in `retryOnTransportError` (`lib/retryAction.ts`). iOS Safari drops the first POST on a stale keep-alive socket (`NSURLErrorNetworkConnectionLost`, "The network connection was lost") → the client catch showed "An unexpected error occurred". Retry only on a *thrown* error; a returned `{ error }` is a real result and passes through.
|
||||||
|
- Tests live in `proof-of-work/tests/`; mock server-action deps with `vi.hoisted()` + `vi.mock`. Route tests run against a real temp SQLite DB (`tests/helpers/db.ts`) with `getCurrentUser` mocked.
|
||||||
|
- **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.2.0:9** — the **SparkControl + local-model arc is complete and confirmed working on-box** (smoke-tested with `RedHatAI/Qwen3.6-35B-A3B-NVFP4`: connected, model auto-detected, `$0`/FREE). Three ships:
|
||||||
|
- **:7** added **SparkControl (local)**, a 6th AI provider — the operator's own self-hosted local-inference gateway. OpenAI-compatible (reuses `generateOpenAIStyle`), **keyless** (`requireApiKey:false` → no `Authorization` header), reached over the **internal same-box address** `http://spark-control.startos:9999/v1` (plain HTTP, no TLS/cert-skip), model auto-detected via `/api/endpoints` (`app/api/ai/sparkcontrol/model`, admin-only + SSRF-guarded). Plus a **base-URL footgun fix**: a custom URL could attach to a fixed-URL provider (claude/openai/gemini) and be silently ignored — both config write paths now null `baseUrl` for non-custom-URL providers + the form clears it on provider change.
|
||||||
|
- **:8** made AI parsing **decimal-tolerant** — `looseInt` (`programSchema.ts`, used by `workoutSchema.ts`) rounds a float (e.g. a half-step `rpe:7.5`) before the zod `.int()` check, on every integer field in both schemas. A local model had failed the whole parse on one decimal.
|
||||||
|
- **:9** added **fuzzy exercise→library matching** (`lib/ai/exerciseMatch.ts`): when the model's `exerciseId` misses, normalize the name (strip `(barbell)` etc.) and auto-map **unique confident** matches in both generate flows (`Overhead Press` → `Overhead Press (barbell)`); ambiguous names stay manual.
|
||||||
|
|
||||||
|
Whole arc is **client/parse-only — no schema/data change** (`AIConfigProfile.provider` is free-text). Mechanics in `docs/guides/ai-subsystem.md` (provider abstraction + "adding a provider" fan-out checklist). **Built + sideloaded** (`immense-voyage.local`, 2026-06-19, `master`) as `proof-of-work_x86_64.s9pk` (80M); tsc clean (app + packaging), lint clean (pre-existing only), **274 tests pass**, `next build` + s9pk build succeed. Registry empty, **publishing parked** (sideload-only via `make install`).
|
||||||
|
|
||||||
|
**Design contract established this session (2026-06-19, committed `7fda9ce`, no UI code changed):** the `design/` folder now holds the durable contract — `DESIGN.md` (9-section brief) + `tokens.tokens.json` (DTCG) + `brand/palette.css` + `inspiration/` provenance. From a Case-B *document-as-is* extract of the as-built dark UI: **monochrome gym-brutalist** (`#0A0A0A` canvas, zinc-only neutral, white primary button, Bebas-uppercase/tracked headings, flat border-based depth), plus two owner calls — **red elevated to the single brand accent `#DC2626`** and a **two-tier radius** (4px controls / 8px containers). AGENTS.md carries the read-before-UI Design line; `ROADMAP.md` → **Design** holds the `design-checker` cleanup backlog (gray→zinc, green→emerald, yellow→amber, `rounded-md`→`rounded`, overlay-only shadows, and a shared `<Button>`). **Read `design/DESIGN.md` before any UI work.**
|
||||||
|
|
||||||
|
**Confirmed on-box (2026-06-19, via `start-cli`):** box runs `1.2.0:9`; launched `as nextjs` with no errors, "Ready in 222ms", and (correctly) **no migration ran** (none of :7–:9 add a column). Operator is generating workouts through SparkControl successfully. Recent prior ships (1.2.0 line): **1.2.0:6** AI "today's workout"; **1.2.0:5** Gear replaces RPE for cardio; **1.2.0:4** watts as first-class set field.
|
||||||
|
|
||||||
|
**On-box follow-up:** the operator is now on the SparkControl config; the old misconfigured `gemini`+`baseUrl` profile (the empty-response trigger) is no longer active — delete it at leisure (cosmetic). Known bug (tracked in `ROADMAP.md` → Known bugs): the **1.2.0:2** Safari first-tap retry did NOT fix the mobile-Safari first-login failure — reproduced through 1.2.0:5 (the workout feature didn't touch auth), first tap shows "An unexpected error occurred", second tap works. Diagnosis captured; the fix is gated on one data point — the first failed request's error code from Safari Web Inspector (`-1005` → client delayed-retry; `502`/`503` → Node keep-alive tuning).
|
||||||
|
|
||||||
|
Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (6 providers incl. **SparkControl**, multi-config, background generation, single-workout generation + refine, history detail, cost/duration, Ollama + SparkControl auto-detect, infinite-scroll exercise history).
|
||||||
|
|
||||||
|
Next steps (priority order):
|
||||||
|
1. **Finish the P3 hardening batch** (`ROADMAP.md` → Security & hardening — timing oracle + exerciseId ownership now DONE): CSP `unsafe-eval`, `/api/health` info disclosure, rate-limit map leak, configurable/shorter sessions (currently 30-day), text max-length. Also unify the 3rd JSON-parse pattern (`try{json}catch{→{}}`) in `programs/[id]/days/[dayId]/start`.
|
||||||
|
2. **Design cleanup batch** (`ROADMAP.md` → Design): conform the code to the just-shipped contract — mechanical palette unifications (gray→zinc ×21, green→emerald ×10, yellow→amber ×13), radius/shadow fixes, then the durable one: extract a shared `<Button>` (the empty `components/common/` is why the white-button drift spread across 44 inline copies). All cosmetic, none ship-blocking.
|
||||||
|
3. Tiered AI prompt formatting (`ROADMAP.md` → AI quality).
|
||||||
|
4. (Later) **Next 15→16** when ready — `next lint` deprecated in 15.5 (removed in 16) + Next 16 breaking changes; its own tested bump.
|
||||||
|
|
||||||
|
Open/parked: rate-limit per-IP correctness depends on the StartOS proxy forwarding real client IPs (unverified on the box). `publish.sh` Step-3 registry no-op (parked w/ publishing). Community-registry 4 blockers (`ROADMAP.md` → Packaging).
|
||||||
|
|
||||||
|
Git remote: `origin` → self-hosted Gitea at `ssh://git@immense-voyage.local:59916/grant/proof-of-work.git`; `master` tracks it. (`~/.proof-of-work/{publish,unpublish}.sh` registry/FileBrowser hosts are separate from this code remote.)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Evaluation — proof-of-work — 2026-06-13
|
||||||
|
|
||||||
|
Intent: A self-hosted, multi-user workout planner and logger (Next.js 14 App Router + server actions/SSE, Prisma/SQLite, bcrypt auth) with an AI program-suggestion subsystem (5 LLM providers, background generation), packaged as a StartOS 0.4 s9pk.
|
||||||
|
|
||||||
|
Agents run: evaluator, security-auditor, exerciser, start9-spec-checker. (reviewer skipped — working tree is clean, no uncommitted diff to review.)
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
This is a well-built, genuinely sophisticated app — clean provider abstraction, careful idempotent StartOS migrations, 177 passing tests, green build/tsc/lint, and consistent per-object authorization across the domain routes. But it ships **one release-blocking multi-tenant breach with three independent confirmations**: `/api/settings/export-db` and `/api/settings/import-db` operate on the *entire instance database* yet are gated only by "is logged in," so any ordinary user can download every other user's data plus all bcrypt hashes and plaintext AI keys, or overwrite the whole DB to mint themselves an admin. It is harmless in single-user mode and catastrophic the moment a second user exists — which is a first-class advertised feature. Below that sit a real authenticated SSRF, a vulnerable Next.js version, several uncaught-input 500s, and a broken README quick-start. Community-registry packaging is separately BLOCKED on four items, but the user has parked registry submission, so those are deferred rather than urgent.
|
||||||
|
|
||||||
|
## Cross-referenced findings
|
||||||
|
|
||||||
|
- **Whole-instance DB export/import un-gated (the P0).** Flagged by all three code agents: evaluator (two P0s), security-auditor (two P0s, with the import path traced to full admin takeover via an injected known-hash User row), and exerciser (observed `/api/settings/export-db` returns 200 to a non-admin, plus a separate dev-only 0-byte bug at the same endpoint). One root cause, two endpoints, three kinds of evidence → kept as the top two P0s.
|
||||||
|
- **SSRF via user-controlled provider `baseUrl`.** Both evaluator (P2) and security-auditor (P1) independently found `lib/ai/providers/{ollama,openai}.ts` fetch an arbitrary URL reachable from `/api/ai/test` and `/api/ai/ollama/models` with no private-range blocking. Merged; severity reconciled to P1 (see Disagreements).
|
||||||
|
- **Login enumeration / timing + CSP `unsafe-eval`.** Both evaluator and security-auditor noted the unknown-email login path lacks a dummy bcrypt compare (timing oracle) and that the shipped CSP allows `unsafe-eval` while the in-repo comment only justifies `unsafe-inline`. Merged into single P3s.
|
||||||
|
- **`/api/health` info disclosure.** Security-auditor (P3) and exerciser both noted the health endpoint returns user count + signupsOpen unauthenticated. Merged.
|
||||||
|
- **Stale install alert / "0.3.5 data snapshot" copy.** start9-spec-checker flagged this in both the manifest install alert and the long description; the evaluator separately confirmed the seed ships zero users. Merged into one packaging item.
|
||||||
|
|
||||||
|
## Priority queue
|
||||||
|
|
||||||
|
- [P0] Any authenticated user can GET `/api/settings/export-db` and download the whole instance DB (all bcrypt hashes + plaintext AI API keys) — `app/api/settings/export-db/route.ts:15`, UI shown to all at `components/settings/SettingsForm.tsx:260`, `app/main/settings/page.tsx:33` — evaluator, security-auditor, exerciser
|
||||||
|
- [P0] Any authenticated user can POST `/api/settings/import-db` and replace the whole instance DB → admin takeover + data destruction — `app/api/settings/import-db/route.ts:18`, replace at `:135` — evaluator, security-auditor
|
||||||
|
- [P1] Authenticated SSRF via attacker-controlled provider `baseUrl` (probe LAN / Start9 services / metadata) — `lib/ai/providers/openai.ts:24`, `lib/ai/providers/ollama.ts:22`, reached via `app/api/ai/test/route.ts:66` and `app/api/ai/ollama/models/route.ts:39` — security-auditor (P1), evaluator (P2)
|
||||||
|
- [P1] Vulnerable Next.js 14.2.35 (RSC DoS, WS-upgrade SSRF, App Router XSS, cache-poisoning) — `proof-of-work/package-lock.json`; fixes land in 15.5.16+ (major bump, test first) — security-auditor
|
||||||
|
- [P1] README quick-start login is broken — `admin@local` fails Zod `.email()` (no TLD) → HTTP 400; seed ships zero users so fresh clone cannot log in via documented creds — `proof-of-work/README.md:23` — exerciser
|
||||||
|
- [P1] `GET /api/settings/export-db` returns a 0-byte file in dev — `resolveDatabasePath()` picks the empty `data/app.db` created by `prisma db push` over the live `prisma/data/app.db` — `proof-of-work/lib/db-file.ts` — exerciser
|
||||||
|
- [P2] No rate limiting on `POST /api/auth` (raw API endpoint) — credential brute-force bypasses the UI server-action's 10/15min cap — `app/api/auth/route.ts` — exerciser
|
||||||
|
- [P2] Login/signup rate limit defeatable via spoofed leftmost `X-Forwarded-For` — `lib/rateLimit.ts:53` (mitigated only if the StartOS proxy overwrites XFF) — security-auditor
|
||||||
|
- [P2] Invalid `date` string in `POST /api/workouts` → HTTP 500 instead of 400 (Prisma throws, generic catch) — `app/api/workouts/route.ts` — exerciser
|
||||||
|
- [P2] Malformed JSON body → HTTP 500 instead of 400 (`request.json()` SyntaxError uncaught; also `/api/import/exercises/seed`) — exerciser
|
||||||
|
- [P2] Negative pagination `offset=-5` on `GET /api/workouts` → HTTP 500 (Prisma rejects negative skip) — exerciser
|
||||||
|
- [P2] Container runs as root — no `USER nextjs` directive before entrypoint, maximizing RCE blast radius — `start9/0.4/Dockerfile` — security-auditor
|
||||||
|
- [P2] Packaging blocker: `license` is `"Proprietary"`, not a valid SPDX id; `LICENSE` file mismatches — `start9/0.4/startos/manifest/index.ts:23`, `start9/0.4/LICENSE` — start9-spec-checker
|
||||||
|
- [P2] Packaging blocker: `instructions.md` absent (required; build fails / user-facing in UI) — `start9/0.4/` — start9-spec-checker
|
||||||
|
- [P2] Packaging blocker: `packageRepo` + `upstreamRepo` both 404 (`github.com/keysat-xyz/proof-of-work`) — `start9/0.4/startos/manifest/index.ts:23-24` — start9-spec-checker
|
||||||
|
- [P2] Packaging blocker: stale install alert + long description claim a "one-time 0.3.5 /data snapshot baked into the image" that no longer happens — `start9/0.4/startos/manifest/i18n.ts:22-33` — start9-spec-checker
|
||||||
|
- [P3] Login timing oracle — unknown-email path returns early with no dummy bcrypt compare, leaking account existence — `app/auth/login/actions.ts:27` — security-auditor, evaluator
|
||||||
|
- [P3] CSP ships `script-src 'unsafe-eval'` while the in-repo comment only justifies `'unsafe-inline'` — `proof-of-work/next.config.js:19` — evaluator, security-auditor
|
||||||
|
- [P3] `/api/health` returns user count + `signupsOpen` to unauthenticated callers — `app/api/health/route.ts:44` — security-auditor, exerciser
|
||||||
|
- [P3] Rate-limit map never evicts fully-expired keys — unbounded distinct-IP growth over process lifetime — `lib/rateLimit.ts:24` — evaluator
|
||||||
|
- [P3] `workout PATCH` / `sets POST` accept `exerciseId` without verifying caller ownership (unlike `programs PATCH`) — `app/api/workouts/[id]/route.ts:128`, `app/api/workouts/[id]/sets/route.ts:61` — security-auditor
|
||||||
|
- [P3] Session tokens valid 30 days, no idle timeout, no rotation on privilege change — `lib/auth.ts` — security-auditor
|
||||||
|
- [P3] No max-length validation on any text field (10KB/100KB names accepted) — exerciser
|
||||||
|
- [P3] `defaultRestSeconds` silently dropped by `POST /api/preferences` though it appears in Settings UI — exerciser
|
||||||
|
- [P3] WAL not enabled in local dev (health-check warns `journal_mode='delete'`) — degraded backup safety for dev only — exerciser
|
||||||
|
- [P3] Stale `.env.example` lists dead `CLAUDE_API_KEY` (keys live per-user in DB) — `proof-of-work/.env.example` — evaluator
|
||||||
|
- [P3] Drifted docs: AGENTS.md/CHANGELOG say "34 tests"; suite is now 177 — evaluator
|
||||||
|
- [P3] Git history (initial commit `1b64c45`) contains a committed SQLite DB with a default admin bcrypt hash — purge before ever making history public — security-auditor
|
||||||
|
- [P3] Packaging warnings: icon is PNG vs template SVG; README is migration-era prose missing spec sections; no `.github/workflows/`; `docsUrls` points at generic Start9 docs; Dockerfile on Node 20 vs LTS 22 — start9-spec-checker
|
||||||
|
- [P3] Repo cruft: legacy `start9/0.4/workout-log_x86_64.s9pk` artifact not removed by `make clean`; `bcryptjs` listed in `start9/0.4/package.json` appears unused (app uses native `bcrypt`) — start9-spec-checker
|
||||||
|
|
||||||
|
## Scorecard
|
||||||
|
|
||||||
|
| Lens | Score /5 | Justification |
|
||||||
|
|---|---|---|
|
||||||
|
| Architecture | 4 | Clean provider abstraction (`lib/ai/providers/index.ts:7`), helpers split out of routes, sound schema with intentional composite indexes, process-local background bus by design. |
|
||||||
|
| Security | 2 | Two P0 instance-DB routes un-gated; otherwise strong (CSPRNG sessions, double-gated admin actions, no signup enumeration, redacted keys). Corroborated by auditor — no adjustment. |
|
||||||
|
| Performance | 4 | Hot paths indexed, WAL tuning at boot, throttled progress flush; only an unbounded rate-limiter map. |
|
||||||
|
| Testing | 4 | 177 tests pass in ~4s covering AI parsing, auth, admin guards, route CRUD, idempotency. *Note:* exerciser found multiple uncaught-input 500s the suite doesn't cover, and no test asserts the instance-DB admin gate — evidence the suite has an input-validation/authz-regression blind spot, but the existing coverage is real, so held at 4. |
|
||||||
|
| Code quality | 4 | Consistent route shape, thorough "why" comments, tsc/lint clean; two minor validation styles coexist. |
|
||||||
|
| Documentation | 4 | README/AGENTS/CHANGELOG detailed and mostly true; broken quick-start creds, stale `.env.example`, drifted test counts, migration-era packaging README. |
|
||||||
|
|
||||||
|
## Disagreements & gaps
|
||||||
|
|
||||||
|
- **SSRF severity (P1 vs P2).** security-auditor rated it P1, evaluator P2 — the split is purely about the self-hosted threat model (evaluator weighted "operator owns the box" down; auditor weighted "real in multi-user mode" up). Resolved to **P1**: the same multi-user reality that makes the DB-export a P0 also makes an authenticated user reaching the operator's LAN a real cross-tenant capability. Noted rather than averaged.
|
||||||
|
- **Packaging "BLOCKER" → P2 mapping.** start9-spec-checker correctly reports the package as BLOCKED for *community-registry submission*. The user has explicitly parked registry publishing (sideload via `make install` only), so these are mapped to P2 (must-fix-before-submit, not on the current critical path) rather than P0. If/when community submission is back on the table, treat all four as hard blockers.
|
||||||
|
- **Shared blind spot — no live/multi-user runtime test.** Every code agent assessed multi-user authz and the AI subsystem *from source*; the exerciser couldn't test live AI generation (no API keys) or true concurrent multi-user load, and nobody exercised the StartOS proxy. So two severity calls (the XFF rate-limit bypass, secure-cookie/HTTPS posture) rest on whether the StartOS reverse proxy rewrites headers — unverified here.
|
||||||
|
- **Untested by design:** live LLM generation end-to-end, PWA/service-worker, StartOS Actions/backup-restore on a real box, and the `import-db` restore path with a valid .db file.
|
||||||
|
|
||||||
|
## Suggested order of work
|
||||||
|
|
||||||
|
1. **Close the P0 first.** Gate `export-db` + `import-db` on `user.isAdmin` (return 403) in both route handlers *and* hide them in `SettingsForm`/`page.tsx`; better, move whole-DB replace into the StartOS operator action layer entirely. Per-user export already exists at `/api/me/export`.
|
||||||
|
2. **Add a regression test** asserting a non-admin gets 403 from both instance-DB routes — this is the exact gap the suite has today, and it locks the P0 fix.
|
||||||
|
3. **Fix the P1 SSRF** — resolve provider `baseUrl` hosts and reject loopback/link-local/private/reserved ranges; restrict scheme to http/https (or limit provider config to admins).
|
||||||
|
4. **Fix the developer-facing P1s** — correct the README quick-start (the seed ships zero users; document the first-run setup flow instead of `admin@local`), and fix `resolveDatabasePath()` so export doesn't pick an empty 0-byte DB.
|
||||||
|
5. **Sweep the input-validation 500s together** — wrap `request.json()` in try/catch→400 and add Zod guards for date/offset across the workouts routes (one shared pattern fixes several P2s); add rate limiting to `POST /api/auth`.
|
||||||
|
6. **Harden the container** — add `USER nextjs` to the Dockerfile; plan the Next.js 15 upgrade as its own tested change.
|
||||||
|
7. **Defer packaging fixes** until registry submission is back on — then clear all four blockers (SPDX license, `instructions.md`, repo URLs, stale install alert) plus the warnings in one pass. Quick wins anytime: delete the legacy `workout-log_x86_64.s9pk`, drop dead `CLAUDE_API_KEY`/`bcryptjs`, fix drifted test counts.
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
# ROADMAP — Proof of Work
|
||||||
|
|
||||||
|
Longer-term backlog. Near-term state + next steps live in `AGENTS.md` → Current state.
|
||||||
|
|
||||||
|
## Known bugs
|
||||||
|
|
||||||
|
- **Mobile-Safari first-login-tap fails ("An unexpected error occurred"); second tap works.** Reproduced on iPhone/iPad Safari against 1.2.0:5 (desktop Safari untested — user declined). The first Sign In tap fails, a second manual tap succeeds. **1.2.0:2's `retryOnTransportError` does NOT fix it.** Diagnosis so far: `LoginForm` only surfaces that error when *both* the initial action call and its in-tap retry throw, so the immediate retry isn't escaping the bad connection — only a fresh user-initiated tap does. Box app logs show no server-side error/500/reset around the attempt, so it's a transport-layer failure, not an app bug.
|
||||||
|
- **Gating data (do this first):** capture the first failed request's error in Safari Web Inspector (iOS→Mac, Network/Console tab). The code picks the fix:
|
||||||
|
- `-1005` "The network connection was lost" → client-side stale keep-alive socket. Fix = a *delayed* retry (let Safari tear down the dead socket before retrying), not the current instant one.
|
||||||
|
- `502`/`503` → StartOS-proxy↔Node keep-alive mismatch (Node closing idle conns the proxy reuses). Fix = raise Node `keepAliveTimeout`/`headersTimeout` server-side; a client retry only masks it.
|
||||||
|
- Files: `lib/retryAction.ts`, `app/auth/login/LoginForm.tsx`, `app/auth/signup/SignupForm.tsx`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Security & hardening (from 2026-06-13 full-eval; full detail + file:line in `EVALUATION.md`)
|
||||||
|
|
||||||
|
- **Still open — verify on the box:** whether the StartOS proxy forwards real client IPs to the app. The rate limiter now keys on the rightmost (trusted-proxy) `X-Forwarded-For` entry; if the proxy instead makes every client look like one IP, the per-IP cap collapses to a single global bucket. Confirm with live headers.
|
||||||
|
- P3 hardening batch (remaining): CSP `unsafe-eval` vs comment, `/api/health` info disclosure, rate-limit map leak, configurable/shorter sessions (currently 30-day), no text max-length. Also unify the 3rd JSON-parse pattern in `programs/[id]/days/[dayId]/start` (`try{json}catch{→{}}`).
|
||||||
|
|
||||||
|
Done in 1.2.0:1–:3: Next 14→15 / React 18→19 bump (1.2.0:1, closed RSC DoS / WS-upgrade SSRF / App Router XSS + middleware-bypass CVEs); iOS-Safari login first-tap retry (1.2.0:2); login timing oracle closed + `exerciseId` ownership enforced on all workout-write & program routes (1.2.0:3).
|
||||||
|
Done in 1.1.0:9 (P2 batch): input-validation 500s → 400 (`lib/http.ts readJsonBody` + explicit guards); `POST /api/auth` rate-limited; XFF anti-spoof; container drops root via su-exec.
|
||||||
|
|
||||||
|
## 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). Blockers found 2026-06-13: non-SPDX `"Proprietary"` license, missing `instructions.md`, 404 `packageRepo`/`upstreamRepo` URLs, stale "0.3.5 data snapshot" install alert + long description; plus warnings (PNG vs SVG icon, migration-era README, no `.github/workflows`, generic `docsUrls`, Node 20 vs 22).
|
||||||
|
|
||||||
|
## Product
|
||||||
|
|
||||||
|
- Adherence tracking: compare logged workouts against the planned `ProgramDay` (the `programDayId` link already exists).
|
||||||
|
- Per-user export/import polish and scheduled backups.
|
||||||
|
- CSV export↔import round-trip: export writes `setX`-prefixed headers (`setCalories`/`setWatts`/`setNotes`) the importer doesn't read (it expects `calories`/`watts`/`notes`), so the app's own CSV export silently drops those on re-import (calories long-standing; watts since 1.2.0:4). Fix by aligning export header names with the parser, or adding the prefixed names as `knownColumns` aliases. (JSON account export/import round-trips fine.)
|
||||||
|
- Charts/progress views over history (the data and 1RM estimates already exist).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Cleanup backlog from the 2026-06-19 design-contract extract (`design/DESIGN.md` + `tokens.tokens.json`). The contract documents the *intended* system; these are the spots where the code diverges. All cosmetic/mechanical — none ship-blocking. Found by the `design-checker` agent (re-run it for fresh file:line lists).
|
||||||
|
|
||||||
|
- **Unify off-palette neutrals → zinc** (21 hits, 12 files): every `hover:bg-gray-100`/`active:bg-gray-200`/`hover:text-gray-200` on the white primary button → `zinc-200`/`zinc-300`. Canonical hover is `zinc-200` (`color.action.primary-hover-bg`). Worst: `app/main/workouts/page.tsx` (4), auth `LoginForm`/`SignupForm`, `dashboard/page.tsx`, `programs/page.tsx`.
|
||||||
|
- **Unify success hue → emerald** (10 hits): `green-*` → `emerald-*`. Worst: `components/settings/SettingsForm.tsx` (6), `WorkoutForm.tsx:696`, `SetRow.tsx:477`, `ExerciseCard.tsx:12`.
|
||||||
|
- **Unify warning hue → amber** (13 hits): `yellow-*` → `amber-*`. Worst: `app/main/import/page-csv.tsx` (6), `SettingsForm.tsx` (6), `ExerciseCard.tsx:14`.
|
||||||
|
- **No solid red button** (1): `components/settings/DangerZone.tsx:101` uses `bg-red-700` — convert to the ghost/wash destructive treatment (`text-red-400 border-red-800 hover:bg-red-950/30`). (Red elsewhere — 65 wash/outline/text uses — already conforms.)
|
||||||
|
- **Radius two-tier (4px control / 8px container)**: `rounded-md` (6px, 27 hits, mostly `SetRow.tsx` ×16 + `WorkoutForm.tsx` ×9) → `rounded`; `rounded-lg` on primary *buttons* (5 hits in `workouts/page.tsx`, `dashboard/page.tsx`) → `rounded`. (`rounded-full` on the FAB is fine.)
|
||||||
|
- **Shadows are overlay-only**: drop `shadow-2xl` on the static login/signup cards (`app/auth/{login,signup}/page.tsx:23`) and `shadow-lg` on the FAB + desktop CTA (`workouts/page.tsx:163,174`) — depth = bg layering + border.
|
||||||
|
- **Sub-scale font sizes** (10 hits): `text-[10px]`/`text-[11px]` (below the 12px `text-xs` floor) → `text-xs`.
|
||||||
|
- **Extract a shared `<Button>`** (`components/common/` is empty): the white primary pattern is inlined 44× across 21 files — the structural reason the `gray-100` drift spread everywhere. A single component is the durable fix and the single point to enforce the contract. (Biggest item; do after the mechanical sweeps.)
|
||||||
|
- **Token wiring decision**: `design/brand/palette.css` (`--pow-*` vars) isn't imported anywhere. For a Tailwind app the class names already *are* the tokens (hexes match the zinc/emerald/amber ramp), so the pragmatic path is to keep using Tailwind names and treat `palette.css`/`tokens.tokens.json` as the canonical cross-reference (and for any raw-CSS context). Alternatively import `palette.css` at the root and migrate the ~30 inlined `bg-[#0A0A0A]` canvas hexes to `var(--pow-bg-canvas)`. Decide before doing a hex→var sweep.
|
||||||
|
|
||||||
|
## Hygiene
|
||||||
|
|
||||||
|
- Delete the legacy `start9/0.4/workout-log_x86_64.s9pk` build artifact; drop unused `bcryptjs` from `start9/0.4/package.json`.
|
||||||
|
- Revisit `workout-planner/` scratch dir — remove if truly unused.
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# Proof of Work — Design Brief
|
||||||
|
|
||||||
|
The durable brand brief for Proof of Work's user-facing UI. Read this and
|
||||||
|
[`tokens.tokens.json`](./tokens.tokens.json) before building or changing any UI, and conform
|
||||||
|
to them. Machine-readable values live in the tokens file and
|
||||||
|
[`brand/palette.css`](./brand/palette.css); this file is the *why* and the rules.
|
||||||
|
|
||||||
|
> **Provenance.** Established 2026-06-19 by a **document-as-is extract** (Case B) of the
|
||||||
|
> as-built UI — there were no prior brand guidelines; the look grew in the code. Values were
|
||||||
|
> harvested by frequency census of the Tailwind classes in `proof-of-work/app` +
|
||||||
|
> `proof-of-work/components` and reconciled with the owner. The only owner-driven *elevations*
|
||||||
|
> over the literal as-built state: red promoted from error-only to a **brand accent**
|
||||||
|
> (canonical `#DC2626`), and a documented two-tier radius rule. See
|
||||||
|
> [`inspiration/README.md`](./inspiration/README.md) for what served as the reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visual theme
|
||||||
|
|
||||||
|
**Monochrome gym-brutalist.** A near-black canvas, a single cool-gray (zinc) neutral ramp,
|
||||||
|
white as the primary voice, and **red as the one accent** — the heat in an otherwise
|
||||||
|
black-and-white system. The feeling is a piece of gym equipment: heavy, blunt, high-contrast,
|
||||||
|
no ornament. The product's voice ("Track. Lift. Dominate.") shows up visually as
|
||||||
|
**UPPERCASE, wide-tracked, condensed display type** and flat, edge-defined surfaces.
|
||||||
|
|
||||||
|
It is **not** soft, pastel, glassy, or playful. No gradients-as-decoration, no drop-shadow
|
||||||
|
depth, no rounded-pill friendliness, no multi-hue palette. Restraint is the brand: color is
|
||||||
|
rare and earns its place.
|
||||||
|
|
||||||
|
## 2. Color palette
|
||||||
|
|
||||||
|
The system is **one neutral ramp (zinc) + white/black + four semantic hues**, on a near-black
|
||||||
|
canvas. Red is both the **brand accent** and the **error/destructive** hue, told apart by
|
||||||
|
*treatment*, never by a second red (see §7).
|
||||||
|
|
||||||
|
**Canvas & surfaces** (depth is built by stepping *up* in lightness, not by shadow):
|
||||||
|
|
||||||
|
| Role | Token | Hex | Notes |
|
||||||
|
|------|-------|-----|-------|
|
||||||
|
| Canvas (app background) | `color.bg.canvas` | `#0A0A0A` | The PWA `theme_color`; `<body>` bg. The anchor. |
|
||||||
|
| Surface (cards, panels) | `color.bg.surface` | `#18181B` | zinc-900 — the default raised surface. |
|
||||||
|
| Surface raised (inputs, chips, hover) | `color.bg.raised` | `#27272A` | zinc-800 — controls and the next step up. |
|
||||||
|
| Surface inset (rare deep wells) | `color.bg.inset` | `#09090B` | zinc-950 — slightly below canvas. |
|
||||||
|
|
||||||
|
**Borders** (the primary depth cue alongside bg layering):
|
||||||
|
|
||||||
|
| Role | Token | Hex |
|
||||||
|
|------|-------|-----|
|
||||||
|
| Subtle (default hairline) | `color.border.subtle` | `#27272A` (zinc-800) |
|
||||||
|
| Default | `color.border.default` | `#3F3F46` (zinc-700) |
|
||||||
|
| Strong (emphasis/hover) | `color.border.strong` | `#52525B` (zinc-600) |
|
||||||
|
|
||||||
|
**Text** (on the dark canvas):
|
||||||
|
|
||||||
|
| Role | Token | Hex |
|
||||||
|
|------|-------|-----|
|
||||||
|
| Primary | `color.text.primary` | `#FFFFFF` |
|
||||||
|
| Secondary | `color.text.secondary` | `#A1A1AA` (zinc-400) |
|
||||||
|
| Muted | `color.text.muted` | `#71717A` (zinc-500) |
|
||||||
|
| Subtle / disabled | `color.text.subtle` | `#52525B` (zinc-600) |
|
||||||
|
| Inverted (on white surfaces) | `color.text.inverted` | `#000000` |
|
||||||
|
|
||||||
|
**Accent / semantic.** The accent and error share `#DC2626`; success/warning/info round out
|
||||||
|
the state palette. On the dark canvas the *text/icon* tint is the lighter -400 step; fills and
|
||||||
|
edges use the -600 step; washes use a translucent dark step.
|
||||||
|
|
||||||
|
| Role | Token | Hex | Use |
|
||||||
|
|------|-------|-----|-----|
|
||||||
|
| **Accent / error (canonical red)** | `color.accent.red` | `#DC2626` (red-600) | Brand emphasis fills/edges **and** destructive intent. |
|
||||||
|
| Accent hover/pressed | `color.accent.red-strong` | `#B91C1C` (red-700) | |
|
||||||
|
| Red text/icon on dark | `color.accent.red-text` | `#F87171` (red-400) | Error text, destructive links, accent labels. |
|
||||||
|
| Red border | `color.accent.red-border` | `#991B1B` (red-800) | Error/destructive outlines. |
|
||||||
|
| Red wash (bg) | `color.accent.red-wash` | `rgba(127,29,29,.30)` (red-900/30) | Error banners, destructive hover. |
|
||||||
|
| Success | `color.state.success` | `#34D399` (emerald-400) text / `#059669` (emerald-600) fill | PRs, saved, positive deltas. |
|
||||||
|
| Warning | `color.state.warning` | `#FBBF24` (amber-400) text / `#78350F` (amber-900) edge | Cautions, cost/limit notices. |
|
||||||
|
| Info | `color.state.info` | `#60A5FA` (blue-400) text / `#172554` (blue-950) wash | Neutral notices (used sparingly). |
|
||||||
|
|
||||||
|
**Primary action color is not a hue — it's white.** The primary button is `#FFFFFF` bg /
|
||||||
|
`#000000` text (see §4). White, not red, is the loudest thing on screen.
|
||||||
|
|
||||||
|
## 3. Typography
|
||||||
|
|
||||||
|
Two families, both already wired as CSS variables in `app/layout.tsx`:
|
||||||
|
|
||||||
|
- **Display — Bebas Neue** (`var(--font-display)`): condensed, all-caps by nature. Used for
|
||||||
|
**all headings (h1–h3), buttons, and labels**, always `text-transform: uppercase` with
|
||||||
|
`letter-spacing: 0.05em` (Tailwind `tracking-wider`). This UPPERCASE + tracking pairing is
|
||||||
|
the single strongest brand signal — ~115 uses in the as-built UI. Don't set body copy in it.
|
||||||
|
- **Body — Space Grotesk** (`var(--font-sans)`): all running text, form values, data, numbers.
|
||||||
|
Use `tabular-nums` for stat/metric columns.
|
||||||
|
|
||||||
|
**Type scale** (Tailwind rem, the app is data-dense so the small end dominates):
|
||||||
|
|
||||||
|
| Token | Size | Typical use |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `font.size.xs` | 12px | Labels, meta, table cells (the workhorse — ~240 uses) |
|
||||||
|
| `font.size.sm` | 14px | Default body / form text (~175 uses) |
|
||||||
|
| `font.size.base` | 16px | Comfortable body |
|
||||||
|
| `font.size.lg` | 18px | Sub-headings |
|
||||||
|
| `font.size.xl` | 20px | |
|
||||||
|
| `font.size.2xl` | 24px | Section headings (Bebas) |
|
||||||
|
| `font.size.3xl` | 30px | Page headings (Bebas) |
|
||||||
|
| `font.size.4xl` | 36px | Hero / display (Bebas) |
|
||||||
|
|
||||||
|
**Weights:** 400 normal, 500 medium (default for emphasis), 600 semibold, 700 bold. Bebas
|
||||||
|
ships a single weight; weight contrast lives in the body font.
|
||||||
|
|
||||||
|
## 4. Component styling
|
||||||
|
|
||||||
|
- **Primary button** — `bg-white text-black`, `font-bold uppercase tracking-wider`,
|
||||||
|
`hover:bg-zinc-200`, `disabled:bg-zinc-700 disabled:text-zinc-500`. Radius 4px (`rounded`),
|
||||||
|
padding ≈ `px-5 py-2` (inline) / `py-3` (full-width). **This is the signature; keep it white.**
|
||||||
|
- **Secondary / accent (ghost) button** — transparent bg, `border` + text in the accent red
|
||||||
|
(`#DC2626` border, `text-red-400`), uppercase tracking. For secondary emphasis (e.g.
|
||||||
|
"Refine"). Never a *solid* red fill (see §7).
|
||||||
|
- **Destructive button** — same ghost treatment in red (`text-red-400`/`text-red-500`,
|
||||||
|
`hover:bg-red-950/30`) or a red-wash block. Destructive is red-as-*outline/wash*, so it never
|
||||||
|
competes with the white primary.
|
||||||
|
- **Cards / panels** — `bg-zinc-900`, `border border-zinc-800`, radius 8px (`rounded-lg`),
|
||||||
|
padding `p-4`. Accent a card by adding a **left edge** `border-l-4` in `#DC2626`.
|
||||||
|
- **Inputs / selects** — `bg-zinc-800` (or `bg-zinc-900`), `border border-zinc-700`, radius 4px,
|
||||||
|
white text, `placeholder` in zinc-500, focus ring `ring-white/20`–`ring-white/30`.
|
||||||
|
- **Badges / chips** — uppercase, `text-xs`, tracked; semantic ones use the wash pattern
|
||||||
|
(tinted text + matching translucent bg + matching border at ~45% — e.g. a red "PR" badge:
|
||||||
|
`text-red-400 bg-red-900/30 border border-red-800`).
|
||||||
|
- **Active nav / selected state** — accent red: `text-red-400` + a `border-b-2` underline or a
|
||||||
|
left indicator in `#DC2626`; inactive items in `text-zinc-500`.
|
||||||
|
- **Error / alert banners** — `bg-red-900/30 border border-red-800 text-red-400`, radius 8px.
|
||||||
|
|
||||||
|
## 5. Layout
|
||||||
|
|
||||||
|
- **Mobile-first PWA**, portrait-primary, installable. Most styling is unprefixed (mobile);
|
||||||
|
`sm:` is the dominant breakpoint, `md:` introduces the desktop sidebar.
|
||||||
|
- **App shell:** a fixed **240px sidebar** on `md+` (`md:pl-[var(--sidebar-width)]` via the
|
||||||
|
`.app-content` utility) and a **64px bottom nav** on mobile. Top nav height 64px. These three
|
||||||
|
dimensions are CSS vars in `globals.css` (`--sidebar-width`, `--nav-height`,
|
||||||
|
`--bottom-nav-height`) — reuse them, don't hardcode.
|
||||||
|
- **Spacing** follows Tailwind's 4px scale. Dense by intent: `p-4` cards, `px-4 py-3` controls,
|
||||||
|
`gap-2`/`gap-3` between elements.
|
||||||
|
- **Content** is the focus; chrome is minimal. Single-column on mobile; the sidebar is the only
|
||||||
|
persistent chrome on desktop.
|
||||||
|
|
||||||
|
## 6. Depth & elevation
|
||||||
|
|
||||||
|
**Flat by design — depth comes from background layering + 1px borders, not shadows.** The
|
||||||
|
surface ladder (`#0A0A0A` canvas → `zinc-900` → `zinc-800`) plus zinc-700/800 hairlines *is*
|
||||||
|
the elevation system. Shadows are reserved for **truly floating overlays only** (modals,
|
||||||
|
popovers, dropdowns) — `shadow-lg`/`shadow-xl`/`shadow-2xl`. Never put a shadow on a static
|
||||||
|
card; raise it with bg + border instead.
|
||||||
|
|
||||||
|
## 7. Do's and don'ts
|
||||||
|
|
||||||
|
**Do**
|
||||||
|
- Keep the **primary button white** (`bg-white text-black`). It's the brand's loudest element.
|
||||||
|
- Use **UPPERCASE + `tracking-wider` Bebas** for headings, buttons, and labels.
|
||||||
|
- Reach for **zinc** for every neutral — backgrounds, borders, secondary text.
|
||||||
|
- Use **`#DC2626` red as the single accent**: active states, emphasis edges, key deltas, links,
|
||||||
|
the destructive intent. Make it rare and deliberate.
|
||||||
|
- Build depth with **bg steps + borders**; keep surfaces flat.
|
||||||
|
- Use the **wash pattern** for semantic blocks (tinted text + translucent bg + matching border).
|
||||||
|
|
||||||
|
**Don't**
|
||||||
|
- ❌ Don't make a **solid red button** — it collides with the white primary *and* with the red
|
||||||
|
destructive meaning. Red buttons are ghost/outline/wash only. (Red as a solid *fill* is for
|
||||||
|
small accents — nav indicators, edges, badges — not full buttons.)
|
||||||
|
- ❌ Don't introduce a **second neutral** (gray/slate/stone) — zinc only. (`gray-100` strays
|
||||||
|
exist in the code; they're cleanup, not precedent.)
|
||||||
|
- ❌ Don't introduce a **second red, second green, or second yellow** — `#DC2626` red, `emerald`
|
||||||
|
success, `amber` warning. (`green-*`/`yellow-*` strays are cleanup.)
|
||||||
|
- ❌ Don't add **decorative shadows or gradients**. Flat only.
|
||||||
|
- ❌ Don't set **body text in Bebas Neue**, or leave headings/labels lowercase.
|
||||||
|
- ❌ Don't add a **new arbitrary radius** — controls are 4px, containers 8px (see §8 below).
|
||||||
|
|
||||||
|
## 8. Responsive behavior
|
||||||
|
|
||||||
|
- **Mobile-first**: author the mobile layout unprefixed, layer desktop with `sm:`/`md:`.
|
||||||
|
- The **bottom nav** is the mobile primary navigation; the **240px sidebar** replaces it at
|
||||||
|
`md+`. The main content reserves the sidebar via `md:pl-[var(--sidebar-width)]`.
|
||||||
|
- Touch targets stay ≥ 44px tall on mobile; the dense `text-xs`/`text-sm` scale is for
|
||||||
|
*information density*, not for shrinking tap targets.
|
||||||
|
- Viewport is locked (`maximum-scale=1, user-scalable=no`) — this is an app, not a document.
|
||||||
|
|
||||||
|
**Radius scale (canonical):**
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| `radius.control` | 4px (`rounded`) | Buttons, inputs, chips, small elements |
|
||||||
|
| `radius.container` | 8px (`rounded-lg`) | Cards, panels, modals, banners |
|
||||||
|
| `radius.full` | 9999px (`rounded-full`) | Pills, avatars, dots |
|
||||||
|
|
||||||
|
(`rounded-md`/6px exists in the code as a third value; treat it as drift toward one of the two.)
|
||||||
|
|
||||||
|
## 9. Agent prompt guide
|
||||||
|
|
||||||
|
When building or editing UI in `proof-of-work/`:
|
||||||
|
|
||||||
|
> Build it **monochrome-first**: near-black canvas (`#0A0A0A`), **zinc** for every neutral,
|
||||||
|
> **white** for primary text and the primary button (`bg-white text-black font-bold uppercase
|
||||||
|
> tracking-wider`). Headings/labels/buttons are **Bebas Neue, UPPERCASE, `tracking-wider`**;
|
||||||
|
> body is **Space Grotesk**, dense (`text-xs`/`text-sm`). The **only accent is red `#DC2626`** —
|
||||||
|
> use it sparingly for active states, emphasis edges (`border-l-4`), key deltas, links, and
|
||||||
|
> destructive intent; **never as a solid button fill**. Build depth with **background layering +
|
||||||
|
> 1px zinc borders**, not shadows (shadows are for floating overlays only). Radius: **4px**
|
||||||
|
> controls, **8px** containers. Pull exact values from `design/tokens.tokens.json` /
|
||||||
|
> `design/brand/palette.css` rather than re-deriving hexes; reuse the layout CSS vars
|
||||||
|
> (`--sidebar-width`, `--nav-height`, `--bottom-nav-height`). Stay in the system — no second
|
||||||
|
> neutral, no second red/green/yellow, no decorative gradients or shadows.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<image href="/icons/gemini-kettlebell.png" width="1024" height="1024"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 146 B |
Binary file not shown.
|
After Width: | Height: | Size: 593 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* Proof of Work — canonical design tokens as CSS custom properties.
|
||||||
|
* Generated-by-hand mirror of design/tokens.tokens.json (the source of truth).
|
||||||
|
* Import once at the app root so surfaces reference var(--pow-*) instead of inlining hexes.
|
||||||
|
* Hexes are the literal Tailwind values the as-built UI already uses (neutral ramp = zinc).
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Backgrounds / surfaces (depth = stepping up in lightness, not shadow) */
|
||||||
|
--pow-bg-canvas: #0A0A0A; /* app background; PWA theme_color */
|
||||||
|
--pow-bg-surface: #18181B; /* zinc-900 — cards, panels */
|
||||||
|
--pow-bg-raised: #27272A; /* zinc-800 — controls, chips, hover */
|
||||||
|
--pow-bg-inset: #09090B; /* zinc-950 — rare deep wells */
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--pow-border-subtle: #27272A; /* zinc-800 — default hairline */
|
||||||
|
--pow-border-default: #3F3F46; /* zinc-700 */
|
||||||
|
--pow-border-strong: #52525B; /* zinc-600 — emphasis */
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--pow-text-primary: #FFFFFF;
|
||||||
|
--pow-text-secondary: #A1A1AA; /* zinc-400 */
|
||||||
|
--pow-text-muted: #71717A; /* zinc-500 */
|
||||||
|
--pow-text-subtle: #52525B; /* zinc-600 — disabled */
|
||||||
|
--pow-text-inverted: #000000; /* on white surfaces */
|
||||||
|
|
||||||
|
/* Accent / error (one canonical red, told apart by treatment) */
|
||||||
|
--pow-accent-red: #DC2626; /* red-600 — accent fills/edges + destructive */
|
||||||
|
--pow-accent-red-strong: #B91C1C; /* red-700 — hover/pressed */
|
||||||
|
--pow-accent-red-text: #F87171; /* red-400 — red text on dark */
|
||||||
|
--pow-accent-red-border: #991B1B; /* red-800 — outlines */
|
||||||
|
--pow-accent-red-wash: rgba(127, 29, 29, 0.30); /* red-900/30 — banners/hover */
|
||||||
|
|
||||||
|
/* Other semantic state */
|
||||||
|
--pow-success: #34D399; /* emerald-400 */
|
||||||
|
--pow-success-fill: #059669; /* emerald-600 */
|
||||||
|
--pow-warning: #FBBF24; /* amber-400 */
|
||||||
|
--pow-warning-edge: #78350F; /* amber-900 */
|
||||||
|
--pow-info: #60A5FA; /* blue-400 */
|
||||||
|
--pow-info-wash: #172554; /* blue-950 */
|
||||||
|
|
||||||
|
/* Primary action = white, not a hue */
|
||||||
|
--pow-action-primary-bg: #FFFFFF;
|
||||||
|
--pow-action-primary-text: #000000;
|
||||||
|
--pow-action-primary-hover-bg: #E4E4E7; /* zinc-200 */
|
||||||
|
--pow-action-primary-disabled-bg: #3F3F46; /* zinc-700 */
|
||||||
|
--pow-action-primary-disabled-text: #71717A;/* zinc-500 */
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--pow-font-display: var(--font-display), 'Bebas Neue', sans-serif;
|
||||||
|
--pow-font-body: var(--font-sans), 'Space Grotesk', system-ui, sans-serif;
|
||||||
|
--pow-tracking-wider: 0.05em;
|
||||||
|
|
||||||
|
/* Radius (two-tier) */
|
||||||
|
--pow-radius-control: 4px; /* buttons, inputs, chips */
|
||||||
|
--pow-radius-container: 8px; /* cards, panels, modals */
|
||||||
|
--pow-radius-full: 9999px; /* pills, avatars */
|
||||||
|
|
||||||
|
/* Layout (mirrors globals.css; the app already defines these) */
|
||||||
|
--pow-sidebar-width: 240px;
|
||||||
|
--pow-nav-height: 64px;
|
||||||
|
--pow-bottom-nav-height: 64px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Inspiration & provenance
|
||||||
|
|
||||||
|
Proof of Work's design was **extracted document-as-is** (Case B) from the existing code on
|
||||||
|
2026-06-19 — there were no prior brand guidelines and no external reference set. So for this
|
||||||
|
repo the "inspiration" is the **as-built UI itself**, plus the brand mark. This folder records
|
||||||
|
the *why/where* behind the contract.
|
||||||
|
|
||||||
|
## De-facto references (the source the look was harvested from)
|
||||||
|
|
||||||
|
- **Brand mark** — [`../brand/logo-kettlebell.png`](../brand/logo-kettlebell.png): a pure-white
|
||||||
|
kettlebell silhouette on near-black. It establishes the core identity directly: **monochrome,
|
||||||
|
white-on-black, no color in the mark.** Everything else follows from this.
|
||||||
|
- **As-built styling surfaces** (where every value was censused by Tailwind-class frequency):
|
||||||
|
- `proof-of-work/app/globals.css` — base bg, heading treatment, scrollbar, layout vars.
|
||||||
|
- `proof-of-work/app/layout.tsx` — fonts (Bebas Neue display, Space Grotesk body), theme color.
|
||||||
|
- `proof-of-work/tailwind.config.ts` — font-family wiring, spacing/radius extensions.
|
||||||
|
- `proof-of-work/components/**` + `proof-of-work/app/main/**` — the inline utility classes that
|
||||||
|
were frequency-ranked to find the canonical neutral (zinc), surface ladder, type scale,
|
||||||
|
radii, and the white-primary / red-error patterns.
|
||||||
|
- `proof-of-work/public/manifest.json` — `theme_color`/`background_color` `#0A0A0A` (the
|
||||||
|
external anchor that confirmed the canonical canvas color).
|
||||||
|
|
||||||
|
## Owner decisions captured in the reconcile (2026-06-19)
|
||||||
|
|
||||||
|
The extract was literal except for two owner-driven calls:
|
||||||
|
|
||||||
|
1. **Red promoted to a brand accent.** As-built, red was error/destructive only. The owner chose
|
||||||
|
to elevate it to *the* accent (still keeping white as the primary button). Canonical red =
|
||||||
|
**`#DC2626`** (Tailwind red-600, "Blood Red"), which also re-tints the error states so the UI
|
||||||
|
carries a single coherent red.
|
||||||
|
- The candidates that were compared on the real `#0A0A0A` background:
|
||||||
|
[`red-accent-candidates.png`](./red-accent-candidates.png) (rendered from
|
||||||
|
[`red-accent-candidates.html`](./red-accent-candidates.html)). Options were Signal `#EF4444`,
|
||||||
|
Blood `#DC2626` *(chosen)*, Vermilion `#FF3B30`, Crimson `#E11D48`.
|
||||||
|
2. **Two-tier radius rule.** The code mixed 4px (`rounded`) and 8px (`rounded-lg`) with no rule;
|
||||||
|
the owner adopted **4px for controls, 8px for containers**.
|
||||||
|
|
||||||
|
Everything else (zinc as the one neutral, white primary button, Bebas-uppercase-tracked
|
||||||
|
headings, flat/border-based depth, dense small type scale) is the as-built look, documented
|
||||||
|
faithfully in [`../DESIGN.md`](../DESIGN.md).
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
body {
|
||||||
|
background:#0A0A0A; color:#fff;
|
||||||
|
font-family:'Space Grotesk', system-ui, sans-serif;
|
||||||
|
padding:36px 40px 44px;
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
}
|
||||||
|
.display { font-family:'Bebas Neue', sans-serif; letter-spacing:.05em; text-transform:uppercase; }
|
||||||
|
h1 { font-family:'Bebas Neue',sans-serif; letter-spacing:.06em; font-size:34px; }
|
||||||
|
.sub { color:#a1a1aa; font-size:13px; margin-top:4px; margin-bottom:26px; }
|
||||||
|
.row {
|
||||||
|
display:grid; grid-template-columns:200px 1fr; gap:28px;
|
||||||
|
align-items:center;
|
||||||
|
padding:22px 0; border-top:1px solid #27272a;
|
||||||
|
}
|
||||||
|
/* left: raw swatch + name */
|
||||||
|
.swatch { height:120px; border-radius:8px; }
|
||||||
|
.name { font-family:'Bebas Neue',sans-serif; letter-spacing:.06em; font-size:26px; margin-top:12px; }
|
||||||
|
.hex { color:#71717a; font-size:13px; font-variant-numeric:tabular-nums; }
|
||||||
|
|
||||||
|
/* right: vignette strip */
|
||||||
|
.vignette { display:flex; gap:20px; align-items:stretch; flex-wrap:nowrap; }
|
||||||
|
.card {
|
||||||
|
background:#18181b; border:1px solid #27272a; border-left:4px solid var(--a);
|
||||||
|
border-radius:8px; padding:14px 16px; width:230px;
|
||||||
|
}
|
||||||
|
.card .label { font-family:'Bebas Neue',sans-serif; letter-spacing:.12em; font-size:13px; color:var(--a); }
|
||||||
|
.card .h { font-family:'Bebas Neue',sans-serif; letter-spacing:.05em; font-size:22px; margin-top:6px; }
|
||||||
|
.card .stat { display:flex; align-items:baseline; gap:8px; margin-top:8px; }
|
||||||
|
.card .stat .n { font-size:30px; font-weight:700; font-variant-numeric:tabular-nums; }
|
||||||
|
.card .stat .d { color:var(--a); font-size:14px; font-weight:600; }
|
||||||
|
|
||||||
|
.col { display:flex; flex-direction:column; gap:12px; justify-content:center; }
|
||||||
|
|
||||||
|
/* nav active state */
|
||||||
|
.nav { display:flex; gap:18px; align-items:center; }
|
||||||
|
.nav a { font-family:'Bebas Neue',sans-serif; letter-spacing:.1em; font-size:14px; color:#71717a; padding-bottom:6px; }
|
||||||
|
.nav a.active { color:var(--a); border-bottom:2px solid var(--a); }
|
||||||
|
|
||||||
|
/* badge (accent wash) */
|
||||||
|
.badge {
|
||||||
|
display:inline-block; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.08em;
|
||||||
|
color:var(--a); background:color-mix(in srgb, var(--a) 15%, transparent);
|
||||||
|
border:1px solid color-mix(in srgb, var(--a) 45%, transparent);
|
||||||
|
padding:4px 10px; border-radius:4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* buttons */
|
||||||
|
.btns { display:flex; gap:10px; }
|
||||||
|
.btn-primary {
|
||||||
|
background:#fff; color:#000; font-weight:700; text-transform:uppercase; letter-spacing:.08em;
|
||||||
|
font-size:12px; padding:9px 16px; border-radius:4px; border:none;
|
||||||
|
}
|
||||||
|
.btn-accent {
|
||||||
|
background:transparent; color:var(--a); border:1px solid var(--a);
|
||||||
|
font-weight:700; text-transform:uppercase; letter-spacing:.08em;
|
||||||
|
font-size:12px; padding:9px 16px; border-radius:4px;
|
||||||
|
}
|
||||||
|
.reflabel { font-size:10px; color:#52525b; text-transform:uppercase; letter-spacing:.1em; margin-top:6px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Proof of Work — red accent candidates</h1>
|
||||||
|
<div class="sub">All on the app background #0A0A0A. White stays the primary button (reference, same in every row); red is used only as an accent: labels, active nav, card edge, stat delta, ghost button, badge.</div>
|
||||||
|
|
||||||
|
<!-- A -->
|
||||||
|
<div class="row" style="--a:#EF4444">
|
||||||
|
<div>
|
||||||
|
<div class="swatch" style="background:#EF4444"></div>
|
||||||
|
<div class="name">A · Signal Red</div>
|
||||||
|
<div class="hex">#EF4444 · Tailwind red-500 (in-family)</div>
|
||||||
|
</div>
|
||||||
|
<div class="vignette">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Personal Record</div>
|
||||||
|
<div class="h">Back Squat</div>
|
||||||
|
<div class="stat"><span class="n">142.5</span><span class="d">▲ +5kg</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="nav"><a class="active">Today</a><a>History</a><a>Programs</a></div>
|
||||||
|
<span class="badge">PR · Week 6</span>
|
||||||
|
<div class="btns"><button class="btn-primary">Save workout</button><button class="btn-accent">Refine</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- B -->
|
||||||
|
<div class="row" style="--a:#DC2626">
|
||||||
|
<div>
|
||||||
|
<div class="swatch" style="background:#DC2626"></div>
|
||||||
|
<div class="name">B · Blood Red</div>
|
||||||
|
<div class="hex">#DC2626 · Tailwind red-600 (deeper)</div>
|
||||||
|
</div>
|
||||||
|
<div class="vignette">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Personal Record</div>
|
||||||
|
<div class="h">Back Squat</div>
|
||||||
|
<div class="stat"><span class="n">142.5</span><span class="d">▲ +5kg</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="nav"><a class="active">Today</a><a>History</a><a>Programs</a></div>
|
||||||
|
<span class="badge">PR · Week 6</span>
|
||||||
|
<div class="btns"><button class="btn-primary">Save workout</button><button class="btn-accent">Refine</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- C -->
|
||||||
|
<div class="row" style="--a:#FF3B30">
|
||||||
|
<div>
|
||||||
|
<div class="swatch" style="background:#FF3B30"></div>
|
||||||
|
<div class="name">C · Vermilion</div>
|
||||||
|
<div class="hex">#FF3B30 · hot orange-red</div>
|
||||||
|
</div>
|
||||||
|
<div class="vignette">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Personal Record</div>
|
||||||
|
<div class="h">Back Squat</div>
|
||||||
|
<div class="stat"><span class="n">142.5</span><span class="d">▲ +5kg</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="nav"><a class="active">Today</a><a>History</a><a>Programs</a></div>
|
||||||
|
<span class="badge">PR · Week 6</span>
|
||||||
|
<div class="btns"><button class="btn-primary">Save workout</button><button class="btn-accent">Refine</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- D -->
|
||||||
|
<div class="row" style="--a:#E11D48">
|
||||||
|
<div>
|
||||||
|
<div class="swatch" style="background:#E11D48"></div>
|
||||||
|
<div class="name">D · Crimson</div>
|
||||||
|
<div class="hex">#E11D48 · cooler, pink-edge (rose-600)</div>
|
||||||
|
</div>
|
||||||
|
<div class="vignette">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Personal Record</div>
|
||||||
|
<div class="h">Back Squat</div>
|
||||||
|
<div class="stat"><span class="n">142.5</span><span class="d">▲ +5kg</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="nav"><a class="active">Today</a><a>History</a><a>Programs</a></div>
|
||||||
|
<span class="badge">PR · Week 6</span>
|
||||||
|
<div class="btns"><button class="btn-primary">Save workout</button><button class="btn-accent">Refine</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 205 KiB |
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"$description": "Proof of Work design tokens (W3C DTCG). Extracted document-as-is from the as-built Tailwind UI on 2026-06-19; red accent (#DC2626) and the two-tier radius rule are owner-driven. Neutral ramp is Tailwind 'zinc'. Hexes are the literal Tailwind values the code already uses.",
|
||||||
|
"color": {
|
||||||
|
"bg": {
|
||||||
|
"canvas": { "$type": "color", "$value": "#0A0A0A", "$description": "App background; PWA theme_color and <body> bg. The anchor value." },
|
||||||
|
"surface": { "$type": "color", "$value": "#18181B", "$description": "zinc-900 — default raised surface (cards, panels)." },
|
||||||
|
"raised": { "$type": "color", "$value": "#27272A", "$description": "zinc-800 — controls, chips, next step up / hover." },
|
||||||
|
"inset": { "$type": "color", "$value": "#09090B", "$description": "zinc-950 — rare deep wells, slightly below canvas." }
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"subtle": { "$type": "color", "$value": "#27272A", "$description": "zinc-800 — default hairline." },
|
||||||
|
"default": { "$type": "color", "$value": "#3F3F46", "$description": "zinc-700." },
|
||||||
|
"strong": { "$type": "color", "$value": "#52525B", "$description": "zinc-600 — emphasis/hover." }
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": { "$type": "color", "$value": "#FFFFFF" },
|
||||||
|
"secondary": { "$type": "color", "$value": "#A1A1AA", "$description": "zinc-400." },
|
||||||
|
"muted": { "$type": "color", "$value": "#71717A", "$description": "zinc-500." },
|
||||||
|
"subtle": { "$type": "color", "$value": "#52525B", "$description": "zinc-600 — disabled/least emphasis." },
|
||||||
|
"inverted": { "$type": "color", "$value": "#000000", "$description": "Text on white/light surfaces (primary button)." }
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"red": { "$type": "color", "$value": "#DC2626", "$description": "Canonical red — brand accent AND error/destructive (red-600). Distinguished by treatment, not by a second red." },
|
||||||
|
"red-strong": { "$type": "color", "$value": "#B91C1C", "$description": "red-700 — hover/pressed for the accent." },
|
||||||
|
"red-text": { "$type": "color", "$value": "#F87171", "$description": "red-400 — red text/icon on the dark canvas." },
|
||||||
|
"red-border": { "$type": "color", "$value": "#991B1B", "$description": "red-800 — error/destructive outlines." },
|
||||||
|
"red-wash": { "$type": "color", "$value": "rgba(127, 29, 29, 0.30)", "$description": "red-900/30 — error banner / destructive-hover background wash." }
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"success": { "$type": "color", "$value": "#34D399", "$description": "emerald-400 — success text/icon on dark." },
|
||||||
|
"success-fill": { "$type": "color", "$value": "#059669", "$description": "emerald-600 — success fill." },
|
||||||
|
"warning": { "$type": "color", "$value": "#FBBF24", "$description": "amber-400 — warning text/icon." },
|
||||||
|
"warning-edge": { "$type": "color", "$value": "#78350F", "$description": "amber-900 — warning border/wash edge." },
|
||||||
|
"info": { "$type": "color", "$value": "#60A5FA", "$description": "blue-400 — info text (used sparingly)." },
|
||||||
|
"info-wash": { "$type": "color", "$value": "#172554", "$description": "blue-950 — info wash background." }
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary-bg": { "$type": "color", "$value": "{color.text.primary}", "$description": "Primary button background is WHITE — not a hue." },
|
||||||
|
"primary-text": { "$type": "color", "$value": "{color.text.inverted}" },
|
||||||
|
"primary-hover-bg": { "$type": "color", "$value": "#E4E4E7", "$description": "zinc-200." },
|
||||||
|
"primary-disabled-bg": { "$type": "color", "$value": "#3F3F46", "$description": "zinc-700." },
|
||||||
|
"primary-disabled-text": { "$type": "color", "$value": "#71717A", "$description": "zinc-500." }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"font": {
|
||||||
|
"family": {
|
||||||
|
"display": { "$type": "fontFamily", "$value": "Bebas Neue", "$description": "var(--font-display). Headings, buttons, labels — always UPPERCASE + tracked." },
|
||||||
|
"body": { "$type": "fontFamily", "$value": "Space Grotesk", "$description": "var(--font-sans). All running text, data, numbers." }
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"xs": { "$type": "dimension", "$value": "12px" },
|
||||||
|
"sm": { "$type": "dimension", "$value": "14px" },
|
||||||
|
"base": { "$type": "dimension", "$value": "16px" },
|
||||||
|
"lg": { "$type": "dimension", "$value": "18px" },
|
||||||
|
"xl": { "$type": "dimension", "$value": "20px" },
|
||||||
|
"2xl": { "$type": "dimension", "$value": "24px" },
|
||||||
|
"3xl": { "$type": "dimension", "$value": "30px" },
|
||||||
|
"4xl": { "$type": "dimension", "$value": "36px" }
|
||||||
|
},
|
||||||
|
"weight": {
|
||||||
|
"normal": { "$type": "fontWeight", "$value": 400 },
|
||||||
|
"medium": { "$type": "fontWeight", "$value": 500 },
|
||||||
|
"semibold": { "$type": "fontWeight", "$value": 600 },
|
||||||
|
"bold": { "$type": "fontWeight", "$value": 700 }
|
||||||
|
},
|
||||||
|
"letterSpacing": {
|
||||||
|
"wider": { "$type": "dimension", "$value": "0.05em", "$description": "Tailwind tracking-wider — the signature pairing with UPPERCASE Bebas." }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"space": {
|
||||||
|
"$description": "Tailwind 4px scale; the values the as-built UI leans on.",
|
||||||
|
"1": { "$type": "dimension", "$value": "4px" },
|
||||||
|
"2": { "$type": "dimension", "$value": "8px" },
|
||||||
|
"3": { "$type": "dimension", "$value": "12px" },
|
||||||
|
"4": { "$type": "dimension", "$value": "16px" },
|
||||||
|
"5": { "$type": "dimension", "$value": "20px" },
|
||||||
|
"6": { "$type": "dimension", "$value": "24px" },
|
||||||
|
"8": { "$type": "dimension", "$value": "32px" }
|
||||||
|
},
|
||||||
|
"radius": {
|
||||||
|
"control": { "$type": "dimension", "$value": "4px", "$description": "Tailwind `rounded` — buttons, inputs, chips." },
|
||||||
|
"container": { "$type": "dimension", "$value": "8px", "$description": "Tailwind `rounded-lg` — cards, panels, modals, banners." },
|
||||||
|
"full": { "$type": "dimension", "$value": "9999px", "$description": "Pills, avatars, dots." }
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"sidebar-width": { "$type": "dimension", "$value": "240px", "$description": "CSS var --sidebar-width; desktop sidebar (md+)." },
|
||||||
|
"nav-height": { "$type": "dimension", "$value": "64px", "$description": "CSS var --nav-height." },
|
||||||
|
"bottom-nav-height": { "$type": "dimension", "$value": "64px", "$description": "CSS var --bottom-nav-height; mobile bottom nav." }
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"overlay": { "$type": "shadow", "$value": "0 10px 15px -3px rgba(0,0,0,0.5)", "$description": "Reserved for floating overlays ONLY (modals, popovers). Static cards use bg layering + borders, never a shadow. Value approximates Tailwind shadow-lg; the codebase also uses shadow-xl/2xl for overlays." }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Two generation kinds (`AIGeneration.kind`)
|
||||||
|
|
||||||
|
The runner spine is shared by two output shapes, discriminated by `AIGeneration.kind`
|
||||||
|
("program" | "workout", default "program"). The runner picks the parser by kind and
|
||||||
|
stores the JSON in the (reused) `parsedProgram` column.
|
||||||
|
|
||||||
|
- **program** (`kind: 'program'`) — `generate/route.ts` → `programSchema.ts`
|
||||||
|
(`PROGRAM_OUTPUT_SHAPE` / `parseAIProgram`). Applied to DB rows via `apply.ts`.
|
||||||
|
Shown in AI · History (which filters `kind: 'program'`).
|
||||||
|
- **workout** (`kind: 'workout'`) — `generate-workout/route.ts` (uses
|
||||||
|
`workoutPrompt.ts` + `workoutSchema.ts`: `WORKOUT_OUTPUT_SHAPE` / `parseAIWorkout`).
|
||||||
|
A single day's session. **No server-side apply**: the client (`GenerateWorkoutClient.tsx`)
|
||||||
|
stashes the reviewed suggestion in `sessionStorage` and routes to
|
||||||
|
`/main/workouts/new?from=ai`, where `AiWorkoutPrefill.tsx` expands it (via
|
||||||
|
`workoutDraft.ts::buildPrefillExercises`) and pre-fills the normal `WorkoutForm` —
|
||||||
|
nothing persists until the user saves through the regular workout path.
|
||||||
|
**Refine = a new workout generation** seeded with the prior suggestion JSON
|
||||||
|
(`priorWorkout` in the route body → REVISION mode in `workoutPrompt.ts`). These rows
|
||||||
|
are ephemeral, so they're excluded from the program-shaped AI · History.
|
||||||
|
- Adding a new kind: extend the union in `KickoffOpts`, add a parser + output-shape,
|
||||||
|
branch the parser selection in `generationRunner.ts`, and decide whether it belongs in
|
||||||
|
History (filtered by kind).
|
||||||
|
|
||||||
|
## Provider abstraction
|
||||||
|
|
||||||
|
- Each provider yields an async iterable of `GenerateChunk` (`text` / `usage` / `done` /
|
||||||
|
`error`); add new ones under `lib/ai/providers/` and register in `index.ts`.
|
||||||
|
`openai.ts` exports both `openai` and `openai-compatible`, so the five provider files
|
||||||
|
register **6** providers (`claude`, `openai`, `openai-compatible`, `gemini`, `ollama`,
|
||||||
|
`sparkcontrol`).
|
||||||
|
- **SparkControl** (`sparkcontrol.ts`) — the operator's own self-hosted local-inference
|
||||||
|
gateway. OpenAI-compatible wire format, so it reuses `generateOpenAIStyle` with
|
||||||
|
`{ requireApiKey: false }` (keyless on the LAN — the streamer omits the `Authorization`
|
||||||
|
header when no key is set). Reached over the **internal same-box StartOS address**
|
||||||
|
(`http://spark-control.startos:9999/v1`, plain HTTP — no TLS, no cert-skip). Custom base
|
||||||
|
URL ⇒ SSRF-guarded + admin-only, same as Ollama. The Settings UI auto-detects the loaded
|
||||||
|
vLLM model via `app/api/ai/sparkcontrol/model` (probes SparkControl's `/api/endpoints`
|
||||||
|
→ `vllm.model`), mirroring the Ollama `/api/tags` auto-detect. Free in the cost UI.
|
||||||
|
- **Base-URL hygiene:** only custom-URL providers (`requiresBaseUrl`: ollama,
|
||||||
|
openai-compatible, sparkcontrol) store a base URL. Both config write paths
|
||||||
|
(`configs` POST + `[id]` PATCH) null it for fixed-URL providers, and the Settings form
|
||||||
|
clears it on provider change — otherwise a stale URL silently rides along to
|
||||||
|
claude/openai/gemini, which ignore it and hit their hardcoded endpoints.
|
||||||
|
- 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).
|
||||||
|
- **Adding a provider** (precedent: `sparkcontrol`, 1.2.0:7) is a fan-out across ~8 spots —
|
||||||
|
miss one and it half-works: the provider file + `ProviderId` union (`types.ts`) + register
|
||||||
|
in `providers/index.ts` (`ALL` + `PROVIDER_ORDER`); the zod `provider` enum in **both**
|
||||||
|
`configs` POST and `[id]` PATCH (+ `defaultName` PRETTY map); the UI `PROVIDERS` list in
|
||||||
|
`AIIntegration.tsx` (`requiresKey`/`requiresUrl` must mirror the server `requiresApiKey`/
|
||||||
|
`requiresBaseUrl`); `MODEL_MENU` (`[]` if no curated menu) + an `estimateCost` branch
|
||||||
|
(free/null for self-hosted). A custom-URL provider is admin-only + SSRF-guarded everywhere
|
||||||
|
(configs POST/PATCH, `ai/test`, any probe route) and must appear in those routes' 403
|
||||||
|
enumeration strings. `ai/test` and `generate` work for free once it's in `getProvider`.
|
||||||
|
|
||||||
|
## Model-output robustness (esp. local models)
|
||||||
|
|
||||||
|
Local models (Qwen via SparkControl, Ollama) don't honor the JSON contract as tightly
|
||||||
|
as the cloud APIs, so the parse/apply path is deliberately tolerant. Two layers, both
|
||||||
|
added after the first SparkControl run surfaced the failures live:
|
||||||
|
|
||||||
|
- **Decimal integers** (1.2.0:8): models emit `"rpe": 7.5` / `"reps": 8.0` where the
|
||||||
|
schema expects ints. `looseInt(z.number().int()…)` (`programSchema.ts`, used by
|
||||||
|
`workoutSchema.ts`) rounds a number to the nearest int **before** the `.int()` check —
|
||||||
|
wrap every integer field in both schemas with it. Transform-before-validate, so inferred
|
||||||
|
types are unchanged. Without it, one stray decimal fails the ENTIRE parse.
|
||||||
|
- **Exercise→library name matching** (1.2.0:9): models return a good `exerciseName` with a
|
||||||
|
null or invented `exerciseId`. `lib/ai/exerciseMatch.ts` (`resolveExerciseIds`) normalizes
|
||||||
|
the name (lowercase, strip the `(barbell)`-style qualifier + punctuation) and auto-maps
|
||||||
|
only **unique confident** matches; ambiguous/unknown stay null so the UI flags them for
|
||||||
|
manual mapping. Wired into BOTH generate flows at the parse→display boundary
|
||||||
|
(`GenerateWorkoutClient`, `GenerateClient`) — re-resolve there if you add a third flow.
|
||||||
|
- **Latency characteristic (not a bug):** a thinking model (Qwen3.x) spends most of its
|
||||||
|
output tokens on internal reasoning, streamed as `reasoning_content` — which the OpenAI
|
||||||
|
streamer ignores (it reads only `delta.content`). So `tokensOut` can be ~10× the visible
|
||||||
|
JSON and a generation runs minutes (e.g. 7.4k out, 2.8k-char JSON, ~3 min on a DGX Spark
|
||||||
|
at ~41 tok/s). The lever is **disabling thinking on the vLLM/SparkControl side** (or via a
|
||||||
|
`chat_template_kwargs:{enable_thinking:false}` request param); left on by owner's choice.
|
||||||
|
|
||||||
|
## SSRF / provider-URL safety
|
||||||
|
|
||||||
|
- Any `fetch` to a user-supplied provider base URL MUST go through
|
||||||
|
`assertSafeProviderUrl` (`lib/ai/safeUrl.ts`) first — it enforces http(s) and blocks
|
||||||
|
link-local/cloud-metadata (169.254/16, fe80::/10) + unspecified. **Private-LAN +
|
||||||
|
loopback are allowed on purpose** (reaching `ollama.startos`/LAN gateways is the
|
||||||
|
feature). Currently wired into `providers/ollama.ts`, the `openai-compatible` path in
|
||||||
|
`providers/openai.ts` (NOT the fixed `api.openai.com` path), and the `ai/ollama/models`
|
||||||
|
probe. Add the guard to any new user-URL fetch path.
|
||||||
|
- Custom-URL providers (those with `requiresBaseUrl`: ollama, openai-compatible) are
|
||||||
|
**admin-only** — `isCustomUrlProvider` gates `ai/configs` POST + `[id]` PATCH + `ai/test`,
|
||||||
|
and `ai/ollama/models` is fully admin-only. The Settings UI hides them from non-admins.
|
||||||
|
This is a second defense layer on top of the IP block; keep both when adding routes.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Database
|
# Database
|
||||||
DATABASE_URL=file:./data/app.db
|
DATABASE_URL=file:./data/app.db
|
||||||
|
|
||||||
# API Keys
|
# AI provider API keys are NOT configured here — each user sets their own
|
||||||
CLAUDE_API_KEY=your_claude_api_key_here
|
# key per provider in the app (Settings → AI), stored in the database.
|
||||||
|
|||||||
+12
-3
@@ -11,16 +11,25 @@ npm install
|
|||||||
# Set up the database
|
# Set up the database
|
||||||
npx prisma db push
|
npx prisma db push
|
||||||
|
|
||||||
# Seed with exercises and default user
|
# Seed the InstanceSettings singleton
|
||||||
npm run db:seed
|
npm run db:seed
|
||||||
|
|
||||||
|
# Create the first admin (fresh installs ship with NO users — see below).
|
||||||
|
# Use a real-looking email; "admin@local" is rejected (no TLD).
|
||||||
|
npm run create-admin -- you@example.com yourpassword "Your Name"
|
||||||
|
|
||||||
# Start development server
|
# Start development server
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
Open [http://localhost:3000](http://localhost:3000) and log in with the
|
||||||
|
email/password you just created.
|
||||||
|
|
||||||
**Default login:** `admin@local` / `workout123`
|
**No default account.** Fresh installs ship with zero users on purpose, so there
|
||||||
|
are no default credentials to forget and leak. In production (StartOS) the
|
||||||
|
operator creates the first admin via the **Actions → Set admin credentials**
|
||||||
|
action; locally, `npm run create-admin` is the equivalent. Once an admin exists,
|
||||||
|
additional users sign up at `/auth/signup` (if sign-ups are enabled in Settings).
|
||||||
|
|
||||||
## Access from Other Devices
|
## Access from Other Devices
|
||||||
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
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,34 @@
|
|||||||
|
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,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const params = await context.params;
|
||||||
|
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,177 @@
|
|||||||
|
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';
|
||||||
|
import { isCustomUrlProvider } from '@/lib/ai/providers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const params = await context.params;
|
||||||
|
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,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const params = await context.params;
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// Admin-only custom-URL surface (see configs POST). Blocks a non-admin from
|
||||||
|
// setting a base URL, or editing a custom-URL provider config at all.
|
||||||
|
if (
|
||||||
|
!user.isAdmin &&
|
||||||
|
(parsed.data.baseUrl || isCustomUrlProvider(existing.provider))
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'Only an admin can configure providers with a custom base URL (Ollama, SparkControl, OpenAI-compatible).',
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
// Fixed-URL providers (claude/openai/gemini) ignore a base URL — never let an
|
||||||
|
// edit attach one (the footgun that produced a gemini config carrying a stale
|
||||||
|
// baseUrl). Provider can't change on PATCH, so `existing.provider` is authoritative.
|
||||||
|
if (parsed.data.baseUrl !== undefined)
|
||||||
|
data.baseUrl = isCustomUrlProvider(existing.provider)
|
||||||
|
? parsed.data.baseUrl || null
|
||||||
|
: 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,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const params = await context.params;
|
||||||
|
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,154 @@
|
|||||||
|
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';
|
||||||
|
import { isCustomUrlProvider } from '@/lib/ai/providers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
'sparkcontrol',
|
||||||
|
] 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;
|
||||||
|
|
||||||
|
// Custom-URL providers (Ollama, SparkControl, OpenAI-compatible) are
|
||||||
|
// admin-only — a non-admin pointing the server at an arbitrary URL is the
|
||||||
|
// SSRF actor vector. Fixed-URL cloud providers stay per-user.
|
||||||
|
if (!user.isAdmin && (baseUrl || isCustomUrlProvider(provider))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'Only an admin can configure providers with a custom base URL (Ollama, SparkControl, OpenAI-compatible).',
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only custom-URL providers (Ollama / OpenAI-compatible / SparkControl) carry
|
||||||
|
// a base URL. Fixed-URL providers (claude/openai/gemini) hit their hardcoded
|
||||||
|
// endpoint and ignore it — so we drop any stale baseUrl here rather than
|
||||||
|
// storing an impossible config (the footgun behind the gemini-with-a-baseURL
|
||||||
|
// mismatch). The UI also clears the field on provider change.
|
||||||
|
const normalizedBaseUrl = isCustomUrlProvider(provider) ? baseUrl || null : null;
|
||||||
|
|
||||||
|
const profile = await prisma.aIConfigProfile.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
name: name ?? defaultName(provider, model),
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
baseUrl: normalizedBaseUrl,
|
||||||
|
apiKey: apiKey || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (setActive) {
|
||||||
|
await activate(user.id, profile.id, {
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
baseUrl: normalizedBaseUrl,
|
||||||
|
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',
|
||||||
|
sparkcontrol: 'SparkControl',
|
||||||
|
};
|
||||||
|
const label = PRETTY[provider] ?? provider;
|
||||||
|
return `${label} · ${model}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { WORKOUT_OUTPUT_SHAPE, aiWorkoutSchema } from '@/lib/ai/workoutSchema';
|
||||||
|
import {
|
||||||
|
buildHistorySummary,
|
||||||
|
formatHistoryContext,
|
||||||
|
} from '@/lib/ai/historyContext';
|
||||||
|
import { buildWorkoutSystemPrompt } from '@/lib/ai/workoutPrompt';
|
||||||
|
import { kickoffGeneration } from '@/lib/ai/generationRunner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/generate-workout
|
||||||
|
*
|
||||||
|
* Kicks off a background runner (kind="workout") that streams a single
|
||||||
|
* day's workout suggestion, and returns the generation id. The caller
|
||||||
|
* subscribes via GET /api/ai/generations/[id]/stream (SSE) — the same
|
||||||
|
* spine as program generation.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* { userInput: string, includeHistory?: boolean, priorWorkout?: AIWorkout }
|
||||||
|
*
|
||||||
|
* `priorWorkout` switches the prompt into REVISION mode: userInput is the
|
||||||
|
* change instruction and the model re-emits the full revised workout.
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* 201 { id: "...generationId..." }
|
||||||
|
* 400 { error: "..." }
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
userInput: z.string().min(1),
|
||||||
|
includeHistory: z.boolean().optional().default(false),
|
||||||
|
// The current suggestion, when refining. Validated against the same
|
||||||
|
// shape the model emits so we only ever feed it well-formed JSON.
|
||||||
|
priorWorkout: aiWorkoutSchema.optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weightUnit = (prefs.defaultWeightUnit as 'lbs' | 'kg') || 'lbs';
|
||||||
|
|
||||||
|
// Library for the prompt. We include each exercise's effective logging
|
||||||
|
// unit (`defaultWeightUnit || "lbs"` — the exact unit the saved workout
|
||||||
|
// will store, see WorkoutForm.buildPayload) so the model suggests the
|
||||||
|
// weight NUMBER in that unit. Without this the model would assume the
|
||||||
|
// user's global preferred unit, which diverges for per-exercise unit
|
||||||
|
// overrides (e.g. kettlebells in kg) and silently mislabels the weight.
|
||||||
|
const exercises = await prisma.exercise.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
muscleGroups: true,
|
||||||
|
defaultWeightUnit: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const libraryJson = JSON.stringify(
|
||||||
|
exercises.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
name: e.name,
|
||||||
|
type: e.type,
|
||||||
|
unit: e.defaultWeightUnit || 'lbs',
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocalModel = prefs.aiProvider === 'ollama';
|
||||||
|
const priorWorkoutJson = parsed.data.priorWorkout
|
||||||
|
? JSON.stringify(parsed.data.priorWorkout)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const basePrompt = buildWorkoutSystemPrompt({
|
||||||
|
weightUnit,
|
||||||
|
hasHistoryContext: parsed.data.includeHistory,
|
||||||
|
isLocalModel,
|
||||||
|
priorWorkoutJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
const systemPrompt = `${basePrompt}
|
||||||
|
|
||||||
|
# OUTPUT SHAPE
|
||||||
|
|
||||||
|
${WORKOUT_OUTPUT_SHAPE}
|
||||||
|
|
||||||
|
# LIBRARY (use these exerciseIds; do not invent ids)
|
||||||
|
|
||||||
|
${libraryJson}${historyBlock}`;
|
||||||
|
|
||||||
|
const id = await kickoffGeneration({
|
||||||
|
prisma,
|
||||||
|
userId: user.id,
|
||||||
|
kind: 'workout',
|
||||||
|
templateId: null,
|
||||||
|
templateName: priorWorkoutJson ? 'Workout (refine)' : 'Workout',
|
||||||
|
userInput: parsed.data.userInput,
|
||||||
|
systemPrompt,
|
||||||
|
userPrompt: parsed.data.userInput,
|
||||||
|
provider: prefs.aiProvider,
|
||||||
|
model: prefs.aiModel,
|
||||||
|
apiKey: prefs.aiApiKey,
|
||||||
|
baseUrl: prefs.aiBaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ id }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -1,38 +1,37 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getCurrentUser } from '@/lib/auth';
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { getProvider } from '@/lib/ai/providers';
|
|
||||||
import {
|
import {
|
||||||
PROGRAM_OUTPUT_SHAPE,
|
PROGRAM_OUTPUT_SHAPE,
|
||||||
parseAIProgram,
|
|
||||||
} from '@/lib/ai/programSchema';
|
} 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
|
* POST /api/ai/generate
|
||||||
*
|
*
|
||||||
* Body: { templateId?: string, userInput: string }
|
* Body: { templateId?: string, userInput: string, includeHistory?: boolean }
|
||||||
*
|
*
|
||||||
* Streams the model response as Server-Sent Events:
|
* v1.1.0:4: this endpoint now KICKS OFF a background runner and returns
|
||||||
* event: generation data: {"id":"...generationId..."}
|
* the new generation id immediately. The caller subscribes to live
|
||||||
* event: text data: {"delta":"..."}
|
* deltas via GET /api/ai/generations/[id]/stream (SSE) or polls via
|
||||||
* event: usage data: {"tokensIn":N,"tokensOut":M}
|
* GET /api/ai/generations/[id]. Navigating away no longer cancels the
|
||||||
* event: complete data: {"parsedOk":true|false,"errorMessage":"..."}
|
* generation — the runner keeps writing to the row in the background.
|
||||||
*
|
*
|
||||||
* Reads the user's AI provider config from UserPreferences. The full
|
* Response:
|
||||||
* library of exercises is appended to the system prompt so the model
|
* 201 { id: "...generationId..." }
|
||||||
* picks real exercise IDs.
|
* 400 { error: "..." }
|
||||||
*
|
|
||||||
* On error (no provider configured, model error, etc.) emits a single
|
|
||||||
* `event: error` and closes.
|
|
||||||
*
|
|
||||||
* Always writes one AIGeneration row, regardless of success — so the
|
|
||||||
* History page can show failed attempts too.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
templateId: z.string().optional().nullable(),
|
templateId: z.string().optional().nullable(),
|
||||||
userInput: z.string().min(1),
|
userInput: z.string().min(1),
|
||||||
|
includeHistory: z.boolean().optional().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -40,53 +39,34 @@ export const dynamic = 'force-dynamic';
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
status: 401,
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const parsed = bodySchema.safeParse(body);
|
const parsed = bodySchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return new Response(
|
return NextResponse.json(
|
||||||
JSON.stringify({
|
{ error: 'Invalid body', details: parsed.error.errors },
|
||||||
error: 'Invalid body',
|
{ status: 400 },
|
||||||
details: parsed.error.errors,
|
|
||||||
}),
|
|
||||||
{ status: 400, headers: { 'content-type': 'application/json' } },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the user's AI provider config.
|
|
||||||
const prefs = await prisma.userPreferences.findUnique({
|
const prefs = await prisma.userPreferences.findUnique({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
});
|
});
|
||||||
if (!prefs?.aiProvider || !prefs?.aiModel) {
|
if (!prefs?.aiProvider || !prefs?.aiModel) {
|
||||||
return new Response(
|
return NextResponse.json(
|
||||||
JSON.stringify({
|
{
|
||||||
error:
|
error:
|
||||||
'AI is not configured. Open Settings → AI integration and pick a provider + model.',
|
'AI is not configured. Open Settings → AI integration and pick a provider + model.',
|
||||||
}),
|
},
|
||||||
{ status: 400, headers: { 'content-type': 'application/json' } },
|
{ status: 400 },
|
||||||
);
|
|
||||||
}
|
|
||||||
const provider = getProvider(prefs.aiProvider);
|
|
||||||
if (!provider) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: `Unknown provider: ${prefs.aiProvider}` }),
|
|
||||||
{ status: 400, headers: { 'content-type': 'application/json' } },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the template if provided, else use a no-op default.
|
// Load the template if provided.
|
||||||
let template:
|
let template:
|
||||||
| {
|
| { id: string; name: string; systemPrompt: string; userPromptTemplate: string }
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
systemPrompt: string;
|
|
||||||
userPromptTemplate: string;
|
|
||||||
}
|
|
||||||
| null = null;
|
| null = null;
|
||||||
if (parsed.data.templateId) {
|
if (parsed.data.templateId) {
|
||||||
const t = await prisma.aIPromptTemplate.findFirst({
|
const t = await prisma.aIPromptTemplate.findFirst({
|
||||||
@@ -102,23 +82,15 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!t) {
|
if (!t) {
|
||||||
return new Response(
|
return NextResponse.json({ error: 'Template not found.' }, { status: 404 });
|
||||||
JSON.stringify({ error: 'Template not found.' }),
|
|
||||||
{ status: 404, headers: { 'content-type': 'application/json' } },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
template = t;
|
template = t;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the user's exercise library to embed in the system prompt.
|
// Library for the prompt.
|
||||||
const exercises = await prisma.exercise.findMany({
|
const exercises = await prisma.exercise.findMany({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
select: {
|
select: { id: true, name: true, type: true, muscleGroups: true },
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
type: true,
|
|
||||||
muscleGroups: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const libraryJson = JSON.stringify(
|
const libraryJson = JSON.stringify(
|
||||||
exercises.map((e) => ({
|
exercises.map((e) => ({
|
||||||
@@ -135,131 +107,59 @@ export async function POST(request: NextRequest) {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stitch the final system + user prompts.
|
// History context if requested.
|
||||||
const baseSystem = template?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
let historyBlock = '';
|
||||||
const systemPrompt = `${baseSystem}
|
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
|
||||||
|
|
||||||
OUTPUT SHAPE — emit ONLY a JSON object matching this shape (no commentary, no markdown fences):
|
|
||||||
${PROGRAM_OUTPUT_SHAPE}
|
${PROGRAM_OUTPUT_SHAPE}
|
||||||
|
|
||||||
LIBRARY — pick exerciseId values from this list when possible. If you need an exercise the user doesn't have, set exerciseId to null and put the proposed name in exerciseName; the user will resolve it during preview.
|
# LIBRARY (use these exerciseIds; do not invent ids)
|
||||||
${libraryJson}`;
|
|
||||||
|
${libraryJson}${historyBlock}`;
|
||||||
|
|
||||||
const userPromptBody =
|
const userPromptBody =
|
||||||
template?.userPromptTemplate.replace(/{{userInput}}/g, parsed.data.userInput) ??
|
template?.userPromptTemplate.replace(/{{userInput}}/g, parsed.data.userInput) ??
|
||||||
parsed.data.userInput;
|
parsed.data.userInput;
|
||||||
|
|
||||||
// Persist the pending row up front so the user can see it in
|
const id = await kickoffGeneration({
|
||||||
// history even if the stream dies mid-flight.
|
prisma,
|
||||||
const generation = await prisma.aIGeneration.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
kind: 'program',
|
||||||
templateId: template?.id ?? null,
|
templateId: template?.id ?? null,
|
||||||
templateName: template?.name ?? null,
|
templateName: template?.name ?? null,
|
||||||
userInput: parsed.data.userInput,
|
userInput: parsed.data.userInput,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
userPrompt: userPromptBody,
|
userPrompt: userPromptBody,
|
||||||
provider: provider.id,
|
provider: prefs.aiProvider,
|
||||||
model: prefs.aiModel,
|
model: prefs.aiModel,
|
||||||
status: 'pending',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stream the model output as SSE.
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const stream = new ReadableStream<Uint8Array>({
|
|
||||||
async start(controller) {
|
|
||||||
const send = (event: string, data: unknown) =>
|
|
||||||
controller.enqueue(
|
|
||||||
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
|
|
||||||
);
|
|
||||||
send('generation', { id: generation.id });
|
|
||||||
|
|
||||||
let raw = '';
|
|
||||||
let tokensIn: number | undefined;
|
|
||||||
let tokensOut: number | undefined;
|
|
||||||
let providerError: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const chunk of provider.generate({
|
|
||||||
apiKey: prefs.aiApiKey,
|
apiKey: prefs.aiApiKey,
|
||||||
baseUrl: prefs.aiBaseUrl,
|
baseUrl: prefs.aiBaseUrl,
|
||||||
model: prefs.aiModel!, // validated non-null at top of POST
|
|
||||||
systemPrompt,
|
|
||||||
userPrompt: userPromptBody,
|
|
||||||
signal: request.signal,
|
|
||||||
})) {
|
|
||||||
if (chunk.type === 'text') {
|
|
||||||
raw += chunk.delta;
|
|
||||||
send('text', { delta: chunk.delta });
|
|
||||||
} else if (chunk.type === 'usage') {
|
|
||||||
tokensIn = chunk.tokensIn;
|
|
||||||
tokensOut = chunk.tokensOut;
|
|
||||||
} else if (chunk.type === 'error') {
|
|
||||||
providerError = chunk.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
providerError = (e as Error).message;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse + validate the assembled response.
|
|
||||||
let parsedOk = false;
|
|
||||||
let parseErr: string | null = null;
|
|
||||||
let parsedJson: string | null = null;
|
|
||||||
if (!providerError && raw) {
|
|
||||||
const r = parseAIProgram(raw);
|
|
||||||
if (r.ok) {
|
|
||||||
parsedOk = true;
|
|
||||||
parsedJson = JSON.stringify(r.program);
|
|
||||||
} else {
|
|
||||||
parseErr = r.reason;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist the final state.
|
|
||||||
const status = providerError
|
|
||||||
? 'failed'
|
|
||||||
: parsedOk
|
|
||||||
? 'completed'
|
|
||||||
: 'failed';
|
|
||||||
const errorMessage =
|
|
||||||
providerError ?? (parsedOk ? null : parseErr ?? 'Empty response');
|
|
||||||
await prisma.aIGeneration.update({
|
|
||||||
where: { id: generation.id },
|
|
||||||
data: {
|
|
||||||
rawResponse: raw || null,
|
|
||||||
parsedProgram: parsedJson,
|
|
||||||
tokensIn: tokensIn ?? null,
|
|
||||||
tokensOut: tokensOut ?? null,
|
|
||||||
status,
|
|
||||||
errorMessage,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
send('usage', { tokensIn, tokensOut });
|
return NextResponse.json({ id }, { status: 201 });
|
||||||
send('complete', { parsedOk, errorMessage });
|
|
||||||
controller.close();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(stream, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'content-type': 'text/event-stream',
|
|
||||||
'cache-control': 'no-store',
|
|
||||||
'x-accel-buffering': 'no', // disable nginx buffering if proxied
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SYSTEM_PROMPT = `You are a strength and conditioning coach. The user will describe what they want; you produce a complete training program as JSON.
|
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).`;
|
||||||
|
|
||||||
Constraints:
|
|
||||||
- Pick exercises from the LIBRARY below by their id. Prefer compound lifts for primary slots and accessories for the back half of each session.
|
|
||||||
- Keep volume reasonable: 4-7 exercises per session, 60-75 minutes total.
|
|
||||||
- Use rep ranges that match the goal: hypertrophy 6-12, strength 3-6, power 1-5.
|
|
||||||
- For each exercise specify sets + reps (range or single) + rest in seconds. RPE is optional but useful for intensity-based programs.
|
|
||||||
- If the user asks for something a single library exercise can't satisfy, pick the closest fit and add a coaching note explaining the variation.
|
|
||||||
|
|
||||||
If you cannot produce a complete program for any reason, emit a JSON object with the durationWeeks and weeks arrays best-effort and add a top-level "description" explaining the gap.`;
|
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import { prisma } from '@/lib/prisma';
|
|||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: { id: string } },
|
context: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
@@ -29,8 +30,9 @@ export async function GET(
|
|||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: { id: string } },
|
context: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const params = await context.params;
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -16,7 +16,8 @@ export async function GET(request: NextRequest) {
|
|||||||
const offset = Math.max(parseInt(sp.get('offset') || '0'), 0);
|
const offset = Math.max(parseInt(sp.get('offset') || '0'), 0);
|
||||||
|
|
||||||
const rows = await prisma.aIGeneration.findMany({
|
const rows = await prisma.aIGeneration.findMany({
|
||||||
where: { userId: user.id },
|
// Program history only; workout-kind rows are ephemeral (see history page).
|
||||||
|
where: { userId: user.id, kind: 'program' },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: limit + 1,
|
take: limit + 1,
|
||||||
skip: offset,
|
skip: offset,
|
||||||
@@ -28,6 +29,7 @@ export async function GET(request: NextRequest) {
|
|||||||
model: true,
|
model: true,
|
||||||
tokensIn: true,
|
tokensIn: true,
|
||||||
tokensOut: true,
|
tokensOut: true,
|
||||||
|
durationMs: true,
|
||||||
status: true,
|
status: true,
|
||||||
errorMessage: true,
|
errorMessage: true,
|
||||||
appliedProgramId: true,
|
appliedProgramId: true,
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { assertSafeProviderUrl } from '@/lib/ai/safeUrl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 });
|
||||||
|
// Probing Ollama URLs is the admin-only custom-URL surface (EVALUATION.md
|
||||||
|
// P1) — a non-admin shouldn't be able to fingerprint the local network.
|
||||||
|
if (!user.isAdmin)
|
||||||
|
return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 });
|
||||||
|
|
||||||
|
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 url = baseUrl.replace(/\/$/, '') + '/api/tags';
|
||||||
|
try {
|
||||||
|
await assertSafeProviderUrl(url);
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
baseUrl,
|
||||||
|
error: (e as Error).message,
|
||||||
|
ms: Date.now() - t0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
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,109 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { assertSafeProviderUrl } from '@/lib/ai/safeUrl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ai/sparkcontrol/model?baseUrl=...
|
||||||
|
*
|
||||||
|
* Probes SparkControl's service-discovery endpoint (`/api/endpoints`) and
|
||||||
|
* returns the model vLLM currently has loaded, so the Settings UI can
|
||||||
|
* auto-fill the model field (the same role the Ollama /api/tags probe plays).
|
||||||
|
* When no baseUrl is given it walks the canonical same-box StartOS addresses
|
||||||
|
* and, on a hit, hands back a `baseUrl` (with the `/v1` chat suffix) the UI
|
||||||
|
* can pre-fill.
|
||||||
|
*
|
||||||
|
* `/api/endpoints` lives at the host root, NOT under `/v1` — so we strip a
|
||||||
|
* trailing `/v1` off the configured chat base URL before probing.
|
||||||
|
*
|
||||||
|
* Authenticated + admin-only: pointing the server at an arbitrary URL is the
|
||||||
|
* SSRF surface (same gate as the Ollama probe), and a non-admin shouldn't be
|
||||||
|
* able to fingerprint the local network.
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* { ok: true, baseUrl, model: string | null, ready: boolean | null, ms }
|
||||||
|
* { ok: false, baseUrl, error, ms }
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PROBE_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
// Canonical same-box addresses (StartOS 0.4 `.startos`, legacy 0.3 `.embassy`),
|
||||||
|
// including the `/v1` chat suffix the config field expects.
|
||||||
|
const DEFAULT_CANDIDATES = [
|
||||||
|
'http://spark-control.startos:9999/v1',
|
||||||
|
'http://spark-control.embassy:9999/v1',
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
|
||||||
|
// Custom-URL surface ⇒ admin-only (same as the Ollama probe).
|
||||||
|
if (!user.isAdmin)
|
||||||
|
return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 });
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const explicit = url.searchParams.get('baseUrl');
|
||||||
|
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, surface the failure right away.
|
||||||
|
if (explicit) return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: false,
|
||||||
|
baseUrl: candidates[0],
|
||||||
|
error: 'No SparkControl instance responded at the default StartOS addresses.',
|
||||||
|
ms: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EndpointsResponse {
|
||||||
|
vllm?: { ready?: boolean; model?: string | null; disabled?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(baseUrl: string) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
let url: string;
|
||||||
|
try {
|
||||||
|
// `/api/endpoints` lives at the host root, independent of the `/v1` chat
|
||||||
|
// path — so probe the origin, not a string-derived prefix. (new URL also
|
||||||
|
// throws on a malformed baseUrl, which this catch turns into ok:false.)
|
||||||
|
url = new URL(baseUrl).origin + '/api/endpoints';
|
||||||
|
await assertSafeProviderUrl(url);
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false as const, baseUrl, error: (e as Error).message, ms: Date.now() - t0 };
|
||||||
|
}
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { signal: ctrl.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!res.ok) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
baseUrl,
|
||||||
|
error: `SparkControl returned HTTP ${res.status}`,
|
||||||
|
ms: Date.now() - t0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as EndpointsResponse;
|
||||||
|
return {
|
||||||
|
ok: true as const,
|
||||||
|
baseUrl,
|
||||||
|
model: body.vllm?.model ?? null,
|
||||||
|
ready: body.vllm?.ready ?? 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,8 +48,9 @@ async function loadAndCheck(
|
|||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } },
|
context: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user)
|
if (!user)
|
||||||
@@ -82,8 +83,9 @@ export async function PATCH(
|
|||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: { id: string } },
|
context: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user)
|
if (!user)
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getProvider, isCustomUrlProvider } 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Testing an arbitrary base URL is the same SSRF surface as configuring
|
||||||
|
// one — admin-only. Non-admins may only test fixed-URL cloud providers.
|
||||||
|
if (!user.isAdmin && (baseUrl || isCustomUrlProvider(provider))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
'Only an admin can test providers with a custom base URL (Ollama, SparkControl, OpenAI-compatible).',
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { verifyPassword, createSession } from '@/lib/auth';
|
import { verifyPasswordOrDummy, createSession } from '@/lib/auth';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { readJsonBody } from '@/lib/http';
|
||||||
|
import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit';
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -10,7 +12,20 @@ const loginSchema = z.object({
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
// Per-IP cap, sharing the `login:${ip}` bucket with the UI login
|
||||||
|
// server action (app/auth/login/actions.ts): 10 attempts / 15 min.
|
||||||
|
// Without this the raw API endpoint is an uncapped credential-stuffing
|
||||||
|
// surface that bypasses the server-action's limiter.
|
||||||
|
const ip = clientIpFromHeaders(request.headers);
|
||||||
|
const limited = rateLimit(`login:${ip}`, { limit: 10, windowMs: 15 * 60_000 });
|
||||||
|
if (!limited.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Too many login attempts. Try again in ${limited.retryAfterSec}s.` },
|
||||||
|
{ status: 429, headers: { 'Retry-After': String(limited.retryAfterSec) } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
const { email, password } = loginSchema.parse(body);
|
const { email, password } = loginSchema.parse(body);
|
||||||
|
|
||||||
// Look up user by email
|
// Look up user by email
|
||||||
@@ -18,17 +33,14 @@ export async function POST(request: NextRequest) {
|
|||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
// Always run a bcrypt compare (against a dummy hash when the email is
|
||||||
return NextResponse.json(
|
// unknown) so response time doesn't reveal whether an account exists.
|
||||||
{ error: 'Invalid email or password' },
|
const isValid = await verifyPasswordOrDummy(
|
||||||
{ status: 401 }
|
password,
|
||||||
|
user?.passwordHash ?? null,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the password
|
if (!user || !isValid) {
|
||||||
const isValid = await verifyPassword(password, user.passwordHash);
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid email or password' },
|
{ error: 'Invalid email or password' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { readJsonBody } from "@/lib/http";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -21,8 +22,9 @@ import { z } from "zod";
|
|||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
context: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -99,8 +101,9 @@ const updateExerciseSchema = z.object({
|
|||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
context: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -118,7 +121,7 @@ export async function PATCH(
|
|||||||
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
|
return NextResponse.json({ error: "Exercise not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await readJsonBody(request);
|
||||||
const validated = updateExerciseSchema.parse(body);
|
const validated = updateExerciseSchema.parse(body);
|
||||||
|
|
||||||
const data: any = {};
|
const data: any = {};
|
||||||
@@ -165,8 +168,9 @@ export async function PATCH(
|
|||||||
*/
|
*/
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
context: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { getExercises, createExercise } from "@/lib/db/exercises";
|
import { getExercises, createExercise } from "@/lib/db/exercises";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { readJsonBody } from "@/lib/http";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await readJsonBody(request);
|
||||||
const validated = CreateExerciseSchema.parse(body);
|
const validated = CreateExerciseSchema.parse(body);
|
||||||
|
|
||||||
const existing = await prisma.exercise.findUnique({
|
const existing = await prisma.exercise.findUnique({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { readJsonBody } from "@/lib/http";
|
||||||
|
|
||||||
const SeedExerciseSchema = z.object({
|
const SeedExerciseSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@@ -26,7 +27,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await readJsonBody(request);
|
||||||
const parsed = SeedPayloadSchema.parse(body);
|
const parsed = SeedPayloadSchema.parse(body);
|
||||||
|
|
||||||
const existingExercises = await prisma.exercise.findMany({
|
const existingExercises = await prisma.exercise.findMany({
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ interface ParsedSet {
|
|||||||
distance?: number;
|
distance?: number;
|
||||||
distanceUnit?: string;
|
distanceUnit?: string;
|
||||||
calories?: number;
|
calories?: number;
|
||||||
|
watts?: number;
|
||||||
rpe?: number;
|
rpe?: number;
|
||||||
|
gear?: number;
|
||||||
customMetrics?: Record<string, string>;
|
customMetrics?: Record<string, string>;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
@@ -136,7 +138,9 @@ export async function POST(request: NextRequest) {
|
|||||||
"distance_unit",
|
"distance_unit",
|
||||||
"distanceunit",
|
"distanceunit",
|
||||||
"calories",
|
"calories",
|
||||||
|
"watts",
|
||||||
"rpe",
|
"rpe",
|
||||||
|
"gear",
|
||||||
"notes",
|
"notes",
|
||||||
"custom_metrics_json",
|
"custom_metrics_json",
|
||||||
"custommetricsjson",
|
"custommetricsjson",
|
||||||
@@ -199,7 +203,9 @@ export async function POST(request: NextRequest) {
|
|||||||
const distance = parseFloatMaybe(row.distance);
|
const distance = parseFloatMaybe(row.distance);
|
||||||
const distanceUnit = row.distance_unit || row.distanceunit || (distance !== undefined ? "mi" : undefined);
|
const distanceUnit = row.distance_unit || row.distanceunit || (distance !== undefined ? "mi" : undefined);
|
||||||
const calories = parseIntMaybe(row.calories);
|
const calories = parseIntMaybe(row.calories);
|
||||||
|
const watts = parseIntMaybe(row.watts);
|
||||||
const rpe = parseIntMaybe(row.rpe);
|
const rpe = parseIntMaybe(row.rpe);
|
||||||
|
const gear = parseIntMaybe(row.gear);
|
||||||
|
|
||||||
const customMetrics: Record<string, string> = {};
|
const customMetrics: Record<string, string> = {};
|
||||||
const customJson = row.custom_metrics_json || row.custommetricsjson;
|
const customJson = row.custom_metrics_json || row.custommetricsjson;
|
||||||
@@ -253,7 +259,9 @@ export async function POST(request: NextRequest) {
|
|||||||
distance,
|
distance,
|
||||||
distanceUnit,
|
distanceUnit,
|
||||||
calories,
|
calories,
|
||||||
|
watts,
|
||||||
rpe,
|
rpe,
|
||||||
|
gear,
|
||||||
customMetrics: Object.keys(customMetrics).length > 0 ? customMetrics : undefined,
|
customMetrics: Object.keys(customMetrics).length > 0 ? customMetrics : undefined,
|
||||||
notes: notes || undefined,
|
notes: notes || undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,10 +51,12 @@ const setLogImport = z.object({
|
|||||||
weight: z.number().nullable().optional(),
|
weight: z.number().nullable().optional(),
|
||||||
weightUnit: z.string().optional(),
|
weightUnit: z.string().optional(),
|
||||||
rpe: z.number().int().nullable().optional(),
|
rpe: z.number().int().nullable().optional(),
|
||||||
|
gear: z.number().int().nullable().optional(),
|
||||||
durationSeconds: z.number().int().nullable().optional(),
|
durationSeconds: z.number().int().nullable().optional(),
|
||||||
distance: z.number().nullable().optional(),
|
distance: z.number().nullable().optional(),
|
||||||
distanceUnit: z.string().nullable().optional(),
|
distanceUnit: z.string().nullable().optional(),
|
||||||
calories: z.number().int().nullable().optional(),
|
calories: z.number().int().nullable().optional(),
|
||||||
|
watts: z.number().int().nullable().optional(),
|
||||||
customMetrics: z.string().nullable().optional(),
|
customMetrics: z.string().nullable().optional(),
|
||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
// The exported set carries an exerciseId pointing into the export's
|
// The exported set carries an exerciseId pointing into the export's
|
||||||
@@ -92,7 +94,15 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
// This route uses safeParse (not an `instanceof z.ZodError` catch), so a
|
||||||
|
// malformed body would otherwise reach the generic catch as a 500. Guard
|
||||||
|
// it explicitly — matches the pattern in app/api/admin/signups/route.ts.
|
||||||
|
let body: any;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
const parsed = requestBody.safeParse(body);
|
const parsed = requestBody.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -193,10 +203,12 @@ export async function POST(request: NextRequest) {
|
|||||||
weight: s.weight ?? null,
|
weight: s.weight ?? null,
|
||||||
weightUnit: s.weightUnit ?? 'lbs',
|
weightUnit: s.weightUnit ?? 'lbs',
|
||||||
rpe: s.rpe ?? null,
|
rpe: s.rpe ?? null,
|
||||||
|
gear: s.gear ?? null,
|
||||||
durationSeconds: s.durationSeconds ?? null,
|
durationSeconds: s.durationSeconds ?? null,
|
||||||
distance: s.distance ?? null,
|
distance: s.distance ?? null,
|
||||||
distanceUnit: s.distanceUnit ?? null,
|
distanceUnit: s.distanceUnit ?? null,
|
||||||
calories: s.calories ?? null,
|
calories: s.calories ?? null,
|
||||||
|
watts: s.watts ?? null,
|
||||||
customMetrics: s.customMetrics ?? null,
|
customMetrics: s.customMetrics ?? null,
|
||||||
notes: s.notes ?? null,
|
notes: s.notes ?? null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { readJsonBody } from "@/lib/http";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await readJsonBody(request);
|
||||||
const validated = PreferencesSchema.parse(body);
|
const validated = PreferencesSchema.parse(body);
|
||||||
|
|
||||||
let preferences = await prisma.userPreferences.findUnique({
|
let preferences = await prisma.userPreferences.findUnique({
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ const bodySchema = z.object({
|
|||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string; dayId: string } },
|
context: { params: Promise<{ id: string; dayId: string }> },
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
@@ -57,21 +58,35 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Build SetLog rows: for each planned exercise, pre-create N
|
||||||
// empty sets where N = exercise.sets ?? 1. The user fills in
|
// empty sets where N = exercise.sets ?? 1. The user fills in
|
||||||
// reps/weight when they actually do them.
|
// 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: {
|
const setLogsCreate: {
|
||||||
exerciseId: string;
|
exerciseId: string;
|
||||||
setNumber: number;
|
setNumber: number;
|
||||||
|
weight: number | null;
|
||||||
weightUnit: string;
|
weightUnit: string;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
for (const ex of day.exercises) {
|
for (const ex of day.exercises) {
|
||||||
const setCount = ex.sets ?? 1;
|
const setCount = ex.sets ?? 1;
|
||||||
|
const unit =
|
||||||
|
ex.suggestedWeightUnit ?? ex.exercise.defaultWeightUnit ?? userPrefUnit;
|
||||||
for (let n = 1; n <= setCount; n++) {
|
for (let n = 1; n <= setCount; n++) {
|
||||||
setLogsCreate.push({
|
setLogsCreate.push({
|
||||||
exerciseId: ex.exerciseId,
|
exerciseId: ex.exerciseId,
|
||||||
setNumber: n,
|
setNumber: n,
|
||||||
weightUnit: ex.exercise.defaultWeightUnit ?? "lbs",
|
weight: ex.suggestedWeight ?? null,
|
||||||
|
weightUnit: unit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { z } from "zod";
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { readJsonBody } from "@/lib/http";
|
||||||
|
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
|
||||||
import { getProgramById } from "@/lib/db/programs";
|
import { getProgramById } from "@/lib/db/programs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,8 +55,9 @@ const patchSchema = z.object({
|
|||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: { id: string } },
|
context: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
const program = await getProgramById(user.id, params.id);
|
const program = await getProgramById(user.id, params.id);
|
||||||
@@ -66,8 +69,9 @@ export async function GET(
|
|||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } },
|
context: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
@@ -80,22 +84,18 @@ export async function PATCH(
|
|||||||
return NextResponse.json({ error: "Program not found" }, { status: 404 });
|
return NextResponse.json({ error: "Program not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await readJsonBody(request);
|
||||||
const validated = patchSchema.parse(body);
|
const validated = patchSchema.parse(body);
|
||||||
|
|
||||||
// If replacing the tree, verify exercise ownership.
|
// If replacing the tree, verify exercise ownership
|
||||||
|
// (see lib/exerciseOwnership).
|
||||||
if (validated.weeks) {
|
if (validated.weeks) {
|
||||||
const allExerciseIds = new Set<string>();
|
const allExerciseIds = new Set<string>();
|
||||||
for (const w of validated.weeks)
|
for (const w of validated.weeks)
|
||||||
for (const d of w.days)
|
for (const d of w.days)
|
||||||
for (const ex of d.exercises) allExerciseIds.add(ex.exerciseId);
|
for (const ex of d.exercises) allExerciseIds.add(ex.exerciseId);
|
||||||
if (allExerciseIds.size > 0) {
|
|
||||||
const owned = await prisma.exercise.findMany({
|
const bad = await findUnownedExerciseIds(user.id, allExerciseIds);
|
||||||
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) {
|
if (bad.length > 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Some exerciseIds don't exist in your library", details: bad },
|
{ error: "Some exerciseIds don't exist in your library", details: bad },
|
||||||
@@ -103,7 +103,6 @@ export async function PATCH(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const programData: Prisma.ProgramUpdateInput = {};
|
const programData: Prisma.ProgramUpdateInput = {};
|
||||||
if (validated.name !== undefined) programData.name = validated.name;
|
if (validated.name !== undefined) programData.name = validated.name;
|
||||||
@@ -182,8 +181,9 @@ export async function PATCH(
|
|||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: { id: string } },
|
context: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { z } from "zod";
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { readJsonBody } from "@/lib/http";
|
||||||
|
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
|
||||||
import { getPrograms } from "@/lib/db/programs";
|
import { getPrograms } from "@/lib/db/programs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,29 +63,23 @@ export async function POST(request: NextRequest) {
|
|||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await readJsonBody(request);
|
||||||
const validated = createProgramSchema.parse(body);
|
const validated = createProgramSchema.parse(body);
|
||||||
|
|
||||||
// Verify any referenced exerciseIds belong to this user.
|
// Verify any referenced exerciseIds belong to this user
|
||||||
|
// (see lib/exerciseOwnership).
|
||||||
const allExerciseIds = new Set<string>();
|
const allExerciseIds = new Set<string>();
|
||||||
for (const w of validated.weeks)
|
for (const w of validated.weeks)
|
||||||
for (const d of w.days)
|
for (const d of w.days)
|
||||||
for (const ex of d.exercises) allExerciseIds.add(ex.exerciseId);
|
for (const ex of d.exercises) allExerciseIds.add(ex.exerciseId);
|
||||||
|
|
||||||
if (allExerciseIds.size > 0) {
|
const bad = await findUnownedExerciseIds(user.id, allExerciseIds);
|
||||||
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) {
|
if (bad.length > 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Some exerciseIds don't exist in your library", details: bad },
|
{ error: "Some exerciseIds don't exist in your library", details: bad },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const program = await prisma.$transaction(async (tx) => {
|
const program = await prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.program.create({
|
const created = await tx.program.create({
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ export async function GET() {
|
|||||||
"distance",
|
"distance",
|
||||||
"distanceUnit",
|
"distanceUnit",
|
||||||
"setCalories",
|
"setCalories",
|
||||||
|
"setWatts",
|
||||||
"rpe",
|
"rpe",
|
||||||
|
"setGear",
|
||||||
"setNotes",
|
"setNotes",
|
||||||
"customMetricsJson",
|
"customMetricsJson",
|
||||||
];
|
];
|
||||||
@@ -101,7 +103,9 @@ export async function GET() {
|
|||||||
set.distance ?? "",
|
set.distance ?? "",
|
||||||
set.distanceUnit ?? "",
|
set.distanceUnit ?? "",
|
||||||
set.calories ?? "",
|
set.calories ?? "",
|
||||||
|
set.watts ?? "",
|
||||||
set.rpe ?? "",
|
set.rpe ?? "",
|
||||||
|
set.gear ?? "",
|
||||||
set.notes ?? "",
|
set.notes ?? "",
|
||||||
set.customMetrics ?? "",
|
set.customMetrics ?? "",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export async function GET() {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
// Whole-instance operation: the file contains every user's data and
|
||||||
|
// password hashes. Admin-only — regular users use /api/me/export.
|
||||||
|
if (!user.isAdmin) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const dbPath = resolveDatabasePath();
|
const dbPath = resolveDatabasePath();
|
||||||
const data = await readFile(dbPath);
|
const data = await readFile(dbPath);
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
// Replaces the entire instance database — admin-only. Without this a
|
||||||
|
// regular user could overwrite the DB to mint themselves an admin.
|
||||||
|
if (!user.isAdmin) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get("database") as File | null;
|
const file = formData.get("database") as File | null;
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { readJsonBody } from "@/lib/http";
|
||||||
|
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// GET: Get workout by ID
|
// GET: Get workout by ID
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
context: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -54,10 +57,12 @@ const setSchema = z.object({
|
|||||||
weight: z.number().optional().nullable(),
|
weight: z.number().optional().nullable(),
|
||||||
weightUnit: z.string().default("lbs"),
|
weightUnit: z.string().default("lbs"),
|
||||||
rpe: z.number().int().min(1).max(10).optional().nullable(),
|
rpe: z.number().int().min(1).max(10).optional().nullable(),
|
||||||
|
gear: z.number().int().min(1).max(5).optional().nullable(),
|
||||||
durationSeconds: z.number().int().positive().optional().nullable(),
|
durationSeconds: z.number().int().positive().optional().nullable(),
|
||||||
distance: z.number().positive().optional().nullable(),
|
distance: z.number().positive().optional().nullable(),
|
||||||
distanceUnit: z.string().optional().nullable(),
|
distanceUnit: z.string().optional().nullable(),
|
||||||
calories: z.number().int().positive().optional().nullable(),
|
calories: z.number().int().positive().optional().nullable(),
|
||||||
|
watts: z.number().int().positive().optional().nullable(),
|
||||||
customMetrics: z.record(z.string()).optional().nullable(),
|
customMetrics: z.record(z.string()).optional().nullable(),
|
||||||
notes: z.string().optional().nullable(),
|
notes: z.string().optional().nullable(),
|
||||||
});
|
});
|
||||||
@@ -74,8 +79,9 @@ const updateWorkoutSchema = z.object({
|
|||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
context: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -95,9 +101,24 @@ export async function PATCH(
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await readJsonBody(request);
|
||||||
const validated = updateWorkoutSchema.parse(body);
|
const validated = updateWorkoutSchema.parse(body);
|
||||||
|
|
||||||
|
// When replacing sets, every referenced exercise must belong to this
|
||||||
|
// user (see lib/exerciseOwnership).
|
||||||
|
if (validated.sets) {
|
||||||
|
const bad = await findUnownedExerciseIds(
|
||||||
|
user.id,
|
||||||
|
validated.sets.map((s) => s.exerciseId),
|
||||||
|
);
|
||||||
|
if (bad.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Some exerciseIds don't exist in your library", details: bad },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const workoutData: Record<string, unknown> = {};
|
const workoutData: Record<string, unknown> = {};
|
||||||
if (validated.name !== undefined) workoutData.name = validated.name;
|
if (validated.name !== undefined) workoutData.name = validated.name;
|
||||||
if (validated.notes !== undefined) workoutData.notes = validated.notes || null;
|
if (validated.notes !== undefined) workoutData.notes = validated.notes || null;
|
||||||
@@ -133,10 +154,12 @@ export async function PATCH(
|
|||||||
weight: set.weight ?? undefined,
|
weight: set.weight ?? undefined,
|
||||||
weightUnit: set.weightUnit,
|
weightUnit: set.weightUnit,
|
||||||
rpe: set.rpe ?? undefined,
|
rpe: set.rpe ?? undefined,
|
||||||
|
gear: set.gear ?? undefined,
|
||||||
durationSeconds: set.durationSeconds ?? undefined,
|
durationSeconds: set.durationSeconds ?? undefined,
|
||||||
distance: set.distance ?? undefined,
|
distance: set.distance ?? undefined,
|
||||||
distanceUnit: set.distanceUnit ?? undefined,
|
distanceUnit: set.distanceUnit ?? undefined,
|
||||||
calories: set.calories ?? undefined,
|
calories: set.calories ?? undefined,
|
||||||
|
watts: set.watts ?? undefined,
|
||||||
customMetrics:
|
customMetrics:
|
||||||
set.customMetrics && Object.keys(set.customMetrics).length > 0
|
set.customMetrics && Object.keys(set.customMetrics).length > 0
|
||||||
? JSON.stringify(set.customMetrics)
|
? JSON.stringify(set.customMetrics)
|
||||||
@@ -198,8 +221,9 @@ export async function PATCH(
|
|||||||
// DELETE: Delete workout
|
// DELETE: Delete workout
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
context: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { readJsonBody } from "@/lib/http";
|
||||||
|
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const addSetsSchema = z.object({
|
const addSetsSchema = z.object({
|
||||||
@@ -12,10 +14,12 @@ const addSetsSchema = z.object({
|
|||||||
weight: z.number().optional(),
|
weight: z.number().optional(),
|
||||||
weightUnit: z.string().default("lbs"),
|
weightUnit: z.string().default("lbs"),
|
||||||
rpe: z.number().int().min(1).max(10).optional(),
|
rpe: z.number().int().min(1).max(10).optional(),
|
||||||
|
gear: z.number().int().min(1).max(5).optional(),
|
||||||
durationSeconds: z.number().int().positive().optional(),
|
durationSeconds: z.number().int().positive().optional(),
|
||||||
distance: z.number().positive().optional(),
|
distance: z.number().positive().optional(),
|
||||||
distanceUnit: z.string().optional(),
|
distanceUnit: z.string().optional(),
|
||||||
calories: z.number().int().positive().optional(),
|
calories: z.number().int().positive().optional(),
|
||||||
|
watts: z.number().int().positive().optional(),
|
||||||
customMetrics: z.record(z.string()).optional(),
|
customMetrics: z.record(z.string()).optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
})
|
})
|
||||||
@@ -25,8 +29,9 @@ const addSetsSchema = z.object({
|
|||||||
// POST: Add an exercise's sets to an existing workout
|
// POST: Add an exercise's sets to an existing workout
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
context: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -46,9 +51,18 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await readJsonBody(request);
|
||||||
const validated = addSetsSchema.parse(body);
|
const validated = addSetsSchema.parse(body);
|
||||||
|
|
||||||
|
// The exercise must belong to this user (see lib/exerciseOwnership).
|
||||||
|
const bad = await findUnownedExerciseIds(user.id, [validated.exerciseId]);
|
||||||
|
if (bad.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Some exerciseIds don't exist in your library", details: bad },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete existing sets for this exercise in this workout (replace mode)
|
// Delete existing sets for this exercise in this workout (replace mode)
|
||||||
await prisma.setLog.deleteMany({
|
await prisma.setLog.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -67,10 +81,12 @@ export async function POST(
|
|||||||
weight: set.weight,
|
weight: set.weight,
|
||||||
weightUnit: set.weightUnit,
|
weightUnit: set.weightUnit,
|
||||||
rpe: set.rpe,
|
rpe: set.rpe,
|
||||||
|
gear: set.gear,
|
||||||
durationSeconds: set.durationSeconds,
|
durationSeconds: set.durationSeconds,
|
||||||
distance: set.distance,
|
distance: set.distance,
|
||||||
distanceUnit: set.distanceUnit,
|
distanceUnit: set.distanceUnit,
|
||||||
calories: set.calories,
|
calories: set.calories,
|
||||||
|
watts: set.watts,
|
||||||
customMetrics:
|
customMetrics:
|
||||||
set.customMetrics && Object.keys(set.customMetrics).length > 0
|
set.customMetrics && Object.keys(set.customMetrics).length > 0
|
||||||
? JSON.stringify(set.customMetrics)
|
? JSON.stringify(set.customMetrics)
|
||||||
@@ -109,8 +125,9 @@ export async function POST(
|
|||||||
// DELETE: Remove all sets for a specific exercise from a workout
|
// DELETE: Remove all sets for a specific exercise from a workout
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
context: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const params = await context.params;
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { readJsonBody } from "@/lib/http";
|
||||||
|
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
|
||||||
|
|
||||||
const setSchema = z.object({
|
const setSchema = z.object({
|
||||||
reps: z.number().int().positive().optional(),
|
reps: z.number().int().positive().optional(),
|
||||||
@@ -11,7 +13,9 @@ const setSchema = z.object({
|
|||||||
distance: z.number().positive().optional(),
|
distance: z.number().positive().optional(),
|
||||||
distanceUnit: z.string().optional(),
|
distanceUnit: z.string().optional(),
|
||||||
calories: z.number().int().positive().optional(),
|
calories: z.number().int().positive().optional(),
|
||||||
|
watts: z.number().int().positive().optional(),
|
||||||
rpe: z.number().int().min(1).max(10).optional(),
|
rpe: z.number().int().min(1).max(10).optional(),
|
||||||
|
gear: z.number().int().min(1).max(5).optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,9 +44,25 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await readJsonBody(request);
|
||||||
const validated = saveImportSchema.parse(body);
|
const validated = saveImportSchema.parse(body);
|
||||||
|
|
||||||
|
// An explicitly-matched `existingExerciseId` must belong to this user;
|
||||||
|
// name-matched and newly-created exercises are owned by construction
|
||||||
|
// (see lib/exerciseOwnership).
|
||||||
|
const claimedIds = validated.workouts.flatMap((w) =>
|
||||||
|
w.exercises
|
||||||
|
.map((e) => e.existingExerciseId)
|
||||||
|
.filter((id): id is string => !!id)
|
||||||
|
);
|
||||||
|
const bad = await findUnownedExerciseIds(user.id, claimedIds);
|
||||||
|
if (bad.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Some exerciseIds don't exist in your library", details: bad },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Load all user exercises for matching
|
// Load all user exercises for matching
|
||||||
const existingExercises = await prisma.exercise.findMany({
|
const existingExercises = await prisma.exercise.findMany({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
@@ -105,10 +125,12 @@ export async function POST(request: Request) {
|
|||||||
weight: set.weight || null,
|
weight: set.weight || null,
|
||||||
weightUnit: set.weightUnit || "lbs",
|
weightUnit: set.weightUnit || "lbs",
|
||||||
rpe: set.rpe || null,
|
rpe: set.rpe || null,
|
||||||
|
gear: set.gear || null,
|
||||||
durationSeconds: set.durationSeconds || null,
|
durationSeconds: set.durationSeconds || null,
|
||||||
distance: set.distance || null,
|
distance: set.distance || null,
|
||||||
distanceUnit: set.distanceUnit || null,
|
distanceUnit: set.distanceUnit || null,
|
||||||
calories: set.calories || null,
|
calories: set.calories || null,
|
||||||
|
watts: set.watts || null,
|
||||||
notes: set.notes || null,
|
notes: set.notes || null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { z } from "zod";
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { readJsonBody } from "@/lib/http";
|
||||||
|
import { findUnownedExerciseIds } from "@/lib/exerciseOwnership";
|
||||||
|
|
||||||
// Schema now supports creating empty workouts (just date) or with sets
|
// Schema now supports creating empty workouts (just date) or with sets
|
||||||
const createWorkoutSchema = z.object({
|
const createWorkoutSchema = z.object({
|
||||||
@@ -11,7 +13,10 @@ const createWorkoutSchema = z.object({
|
|||||||
durationMinutes: z.number().int().positive().optional(),
|
durationMinutes: z.number().int().positive().optional(),
|
||||||
difficulty: z.number().int().min(1).max(10).optional(),
|
difficulty: z.number().int().min(1).max(10).optional(),
|
||||||
caloriesBurned: z.number().int().positive().optional(),
|
caloriesBurned: z.number().int().positive().optional(),
|
||||||
date: z.string().optional(), // ISO date string or date-only string
|
date: z
|
||||||
|
.string()
|
||||||
|
.refine((s) => !Number.isNaN(Date.parse(s)), { message: "Invalid date" })
|
||||||
|
.optional(), // ISO date string or date-only string
|
||||||
sets: z
|
sets: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -21,10 +26,12 @@ const createWorkoutSchema = z.object({
|
|||||||
weight: z.number().positive().optional(),
|
weight: z.number().positive().optional(),
|
||||||
weightUnit: z.string().default("lbs"),
|
weightUnit: z.string().default("lbs"),
|
||||||
rpe: z.number().int().min(1).max(10).optional(),
|
rpe: z.number().int().min(1).max(10).optional(),
|
||||||
|
gear: z.number().int().min(1).max(5).optional(),
|
||||||
durationSeconds: z.number().int().positive().optional(),
|
durationSeconds: z.number().int().positive().optional(),
|
||||||
distance: z.number().positive().optional(),
|
distance: z.number().positive().optional(),
|
||||||
distanceUnit: z.string().optional(),
|
distanceUnit: z.string().optional(),
|
||||||
calories: z.number().int().positive().optional(),
|
calories: z.number().int().positive().optional(),
|
||||||
|
watts: z.number().int().positive().optional(),
|
||||||
customMetrics: z.record(z.string()).optional(),
|
customMetrics: z.record(z.string()).optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
})
|
})
|
||||||
@@ -45,8 +52,25 @@ export async function GET(request: NextRequest) {
|
|||||||
const query = searchParams.get("q");
|
const query = searchParams.get("q");
|
||||||
const dateFrom = searchParams.get("dateFrom");
|
const dateFrom = searchParams.get("dateFrom");
|
||||||
const dateTo = searchParams.get("dateTo");
|
const dateTo = searchParams.get("dateTo");
|
||||||
const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 100);
|
// Validate pagination up front: a negative offset or non-numeric value
|
||||||
const offset = parseInt(searchParams.get("offset") || "0");
|
// would otherwise reach Prisma's `skip`/`take` and throw a generic 500.
|
||||||
|
const pagination = z
|
||||||
|
.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||||
|
offset: z.coerce.number().int().min(0).default(0),
|
||||||
|
})
|
||||||
|
.safeParse({
|
||||||
|
limit: searchParams.get("limit") || undefined,
|
||||||
|
offset: searchParams.get("offset") || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pagination.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid pagination parameters", details: pagination.error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { limit, offset } = pagination.data;
|
||||||
|
|
||||||
const where: Prisma.WorkoutWhereInput = {
|
const where: Prisma.WorkoutWhereInput = {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -116,9 +140,22 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await readJsonBody(request);
|
||||||
const validated = createWorkoutSchema.parse(body);
|
const validated = createWorkoutSchema.parse(body);
|
||||||
|
|
||||||
|
// Every referenced exercise must belong to this user (see
|
||||||
|
// lib/exerciseOwnership).
|
||||||
|
const bad = await findUnownedExerciseIds(
|
||||||
|
user.id,
|
||||||
|
validated.sets.map((s) => s.exerciseId),
|
||||||
|
);
|
||||||
|
if (bad.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Some exerciseIds don't exist in your library", details: bad },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const workoutDate = validated.date ? new Date(validated.date) : new Date();
|
const workoutDate = validated.date ? new Date(validated.date) : new Date();
|
||||||
|
|
||||||
const createData: Prisma.WorkoutCreateInput = {
|
const createData: Prisma.WorkoutCreateInput = {
|
||||||
@@ -139,10 +176,12 @@ export async function POST(request: NextRequest) {
|
|||||||
weight: set.weight,
|
weight: set.weight,
|
||||||
weightUnit: set.weightUnit,
|
weightUnit: set.weightUnit,
|
||||||
rpe: set.rpe,
|
rpe: set.rpe,
|
||||||
|
gear: set.gear,
|
||||||
durationSeconds: set.durationSeconds,
|
durationSeconds: set.durationSeconds,
|
||||||
distance: set.distance,
|
distance: set.distance,
|
||||||
distanceUnit: set.distanceUnit,
|
distanceUnit: set.distanceUnit,
|
||||||
calories: set.calories,
|
calories: set.calories,
|
||||||
|
watts: set.watts,
|
||||||
customMetrics:
|
customMetrics:
|
||||||
set.customMetrics && Object.keys(set.customMetrics).length > 0
|
set.customMetrics && Object.keys(set.customMetrics).length > 0
|
||||||
? JSON.stringify(set.customMetrics)
|
? JSON.stringify(set.customMetrics)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { loginAction } from './actions';
|
import { loginAction } from './actions';
|
||||||
|
import { retryOnTransportError } from '@/lib/retryAction';
|
||||||
|
|
||||||
export default function LoginForm() {
|
export default function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -17,7 +18,9 @@ export default function LoginForm() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await loginAction(email, password);
|
const result = await retryOnTransportError(() =>
|
||||||
|
loginAction(email, password)
|
||||||
|
);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { cookies, headers } from 'next/headers';
|
import { cookies, headers } from 'next/headers';
|
||||||
import { verifyPassword, createSession } from '@/lib/auth';
|
import { verifyPasswordOrDummy, createSession } from '@/lib/auth';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit';
|
import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit';
|
||||||
|
|
||||||
@@ -24,14 +24,14 @@ export async function loginAction(email: string, password: string) {
|
|||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
// Always run a bcrypt compare (against a dummy hash when the email is
|
||||||
return { error: 'Invalid email or password' };
|
// unknown) so response time doesn't reveal whether an account exists.
|
||||||
}
|
const isValid = await verifyPasswordOrDummy(
|
||||||
|
password,
|
||||||
|
user?.passwordHash ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
// Verify the password
|
if (!user || !isValid) {
|
||||||
const isValid = await verifyPassword(password, user.passwordHash);
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return { error: 'Invalid email or password' };
|
return { error: 'Invalid email or password' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { signupAction } from './actions';
|
import { signupAction } from './actions';
|
||||||
|
import { retryOnTransportError } from '@/lib/retryAction';
|
||||||
|
|
||||||
export default function SignupForm() {
|
export default function SignupForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -19,7 +20,9 @@ export default function SignupForm() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await signupAction(email, password, passwordConfirm, name);
|
const result = await retryOnTransportError(() =>
|
||||||
|
signupAction(email, password, passwordConfirm, name)
|
||||||
|
);
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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 GenerateWorkoutClient from '@/components/ai/GenerateWorkoutClient';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function GenerateWorkoutPage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/auth/login');
|
||||||
|
|
||||||
|
const [exercises, prefs, workoutCount] = await Promise.all([
|
||||||
|
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/ai"
|
||||||
|
className="text-zinc-400 hover:text-white"
|
||||||
|
aria-label="Back to AI"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-white">
|
||||||
|
AI · Today's workout
|
||||||
|
</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 a workout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<GenerateWorkoutClient
|
||||||
|
exercises={exercises}
|
||||||
|
providerLabel={prefs!.aiProvider!}
|
||||||
|
modelLabel={prefs!.aiModel!}
|
||||||
|
workoutCount={workoutCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ export default async function GeneratePage() {
|
|||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) redirect('/auth/login');
|
if (!user) redirect('/auth/login');
|
||||||
|
|
||||||
const [templates, exercises, prefs] = await Promise.all([
|
const [templates, exercises, prefs, workoutCount] = await Promise.all([
|
||||||
prisma.aIPromptTemplate.findMany({
|
prisma.aIPromptTemplate.findMany({
|
||||||
where: { OR: [{ userId: null }, { userId: user.id }] },
|
where: { OR: [{ userId: null }, { userId: user.id }] },
|
||||||
orderBy: [{ isBuiltIn: 'desc' }, { name: 'asc' }],
|
orderBy: [{ isBuiltIn: 'desc' }, { name: 'asc' }],
|
||||||
@@ -31,6 +31,9 @@ export default async function GeneratePage() {
|
|||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
select: { aiProvider: true, aiModel: true },
|
select: { aiProvider: true, aiModel: true },
|
||||||
}),
|
}),
|
||||||
|
prisma.workout.count({
|
||||||
|
where: { userId: user.id, deletedAt: null },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
|
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
|
||||||
@@ -74,6 +77,7 @@ export default async function GeneratePage() {
|
|||||||
exercises={exercises}
|
exercises={exercises}
|
||||||
providerLabel={prefs!.aiProvider!}
|
providerLabel={prefs!.aiProvider!}
|
||||||
modelLabel={prefs!.aiModel!}
|
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(props: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await props.params;
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect('/auth/login');
|
||||||
|
|
||||||
|
const [row, exercises] = await Promise.all([
|
||||||
|
prisma.aIGeneration.findFirst({
|
||||||
|
// Program history only — workout-kind rows aren't shown here.
|
||||||
|
where: { id: params.id, userId: user.id, kind: 'program' },
|
||||||
|
}),
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,10 @@ export default async function HistoryPage() {
|
|||||||
if (!user) redirect('/auth/login');
|
if (!user) redirect('/auth/login');
|
||||||
|
|
||||||
const rows = await prisma.aIGeneration.findMany({
|
const rows = await prisma.aIGeneration.findMany({
|
||||||
where: { userId: user.id },
|
// Program history only. Single-workout generations (kind="workout")
|
||||||
|
// are ephemeral — the durable record is the saved Workout — so they
|
||||||
|
// don't belong in this program-shaped list/detail.
|
||||||
|
where: { userId: user.id, kind: 'program' },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 25,
|
take: 25,
|
||||||
select: {
|
select: {
|
||||||
@@ -23,6 +26,7 @@ export default async function HistoryPage() {
|
|||||||
model: true,
|
model: true,
|
||||||
tokensIn: true,
|
tokensIn: true,
|
||||||
tokensOut: true,
|
tokensOut: true,
|
||||||
|
durationMs: true,
|
||||||
status: true,
|
status: true,
|
||||||
errorMessage: true,
|
errorMessage: true,
|
||||||
appliedProgramId: true,
|
appliedProgramId: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Sparkles, ListChecks, History } from 'lucide-react';
|
import { Sparkles, ListChecks, History, Dumbbell } from 'lucide-react';
|
||||||
import { getCurrentUser } from '@/lib/auth';
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
@@ -17,6 +17,14 @@ export default async function AIIndexPage() {
|
|||||||
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
|
const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel;
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
|
{
|
||||||
|
href: '/main/ai/generate-workout',
|
||||||
|
icon: Dumbbell,
|
||||||
|
title: "Today's workout",
|
||||||
|
blurb:
|
||||||
|
'Describe today\'s session in plain words and get a ready-to-log workout — exercises with suggested weights and reps from your history. Refine it, then pre-fill the log.',
|
||||||
|
disabled: !aiConfigured,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/main/ai/generate',
|
href: '/main/ai/generate',
|
||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const INPUT_FIELD_OPTIONS = [
|
|||||||
{ value: "duration", label: "Duration" },
|
{ value: "duration", label: "Duration" },
|
||||||
{ value: "distance", label: "Distance" },
|
{ value: "distance", label: "Distance" },
|
||||||
{ value: "calories", label: "Calories" },
|
{ value: "calories", label: "Calories" },
|
||||||
|
{ value: "watts", label: "Avg. watts" },
|
||||||
{ value: "notes", label: "Notes" },
|
{ value: "notes", label: "Notes" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ interface ParsedSet {
|
|||||||
distance?: number;
|
distance?: number;
|
||||||
distanceUnit?: string;
|
distanceUnit?: string;
|
||||||
calories?: number;
|
calories?: number;
|
||||||
|
watts?: number;
|
||||||
rpe?: number;
|
rpe?: number;
|
||||||
|
gear?: number;
|
||||||
customMetrics?: Record<string, string>;
|
customMetrics?: Record<string, string>;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
@@ -392,9 +394,15 @@ export default function ImportCSVPage() {
|
|||||||
if (typeof set.calories === "number" && !Number.isNaN(set.calories)) {
|
if (typeof set.calories === "number" && !Number.isNaN(set.calories)) {
|
||||||
payloadSet.calories = set.calories;
|
payloadSet.calories = set.calories;
|
||||||
}
|
}
|
||||||
|
if (typeof set.watts === "number" && !Number.isNaN(set.watts)) {
|
||||||
|
payloadSet.watts = set.watts;
|
||||||
|
}
|
||||||
if (typeof set.rpe === "number" && !Number.isNaN(set.rpe)) {
|
if (typeof set.rpe === "number" && !Number.isNaN(set.rpe)) {
|
||||||
payloadSet.rpe = set.rpe;
|
payloadSet.rpe = set.rpe;
|
||||||
}
|
}
|
||||||
|
if (typeof set.gear === "number" && !Number.isNaN(set.gear)) {
|
||||||
|
payloadSet.gear = set.gear;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
set.customMetrics &&
|
set.customMetrics &&
|
||||||
typeof set.customMetrics === "object" &&
|
typeof set.customMetrics === "object" &&
|
||||||
@@ -763,7 +771,7 @@ export default function ImportCSVPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-zinc-500">
|
<p className="text-sm text-zinc-500">
|
||||||
CSV columns: date, exercise, set, weight, reps, duration_seconds,
|
CSV columns: date, exercise, set, weight, reps, duration_seconds,
|
||||||
distance, distance_unit, calories, rpe, notes, custom_*
|
distance, distance_unit, calories, watts, rpe, gear, notes, custom_*
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{loading && (
|
{loading && (
|
||||||
@@ -778,12 +786,12 @@ export default function ImportCSVPage() {
|
|||||||
CSV Format Example
|
CSV Format Example
|
||||||
</h3>
|
</h3>
|
||||||
<pre className="text-xs text-zinc-400 overflow-x-auto bg-zinc-950 p-4 rounded border border-zinc-800">
|
<pre className="text-xs text-zinc-400 overflow-x-auto bg-zinc-950 p-4 rounded border border-zinc-800">
|
||||||
{`date,exercise,set,weight,weight_unit,reps,duration_seconds,distance,distance_unit,calories,rpe,notes,custom_temperature,custom_watts,custom_metrics_json
|
{`date,exercise,set,weight,weight_unit,reps,duration_seconds,distance,distance_unit,calories,watts,rpe,gear,notes,custom_temperature,custom_metrics_json
|
||||||
2025-02-15,Bench,1,225,lbs,5,,,,,8,good form,,,
|
2025-02-15,Bench,1,225,lbs,5,,,,,,8,,good form,,
|
||||||
2025-02-15,Bench,2,225,lbs,5,,,,,8,,,,
|
2025-02-15,Bench,2,225,lbs,5,,,,,,8,,,,
|
||||||
2025-02-16,Squat,1,315,lbs,8,,,,,9,30kg per leg,,,
|
2025-02-16,Squat,1,315,lbs,8,,,,,,9,,30kg per leg,,
|
||||||
2025-02-17,Assault Bike,1,,, ,900,5,mi,120,7,,,"{\"resistance\":\"8\"}"
|
2025-02-17,Assault Bike,1,,, ,900,5,mi,120,157,,4,,,"{\"resistance\":\"8\"}"
|
||||||
2025-02-18,Cold Plunge,1,,, ,180,,,,,felt great,50,,`}
|
2025-02-18,Cold Plunge,1,,, ,180,,,,,,,felt great,50,`}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|||||||
@@ -14,23 +14,77 @@ 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/programs', label: 'Programs', icon: Calendar },
|
||||||
{ href: '/main/ai', label: 'AI', icon: Sparkles },
|
{
|
||||||
|
href: '/main/ai',
|
||||||
|
label: 'AI',
|
||||||
|
icon: Sparkles,
|
||||||
|
subItems: [
|
||||||
|
{ href: '/main/ai/generate-workout', label: "Today's workout" },
|
||||||
|
{ href: '/main/ai/generate', label: 'Generate program' },
|
||||||
|
{ 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 () => {
|
||||||
@@ -46,14 +100,14 @@ 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 (
|
||||||
|
<div key={link.href}>
|
||||||
<a
|
<a
|
||||||
key={link.href}
|
|
||||||
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
|
||||||
@@ -64,6 +118,32 @@ export default function Navigation({ userName }: NavigationProps) {
|
|||||||
<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>
|
||||||
@@ -84,7 +164,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) => {
|
||||||
|
|||||||
@@ -14,11 +14,10 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
export default async function ProgramDetailPage({
|
export default async function ProgramDetailPage(props: {
|
||||||
params,
|
params: Promise<{ id: string }>;
|
||||||
}: {
|
|
||||||
params: { id: string };
|
|
||||||
}) {
|
}) {
|
||||||
|
const params = await props.params;
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) redirect('/auth/login');
|
if (!user) redirect('/auth/login');
|
||||||
|
|
||||||
|
|||||||
@@ -30,17 +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>
|
||||||
<AIIntegration />
|
<div id="ai"><AIIntegration isAdmin={user.isAdmin} /></div>
|
||||||
<ExportMyData />
|
<div id="data"><ExportMyData /></div>
|
||||||
{user.isAdmin && instanceSettings && (
|
{user.isAdmin && instanceSettings && (
|
||||||
|
<div id="instance">
|
||||||
<AdminInstanceSettings
|
<AdminInstanceSettings
|
||||||
initialSignupsOpen={instanceSettings.signupsOpen}
|
initialSignupsOpen={instanceSettings.signupsOpen}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<DangerZone />
|
<div id="danger"><DangerZone /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ function buildSetSummary(set: {
|
|||||||
weightUnit?: string | null;
|
weightUnit?: string | null;
|
||||||
reps?: number | null;
|
reps?: number | null;
|
||||||
rpe?: number | null;
|
rpe?: number | null;
|
||||||
|
gear?: number | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
durationSeconds?: number | null;
|
durationSeconds?: number | null;
|
||||||
distance?: number | null;
|
distance?: number | null;
|
||||||
calories?: number | null;
|
calories?: number | null;
|
||||||
|
watts?: number | null;
|
||||||
customMetrics?: string | null;
|
customMetrics?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
@@ -35,15 +37,25 @@ function buildSetSummary(set: {
|
|||||||
}
|
}
|
||||||
if ((set as any).distance) parts.push(`${(set as any).distance} mi`);
|
if ((set as any).distance) parts.push(`${(set as any).distance} mi`);
|
||||||
if ((set as any).calories) parts.push(`${(set as any).calories} cal`);
|
if ((set as any).calories) parts.push(`${(set as any).calories} cal`);
|
||||||
|
if ((set as any).watts) parts.push(`${(set as any).watts} W`);
|
||||||
if ((set as any).customMetrics) {
|
if ((set as any).customMetrics) {
|
||||||
try {
|
try {
|
||||||
const custom = JSON.parse((set as any).customMetrics) as Record<string, string>;
|
const custom = JSON.parse((set as any).customMetrics) as Record<string, string>;
|
||||||
for (const [k, v] of Object.entries(custom)) {
|
for (const [k, v] of Object.entries(custom)) {
|
||||||
if (v) parts.push(`${k}: ${v}`);
|
if (!v) continue;
|
||||||
|
// Watts is now a first-class column. Legacy sets still carry it under
|
||||||
|
// customMetrics — render it the same way (and skip if the column
|
||||||
|
// already supplied it) so old and new sets read identically.
|
||||||
|
if (k === "watts") {
|
||||||
|
if (!(set as any).watts) parts.push(`${v} W`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
parts.push(`${k}: ${v}`);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
if (set.rpe) parts.push(`RPE ${set.rpe}`);
|
if (set.gear) parts.push(`Gear ${set.gear}`);
|
||||||
|
else if (set.rpe) parts.push(`RPE ${set.rpe}`);
|
||||||
if (set.notes) parts.push(set.notes);
|
if (set.notes) parts.push(set.notes);
|
||||||
return parts.length > 0 ? parts.join(" · ") : "No data";
|
return parts.length > 0 ? parts.join(" · ") : "No data";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,17 @@ import { getCurrentUser } from "@/lib/auth";
|
|||||||
import { getExercises } from "@/lib/db/exercises";
|
import { getExercises } from "@/lib/db/exercises";
|
||||||
import { getWorkoutById } from "@/lib/db/workouts";
|
import { getWorkoutById } from "@/lib/db/workouts";
|
||||||
import WorkoutForm, { EditWorkoutData } from "@/components/workouts/WorkoutForm";
|
import WorkoutForm, { EditWorkoutData } from "@/components/workouts/WorkoutForm";
|
||||||
|
import AiWorkoutPrefill from "@/components/workouts/AiWorkoutPrefill";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Log Workout",
|
title: "Log Workout",
|
||||||
description: "Log a new workout",
|
description: "Log a new workout",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function NewWorkoutPage({
|
export default async function NewWorkoutPage(props: {
|
||||||
searchParams,
|
searchParams: Promise<{ edit?: string; from?: string }>;
|
||||||
}: {
|
|
||||||
searchParams: { edit?: string };
|
|
||||||
}) {
|
}) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect("/auth/login");
|
redirect("/auth/login");
|
||||||
@@ -23,6 +23,11 @@ export default async function NewWorkoutPage({
|
|||||||
|
|
||||||
const exercises = await getExercises(user.id);
|
const exercises = await getExercises(user.id);
|
||||||
|
|
||||||
|
// Coming from the AI "today's workout" flow: the suggestion is in
|
||||||
|
// sessionStorage (client-only), so a client wrapper reads it and
|
||||||
|
// pre-fills the form. No ?edit fetch here.
|
||||||
|
const fromAi = searchParams.from === "ai";
|
||||||
|
|
||||||
// If ?edit=WORKOUT_ID, fetch existing workout for editing
|
// If ?edit=WORKOUT_ID, fetch existing workout for editing
|
||||||
let editWorkout: EditWorkoutData | undefined;
|
let editWorkout: EditWorkoutData | undefined;
|
||||||
if (searchParams.edit) {
|
if (searchParams.edit) {
|
||||||
@@ -51,9 +56,11 @@ export default async function NewWorkoutPage({
|
|||||||
reps: set.reps ?? undefined,
|
reps: set.reps ?? undefined,
|
||||||
weight: set.weight ?? undefined,
|
weight: set.weight ?? undefined,
|
||||||
rpe: set.rpe ?? undefined,
|
rpe: set.rpe ?? undefined,
|
||||||
|
gear: set.gear ?? undefined,
|
||||||
durationSeconds: set.durationSeconds ?? undefined,
|
durationSeconds: set.durationSeconds ?? undefined,
|
||||||
distance: set.distance ?? undefined,
|
distance: set.distance ?? undefined,
|
||||||
calories: set.calories ?? undefined,
|
calories: set.calories ?? undefined,
|
||||||
|
watts: set.watts ?? undefined,
|
||||||
customMetrics,
|
customMetrics,
|
||||||
notes: set.notes ?? undefined,
|
notes: set.notes ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -94,11 +101,15 @@ export default async function NewWorkoutPage({
|
|||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<div className="max-w-2xl mx-auto px-4 py-6 pb-12">
|
<div className="max-w-2xl mx-auto px-4 py-6 pb-12">
|
||||||
|
{fromAi ? (
|
||||||
|
<AiWorkoutPrefill exercises={exercises} />
|
||||||
|
) : (
|
||||||
<WorkoutForm
|
<WorkoutForm
|
||||||
exercises={exercises}
|
exercises={exercises}
|
||||||
recentlyUsedExercises={[]}
|
recentlyUsedExercises={[]}
|
||||||
editWorkout={editWorkout}
|
editWorkout={editWorkout}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import WorkoutsList from "@/components/workouts/WorkoutsList";
|
|||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
searchParams: { q?: string; dateFrom?: string; dateTo?: string };
|
searchParams: Promise<{ q?: string; dateFrom?: string; dateTo?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@@ -18,7 +18,8 @@ export const metadata = {
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
export default async function WorkoutsPage({ searchParams }: PageProps) {
|
export default async function WorkoutsPage(props: PageProps) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect("/auth/login");
|
redirect("/auth/login");
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Loader2, Sparkles, Square } from 'lucide-react';
|
import { Loader2, Sparkles } from 'lucide-react';
|
||||||
|
import { lenientJsonParse } from '@/lib/ai/lenientJson';
|
||||||
|
import { estimateCost, formatCost } from '@/lib/ai/pricing';
|
||||||
|
import { resolveExerciseIds } from '@/lib/ai/exerciseMatch';
|
||||||
|
|
||||||
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
@@ -28,6 +31,8 @@ interface AIExercise {
|
|||||||
repsMax?: number | null;
|
repsMax?: number | null;
|
||||||
rpe?: number | null;
|
rpe?: number | null;
|
||||||
restSeconds?: number | null;
|
restSeconds?: number | null;
|
||||||
|
suggestedWeight?: number | null;
|
||||||
|
suggestedWeightUnit?: 'lbs' | 'kg' | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
}
|
}
|
||||||
interface AIDay {
|
interface AIDay {
|
||||||
@@ -52,7 +57,15 @@ interface AIProgram {
|
|||||||
|
|
||||||
type Phase =
|
type Phase =
|
||||||
| { kind: 'idle' }
|
| { kind: 'idle' }
|
||||||
| { kind: 'streaming'; raw: string }
|
| {
|
||||||
|
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: 'parsed'; raw: string; program: AIProgram }
|
||||||
| { kind: 'failed'; raw: string; message: string };
|
| { kind: 'failed'; raw: string; message: string };
|
||||||
|
|
||||||
@@ -61,33 +74,44 @@ export default function GenerateClient({
|
|||||||
exercises,
|
exercises,
|
||||||
providerLabel,
|
providerLabel,
|
||||||
modelLabel,
|
modelLabel,
|
||||||
|
workoutCount,
|
||||||
}: {
|
}: {
|
||||||
templates: Template[];
|
templates: Template[];
|
||||||
exercises: LibraryExercise[];
|
exercises: LibraryExercise[];
|
||||||
providerLabel: string;
|
providerLabel: string;
|
||||||
modelLabel: string;
|
modelLabel: string;
|
||||||
|
workoutCount: number;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [templateId, setTemplateId] = useState(templates[0]?.id ?? '');
|
const [templateId, setTemplateId] = useState(templates[0]?.id ?? '');
|
||||||
const [userInput, setUserInput] = useState('');
|
const [userInput, setUserInput] = useState('');
|
||||||
|
const [includeHistory, setIncludeHistory] = useState(workoutCount >= 10);
|
||||||
const [generationId, setGenerationId] = useState<string | null>(null);
|
const [generationId, setGenerationId] = useState<string | null>(null);
|
||||||
const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
|
const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
|
||||||
const [tokens, setTokens] = useState<{ in?: number; out?: number }>({});
|
const [tokens, setTokens] = useState<{ in?: number; out?: number; durationMs?: number }>({});
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const [navWarning, setNavWarning] = useState(false);
|
||||||
|
const closeStreamRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
const selectedTemplate = useMemo(
|
// Wire up native warning if the user tries to leave during a stream.
|
||||||
() => templates.find((t) => t.id === templateId),
|
useEffect(() => {
|
||||||
[templates, templateId],
|
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 () => {
|
const handleGenerate = async () => {
|
||||||
if (!userInput.trim()) return;
|
if (!userInput.trim()) return;
|
||||||
setPhase({ kind: 'streaming', raw: '' });
|
setPhase({ kind: 'streaming', raw: '', lastPartial: null });
|
||||||
setGenerationId(null);
|
setGenerationId(null);
|
||||||
setTokens({});
|
setTokens({});
|
||||||
|
|
||||||
abortRef.current = new AbortController();
|
let id: string;
|
||||||
let raw = '';
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai/generate', {
|
const res = await fetch('/api/ai/generate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -95,11 +119,11 @@ export default function GenerateClient({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
templateId: templateId || null,
|
templateId: templateId || null,
|
||||||
userInput,
|
userInput,
|
||||||
|
includeHistory,
|
||||||
}),
|
}),
|
||||||
signal: abortRef.current.signal,
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
setPhase({
|
setPhase({
|
||||||
kind: 'failed',
|
kind: 'failed',
|
||||||
raw: '',
|
raw: '',
|
||||||
@@ -107,114 +131,140 @@ export default function GenerateClient({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!res.body) {
|
id = body.id;
|
||||||
setPhase({ kind: 'failed', raw: '', message: 'No response body.' });
|
setGenerationId(id);
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Parse SSE stream
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buf = '';
|
|
||||||
let done = false;
|
|
||||||
while (!done) {
|
|
||||||
const { value, done: d } = await reader.read();
|
|
||||||
if (d) {
|
|
||||||
done = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
buf += decoder.decode(value, { stream: true });
|
|
||||||
let idx;
|
|
||||||
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
||||||
const event = buf.slice(0, idx);
|
|
||||||
buf = buf.slice(idx + 2);
|
|
||||||
let evtName = 'message';
|
|
||||||
const dataLines: string[] = [];
|
|
||||||
for (const line of event.split('\n')) {
|
|
||||||
if (line.startsWith('event:')) evtName = line.slice(6).trim();
|
|
||||||
else if (line.startsWith('data:'))
|
|
||||||
dataLines.push(line.slice(5).trimStart());
|
|
||||||
}
|
|
||||||
if (!dataLines.length) continue;
|
|
||||||
const data = dataLines.join('\n');
|
|
||||||
let parsed: any;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(data);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (evtName === 'generation') {
|
|
||||||
setGenerationId(parsed.id);
|
|
||||||
} else if (evtName === 'text') {
|
|
||||||
raw += parsed.delta;
|
|
||||||
setPhase({ kind: 'streaming', raw });
|
|
||||||
} else if (evtName === 'usage') {
|
|
||||||
setTokens({ in: parsed.tokensIn, out: parsed.tokensOut });
|
|
||||||
} else if (evtName === 'complete') {
|
|
||||||
// Server already validated/stored the parsed program. We
|
|
||||||
// fetch the generation record AFTER the stream closes
|
|
||||||
// (below) to get the parsed JSON. Just record the
|
|
||||||
// success/failure outcome here; if it failed, render
|
|
||||||
// the error inline now since we're not going to fetch.
|
|
||||||
if (!parsed.parsedOk) {
|
|
||||||
setPhase({
|
|
||||||
kind: 'failed',
|
|
||||||
raw,
|
|
||||||
message: parsed.errorMessage ?? 'Failed to parse model output.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ((e as Error).name === 'AbortError') {
|
setPhase({ kind: 'failed', raw: '', message: (e as Error).message });
|
||||||
setPhase({ kind: 'failed', raw, message: 'Cancelled.' });
|
|
||||||
} else {
|
|
||||||
setPhase({
|
|
||||||
kind: 'failed',
|
|
||||||
raw,
|
|
||||||
message: (e as Error).message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// After stream closes, fetch the generation row to get the parsed
|
// Attach to the SSE stream.
|
||||||
// program (we don't try to re-parse client-side — server already did).
|
attachStream(id);
|
||||||
const id = generationIdRef.current;
|
};
|
||||||
if (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}`);
|
const r = await fetch(`/api/ai/generations/${id}`);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const gen = await r.json();
|
const gen = await r.json();
|
||||||
if (gen.status === 'completed' && gen.parsedProgram) {
|
if (gen.parsedProgram) {
|
||||||
|
const parsed = JSON.parse(gen.parsedProgram) as AIProgram;
|
||||||
|
// Auto-resolve exercises the model named but didn't (or wrongly)
|
||||||
|
// id'd against the library, so the user isn't asked to hand-map an
|
||||||
|
// exercise they already own. Ambiguous ones stay unmapped.
|
||||||
setPhase({
|
setPhase({
|
||||||
kind: 'parsed',
|
kind: 'parsed',
|
||||||
raw,
|
raw,
|
||||||
program: JSON.parse(gen.parsedProgram) as AIProgram,
|
program: {
|
||||||
|
...parsed,
|
||||||
|
weeks: parsed.weeks.map((w) => ({
|
||||||
|
...w,
|
||||||
|
days: w.days.map((d) => ({
|
||||||
|
...d,
|
||||||
|
exercises: resolveExerciseIds(d.exercises, exercises),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (gen.status === 'failed') {
|
}
|
||||||
|
}
|
||||||
setPhase({
|
setPhase({
|
||||||
kind: 'failed',
|
kind: 'failed',
|
||||||
raw,
|
raw,
|
||||||
message: gen.errorMessage ?? 'Failed.',
|
message: data.errorMessage ?? 'Failed to parse model output.',
|
||||||
});
|
});
|
||||||
return;
|
});
|
||||||
}
|
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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Capture the generationId in a ref so the async fetch after the
|
// Beforeunload warning while streaming — important since the user can
|
||||||
// stream has access to it (the closure above sees the initial null).
|
// CLOSE the tab and the generation continues server-side, but data
|
||||||
const generationIdRef = useRef<string | null>(null);
|
// sent after they close won't be visible until they re-open and look
|
||||||
|
// at history.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
generationIdRef.current = generationId;
|
if (!navWarning) return;
|
||||||
}, [generationId]);
|
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
const handleCancel = () => {
|
e.returnValue = '';
|
||||||
abortRef.current?.abort();
|
|
||||||
};
|
};
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -256,48 +306,90 @@ export default function GenerateClient({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
{phase.kind === 'streaming' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCancel}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded border border-red-900 text-red-400 text-xs uppercase tracking-wider hover:bg-red-900/30"
|
|
||||||
>
|
|
||||||
<Square className="w-3.5 h-3.5" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
disabled={!userInput.trim()}
|
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"
|
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" />
|
<Sparkles className="w-4 h-4" />
|
||||||
Generate
|
Generate
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{(phase.kind === 'streaming' || phase.kind === 'failed' || phase.kind === 'parsed') && (
|
{(phase.kind === 'streaming' || phase.kind === 'failed' || phase.kind === 'parsed') && (
|
||||||
<section className="space-y-3">
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
|
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
|
||||||
{phase.kind === 'streaming' ? 'Generating...' : 'Response'}
|
{phase.kind === 'streaming' ? 'Generating…' : 'Response'}
|
||||||
</h2>
|
</h2>
|
||||||
{(tokens.in != null || tokens.out != null) && (
|
|
||||||
<span className="text-[11px] text-zinc-500 uppercase tracking-wider">
|
<span className="text-[11px] text-zinc-500 uppercase tracking-wider">
|
||||||
{tokens.in ?? '?'} in · {tokens.out ?? '?'} out
|
{tokens.in != null && (
|
||||||
</span>
|
<>
|
||||||
|
{tokens.in} in · {tokens.out ?? '?'} out
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
{costStr && <> · {costStr}</>}
|
||||||
|
{tokens.durationMs != null && (
|
||||||
|
<> · {(tokens.durationMs / 1000).toFixed(1)}s</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{phase.kind === 'streaming' && (
|
{phase.kind === 'streaming' && (
|
||||||
<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">
|
<>
|
||||||
{phase.raw || '(waiting for first token...)'}
|
{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" />
|
<Loader2 className="inline w-3 h-3 animate-spin ml-2" />
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase.kind === 'failed' && (
|
{phase.kind === 'failed' && (
|
||||||
@@ -360,9 +452,14 @@ function ProgramPreview({
|
|||||||
let n = 0;
|
let n = 0;
|
||||||
for (const w of program.weeks)
|
for (const w of program.weeks)
|
||||||
for (const d of w.days)
|
for (const d of w.days)
|
||||||
for (const ex of d.exercises) if (!ex.exerciseId) n++;
|
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;
|
return n;
|
||||||
}, [program]);
|
}, [program, exerciseLookup]);
|
||||||
|
|
||||||
const setExerciseId = (
|
const setExerciseId = (
|
||||||
weekIdx: number,
|
weekIdx: number,
|
||||||
@@ -381,7 +478,6 @@ function ProgramPreview({
|
|||||||
setProgram((p) => {
|
setProgram((p) => {
|
||||||
const next = structuredClone(p);
|
const next = structuredClone(p);
|
||||||
next.weeks[weekIdx].days[dayIdx].exercises.splice(exIdx, 1);
|
next.weeks[weekIdx].days[dayIdx].exercises.splice(exIdx, 1);
|
||||||
// Renumber order
|
|
||||||
next.weeks[weekIdx].days[dayIdx].exercises.forEach(
|
next.weeks[weekIdx].days[dayIdx].exercises.forEach(
|
||||||
(ex: AIExercise, i: number) => {
|
(ex: AIExercise, i: number) => {
|
||||||
ex.order = i;
|
ex.order = i;
|
||||||
@@ -451,9 +547,7 @@ function ProgramPreview({
|
|||||||
>
|
>
|
||||||
<summary className="cursor-pointer px-3 py-2 text-sm text-white">
|
<summary className="cursor-pointer px-3 py-2 text-sm text-white">
|
||||||
Week {w.weekNumber}
|
Week {w.weekNumber}
|
||||||
{w.phase && (
|
{w.phase && <span className="text-zinc-500"> · {w.phase}</span>}
|
||||||
<span className="text-zinc-500"> · {w.phase}</span>
|
|
||||||
)}
|
|
||||||
<span className="text-zinc-600 text-xs">
|
<span className="text-zinc-600 text-xs">
|
||||||
{' '}
|
{' '}
|
||||||
({w.days.length} day{w.days.length === 1 ? '' : 's'})
|
({w.days.length} day{w.days.length === 1 ? '' : 's'})
|
||||||
@@ -476,14 +570,19 @@ function ProgramPreview({
|
|||||||
</p>
|
</p>
|
||||||
<ul className="mt-2 space-y-2">
|
<ul className="mt-2 space-y-2">
|
||||||
{d.exercises.map((ex, eIdx) => {
|
{d.exercises.map((ex, eIdx) => {
|
||||||
const isUnknown = !ex.exerciseId;
|
const isUnknown =
|
||||||
|
!ex.exerciseId || !exerciseLookup.has(ex.exerciseId);
|
||||||
const lib = ex.exerciseId
|
const lib = ex.exerciseId
|
||||||
? exerciseLookup.get(ex.exerciseId)
|
? exerciseLookup.get(ex.exerciseId)
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={eIdx}
|
key={eIdx}
|
||||||
className={`text-sm ${isUnknown ? 'bg-amber-950/30 border border-amber-900' : 'bg-zinc-950 border border-zinc-800'} rounded p-2`}
|
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="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -495,12 +594,15 @@ function ProgramPreview({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(ex.sets || ex.repsMin || ex.repsMax || ex.rpe || ex.restSeconds) && (
|
{(ex.sets || ex.repsMin || ex.repsMax || ex.rpe || ex.restSeconds || ex.suggestedWeight) && (
|
||||||
<div className="text-xs text-zinc-500 mt-0.5">
|
<div className="text-xs text-zinc-500 mt-0.5">
|
||||||
{ex.sets ? `${ex.sets}×` : ''}
|
{ex.sets ? `${ex.sets}×` : ''}
|
||||||
{ex.repsMin === ex.repsMax || !ex.repsMax
|
{ex.repsMin === ex.repsMax || !ex.repsMax
|
||||||
? (ex.repsMin ?? '?')
|
? (ex.repsMin ?? '?')
|
||||||
: `${ex.repsMin}-${ex.repsMax}`}
|
: `${ex.repsMin}-${ex.repsMax}`}
|
||||||
|
{ex.suggestedWeight != null && (
|
||||||
|
<> @ {ex.suggestedWeight}{ex.suggestedWeightUnit ?? ''}</>
|
||||||
|
)}
|
||||||
{ex.rpe ? ` @ RPE ${ex.rpe}` : ''}
|
{ex.rpe ? ` @ RPE ${ex.rpe}` : ''}
|
||||||
{ex.restSeconds ? ` · rest ${ex.restSeconds}s` : ''}
|
{ex.restSeconds ? ` · rest ${ex.restSeconds}s` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -523,14 +625,14 @@ function ProgramPreview({
|
|||||||
{isUnknown && (
|
{isUnknown && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<select
|
<select
|
||||||
value=""
|
value={ex.exerciseId ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setExerciseId(wIdx, dIdx, eIdx, e.target.value || null)
|
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"
|
className="w-full text-xs px-2 py-1 rounded border border-amber-900 bg-zinc-900 text-white"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
Map to existing exercise...
|
Map to existing exercise…
|
||||||
</option>
|
</option>
|
||||||
{exercises.map((opt) => (
|
{exercises.map((opt) => (
|
||||||
<option key={opt.id} value={opt.id}>
|
<option key={opt.id} value={opt.id}>
|
||||||
@@ -589,7 +691,7 @@ function ProgramPreview({
|
|||||||
{applying ? (
|
{applying ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="inline w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="inline w-4 h-4 animate-spin mr-2" />
|
||||||
Applying...
|
Applying…
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Apply this program'
|
'Apply this program'
|
||||||
@@ -613,3 +715,47 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
|||||||
</label>
|
</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,635 @@
|
|||||||
|
'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';
|
||||||
|
import { resolveExerciseIds } from '@/lib/ai/exerciseMatch';
|
||||||
|
import type { AiWorkoutDraft } from '@/lib/ai/workoutDraft';
|
||||||
|
|
||||||
|
interface LibraryExercise {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI output shape — mirrors lib/ai/workoutSchema.ts (AIWorkout).
|
||||||
|
interface AIWorkoutExercise {
|
||||||
|
exerciseId: string | null;
|
||||||
|
exerciseName: string;
|
||||||
|
order: number;
|
||||||
|
sets?: number | null;
|
||||||
|
reps?: number | null;
|
||||||
|
suggestedWeight?: number | null;
|
||||||
|
suggestedWeightUnit?: 'lbs' | 'kg' | null;
|
||||||
|
rpe?: number | null;
|
||||||
|
gear?: number | null;
|
||||||
|
durationSeconds?: number | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
interface AIWorkout {
|
||||||
|
name: string;
|
||||||
|
notes?: string | null;
|
||||||
|
exercises: AIWorkoutExercise[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ephemeral draft we hand to the New Workout form via sessionStorage.
|
||||||
|
export const AI_WORKOUT_DRAFT_KEY = 'ai-workout-draft';
|
||||||
|
|
||||||
|
type Phase =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'streaming'; raw: string; lastPartial: Partial<AIWorkout> | null }
|
||||||
|
| { kind: 'failed'; raw: string; message: string };
|
||||||
|
|
||||||
|
export default function GenerateWorkoutClient({
|
||||||
|
exercises,
|
||||||
|
providerLabel,
|
||||||
|
modelLabel,
|
||||||
|
workoutCount,
|
||||||
|
}: {
|
||||||
|
exercises: LibraryExercise[];
|
||||||
|
providerLabel: string;
|
||||||
|
modelLabel: string;
|
||||||
|
workoutCount: number;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [userInput, setUserInput] = useState('');
|
||||||
|
const [includeHistory, setIncludeHistory] = useState(workoutCount >= 1);
|
||||||
|
const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
|
||||||
|
// The editable suggestion once parsed. Lifted to the parent so the
|
||||||
|
// Refine action can send the user's current edits back as the prior
|
||||||
|
// workout. null until the first successful parse.
|
||||||
|
const [workout, setWorkout] = useState<AIWorkout | null>(null);
|
||||||
|
// Refine instruction lives here (not in WorkoutPreview) because the
|
||||||
|
// preview unmounts while streaming — keeping it in the parent means a
|
||||||
|
// failed refine doesn't lose what the user typed; we clear it only on
|
||||||
|
// a successful regeneration.
|
||||||
|
const [refineInput, setRefineInput] = useState('');
|
||||||
|
const [tokens, setTokens] = useState<{ in?: number; out?: number; durationMs?: number }>({});
|
||||||
|
const closeStreamRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
const streaming = phase.kind === 'streaming';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a generation. `priorWorkout` present → REVISION mode: `input`
|
||||||
|
* is the change instruction and the model re-emits the full workout.
|
||||||
|
*/
|
||||||
|
const runGeneration = async (input: string, priorWorkout?: AIWorkout) => {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
setPhase({ kind: 'streaming', raw: '', lastPartial: null });
|
||||||
|
setTokens({});
|
||||||
|
|
||||||
|
let id: string;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/generate-workout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
userInput: input,
|
||||||
|
includeHistory,
|
||||||
|
priorWorkout: priorWorkout ?? null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
setPhase({ kind: 'failed', raw: '', message: body.error ?? `HTTP ${res.status}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
id = body.id;
|
||||||
|
} catch (e) {
|
||||||
|
setPhase({ kind: 'failed', raw: '', message: (e as Error).message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachStream(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachStream = (id: string) => {
|
||||||
|
const es = new EventSource(`/api/ai/generations/${id}/stream`);
|
||||||
|
closeStreamRef.current = () => es.close();
|
||||||
|
let raw = '';
|
||||||
|
let lastPartial: Partial<AIWorkout> | null = null;
|
||||||
|
|
||||||
|
es.addEventListener('text', (ev) => {
|
||||||
|
const data = JSON.parse((ev as MessageEvent).data);
|
||||||
|
raw += data.delta;
|
||||||
|
const next = lenientJsonParse(raw) as Partial<AIWorkout> | null;
|
||||||
|
if (next) lastPartial = next; // sticky — kills flicker between parses
|
||||||
|
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) {
|
||||||
|
const r = await fetch(`/api/ai/generations/${id}`);
|
||||||
|
if (r.ok) {
|
||||||
|
const gen = await r.json();
|
||||||
|
if (gen.parsedProgram) {
|
||||||
|
const parsed = JSON.parse(gen.parsedProgram) as AIWorkout;
|
||||||
|
// Auto-resolve exercises the model named but didn't (or wrongly)
|
||||||
|
// id'd against the library, so the user isn't asked to hand-map an
|
||||||
|
// exercise they already own (e.g. "Overhead Press" -> "Overhead
|
||||||
|
// Press (barbell)"). Ambiguous ones stay unmapped.
|
||||||
|
setWorkout({
|
||||||
|
...parsed,
|
||||||
|
exercises: resolveExerciseIds(parsed.exercises, exercises),
|
||||||
|
});
|
||||||
|
setRefineInput(''); // consumed — clear only on success
|
||||||
|
setPhase({ kind: 'idle' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPhase({
|
||||||
|
kind: 'failed',
|
||||||
|
raw,
|
||||||
|
message: data.errorMessage ?? 'Failed to parse model output.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
es.onerror = () => {
|
||||||
|
if (es.readyState === EventSource.CLOSED) {
|
||||||
|
closeStreamRef.current = null;
|
||||||
|
setPhase((p) =>
|
||||||
|
p.kind === 'streaming'
|
||||||
|
? {
|
||||||
|
kind: 'failed',
|
||||||
|
raw: p.raw,
|
||||||
|
message:
|
||||||
|
'Stream disconnected. The generation may still be running — check AI · History.',
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Warn before unload while streaming (the runner keeps going server-side).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!streaming) return;
|
||||||
|
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', onBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
||||||
|
}, [streaming]);
|
||||||
|
|
||||||
|
// Detach on unmount; the server keeps generating regardless.
|
||||||
|
useEffect(() => () => closeStreamRef.current?.(), []);
|
||||||
|
|
||||||
|
const costStr = useMemo(() => {
|
||||||
|
if (tokens.in == null || tokens.out == null) return null;
|
||||||
|
return formatCost(
|
||||||
|
estimateCost({
|
||||||
|
provider: providerLabel,
|
||||||
|
model: modelLabel,
|
||||||
|
tokensIn: tokens.in,
|
||||||
|
tokensOut: tokens.out,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [providerLabel, modelLabel, tokens.in, tokens.out]);
|
||||||
|
|
||||||
|
const showResult = streaming || phase.kind === 'failed' || workout != null;
|
||||||
|
|
||||||
|
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="Describe today's workout">
|
||||||
|
<textarea
|
||||||
|
value={userInput}
|
||||||
|
onChange={(e) => setUserInput(e.target.value)}
|
||||||
|
placeholder="e.g. Upper body, focus on shoulders. Overhead press, 4 working sets. Abs: landmine rotation, oblique cable, landmine hollow-body hold. Pull-ups, biceps and triceps."
|
||||||
|
rows={6}
|
||||||
|
className={inputClass}
|
||||||
|
disabled={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={streaming || workoutCount === 0}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Use my history to suggest weights{' '}
|
||||||
|
<span className="text-zinc-500">
|
||||||
|
({workoutCount === 0
|
||||||
|
? 'no workouts logged yet — disabled'
|
||||||
|
: 'last 90 days · recent working weights per exercise'}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runGeneration(userInput)}
|
||||||
|
disabled={!userInput.trim() || 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" />
|
||||||
|
{workout ? 'Regenerate' : 'Generate workout'}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{showResult && (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
|
||||||
|
{streaming ? 'Generating…' : 'Suggested workout'}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!streaming && workout && (
|
||||||
|
<WorkoutPreview
|
||||||
|
workout={workout}
|
||||||
|
setWorkout={(updater) => setWorkout((w) => (w ? updater(w) : w))}
|
||||||
|
exercises={exercises}
|
||||||
|
refineInput={refineInput}
|
||||||
|
setRefineInput={setRefineInput}
|
||||||
|
onRefine={() => runGeneration(refineInput, workout)}
|
||||||
|
onUse={(draft) => {
|
||||||
|
sessionStorage.setItem(AI_WORKOUT_DRAFT_KEY, JSON.stringify(draft));
|
||||||
|
router.push('/main/workouts/new?from=ai');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkoutPreview({
|
||||||
|
workout,
|
||||||
|
setWorkout,
|
||||||
|
exercises,
|
||||||
|
refineInput,
|
||||||
|
setRefineInput,
|
||||||
|
onRefine,
|
||||||
|
onUse,
|
||||||
|
}: {
|
||||||
|
workout: AIWorkout;
|
||||||
|
setWorkout: (updater: (w: AIWorkout) => AIWorkout) => void;
|
||||||
|
exercises: LibraryExercise[];
|
||||||
|
refineInput: string;
|
||||||
|
setRefineInput: (v: string) => void;
|
||||||
|
onRefine: () => void;
|
||||||
|
onUse: (draft: AiWorkoutDraft) => void;
|
||||||
|
}) {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const exerciseLookup = useMemo(
|
||||||
|
() => new Map(exercises.map((e) => [e.id, e])),
|
||||||
|
[exercises],
|
||||||
|
);
|
||||||
|
|
||||||
|
const unresolvedCount = useMemo(
|
||||||
|
() =>
|
||||||
|
workout.exercises.filter(
|
||||||
|
(ex) => !ex.exerciseId || !exerciseLookup.has(ex.exerciseId),
|
||||||
|
).length,
|
||||||
|
[workout, exerciseLookup],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateExercise = (
|
||||||
|
idx: number,
|
||||||
|
patch: Partial<AIWorkoutExercise>,
|
||||||
|
) => {
|
||||||
|
setWorkout((w) => {
|
||||||
|
const next = structuredClone(w);
|
||||||
|
next.exercises[idx] = { ...next.exercises[idx], ...patch };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeExercise = (idx: number) => {
|
||||||
|
setWorkout((w) => {
|
||||||
|
const next = structuredClone(w);
|
||||||
|
next.exercises.splice(idx, 1);
|
||||||
|
next.exercises.forEach((ex, i) => (ex.order = i));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const numOrNull = (v: string) => {
|
||||||
|
if (v.trim() === '') return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUse = () => {
|
||||||
|
if (unresolvedCount > 0) {
|
||||||
|
setError(`Map or remove the ${unresolvedCount} unknown exercise(s) first.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
onUse({
|
||||||
|
name: workout.name,
|
||||||
|
notes: workout.notes ?? undefined,
|
||||||
|
exercises: workout.exercises.map((ex) => ({
|
||||||
|
exerciseId: ex.exerciseId!,
|
||||||
|
sets: ex.sets && ex.sets > 0 ? ex.sets : 3,
|
||||||
|
reps: ex.reps ?? undefined,
|
||||||
|
suggestedWeight: ex.suggestedWeight ?? undefined,
|
||||||
|
suggestedWeightUnit: ex.suggestedWeightUnit ?? undefined,
|
||||||
|
rpe: ex.rpe ?? undefined,
|
||||||
|
gear: ex.gear ?? undefined,
|
||||||
|
durationSeconds: ex.durationSeconds ?? undefined,
|
||||||
|
notes: ex.notes ?? undefined,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
value={workout.name}
|
||||||
|
onChange={(e) => setWorkout((w) => ({ ...w, name: e.target.value }))}
|
||||||
|
className="text-lg font-bold text-white bg-transparent border-b border-transparent hover:border-zinc-700 focus:border-zinc-500 focus:outline-none w-full"
|
||||||
|
/>
|
||||||
|
{workout.notes && (
|
||||||
|
<p className="text-sm text-zinc-400 mt-1 italic">{workout.notes}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
{workout.exercises.length} exercise
|
||||||
|
{workout.exercises.length === 1 ? '' : 's'}
|
||||||
|
</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 using this workout.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{workout.exercises.map((ex, idx) => {
|
||||||
|
const isUnknown = !ex.exerciseId || !exerciseLookup.has(ex.exerciseId);
|
||||||
|
const lib = ex.exerciseId ? exerciseLookup.get(ex.exerciseId) : null;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className={`rounded p-3 ${
|
||||||
|
isUnknown
|
||||||
|
? 'bg-amber-950/30 border border-amber-900'
|
||||||
|
: 'bg-zinc-950 border border-zinc-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="text-white text-sm">
|
||||||
|
{lib?.name ?? ex.exerciseName}
|
||||||
|
{isUnknown && (
|
||||||
|
<span className="ml-2 text-[10px] uppercase tracking-wider text-amber-400">
|
||||||
|
not in library
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeExercise(idx)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 px-1"
|
||||||
|
title="Remove from workout"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isUnknown && (
|
||||||
|
<select
|
||||||
|
value={ex.exerciseId ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateExercise(idx, { exerciseId: e.target.value || null })
|
||||||
|
}
|
||||||
|
className="mt-2 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 className="mt-2 grid grid-cols-3 gap-2">
|
||||||
|
<NumField
|
||||||
|
label="Sets"
|
||||||
|
value={ex.sets}
|
||||||
|
onChange={(v) => updateExercise(idx, { sets: v })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label="Reps"
|
||||||
|
value={ex.reps}
|
||||||
|
onChange={(v) => updateExercise(idx, { reps: v })}
|
||||||
|
/>
|
||||||
|
<NumField
|
||||||
|
label={`Weight${ex.suggestedWeightUnit ? ` (${ex.suggestedWeightUnit})` : ''}`}
|
||||||
|
value={ex.suggestedWeight}
|
||||||
|
step="any"
|
||||||
|
onChange={(v) => updateExercise(idx, { suggestedWeight: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{ex.notes && (
|
||||||
|
<div className="text-xs text-zinc-400 mt-2 italic">{ex.notes}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="border-t border-zinc-800 pt-4 space-y-3">
|
||||||
|
<Field label="Refine (send a change back to the AI)">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={refineInput}
|
||||||
|
onChange={(e) => setRefineInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Cleared by the parent only on a successful regeneration, so
|
||||||
|
// a failed refine keeps what the user typed.
|
||||||
|
if (e.key === 'Enter' && refineInput.trim()) onRefine();
|
||||||
|
}}
|
||||||
|
placeholder="e.g. make overhead press 5 sets; swap the oblique exercise"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (refineInput.trim()) onRefine();
|
||||||
|
}}
|
||||||
|
disabled={!refineInput.trim()}
|
||||||
|
className="shrink-0 px-4 py-2 rounded bg-zinc-700 text-white font-bold text-xs uppercase tracking-wider hover:bg-zinc-600 disabled:bg-zinc-800 disabled:text-zinc-600"
|
||||||
|
>
|
||||||
|
Refine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{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={handleUse}
|
||||||
|
disabled={unresolvedCount > 0 || workout.exercises.length === 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"
|
||||||
|
>
|
||||||
|
Use this workout
|
||||||
|
</button>
|
||||||
|
<p className="text-[11px] text-zinc-500">
|
||||||
|
Opens a pre-filled workout — nothing is saved until you save it there.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function NumField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
step,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value?: number | null;
|
||||||
|
step?: string;
|
||||||
|
onChange: (v: number | null) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider block mb-0.5">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step={step}
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(numOrNull(e.target.value))}
|
||||||
|
className="w-full px-2 py-1 text-sm rounded border border-zinc-700 bg-zinc-800 text-white focus:outline-none focus:ring-1 focus:ring-white/30"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AIWorkout> }) {
|
||||||
|
const exercises = (partial.exercises as AIWorkoutExercise[] | 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 workout…{' '}
|
||||||
|
{partial.name && (
|
||||||
|
<span className="text-white font-semibold">{partial.name}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{exercises.length > 0 && (
|
||||||
|
<ul className="text-xs text-zinc-300 space-y-1">
|
||||||
|
{exercises.map((ex, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<span className="text-zinc-500">{(ex?.order ?? i) + 1}.</span>{' '}
|
||||||
|
{ex?.exerciseName ?? '…'}
|
||||||
|
{ex?.sets ? (
|
||||||
|
<span className="text-zinc-500">
|
||||||
|
{' '}
|
||||||
|
· {ex.sets}×{ex.reps ?? '?'}
|
||||||
|
{ex.suggestedWeight != null
|
||||||
|
? ` @ ${ex.suggestedWeight}${ex.suggestedWeightUnit ?? ''}`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</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`;
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Trash2, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
|
import { Trash2, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { estimateCost, formatCost } from '@/lib/ai/pricing';
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,6 +13,7 @@ interface Row {
|
|||||||
model: string;
|
model: string;
|
||||||
tokensIn: number | null;
|
tokensIn: number | null;
|
||||||
tokensOut: number | null;
|
tokensOut: number | null;
|
||||||
|
durationMs: number | null;
|
||||||
status: string;
|
status: string;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
appliedProgramId: string | null;
|
appliedProgramId: string | null;
|
||||||
@@ -26,6 +28,34 @@ export default function HistoryList({
|
|||||||
const [rows, setRows] = useState(initialRows);
|
const [rows, setRows] = useState(initialRows);
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
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) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm('Delete this generation? The applied Program (if any) stays.'))
|
if (!confirm('Delete this generation? The applied Program (if any) stays.'))
|
||||||
return;
|
return;
|
||||||
@@ -48,15 +78,27 @@ export default function HistoryList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<ul className="space-y-3">
|
||||||
{rows.map((r) => (
|
{rowsWithCost.map((r) => (
|
||||||
<li
|
<li
|
||||||
key={r.id}
|
key={r.id}
|
||||||
className="bg-zinc-900 border border-zinc-800 rounded p-4"
|
className="bg-zinc-900 border border-zinc-800 rounded p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<Link
|
||||||
<div className="flex items-center gap-2 text-xs text-zinc-500 uppercase tracking-wider">
|
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} />
|
<StatusBadge status={r.status} />
|
||||||
<span>{new Date(r.createdAt).toLocaleString()}</span>
|
<span>{new Date(r.createdAt).toLocaleString()}</span>
|
||||||
<span className="text-zinc-600">·</span>
|
<span className="text-zinc-600">·</span>
|
||||||
@@ -71,6 +113,22 @@ export default function HistoryList({
|
|||||||
</span>
|
</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>
|
</div>
|
||||||
{r.templateName && (
|
{r.templateName && (
|
||||||
<p className="text-xs text-zinc-400 mt-1">
|
<p className="text-xs text-zinc-400 mt-1">
|
||||||
@@ -86,14 +144,11 @@ export default function HistoryList({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{r.appliedProgramId && (
|
{r.appliedProgramId && (
|
||||||
<Link
|
<span className="inline-block text-xs text-emerald-400 mt-2">
|
||||||
href={`/main/programs/${r.appliedProgramId}`}
|
✓ applied to a program
|
||||||
className="inline-block text-xs text-emerald-400 underline mt-2"
|
</span>
|
||||||
>
|
|
||||||
View applied program →
|
|
||||||
</Link>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDelete(r.id)}
|
onClick={() => handleDelete(r.id)}
|
||||||
@@ -111,9 +166,18 @@ export default function HistoryList({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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 }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const map: Record<string, { color: string; icon: typeof CheckCircle2 }> = {
|
const map: Record<string, { color: string; icon: typeof CheckCircle2 }> = {
|
||||||
pending: { color: 'text-zinc-400', icon: Loader2 },
|
pending: { color: 'text-zinc-400', icon: Loader2 },
|
||||||
|
|||||||
@@ -1,79 +1,519 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Loader2 } from 'lucide-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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// UI-side provider metadata. `requiresUrl` mirrors the `requiresBaseUrl` flag
|
||||||
|
// on the server providers (lib/ai/providers); keep the two in sync when adding
|
||||||
|
// a provider. `requiresUrl: true` ⇒ custom-URL ⇒ admin-only (see configs API).
|
||||||
const PROVIDERS = [
|
const PROVIDERS = [
|
||||||
{ id: 'claude', label: 'Anthropic Claude', requiresKey: true, requiresUrl: false, modelHint: 'claude-sonnet-4-5 / claude-opus-4-5' },
|
{ id: 'claude', label: 'Anthropic Claude', requiresKey: true, requiresUrl: false },
|
||||||
{ id: 'openai', label: 'OpenAI', requiresKey: true, requiresUrl: false, modelHint: 'gpt-5 / gpt-5-mini' },
|
{ id: 'openai', label: 'OpenAI', requiresKey: true, requiresUrl: false },
|
||||||
{ id: 'openai-compatible', label: 'OpenAI-compatible (custom URL)', requiresKey: true, requiresUrl: true, modelHint: 'whatever your gateway exposes' },
|
{
|
||||||
{ id: 'gemini', label: 'Google Gemini', requiresKey: true, requiresUrl: false, modelHint: 'gemini-2.0-flash / gemini-2.5-pro' },
|
id: 'openai-compatible',
|
||||||
{ id: 'ollama', label: 'Ollama (self-hosted)', requiresKey: false, requiresUrl: true, modelHint: 'llama3.1:8b / qwen2.5:14b' },
|
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 },
|
||||||
|
{
|
||||||
|
id: 'sparkcontrol',
|
||||||
|
label: 'SparkControl (local)',
|
||||||
|
requiresKey: false,
|
||||||
|
requiresUrl: true,
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
interface Config {
|
type ProviderId = (typeof PROVIDERS)[number]['id'];
|
||||||
aiProvider: string | null;
|
|
||||||
aiModel: string | null;
|
interface SavedConfig {
|
||||||
aiBaseUrl: string | null;
|
id: string;
|
||||||
aiKeyConfigured: boolean;
|
name: string;
|
||||||
|
provider: ProviderId;
|
||||||
|
model: string;
|
||||||
|
baseUrl: string | null;
|
||||||
|
keyConfigured: boolean;
|
||||||
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AIIntegration() {
|
type TestResult =
|
||||||
const [cfg, setCfg] = useState<Config | null>(null);
|
| { ok: true; sample: string; tokensIn?: number; tokensOut?: number; ms: number }
|
||||||
const [provider, setProvider] = useState<string>('');
|
| { ok: false; error: string; ms?: number };
|
||||||
const [model, setModel] = useState('');
|
|
||||||
const [baseUrl, setBaseUrl] = useState('');
|
export default function AIIntegration({ isAdmin }: { isAdmin: boolean }) {
|
||||||
const [apiKey, setApiKey] = useState('');
|
const [configs, setConfigs] = useState<SavedConfig[]>([]);
|
||||||
const [showKey, setShowKey] = useState(false);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [keyDirty, setKeyDirty] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetch('/api/ai/config')
|
refresh();
|
||||||
.then((r) => r.json())
|
|
||||||
.then((c) => {
|
|
||||||
setCfg(c);
|
|
||||||
setProvider(c.aiProvider ?? '');
|
|
||||||
setModel(c.aiModel ?? '');
|
|
||||||
setBaseUrl(c.aiBaseUrl ?? '');
|
|
||||||
})
|
|
||||||
.catch(() => setError('Failed to load AI config.'));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
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
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
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,
|
||||||
|
isAdmin,
|
||||||
|
isActive,
|
||||||
|
isEditing,
|
||||||
|
onActivate,
|
||||||
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
cfg: SavedConfig;
|
||||||
|
isAdmin: boolean;
|
||||||
|
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
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
initial={cfg}
|
||||||
|
onCancel={onEdit}
|
||||||
|
onCreated={onSaved}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigFormProps {
|
||||||
|
/** When set: editing this saved config (PATCH). Otherwise: creating new (POST). */
|
||||||
|
initial?: SavedConfig;
|
||||||
|
/** Custom-URL providers (Ollama / OpenAI-compatible) are admin-only. */
|
||||||
|
isAdmin: boolean;
|
||||||
|
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, isAdmin, onCancel, onCreated }: ConfigFormProps) {
|
||||||
|
const isEdit = !!initial;
|
||||||
|
// Non-admins can't configure custom-URL providers — hide them from the
|
||||||
|
// dropdown (the server enforces this too; see app/api/ai/configs).
|
||||||
|
const availableProviders = isAdmin
|
||||||
|
? PROVIDERS
|
||||||
|
: PROVIDERS.filter((p) => !p.requiresUrl);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// SparkControl auto-detect (the currently-loaded vLLM model via /api/endpoints).
|
||||||
|
const [sparkModel, setSparkModel] = useState<string | null>(null);
|
||||||
|
const [sparkProbing, setSparkProbing] = useState(false);
|
||||||
|
const [sparkProbeError, setSparkProbeError] = useState<string | null>(null);
|
||||||
|
|
||||||
const meta = PROVIDERS.find((p) => p.id === provider);
|
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]);
|
||||||
|
|
||||||
|
// Probe SparkControl on provider switch (or baseUrl change while selected):
|
||||||
|
// pre-fill the canonical same-box URL and the loaded model name.
|
||||||
|
useEffect(() => {
|
||||||
|
if (provider !== 'sparkcontrol') {
|
||||||
|
setSparkModel(null);
|
||||||
|
setSparkProbeError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setSparkProbing(true);
|
||||||
|
setSparkProbeError(null);
|
||||||
|
const url = baseUrl
|
||||||
|
? `/api/ai/sparkcontrol/model?baseUrl=${encodeURIComponent(baseUrl)}`
|
||||||
|
: '/api/ai/sparkcontrol/model';
|
||||||
|
fetch(url)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((b) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (b.ok) {
|
||||||
|
setSparkModel(b.model ?? null);
|
||||||
|
setSparkProbeError(null);
|
||||||
|
// Pre-fill the URL (with /v1) if the user hadn't typed one yet.
|
||||||
|
if (!baseUrl && b.baseUrl) setBaseUrl(b.baseUrl);
|
||||||
|
// Pre-pick the loaded model in create mode if the field is empty.
|
||||||
|
if (!isEdit && !model && b.model) setModel(b.model);
|
||||||
|
} else {
|
||||||
|
setSparkModel(null);
|
||||||
|
setSparkProbeError(b.error ?? 'Probe failed');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) setSparkProbeError((e as Error).message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setSparkProbing(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// 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 () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(false);
|
|
||||||
try {
|
try {
|
||||||
const body: Record<string, string | null> = {
|
const body: Record<string, unknown> = {
|
||||||
aiProvider: provider || null,
|
name: name || undefined,
|
||||||
aiModel: model || null,
|
provider,
|
||||||
aiBaseUrl: baseUrl || null,
|
model,
|
||||||
|
baseUrl: baseUrl || null,
|
||||||
};
|
};
|
||||||
// Only send apiKey if it was changed (avoids stomping a stored key
|
if (apiKey) body.apiKey = apiKey;
|
||||||
// when the user just edits the model name).
|
if (!isEdit) body.setActive = setActive;
|
||||||
if (keyDirty) body.aiApiKey = apiKey || null;
|
|
||||||
|
|
||||||
const res = await fetch('/api/ai/config', {
|
const url = isEdit ? `/api/ai/configs/${initial.id}` : '/api/ai/configs';
|
||||||
method: 'POST',
|
const method = isEdit ? 'PATCH' : 'POST';
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method,
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!r.ok) {
|
||||||
const b = await res.json().catch(() => ({}));
|
const b = await r.json().catch(() => ({}));
|
||||||
throw new Error(b.error ?? `HTTP ${res.status}`);
|
throw new Error(b.error ?? `HTTP ${r.status}`);
|
||||||
}
|
}
|
||||||
setSuccess(true);
|
onCreated();
|
||||||
setKeyDirty(false);
|
|
||||||
setApiKey('');
|
|
||||||
// Refresh the "configured" indicator
|
|
||||||
const c = await (await fetch('/api/ai/config')).json();
|
|
||||||
setCfg(c);
|
|
||||||
setTimeout(() => setSuccess(false), 4000);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -81,46 +521,184 @@ export default function AIIntegration() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleTestDraft = async () => {
|
||||||
<section className="bg-zinc-900 border border-zinc-800 rounded-lg p-6 space-y-4">
|
setTesting(true);
|
||||||
<header>
|
setTestResult(null);
|
||||||
<h2 className="text-lg font-bold text-white">AI integration</h2>
|
try {
|
||||||
<p className="text-sm text-zinc-500 mt-1">
|
const r = await fetch('/api/ai/test', {
|
||||||
Connect a model to generate training programs from natural-language
|
method: 'POST',
|
||||||
prompts. Pick a provider, enter a model + key, and the{' '}
|
headers: { 'content-type': 'application/json' },
|
||||||
<span className="text-zinc-300">AI → Generate</span> page will use
|
body: JSON.stringify({
|
||||||
it. Self-hosted Ollama running on your StartOS host needs no key —
|
provider,
|
||||||
just point Base URL at it (e.g.{' '}
|
model,
|
||||||
<code className="text-zinc-400">http://ollama.embassy:11434</code>).
|
baseUrl: baseUrl || null,
|
||||||
</p>
|
apiKey: apiKey || null,
|
||||||
</header>
|
// 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>
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Field label="Provider">
|
<Field label="Provider">
|
||||||
<select
|
<select
|
||||||
value={provider}
|
value={provider}
|
||||||
onChange={(e) => setProvider(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setProvider(e.target.value as ProviderId);
|
||||||
|
setModel(''); // reset on provider change
|
||||||
|
setModelMode('menu');
|
||||||
|
// Clear any URL typed for a previous (custom-URL) provider so it
|
||||||
|
// can't ride along to a fixed-URL provider whose field is hidden.
|
||||||
|
setBaseUrl('');
|
||||||
|
}}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
|
disabled={isEdit}
|
||||||
>
|
>
|
||||||
<option value="">— Disabled (no AI) —</option>
|
{availableProviders.map((p) => (
|
||||||
{PROVIDERS.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{p.label}
|
{p.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
</Field>
|
||||||
|
|
||||||
{provider && (
|
{/* Ollama: replace the model dropdown with installed models if probe succeeded */}
|
||||||
|
{provider === 'ollama' ? (
|
||||||
|
<Field
|
||||||
|
label={
|
||||||
<>
|
<>
|
||||||
<Field label="Model">
|
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
|
<input
|
||||||
value={model}
|
value={model}
|
||||||
onChange={(e) => setModel(e.target.value)}
|
onChange={(e) => setModel(e.target.value)}
|
||||||
placeholder={meta?.modelHint ?? ''}
|
placeholder="llama3.1:8b · qwen2.5:14b · mistral:7b"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
) : provider === 'sparkcontrol' ? (
|
||||||
|
<Field
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
Model{' '}
|
||||||
|
{sparkProbing ? (
|
||||||
|
<span className="text-zinc-500 normal-case font-normal">· detecting…</span>
|
||||||
|
) : sparkModel ? (
|
||||||
|
<span className="text-emerald-400 normal-case font-normal">· detected</span>
|
||||||
|
) : sparkProbeError ? (
|
||||||
|
<span className="text-amber-400 normal-case font-normal">
|
||||||
|
· could not reach SparkControl (type a name)
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
placeholder={sparkModel ?? 'auto-detected from SparkControl'}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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 && (
|
{meta?.requiresUrl && (
|
||||||
<Field label="Base URL">
|
<Field label="Base URL">
|
||||||
@@ -129,7 +707,9 @@ export default function AIIntegration() {
|
|||||||
onChange={(e) => setBaseUrl(e.target.value)}
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
placeholder={
|
placeholder={
|
||||||
meta.id === 'ollama'
|
meta.id === 'ollama'
|
||||||
? 'http://ollama.embassy:11434'
|
? 'http://ollama.startos:11434'
|
||||||
|
: meta.id === 'sparkcontrol'
|
||||||
|
? 'http://spark-control.startos:9999/v1'
|
||||||
: 'https://your-gateway.example.com/v1'
|
: 'https://your-gateway.example.com/v1'
|
||||||
}
|
}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
@@ -140,21 +720,23 @@ export default function AIIntegration() {
|
|||||||
{meta?.requiresKey && (
|
{meta?.requiresKey && (
|
||||||
<Field
|
<Field
|
||||||
label={
|
label={
|
||||||
cfg?.aiKeyConfigured && !keyDirty
|
<>
|
||||||
? 'API key (configured — leave blank to keep)'
|
API key{' '}
|
||||||
: '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">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type={showKey ? 'text' : 'password'}
|
type={showKey ? 'text' : 'password'}
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => {
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
setApiKey(e.target.value);
|
|
||||||
setKeyDirty(true);
|
|
||||||
}}
|
|
||||||
placeholder={
|
placeholder={
|
||||||
cfg?.aiKeyConfigured && !keyDirty ? '••••••••' : 'sk-...'
|
isEdit && initial?.keyConfigured ? '•••••••• (saved)' : 'sk-...'
|
||||||
}
|
}
|
||||||
className={`${inputClass} pr-12`}
|
className={`${inputClass} pr-12`}
|
||||||
/>
|
/>
|
||||||
@@ -167,12 +749,21 @@ export default function AIIntegration() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-zinc-500 mt-1">
|
<p className="text-[11px] text-zinc-500 mt-1">
|
||||||
Stored plaintext in /data/app.db. Kept inside your StartOS
|
Stored plaintext in /data/app.db on your StartOS host. Never sent
|
||||||
host; never sent anywhere except the provider you pick.
|
anywhere except the provider you pick.
|
||||||
</p>
|
</p>
|
||||||
</Field>
|
</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 && (
|
{error && (
|
||||||
@@ -180,36 +771,91 @@ export default function AIIntegration() {
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{success && (
|
{testResult && (
|
||||||
<div className="rounded bg-emerald-900/40 px-3 py-2 border border-emerald-800 text-xs text-emerald-300">
|
<div
|
||||||
Saved.
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
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"
|
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 ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="inline w-4 h-4 animate-spin mr-2" />
|
<Loader2 className="inline w-4 h-4 animate-spin mr-2" />
|
||||||
Saving...
|
Saving…
|
||||||
</>
|
</>
|
||||||
|
) : isEdit ? (
|
||||||
|
'Save changes'
|
||||||
) : (
|
) : (
|
||||||
'Save AI config'
|
'Add this config'
|
||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputClass =
|
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';
|
'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 }) {
|
function Field({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider block mb-1">
|
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider block mb-1">
|
||||||
|
|||||||
@@ -191,15 +191,17 @@ export default function SettingsForm({ user }: { user: User }) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Database Import Section */}
|
{/* Database Import Section */}
|
||||||
<DatabaseExport />
|
<DatabaseExport isAdmin={user.isAdmin} />
|
||||||
<WorkoutCsvImportShortcut />
|
<WorkoutCsvImportShortcut />
|
||||||
<DatabaseImport />
|
{/* Whole-instance DB replace is admin-only (it overwrites every
|
||||||
|
user's data); the per-user CSV import above stays for everyone. */}
|
||||||
|
{user.isAdmin && <DatabaseImport />}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Database Export Component ----------
|
// ---------- Database Export Component ----------
|
||||||
function DatabaseExport() {
|
function DatabaseExport({ isAdmin }: { isAdmin: boolean }) {
|
||||||
const [exportingDb, setExportingDb] = useState(false);
|
const [exportingDb, setExportingDb] = useState(false);
|
||||||
const [exportingCsv, setExportingCsv] = useState(false);
|
const [exportingCsv, setExportingCsv] = useState(false);
|
||||||
const [exportError, setExportError] = useState<string | null>(null);
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
@@ -246,7 +248,9 @@ function DatabaseExport() {
|
|||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
||||||
<h2 className="text-lg font-bold text-white mb-1">Export Backups</h2>
|
<h2 className="text-lg font-bold text-white mb-1">Export Backups</h2>
|
||||||
<p className="text-sm text-zinc-500 mb-4">
|
<p className="text-sm text-zinc-500 mb-4">
|
||||||
Download a full database backup or a CSV export of workout logs.
|
{isAdmin
|
||||||
|
? "Download a full database backup or a CSV export of workout logs."
|
||||||
|
: "Download a CSV export of your workout logs."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{exportError && (
|
{exportError && (
|
||||||
@@ -257,6 +261,7 @@ function DatabaseExport() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -281,6 +286,7 @@ function DatabaseExport() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Exercise } from '@prisma/client';
|
||||||
|
import WorkoutForm, { EditWorkoutData } from './WorkoutForm';
|
||||||
|
import { AI_WORKOUT_DRAFT_KEY } from '@/components/ai/GenerateWorkoutClient';
|
||||||
|
import { buildPrefillExercises, type AiWorkoutDraft } from '@/lib/ai/workoutDraft';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the ephemeral AI workout draft from sessionStorage (stashed by
|
||||||
|
* GenerateWorkoutClient before navigating to /main/workouts/new?from=ai),
|
||||||
|
* expands each suggested exercise into N pre-filled SetLogs, and renders
|
||||||
|
* the normal WorkoutForm. Nothing is persisted until the user saves
|
||||||
|
* through the regular workout path.
|
||||||
|
*
|
||||||
|
* The draft has no workout id, so WorkoutForm's first save CREATEs.
|
||||||
|
* Effort follows the app convention: cardio → gear (1-5), else → rpe.
|
||||||
|
*
|
||||||
|
* If the draft is missing (e.g. a refresh cleared it), we fall back to a
|
||||||
|
* blank form so the page is never broken.
|
||||||
|
*/
|
||||||
|
export default function AiWorkoutPrefill({
|
||||||
|
exercises,
|
||||||
|
}: {
|
||||||
|
exercises: Exercise[];
|
||||||
|
}) {
|
||||||
|
// Read + build once on mount via a lazy initializer. This stays PURE
|
||||||
|
// (no sessionStorage mutation) so React's StrictMode double-invoke is
|
||||||
|
// safe — both passes read the same draft. The one-shot removal happens
|
||||||
|
// in the effect below, after the value is captured.
|
||||||
|
const [editWorkout] = useState<EditWorkoutData | undefined>(() => {
|
||||||
|
if (typeof window === 'undefined') return undefined;
|
||||||
|
const raw = sessionStorage.getItem(AI_WORKOUT_DRAFT_KEY);
|
||||||
|
if (!raw) return undefined;
|
||||||
|
|
||||||
|
let draft: AiWorkoutDraft;
|
||||||
|
try {
|
||||||
|
draft = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const builtExercises: EditWorkoutData['exercises'] =
|
||||||
|
buildPrefillExercises(draft, exercises);
|
||||||
|
|
||||||
|
if (builtExercises.length === 0) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// No id → first save CREATEs a new workout.
|
||||||
|
name: draft.name || '',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
notes: draft.notes,
|
||||||
|
exercises: builtExercises,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the one-shot draft after mount so a manual reload starts blank.
|
||||||
|
// removeItem is idempotent, so StrictMode's double-run is harmless.
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.removeItem(AI_WORKOUT_DRAFT_KEY);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkoutForm
|
||||||
|
exercises={exercises}
|
||||||
|
recentlyUsedExercises={[]}
|
||||||
|
editWorkout={editWorkout}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ export default function ExercisePicker({
|
|||||||
// Derive custom types/muscles/fields from existing exercises
|
// Derive custom types/muscles/fields from existing exercises
|
||||||
const knownTypeValues = EXERCISE_TYPES.map((t) => t.value);
|
const knownTypeValues = EXERCISE_TYPES.map((t) => t.value);
|
||||||
const knownMuscleValues = MUSCLE_GROUPS.map((g) => g.toLowerCase());
|
const knownMuscleValues = MUSCLE_GROUPS.map((g) => g.toLowerCase());
|
||||||
const knownFieldValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"];
|
const knownFieldValues = ["sets", "reps", "weight", "duration", "distance", "calories", "watts", "notes"];
|
||||||
|
|
||||||
const derivedCustomTypes = useMemo(() => {
|
const derivedCustomTypes = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
@@ -506,6 +506,7 @@ export default function ExercisePicker({
|
|||||||
{ value: "duration", label: "Time" },
|
{ value: "duration", label: "Time" },
|
||||||
{ value: "distance", label: "Distance" },
|
{ value: "distance", label: "Distance" },
|
||||||
{ value: "calories", label: "Calories" },
|
{ value: "calories", label: "Calories" },
|
||||||
|
{ value: "watts", label: "Avg. watts" },
|
||||||
{ value: "notes", label: "Notes" },
|
{ value: "notes", label: "Notes" },
|
||||||
...customFieldOptions,
|
...customFieldOptions,
|
||||||
].map((field) => (
|
].map((field) => (
|
||||||
@@ -527,7 +528,7 @@ export default function ExercisePicker({
|
|||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const val = newFieldText.trim().toLowerCase();
|
const val = newFieldText.trim().toLowerCase();
|
||||||
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"];
|
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "watts", "notes"];
|
||||||
if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) {
|
if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) {
|
||||||
setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
|
setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
|
||||||
}
|
}
|
||||||
@@ -545,7 +546,7 @@ export default function ExercisePicker({
|
|||||||
onChange={(e) => setNewFieldText(e.target.value)}
|
onChange={(e) => setNewFieldText(e.target.value)}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
const val = newFieldText.trim().toLowerCase();
|
const val = newFieldText.trim().toLowerCase();
|
||||||
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"];
|
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "watts", "notes"];
|
||||||
if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) {
|
if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) {
|
||||||
setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
|
setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type InputField =
|
|||||||
| "duration"
|
| "duration"
|
||||||
| "distance"
|
| "distance"
|
||||||
| "calories"
|
| "calories"
|
||||||
|
| "watts"
|
||||||
| "notes"
|
| "notes"
|
||||||
| string;
|
| string;
|
||||||
|
|
||||||
@@ -17,13 +18,17 @@ export interface SetRowProps {
|
|||||||
setNumber: number;
|
setNumber: number;
|
||||||
inputFields?: InputField[];
|
inputFields?: InputField[];
|
||||||
weightUnit?: string;
|
weightUnit?: string;
|
||||||
|
/** Cardio sets log breathing "Gear" (1-5) instead of RPE (6-10). */
|
||||||
|
isCardio?: boolean;
|
||||||
initialReps?: number;
|
initialReps?: number;
|
||||||
initialWeight?: number;
|
initialWeight?: number;
|
||||||
initialRpe?: number;
|
initialRpe?: number;
|
||||||
|
initialGear?: number;
|
||||||
initialNotes?: string;
|
initialNotes?: string;
|
||||||
initialDuration?: number;
|
initialDuration?: number;
|
||||||
initialDistance?: number;
|
initialDistance?: number;
|
||||||
initialCalories?: number;
|
initialCalories?: number;
|
||||||
|
initialWatts?: number;
|
||||||
initialCustomMetrics?: Record<string, string>;
|
initialCustomMetrics?: Record<string, string>;
|
||||||
initialLocked?: boolean;
|
initialLocked?: boolean;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
@@ -31,10 +36,12 @@ export interface SetRowProps {
|
|||||||
reps?: number;
|
reps?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
rpe?: number;
|
rpe?: number;
|
||||||
|
gear?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
durationSeconds?: number;
|
durationSeconds?: number;
|
||||||
distance?: number;
|
distance?: number;
|
||||||
calories?: number;
|
calories?: number;
|
||||||
|
watts?: number;
|
||||||
customMetrics?: Record<string, string>;
|
customMetrics?: Record<string, string>;
|
||||||
}) => void;
|
}) => void;
|
||||||
onConfirm?: () => void;
|
onConfirm?: () => void;
|
||||||
@@ -42,10 +49,12 @@ export interface SetRowProps {
|
|||||||
weight?: string;
|
weight?: string;
|
||||||
reps?: string;
|
reps?: string;
|
||||||
rpe?: string;
|
rpe?: string;
|
||||||
|
gear?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
duration?: string;
|
duration?: string;
|
||||||
distance?: string;
|
distance?: string;
|
||||||
calories?: string;
|
calories?: string;
|
||||||
|
watts?: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
@@ -54,13 +63,16 @@ export default function SetRow({
|
|||||||
setNumber,
|
setNumber,
|
||||||
inputFields = ["sets", "reps", "weight"],
|
inputFields = ["sets", "reps", "weight"],
|
||||||
weightUnit = "lbs",
|
weightUnit = "lbs",
|
||||||
|
isCardio = false,
|
||||||
initialReps,
|
initialReps,
|
||||||
initialWeight,
|
initialWeight,
|
||||||
initialRpe,
|
initialRpe,
|
||||||
|
initialGear,
|
||||||
initialNotes,
|
initialNotes,
|
||||||
initialDuration,
|
initialDuration,
|
||||||
initialDistance,
|
initialDistance,
|
||||||
initialCalories,
|
initialCalories,
|
||||||
|
initialWatts,
|
||||||
initialCustomMetrics,
|
initialCustomMetrics,
|
||||||
initialLocked = false,
|
initialLocked = false,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
@@ -86,13 +98,21 @@ export default function SetRow({
|
|||||||
const [reps, setReps] = useState(initialReps?.toString() || "");
|
const [reps, setReps] = useState(initialReps?.toString() || "");
|
||||||
const [weight, setWeight] = useState(initialWeight?.toString() || "");
|
const [weight, setWeight] = useState(initialWeight?.toString() || "");
|
||||||
const [rpe, setRpe] = useState(initialRpe?.toString() || "");
|
const [rpe, setRpe] = useState(initialRpe?.toString() || "");
|
||||||
|
const [gear, setGear] = useState(initialGear?.toString() || "");
|
||||||
const [notes, setNotes] = useState(initialNotes || "");
|
const [notes, setNotes] = useState(initialNotes || "");
|
||||||
const [duration, setDuration] = useState(secondsToMinuteString(initialDuration));
|
const [duration, setDuration] = useState(secondsToMinuteString(initialDuration));
|
||||||
const [distance, setDistance] = useState(initialDistance?.toString() || "");
|
const [distance, setDistance] = useState(initialDistance?.toString() || "");
|
||||||
const [calories, setCalories] = useState(initialCalories?.toString() || "");
|
const [calories, setCalories] = useState(initialCalories?.toString() || "");
|
||||||
const [customValues, setCustomValues] = useState<Record<string, string>>(
|
// Watts is now a first-class field. Legacy sets stored it under the
|
||||||
initialCustomMetrics || {}
|
// customMetrics "watts" key — seed from there so old data shows up, and
|
||||||
|
// strip it from customValues so it isn't also rendered in the custom grid.
|
||||||
|
const [watts, setWatts] = useState(
|
||||||
|
initialWatts?.toString() || initialCustomMetrics?.watts || ""
|
||||||
);
|
);
|
||||||
|
const [customValues, setCustomValues] = useState<Record<string, string>>(() => {
|
||||||
|
const { watts: _legacyWatts, ...rest } = initialCustomMetrics || {};
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
const [showNotes, setShowNotes] = useState(!!initialNotes);
|
const [showNotes, setShowNotes] = useState(!!initialNotes);
|
||||||
const [locked, setLocked] = useState(initialLocked);
|
const [locked, setLocked] = useState(initialLocked);
|
||||||
|
|
||||||
@@ -101,6 +121,7 @@ export default function SetRow({
|
|||||||
const showDuration = inputFields.includes("duration");
|
const showDuration = inputFields.includes("duration");
|
||||||
const showDistance = inputFields.includes("distance");
|
const showDistance = inputFields.includes("distance");
|
||||||
const showCalories = inputFields.includes("calories");
|
const showCalories = inputFields.includes("calories");
|
||||||
|
const showWatts = inputFields.includes("watts");
|
||||||
const showNotesField = inputFields.includes("notes");
|
const showNotesField = inputFields.includes("notes");
|
||||||
const customFields = inputFields.filter(
|
const customFields = inputFields.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
@@ -111,6 +132,7 @@ export default function SetRow({
|
|||||||
"duration",
|
"duration",
|
||||||
"distance",
|
"distance",
|
||||||
"calories",
|
"calories",
|
||||||
|
"watts",
|
||||||
"notes",
|
"notes",
|
||||||
].includes(f)
|
].includes(f)
|
||||||
);
|
);
|
||||||
@@ -120,19 +142,23 @@ export default function SetRow({
|
|||||||
reps?: string;
|
reps?: string;
|
||||||
weight?: string;
|
weight?: string;
|
||||||
rpe?: string;
|
rpe?: string;
|
||||||
|
gear?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
duration?: string;
|
duration?: string;
|
||||||
distance?: string;
|
distance?: string;
|
||||||
calories?: string;
|
calories?: string;
|
||||||
|
watts?: string;
|
||||||
customMetrics?: Record<string, string>;
|
customMetrics?: Record<string, string>;
|
||||||
}) => {
|
}) => {
|
||||||
const r = overrides.reps ?? reps;
|
const r = overrides.reps ?? reps;
|
||||||
const w = overrides.weight ?? weight;
|
const w = overrides.weight ?? weight;
|
||||||
const p = overrides.rpe ?? rpe;
|
const p = overrides.rpe ?? rpe;
|
||||||
|
const gr = overrides.gear ?? gear;
|
||||||
const n = overrides.notes ?? notes;
|
const n = overrides.notes ?? notes;
|
||||||
const dur = overrides.duration ?? duration;
|
const dur = overrides.duration ?? duration;
|
||||||
const dist = overrides.distance ?? distance;
|
const dist = overrides.distance ?? distance;
|
||||||
const cal = overrides.calories ?? calories;
|
const cal = overrides.calories ?? calories;
|
||||||
|
const wt = overrides.watts ?? watts;
|
||||||
const cm = overrides.customMetrics ?? customValues;
|
const cm = overrides.customMetrics ?? customValues;
|
||||||
const cleanedCustomMetrics = Object.fromEntries(
|
const cleanedCustomMetrics = Object.fromEntries(
|
||||||
Object.entries(cm).filter(([, value]) => value !== "")
|
Object.entries(cm).filter(([, value]) => value !== "")
|
||||||
@@ -142,17 +168,19 @@ export default function SetRow({
|
|||||||
reps: r ? parseInt(r) : undefined,
|
reps: r ? parseInt(r) : undefined,
|
||||||
weight: w ? parseFloat(w) : undefined,
|
weight: w ? parseFloat(w) : undefined,
|
||||||
rpe: p ? parseInt(p) : undefined,
|
rpe: p ? parseInt(p) : undefined,
|
||||||
|
gear: gr ? parseInt(gr) : undefined,
|
||||||
notes: n || undefined,
|
notes: n || undefined,
|
||||||
durationSeconds: minuteStringToSeconds(dur),
|
durationSeconds: minuteStringToSeconds(dur),
|
||||||
distance: dist ? parseFloat(dist) : undefined,
|
distance: dist ? parseFloat(dist) : undefined,
|
||||||
calories: cal ? parseInt(cal) : undefined,
|
calories: cal ? parseInt(cal) : undefined,
|
||||||
|
watts: wt ? parseInt(wt) : undefined,
|
||||||
customMetrics:
|
customMetrics:
|
||||||
Object.keys(cleanedCustomMetrics).length > 0
|
Object.keys(cleanedCustomMetrics).length > 0
|
||||||
? cleanedCustomMetrics
|
? cleanedCustomMetrics
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[reps, weight, rpe, notes, duration, distance, calories, customValues, onUpdate]
|
[reps, weight, rpe, gear, notes, duration, distance, calories, watts, customValues, onUpdate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
@@ -175,7 +203,7 @@ export default function SetRow({
|
|||||||
const handleNextSet = () => {
|
const handleNextSet = () => {
|
||||||
emitUpdate({});
|
emitUpdate({});
|
||||||
setLocked(true);
|
setLocked(true);
|
||||||
onNextSet?.({ weight, reps, rpe, notes, duration, distance, calories });
|
onNextSet?.({ weight, reps, rpe, gear, notes, duration, distance, calories, watts });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build a summary string for the locked view
|
// Build a summary string for the locked view
|
||||||
@@ -186,11 +214,16 @@ export default function SetRow({
|
|||||||
if (showDuration && duration) parts.push(`${duration} min`);
|
if (showDuration && duration) parts.push(`${duration} min`);
|
||||||
if (showDistance && distance) parts.push(`${distance} mi`);
|
if (showDistance && distance) parts.push(`${distance} mi`);
|
||||||
if (showCalories && calories) parts.push(`${calories} cal`);
|
if (showCalories && calories) parts.push(`${calories} cal`);
|
||||||
|
if (showWatts && watts) parts.push(`${watts} W`);
|
||||||
for (const field of customFields) {
|
for (const field of customFields) {
|
||||||
const value = customValues[field];
|
const value = customValues[field];
|
||||||
if (value) parts.push(`${field}: ${value}`);
|
if (value) parts.push(`${field}: ${value}`);
|
||||||
}
|
}
|
||||||
if (rpe) parts.push(`RPE ${rpe}`);
|
if (isCardio) {
|
||||||
|
if (gear) parts.push(`Gear ${gear}`);
|
||||||
|
} else if (rpe) {
|
||||||
|
parts.push(`RPE ${rpe}`);
|
||||||
|
}
|
||||||
if (showNotesField && notes) parts.push(notes);
|
if (showNotesField && notes) parts.push(notes);
|
||||||
return parts.length > 0 ? parts.join(" · ") : "No data";
|
return parts.length > 0 ? parts.join(" · ") : "No data";
|
||||||
};
|
};
|
||||||
@@ -238,7 +271,7 @@ export default function SetRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine which field gets autoFocus
|
// Determine which field gets autoFocus
|
||||||
const firstField = showWeight ? "weight" : showReps ? "reps" : showDuration ? "duration" : showDistance ? "distance" : showCalories ? "calories" : null;
|
const firstField = showWeight ? "weight" : showReps ? "reps" : showDuration ? "duration" : showDistance ? "distance" : showCalories ? "calories" : showWatts ? "watts" : null;
|
||||||
|
|
||||||
// ---------- EDIT VIEW ----------
|
// ---------- EDIT VIEW ----------
|
||||||
return (
|
return (
|
||||||
@@ -357,7 +390,51 @@ export default function SetRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* RPE select — always shown */}
|
{/* Avg. watts input */}
|
||||||
|
{showWatts && (
|
||||||
|
<div className="flex-1 min-w-[55px]">
|
||||||
|
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
||||||
|
Avg. watts
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
autoFocus={autoFocus && firstField === "watts"}
|
||||||
|
value={watts}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setWatts(val);
|
||||||
|
emitUpdate({ watts: val });
|
||||||
|
}}
|
||||||
|
placeholder="0"
|
||||||
|
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Effort select — Gear (1-5, breathing gear) for cardio, else RPE (6-10) */}
|
||||||
|
{isCardio ? (
|
||||||
|
<div className="flex-1 min-w-[50px] max-w-[60px]">
|
||||||
|
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
||||||
|
Gear
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={gear}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setGear(val);
|
||||||
|
emitUpdate({ gear: val });
|
||||||
|
}}
|
||||||
|
className="w-full px-1.5 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
|
||||||
|
>
|
||||||
|
<option value="">-</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="3">3</option>
|
||||||
|
<option value="4">4</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex-1 min-w-[50px] max-w-[60px]">
|
<div className="flex-1 min-w-[50px] max-w-[60px]">
|
||||||
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
||||||
RPE
|
RPE
|
||||||
@@ -379,6 +456,7 @@ export default function SetRow({
|
|||||||
<option value="10">10</option>
|
<option value="10">10</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Next set button — confirm + add new pre-filled set */}
|
{/* Next set button — confirm + add new pre-filled set */}
|
||||||
{onNextSet && (
|
{onNextSet && (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ChevronDown, ChevronUp, Loader, Trash2, Plus, Save, Pencil, Check, Arro
|
|||||||
import ExercisePicker from "./ExercisePicker";
|
import ExercisePicker from "./ExercisePicker";
|
||||||
import SetRow, { InputField } from "./SetRow";
|
import SetRow, { InputField } from "./SetRow";
|
||||||
import { formatSetsSummary } from "@/lib/formatSets";
|
import { formatSetsSummary } from "@/lib/formatSets";
|
||||||
|
import { isCardioExercise } from "@/lib/exerciseOptions";
|
||||||
|
|
||||||
// --------------- Exercise History Popup ---------------
|
// --------------- Exercise History Popup ---------------
|
||||||
type HistoryEntry = {
|
type HistoryEntry = {
|
||||||
@@ -58,21 +59,28 @@ 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;
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (!entries[0]?.isIntersecting) return;
|
|
||||||
if (loadingMore || !hasMore) return;
|
if (loadingMore || !hasMore) return;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
(async () => {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/exercises/${exerciseId}?offset=${history.length}&limit=${HISTORY_PAGE_SIZE}`,
|
`/api/exercises/${exerciseId}?offset=${history.length}&limit=${HISTORY_PAGE_SIZE}`,
|
||||||
@@ -90,12 +98,22 @@ function ExerciseHistoryPopup({
|
|||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
})();
|
};
|
||||||
},
|
|
||||||
{ root, rootMargin: "60px" },
|
const maybeLoad = () => {
|
||||||
);
|
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||||
observer.observe(sentinel);
|
// 300px lookahead — match WorkoutsList's rootMargin behavior.
|
||||||
return () => observer.disconnect();
|
if (scrollHeight - scrollTop - clientHeight < 300) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 +130,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 +185,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">
|
||||||
@@ -199,9 +233,11 @@ interface ExerciseWithSets {
|
|||||||
reps?: number;
|
reps?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
rpe?: number;
|
rpe?: number;
|
||||||
|
gear?: number;
|
||||||
durationSeconds?: number;
|
durationSeconds?: number;
|
||||||
distance?: number;
|
distance?: number;
|
||||||
calories?: number;
|
calories?: number;
|
||||||
|
watts?: number;
|
||||||
customMetrics?: Record<string, string>;
|
customMetrics?: Record<string, string>;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
forceEdit?: boolean; // When true, start in edit mode even if data is pre-filled
|
forceEdit?: boolean; // When true, start in edit mode even if data is pre-filled
|
||||||
@@ -209,7 +245,10 @@ interface ExerciseWithSets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface EditWorkoutData {
|
export interface EditWorkoutData {
|
||||||
id: string;
|
/// Existing workout id when editing. Omitted for a pre-filled NEW
|
||||||
|
/// workout (e.g. an AI suggestion) so `savedWorkoutId` starts null and
|
||||||
|
/// the first save CREATEs instead of PATCHing a nonexistent id.
|
||||||
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
date: string; // ISO string
|
date: string; // ISO string
|
||||||
durationMinutes?: number | null;
|
durationMinutes?: number | null;
|
||||||
@@ -223,9 +262,11 @@ export interface EditWorkoutData {
|
|||||||
reps?: number;
|
reps?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
rpe?: number;
|
rpe?: number;
|
||||||
|
gear?: number;
|
||||||
durationSeconds?: number;
|
durationSeconds?: number;
|
||||||
distance?: number;
|
distance?: number;
|
||||||
calories?: number;
|
calories?: number;
|
||||||
|
watts?: number;
|
||||||
customMetrics?: Record<string, string>;
|
customMetrics?: Record<string, string>;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -309,10 +350,12 @@ export default function WorkoutForm({
|
|||||||
weight: s.weight,
|
weight: s.weight,
|
||||||
weightUnit: (e.exercise as any).defaultWeightUnit || "lbs",
|
weightUnit: (e.exercise as any).defaultWeightUnit || "lbs",
|
||||||
rpe: s.rpe,
|
rpe: s.rpe,
|
||||||
|
gear: s.gear,
|
||||||
durationSeconds: s.durationSeconds,
|
durationSeconds: s.durationSeconds,
|
||||||
distance: s.distance,
|
distance: s.distance,
|
||||||
distanceUnit: s.distance !== undefined ? "mi" : undefined,
|
distanceUnit: s.distance !== undefined ? "mi" : undefined,
|
||||||
calories: s.calories,
|
calories: s.calories,
|
||||||
|
watts: s.watts,
|
||||||
customMetrics: s.customMetrics,
|
customMetrics: s.customMetrics,
|
||||||
notes: s.notes,
|
notes: s.notes,
|
||||||
}))
|
}))
|
||||||
@@ -471,10 +514,12 @@ export default function WorkoutForm({
|
|||||||
reps?: number;
|
reps?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
rpe?: number;
|
rpe?: number;
|
||||||
|
gear?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
durationSeconds?: number;
|
durationSeconds?: number;
|
||||||
distance?: number;
|
distance?: number;
|
||||||
calories?: number;
|
calories?: number;
|
||||||
|
watts?: number;
|
||||||
customMetrics?: Record<string, string>;
|
customMetrics?: Record<string, string>;
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
@@ -522,6 +567,7 @@ export default function WorkoutForm({
|
|||||||
weight?: string;
|
weight?: string;
|
||||||
reps?: string;
|
reps?: string;
|
||||||
rpe?: string;
|
rpe?: string;
|
||||||
|
gear?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
duration?: string;
|
duration?: string;
|
||||||
distance?: string;
|
distance?: string;
|
||||||
@@ -543,6 +589,7 @@ export default function WorkoutForm({
|
|||||||
weight: currentValues.weight ? parseFloat(currentValues.weight) : undefined,
|
weight: currentValues.weight ? parseFloat(currentValues.weight) : undefined,
|
||||||
reps: undefined, // User typically changes reps per set
|
reps: undefined, // User typically changes reps per set
|
||||||
rpe: currentValues.rpe ? parseInt(currentValues.rpe) : undefined,
|
rpe: currentValues.rpe ? parseInt(currentValues.rpe) : undefined,
|
||||||
|
gear: currentValues.gear ? parseInt(currentValues.gear) : undefined,
|
||||||
notes: currentValues.notes || undefined,
|
notes: currentValues.notes || undefined,
|
||||||
forceEdit: true, // Start in edit mode even though weight is pre-filled
|
forceEdit: true, // Start in edit mode even though weight is pre-filled
|
||||||
},
|
},
|
||||||
@@ -611,9 +658,11 @@ export default function WorkoutForm({
|
|||||||
if (!response.ok) throw new Error("Failed to save workout");
|
if (!response.ok) throw new Error("Failed to save workout");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate back: to detail page if editing, otherwise to list
|
// Navigate back: to detail page if we have a workout id (editing, or
|
||||||
if (editWorkout) {
|
// an AI-prefilled workout that has now been created), otherwise to list.
|
||||||
router.push(`/main/workouts/${savedWorkoutId || editWorkout.id}`);
|
const detailId = savedWorkoutId || editWorkout?.id;
|
||||||
|
if (detailId) {
|
||||||
|
router.push(`/main/workouts/${detailId}`);
|
||||||
} else {
|
} else {
|
||||||
router.push("/main/workouts");
|
router.push("/main/workouts");
|
||||||
}
|
}
|
||||||
@@ -819,12 +868,15 @@ export default function WorkoutForm({
|
|||||||
setNumber={set.setNumber}
|
setNumber={set.setNumber}
|
||||||
inputFields={parseInputFields(item.exercise)}
|
inputFields={parseInputFields(item.exercise)}
|
||||||
weightUnit={(item.exercise as any).defaultWeightUnit || "lbs"}
|
weightUnit={(item.exercise as any).defaultWeightUnit || "lbs"}
|
||||||
|
isCardio={isCardioExercise(item.exercise)}
|
||||||
initialReps={set.reps}
|
initialReps={set.reps}
|
||||||
initialWeight={set.weight}
|
initialWeight={set.weight}
|
||||||
initialRpe={set.rpe}
|
initialRpe={set.rpe}
|
||||||
|
initialGear={set.gear}
|
||||||
initialDuration={set.durationSeconds}
|
initialDuration={set.durationSeconds}
|
||||||
initialDistance={set.distance}
|
initialDistance={set.distance}
|
||||||
initialCalories={set.calories}
|
initialCalories={set.calories}
|
||||||
|
initialWatts={set.watts}
|
||||||
initialCustomMetrics={set.customMetrics}
|
initialCustomMetrics={set.customMetrics}
|
||||||
initialNotes={set.notes}
|
initialNotes={set.notes}
|
||||||
initialLocked={
|
initialLocked={
|
||||||
@@ -836,6 +888,7 @@ export default function WorkoutForm({
|
|||||||
set.durationSeconds ||
|
set.durationSeconds ||
|
||||||
set.distance ||
|
set.distance ||
|
||||||
set.calories ||
|
set.calories ||
|
||||||
|
set.watts ||
|
||||||
(set.customMetrics &&
|
(set.customMetrics &&
|
||||||
Object.values(set.customMetrics).some((v) => v))
|
Object.values(set.customMetrics).some((v) => v))
|
||||||
)
|
)
|
||||||
@@ -847,7 +900,8 @@ export default function WorkoutForm({
|
|||||||
!set.weight &&
|
!set.weight &&
|
||||||
!set.durationSeconds &&
|
!set.durationSeconds &&
|
||||||
!set.distance &&
|
!set.distance &&
|
||||||
!set.calories)
|
!set.calories &&
|
||||||
|
!set.watts)
|
||||||
}
|
}
|
||||||
onUpdate={(data) =>
|
onUpdate={(data) =>
|
||||||
handleUpdateSet(
|
handleUpdateSet(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -122,6 +122,8 @@ export async function applyAIProgram(
|
|||||||
repsMax: ex.repsMax ?? null,
|
repsMax: ex.repsMax ?? null,
|
||||||
rpe: ex.rpe ?? null,
|
rpe: ex.rpe ?? null,
|
||||||
restSeconds: ex.restSeconds ?? null,
|
restSeconds: ex.restSeconds ?? null,
|
||||||
|
suggestedWeight: ex.suggestedWeight ?? null,
|
||||||
|
suggestedWeightUnit: ex.suggestedWeightUnit ?? null,
|
||||||
notes: ex.notes ?? null,
|
notes: ex.notes ?? null,
|
||||||
})) as Prisma.ProgramExerciseCreateManyInput[],
|
})) as Prisma.ProgramExerciseCreateManyInput[],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Resolve an AI-suggested exercise to a library exercise id by NAME — a
|
||||||
|
* fallback for when the model's `exerciseId` is missing or isn't in the
|
||||||
|
* library. Models (local ones especially) often return a good display name
|
||||||
|
* with a null or invented id, e.g. "Overhead Press" when the library has
|
||||||
|
* "Overhead Press (barbell)".
|
||||||
|
*
|
||||||
|
* Rather than make the user hand-map an exercise they clearly already own, we
|
||||||
|
* match on a normalized name and auto-resolve only when the match is
|
||||||
|
* UNAMBIGUOUS. Ambiguous or no-match cases return null so the UI still flags
|
||||||
|
* them for manual mapping — a wrong auto-map is worse than asking.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LibraryEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowercase, drop parenthetical qualifiers ("(barbell)", "(dumbbell)"), and
|
||||||
|
* collapse punctuation/whitespace to single spaces — so "Overhead Press
|
||||||
|
* (Barbell)" and "overhead-press" both normalize to "overhead press".
|
||||||
|
*/
|
||||||
|
export function normalizeExerciseName(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\([^)]*\)/g, ' ') // strip "(barbell)" etc.
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ') // punctuation/symbols → space
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best confident library id for a suggested name, or null when there's no
|
||||||
|
* match OR the match is ambiguous (multiple library exercises fit).
|
||||||
|
* Conservative by design.
|
||||||
|
*/
|
||||||
|
export function matchLibraryExerciseId(
|
||||||
|
name: string,
|
||||||
|
library: LibraryEntry[],
|
||||||
|
): string | null {
|
||||||
|
const q = normalizeExerciseName(name);
|
||||||
|
if (!q) return null;
|
||||||
|
|
||||||
|
const normed = library.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
norm: normalizeExerciseName(e.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 1. Exact normalized match. Unique → take it; a tie (e.g. barbell + dumbbell
|
||||||
|
// variants both normalize the same) → ambiguous, leave for manual mapping.
|
||||||
|
const exact = normed.filter((e) => e.norm === q);
|
||||||
|
if (exact.length === 1) return exact[0].id;
|
||||||
|
if (exact.length > 1) return null;
|
||||||
|
|
||||||
|
// 2. One-sided prefix on a word boundary: the suggested name is a prefix of
|
||||||
|
// exactly one library name, or vice-versa (catches non-parenthetical
|
||||||
|
// qualifiers like "Overhead Press Barbell"). Uniqueness keeps a generic
|
||||||
|
// word like "press" from mapping to anything.
|
||||||
|
const prefix = normed.filter(
|
||||||
|
(e) => e.norm.startsWith(q + ' ') || q.startsWith(e.norm + ' '),
|
||||||
|
);
|
||||||
|
if (prefix.length === 1) return prefix[0].id;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill in unresolved `exerciseId`s on a list of AI-suggested exercises by
|
||||||
|
* confident name match. Already-valid ids and unmatched names are left
|
||||||
|
* untouched (the latter stay null so the UI flags them for manual mapping).
|
||||||
|
*/
|
||||||
|
export function resolveExerciseIds<
|
||||||
|
T extends { exerciseId: string | null; exerciseName: string },
|
||||||
|
>(items: T[], library: LibraryEntry[]): T[] {
|
||||||
|
const libIds = new Set(library.map((e) => e.id));
|
||||||
|
return items.map((it) =>
|
||||||
|
it.exerciseId && libIds.has(it.exerciseId)
|
||||||
|
? it
|
||||||
|
: {
|
||||||
|
...it,
|
||||||
|
exerciseId: matchLibraryExerciseId(it.exerciseName, library) ?? it.exerciseId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
import { parseAIWorkout } from './workoutSchema';
|
||||||
|
|
||||||
|
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;
|
||||||
|
/** "program" (multi-week) or "workout" (single day). Selects the
|
||||||
|
* output parser and is persisted on the row. */
|
||||||
|
kind: 'program' | 'workout';
|
||||||
|
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,
|
||||||
|
kind: opts.kind,
|
||||||
|
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 =
|
||||||
|
opts.kind === 'workout' ? parseAIWorkout(raw) : parseAIProgram(raw);
|
||||||
|
if (r.ok) {
|
||||||
|
parsedOk = true;
|
||||||
|
parsedJson = JSON.stringify('workout' in r ? r.workout : 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,176 @@
|
|||||||
|
/**
|
||||||
|
* 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 + sparkcontrol: no curated menu — model names
|
||||||
|
// are gateway- or host-specific. Ollama auto-detects via /api/tags;
|
||||||
|
// SparkControl auto-detects the loaded model via /api/endpoints.
|
||||||
|
'openai-compatible': [],
|
||||||
|
ollama: [],
|
||||||
|
sparkcontrol: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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 === 'sparkcontrol') return 0; // self-hosted local inference, free
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
@@ -1,5 +1,16 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Models — local ones especially (Qwen via SparkControl, Llama via Ollama) —
|
||||||
|
* sometimes emit a decimal where we expect an integer (`"rpe": 7.5`,
|
||||||
|
* `"reps": 8.0`). Round to the nearest int BEFORE the `.int()` check so one
|
||||||
|
* stray decimal doesn't fail the whole parse. Non-numbers pass through
|
||||||
|
* untouched, so the outer `.optional()`/`.nullable()` still apply. Shared with
|
||||||
|
* the single-workout schema (`workoutSchema.ts`).
|
||||||
|
*/
|
||||||
|
export const looseInt = (schema: z.ZodNumber) =>
|
||||||
|
z.preprocess((v) => (typeof v === 'number' ? Math.round(v) : v), schema);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shape we ask LLMs to produce, validated server-side via 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
|
* after parsing whatever JSON came back. Maps 1:1 onto the existing
|
||||||
@@ -16,24 +27,32 @@ import { z } from 'zod';
|
|||||||
export const aiExerciseSchema = z.object({
|
export const aiExerciseSchema = z.object({
|
||||||
exerciseId: z.string().nullable(),
|
exerciseId: z.string().nullable(),
|
||||||
exerciseName: z.string().min(1),
|
exerciseName: z.string().min(1),
|
||||||
order: z.number().int().nonnegative(),
|
order: looseInt(z.number().int().nonnegative()),
|
||||||
sets: z.number().int().positive().optional().nullable(),
|
sets: looseInt(z.number().int().positive()).optional().nullable(),
|
||||||
repsMin: z.number().int().positive().optional().nullable(),
|
repsMin: looseInt(z.number().int().positive()).optional().nullable(),
|
||||||
repsMax: z.number().int().positive().optional().nullable(),
|
repsMax: looseInt(z.number().int().positive()).optional().nullable(),
|
||||||
rpe: z.number().int().min(1).max(10).optional().nullable(),
|
rpe: looseInt(z.number().int().min(1).max(10)).optional().nullable(),
|
||||||
restSeconds: z.number().int().nonnegative().optional().nullable(),
|
restSeconds: looseInt(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(),
|
notes: z.string().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const aiDaySchema = z.object({
|
export const aiDaySchema = z.object({
|
||||||
dayOfWeek: z.number().int().min(0).max(6),
|
dayOfWeek: looseInt(z.number().int().min(0).max(6)),
|
||||||
name: z.string().optional().nullable(),
|
name: z.string().optional().nullable(),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
exercises: z.array(aiExerciseSchema),
|
exercises: z.array(aiExerciseSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const aiWeekSchema = z.object({
|
export const aiWeekSchema = z.object({
|
||||||
weekNumber: z.number().int().positive(),
|
weekNumber: looseInt(z.number().int().positive()),
|
||||||
phase: z.string().optional().nullable(),
|
phase: z.string().optional().nullable(),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
days: z.array(aiDaySchema),
|
days: z.array(aiDaySchema),
|
||||||
@@ -43,7 +62,7 @@ export const aiProgramSchema = z.object({
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
type: z.string().min(1),
|
type: z.string().min(1),
|
||||||
durationWeeks: z.number().int().positive(),
|
durationWeeks: looseInt(z.number().int().positive()),
|
||||||
weeks: z.array(aiWeekSchema),
|
weeks: z.array(aiWeekSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,14 +95,16 @@ export const PROGRAM_OUTPUT_SHAPE = `{
|
|||||||
"description": "<string, optional>",
|
"description": "<string, optional>",
|
||||||
"exercises": [
|
"exercises": [
|
||||||
{
|
{
|
||||||
"exerciseId": "<string from the library list, or null if you need an exercise the user doesn't have>",
|
"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>",
|
"exerciseName": "<string, the canonical name from the library>",
|
||||||
"order": <int >= 0>,
|
"order": <int >= 0>,
|
||||||
"sets": <int, optional>,
|
"sets": <int, optional but recommended>,
|
||||||
"repsMin": <int, optional>,
|
"repsMin": <int, optional>,
|
||||||
"repsMax": <int, optional>,
|
"repsMax": <int, optional>,
|
||||||
"rpe": <int 1-10, optional>,
|
"rpe": <int 1-10, optional>,
|
||||||
"restSeconds": <int >= 0, 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>"
|
"notes": "<string, optional, coaching note>"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const claude: LLMProvider = {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: opts.model,
|
model: opts.model,
|
||||||
max_tokens: 8000,
|
max_tokens: opts.maxOutputTokens ?? 8000,
|
||||||
stream: true,
|
stream: true,
|
||||||
system: opts.systemPrompt,
|
system: opts.systemPrompt,
|
||||||
messages: [{ role: 'user', content: opts.userPrompt }],
|
messages: [{ role: 'user', content: opts.userPrompt }],
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const gemini: LLMProvider = {
|
|||||||
],
|
],
|
||||||
generationConfig: {
|
generationConfig: {
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
maxOutputTokens: 8000,
|
maxOutputTokens: opts.maxOutputTokens ?? 8000,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
signal: opts.signal,
|
signal: opts.signal,
|
||||||
@@ -56,6 +56,8 @@ export const gemini: LLMProvider = {
|
|||||||
}
|
}
|
||||||
let tokensIn: number | undefined;
|
let tokensIn: number | undefined;
|
||||||
let tokensOut: number | undefined;
|
let tokensOut: number | undefined;
|
||||||
|
let textEmitted = false;
|
||||||
|
let lastFinishReason: string | null = null;
|
||||||
try {
|
try {
|
||||||
// Gemini SSE: same line-delimited "data: ..." frames.
|
// Gemini SSE: same line-delimited "data: ..." frames.
|
||||||
const { sseLines } = await import('../sse');
|
const { sseLines } = await import('../sse');
|
||||||
@@ -66,17 +68,37 @@ export const gemini: LLMProvider = {
|
|||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const parts = evt.candidates?.[0]?.content?.parts;
|
const cand = evt.candidates?.[0];
|
||||||
|
const parts = cand?.content?.parts;
|
||||||
if (Array.isArray(parts)) {
|
if (Array.isArray(parts)) {
|
||||||
for (const p of parts) {
|
for (const p of parts) {
|
||||||
if (p.text) yield { type: 'text', delta: p.text };
|
if (p.text) {
|
||||||
|
yield { type: 'text', delta: p.text };
|
||||||
|
textEmitted = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (cand?.finishReason) {
|
||||||
|
lastFinishReason = cand.finishReason;
|
||||||
|
}
|
||||||
if (evt.usageMetadata) {
|
if (evt.usageMetadata) {
|
||||||
tokensIn = evt.usageMetadata.promptTokenCount;
|
tokensIn = evt.usageMetadata.promptTokenCount;
|
||||||
tokensOut = evt.usageMetadata.candidatesTokenCount;
|
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: 'usage', tokensIn, tokensOut };
|
||||||
yield { type: 'done' };
|
yield { type: 'done' };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -87,3 +109,22 @@ export const gemini: LLMProvider = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ollama } from './ollama';
|
|||||||
import { claude } from './claude';
|
import { claude } from './claude';
|
||||||
import { openai, openaiCompatible } from './openai';
|
import { openai, openaiCompatible } from './openai';
|
||||||
import { gemini } from './gemini';
|
import { gemini } from './gemini';
|
||||||
|
import { sparkcontrol } from './sparkcontrol';
|
||||||
|
|
||||||
const ALL: Record<ProviderId, LLMProvider> = {
|
const ALL: Record<ProviderId, LLMProvider> = {
|
||||||
claude,
|
claude,
|
||||||
@@ -10,12 +11,23 @@ const ALL: Record<ProviderId, LLMProvider> = {
|
|||||||
'openai-compatible': openaiCompatible,
|
'openai-compatible': openaiCompatible,
|
||||||
gemini,
|
gemini,
|
||||||
ollama,
|
ollama,
|
||||||
|
sparkcontrol,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getProvider(id: string): LLMProvider | null {
|
export function getProvider(id: string): LLMProvider | null {
|
||||||
return (ALL as Record<string, LLMProvider | undefined>)[id] ?? null;
|
return (ALL as Record<string, LLMProvider | undefined>)[id] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for providers that take a user-supplied base URL (Ollama, SparkControl,
|
||||||
|
* OpenAI-compatible). Configuring these is admin-only — a non-admin pointing
|
||||||
|
* the server at an arbitrary URL is the SSRF actor vector. The fixed-URL cloud
|
||||||
|
* providers (claude/openai/gemini) stay per-user.
|
||||||
|
*/
|
||||||
|
export function isCustomUrlProvider(id: string): boolean {
|
||||||
|
return !!getProvider(id)?.requiresBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
/** Stable list for UI dropdowns. Order matches the Settings select. */
|
/** Stable list for UI dropdowns. Order matches the Settings select. */
|
||||||
export const PROVIDER_ORDER: ProviderId[] = [
|
export const PROVIDER_ORDER: ProviderId[] = [
|
||||||
'claude',
|
'claude',
|
||||||
@@ -23,6 +35,7 @@ export const PROVIDER_ORDER: ProviderId[] = [
|
|||||||
'openai-compatible',
|
'openai-compatible',
|
||||||
'gemini',
|
'gemini',
|
||||||
'ollama',
|
'ollama',
|
||||||
|
'sparkcontrol',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PROVIDERS = ALL;
|
export const PROVIDERS = ALL;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
|
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
|
||||||
import { ndjsonLines } from '../sse';
|
import { ndjsonLines } from '../sse';
|
||||||
|
import { assertSafeProviderUrl } from '../safeUrl';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ollama: streaming NDJSON over POST /api/chat.
|
* Ollama: streaming NDJSON over POST /api/chat.
|
||||||
@@ -20,6 +21,12 @@ export const ollama: LLMProvider = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const url = opts.baseUrl.replace(/\/$/, '') + '/api/chat';
|
const url = opts.baseUrl.replace(/\/$/, '') + '/api/chat';
|
||||||
|
try {
|
||||||
|
await assertSafeProviderUrl(url);
|
||||||
|
} catch (e) {
|
||||||
|
yield { type: 'error', message: (e as Error).message };
|
||||||
|
return;
|
||||||
|
}
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(url, {
|
res = await fetch(url, {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
|
import type { GenerateChunk, GenerateOpts, LLMProvider } from '../types';
|
||||||
import { sseLines } from '../sse';
|
import { sseLines } from '../sse';
|
||||||
|
import { assertSafeProviderUrl } from '../safeUrl';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic chat-completions streamer used by both OpenAI and the
|
* Generic chat-completions streamer used by both OpenAI and the
|
||||||
@@ -16,8 +17,9 @@ export async function* generateOpenAIStyle(
|
|||||||
opts: GenerateOpts,
|
opts: GenerateOpts,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
providerLabel: string,
|
providerLabel: string,
|
||||||
|
{ requireApiKey = true }: { requireApiKey?: boolean } = {},
|
||||||
): AsyncGenerator<GenerateChunk, void, void> {
|
): AsyncGenerator<GenerateChunk, void, void> {
|
||||||
if (!opts.apiKey) {
|
if (requireApiKey && !opts.apiKey) {
|
||||||
yield { type: 'error', message: `${providerLabel} API key is required.` };
|
yield { type: 'error', message: `${providerLabel} API key is required.` };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -28,12 +30,18 @@ export async function* generateOpenAIStyle(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
authorization: `Bearer ${opts.apiKey}`,
|
// Only send Authorization when we actually have a key. SparkControl
|
||||||
|
// and other keyless LAN gateways take no auth; an empty Bearer would
|
||||||
|
// be wrong (and some servers reject it).
|
||||||
|
...(opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: opts.model,
|
model: opts.model,
|
||||||
stream: true,
|
stream: true,
|
||||||
stream_options: { include_usage: true },
|
stream_options: { include_usage: true },
|
||||||
|
...(opts.maxOutputTokens != null
|
||||||
|
? { max_completion_tokens: opts.maxOutputTokens }
|
||||||
|
: {}),
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: opts.systemPrompt },
|
{ role: 'system', content: opts.systemPrompt },
|
||||||
{ role: 'user', content: opts.userPrompt },
|
{ role: 'user', content: opts.userPrompt },
|
||||||
@@ -107,6 +115,14 @@ export const openaiCompatible: LLMProvider = {
|
|||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// User-supplied base URL → SSRF guard (the fixed-URL `openai` provider
|
||||||
|
// above skips this since api.openai.com is not attacker-controlled).
|
||||||
|
try {
|
||||||
|
await assertSafeProviderUrl(opts.baseUrl);
|
||||||
|
} catch (e) {
|
||||||
|
yield { type: 'error', message: `OpenAI-compatible: ${(e as Error).message}` };
|
||||||
|
return;
|
||||||
|
}
|
||||||
yield* generateOpenAIStyle(opts, opts.baseUrl, 'OpenAI-compatible');
|
yield* generateOpenAIStyle(opts, opts.baseUrl, 'OpenAI-compatible');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { LLMProvider } from '../types';
|
||||||
|
import { generateOpenAIStyle } from './openai';
|
||||||
|
import { assertSafeProviderUrl } from '../safeUrl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SparkControl — a self-hosted local-inference gateway (the operator's own
|
||||||
|
* StartOS package). Its LLM surface is OpenAI-compatible
|
||||||
|
* (`POST {baseUrl}/chat/completions`, SSE `data:` frames, `[DONE]`), so we
|
||||||
|
* reuse the OpenAI-style streamer wholesale.
|
||||||
|
*
|
||||||
|
* Two differences from the generic `openai-compatible` provider:
|
||||||
|
* 1. **No API key.** SparkControl takes no auth on the LAN, so the key is
|
||||||
|
* optional (`requireApiKey: false`) — the streamer omits the
|
||||||
|
* Authorization header when none is set.
|
||||||
|
* 2. **Reached over the internal same-box address** (e.g.
|
||||||
|
* `http://spark-control.startos:9999/v1`) — plain HTTP, no TLS to worry
|
||||||
|
* about. The public LAN interface is HTTPS with a self-signed Start9
|
||||||
|
* cert; we deliberately don't go there, so no cert-verification games.
|
||||||
|
*
|
||||||
|
* Custom base URL ⇒ SSRF-guarded + admin-only, same as Ollama. The model name
|
||||||
|
* is whatever vLLM currently has loaded; the Settings UI auto-detects it via
|
||||||
|
* SparkControl's `/api/endpoints` discovery (see app/api/ai/sparkcontrol/model).
|
||||||
|
*/
|
||||||
|
export const sparkcontrol: LLMProvider = {
|
||||||
|
id: 'sparkcontrol',
|
||||||
|
label: 'SparkControl (local)',
|
||||||
|
requiresApiKey: false,
|
||||||
|
requiresBaseUrl: true,
|
||||||
|
async *generate(opts) {
|
||||||
|
if (!opts.baseUrl) {
|
||||||
|
yield {
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
'Base URL is required (e.g. http://spark-control.startos:9999/v1).',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// User-supplied base URL → SSRF guard (private-LAN + loopback allowed on
|
||||||
|
// purpose; reaching spark-control.startos is the feature).
|
||||||
|
try {
|
||||||
|
await assertSafeProviderUrl(opts.baseUrl);
|
||||||
|
} catch (e) {
|
||||||
|
yield { type: 'error', message: `SparkControl: ${(e as Error).message}` };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield* generateOpenAIStyle(opts, opts.baseUrl, 'SparkControl', {
|
||||||
|
requireApiKey: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { lookup } from "node:dns/promises";
|
||||||
|
import net from "node:net";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSRF guard for user-supplied AI provider base URLs.
|
||||||
|
*
|
||||||
|
* On a self-hosted box, pointing a provider at a private-LAN service — Ollama
|
||||||
|
* at ollama.startos:11434, a LiteLLM/vLLM gateway on 192.168.x, localhost in
|
||||||
|
* dev — is a *feature*, so we deliberately ALLOW private and loopback ranges.
|
||||||
|
* We block only targets that are never a legitimate provider and are valuable
|
||||||
|
* to an attacker: cloud-metadata / link-local (169.254.0.0/16, fe80::/10) and
|
||||||
|
* unspecified addresses (0.0.0.0, ::), plus any non-http(s) scheme.
|
||||||
|
*
|
||||||
|
* We resolve the hostname and check every address it maps to, so a public
|
||||||
|
* name that resolves to a blocked range is caught too. (Note: this is a
|
||||||
|
* pre-flight check; it does not pin the resolved IP, so a DNS-rebinding race
|
||||||
|
* is out of scope — acceptable here since private ranges are allowed anyway.)
|
||||||
|
*/
|
||||||
|
export async function assertSafeProviderUrl(rawUrl: string): Promise<void> {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid URL.");
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
throw new Error("Only http and https URLs are allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL.hostname keeps the brackets on IPv6 literals ([::1]); strip BOTH
|
||||||
|
// (the /g matters — without it only the leading bracket goes, leaving a
|
||||||
|
// trailing ] that fails net.isIP and falls through to a bogus DNS lookup).
|
||||||
|
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
|
||||||
|
|
||||||
|
let addresses: string[];
|
||||||
|
if (net.isIP(hostname)) {
|
||||||
|
addresses = [hostname];
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const resolved = await lookup(hostname, { all: true });
|
||||||
|
addresses = resolved.map((r) => r.address);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Could not resolve host: ${hostname}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const address of addresses) {
|
||||||
|
if (isBlockedAddress(address)) {
|
||||||
|
throw new Error(
|
||||||
|
`Refusing to connect to ${hostname} (${address}): ` +
|
||||||
|
"link-local / cloud-metadata addresses are not allowed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for addresses that are never a legitimate provider target: the
|
||||||
|
* unspecified address and the link-local / cloud-metadata range. Private
|
||||||
|
* (RFC1918) and loopback addresses return false on purpose — see the module
|
||||||
|
* comment. Exported for unit testing.
|
||||||
|
*/
|
||||||
|
export function isBlockedAddress(ip: string): boolean {
|
||||||
|
const family = net.isIP(ip);
|
||||||
|
if (family === 4) {
|
||||||
|
const o = ip.split(".").map(Number);
|
||||||
|
if (o[0] === 0) return true; // 0.0.0.0/8 ("this network" — never a valid target)
|
||||||
|
if (o[0] === 169 && o[1] === 254) return true; // 169.254.0.0/16 link-local + metadata
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (family === 6) {
|
||||||
|
const lower = ip.toLowerCase();
|
||||||
|
if (lower === "::") return true; // unspecified
|
||||||
|
// fe80::/10 link-local spans fe80..febf
|
||||||
|
if (/^fe[89ab]/.test(lower)) return true;
|
||||||
|
// IPv4-mapped (::ffff:a.b.c.d) — re-check the embedded v4 address
|
||||||
|
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||||
|
if (mapped) return isBlockedAddress(mapped[1]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -20,7 +20,8 @@ export type ProviderId =
|
|||||||
| 'openai'
|
| 'openai'
|
||||||
| 'openai-compatible'
|
| 'openai-compatible'
|
||||||
| 'gemini'
|
| 'gemini'
|
||||||
| 'ollama';
|
| 'ollama'
|
||||||
|
| 'sparkcontrol';
|
||||||
|
|
||||||
export interface GenerateOpts {
|
export interface GenerateOpts {
|
||||||
/** API key. Null/undefined for ollama on a trusted LAN. */
|
/** API key. Null/undefined for ollama on a trusted LAN. */
|
||||||
@@ -38,6 +39,14 @@ export interface GenerateOpts {
|
|||||||
userPrompt: string;
|
userPrompt: string;
|
||||||
/** AbortSignal for cancellation; the implementation must respect it. */
|
/** AbortSignal for cancellation; the implementation must respect it. */
|
||||||
signal?: AbortSignal;
|
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 =
|
export type GenerateChunk =
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { isCardioExercise } from '@/lib/exerciseOptions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ephemeral draft the "today's workout" flow hands to the New Workout
|
||||||
|
* form (via sessionStorage). One entry per exercise, with a working set
|
||||||
|
* count plus a single target weight/reps that we expand into N identical
|
||||||
|
* pre-filled sets. Shared by the producer (GenerateWorkoutClient) and the
|
||||||
|
* consumer (AiWorkoutPrefill) so the shape stays in sync.
|
||||||
|
*/
|
||||||
|
export interface AiWorkoutDraftExercise {
|
||||||
|
exerciseId: string;
|
||||||
|
sets: number;
|
||||||
|
reps?: number;
|
||||||
|
suggestedWeight?: number;
|
||||||
|
suggestedWeightUnit?: 'lbs' | 'kg';
|
||||||
|
rpe?: number;
|
||||||
|
gear?: number;
|
||||||
|
durationSeconds?: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
export interface AiWorkoutDraft {
|
||||||
|
name: string;
|
||||||
|
notes?: string;
|
||||||
|
exercises: AiWorkoutDraftExercise[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrefillSet {
|
||||||
|
setNumber: number;
|
||||||
|
reps?: number;
|
||||||
|
weight?: number;
|
||||||
|
rpe?: number;
|
||||||
|
gear?: number;
|
||||||
|
durationSeconds?: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
export interface PrefillExercise<E> {
|
||||||
|
exercise: E;
|
||||||
|
sets: PrefillSet[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default working sets when the model omits a positive count. */
|
||||||
|
const DEFAULT_SET_COUNT = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a draft into pre-filled exercises against the user's library.
|
||||||
|
*
|
||||||
|
* - Exercises whose `exerciseId` isn't in the library are dropped (the
|
||||||
|
* preview forces the user to map them first, so this is just defensive).
|
||||||
|
* - Each exercise becomes `sets` identical SetLogs seeded with the
|
||||||
|
* suggested weight/reps.
|
||||||
|
* - Effort follows the app convention: cardio logs `gear` (1-5), every
|
||||||
|
* other exercise logs `rpe`. We keep only the matching one so a stray
|
||||||
|
* value on the wrong kind never reaches the form.
|
||||||
|
* - The coaching note rides only on the first set (avoids N copies).
|
||||||
|
*/
|
||||||
|
export function buildPrefillExercises<
|
||||||
|
E extends { id: string; type?: string | null; muscleGroups?: string | null },
|
||||||
|
>(draft: AiWorkoutDraft, exercises: E[]): PrefillExercise<E>[] {
|
||||||
|
const byId = new Map(exercises.map((e) => [e.id, e]));
|
||||||
|
const out: PrefillExercise<E>[] = [];
|
||||||
|
for (const d of draft.exercises) {
|
||||||
|
const exercise = byId.get(d.exerciseId);
|
||||||
|
if (!exercise) continue;
|
||||||
|
const cardio = isCardioExercise(exercise);
|
||||||
|
const setCount = d.sets && d.sets > 0 ? d.sets : DEFAULT_SET_COUNT;
|
||||||
|
const sets: PrefillSet[] = Array.from({ length: setCount }, (_, i) => ({
|
||||||
|
setNumber: i + 1,
|
||||||
|
reps: d.reps,
|
||||||
|
weight: d.suggestedWeight,
|
||||||
|
rpe: cardio ? undefined : d.rpe,
|
||||||
|
gear: cardio ? d.gear : undefined,
|
||||||
|
durationSeconds: d.durationSeconds,
|
||||||
|
notes: i === 0 ? d.notes : undefined,
|
||||||
|
}));
|
||||||
|
out.push({ exercise, sets });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* System-prompt builder for the "generate today's workout" flow. The
|
||||||
|
* sibling of systemPromptBase.ts (which targets multi-week programs).
|
||||||
|
*
|
||||||
|
* Job: force the single-workout JSON contract, ground suggested weights
|
||||||
|
* in the user's history, and respect the app's Gear-vs-RPE effort
|
||||||
|
* convention (cardio logs breathing Gear 1-5; everything else logs
|
||||||
|
* RPE 6-10).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface WorkoutPromptOpts {
|
||||||
|
/** "lbs" | "kg" — default suggestedWeightUnit when the model omits one. */
|
||||||
|
weightUnit: 'lbs' | 'kg';
|
||||||
|
/** Whether the user's workout history is included in the prompt. */
|
||||||
|
hasHistoryContext: boolean;
|
||||||
|
/** True when the model is local (Ollama) — needs blunter, shorter rules. */
|
||||||
|
isLocalModel: boolean;
|
||||||
|
/** When refining, the prior suggestion's JSON. Present → revision mode. */
|
||||||
|
priorWorkoutJson?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkoutSystemPrompt(opts: WorkoutPromptOpts): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
'# ROLE',
|
||||||
|
'',
|
||||||
|
"You are a strength & conditioning coach building ONE training session for today from the user's brain-dump. Turn their loose description into a concrete, ready-to-log workout.",
|
||||||
|
'',
|
||||||
|
'# OUTPUT CONTRACT (mandatory)',
|
||||||
|
'',
|
||||||
|
'1. Reply with EXACTLY ONE JSON object matching the OUTPUT SHAPE. No prose before or after. No ```json fences.',
|
||||||
|
'2. Every exercise must use an `exerciseId` from the LIBRARY block. NEVER invent ids. If nothing fits, pick the closest match and explain the substitution in `notes`.',
|
||||||
|
'3. Honor what the user asked for: include the exercises they named, with the set counts / emphasis they specified. Add sensible accessory work only if they asked you to fill out a body part (e.g. "let\'s do biceps and triceps").',
|
||||||
|
`4. Every resistance exercise MUST have a \`suggestedWeight\` (a number) and a target \`reps\`. Cardio, stretching, and bodyweight exercises set \`suggestedWeight\` to null.`,
|
||||||
|
`5. Express \`suggestedWeight\` in THAT exercise's \`unit\` from the LIBRARY block, and set \`suggestedWeightUnit\` to match it (default "${opts.weightUnit}" if none is shown). Don't convert — give the number in the exercise's own unit.`,
|
||||||
|
'6. `sets` is the number of working sets to pre-fill (e.g. the user\'s "4 working sets" → sets: 4).',
|
||||||
|
'7. EFFORT: for CARDIO exercises set `gear` (1-5 breathing gear) and leave `rpe` null. For everything else set `rpe` (1-10) and leave `gear` null.',
|
||||||
|
'8. Use `durationSeconds` instead of `reps` for timed work (holds, carries, intervals).',
|
||||||
|
'9. `notes` is for a short coaching cue — one sentence, optional.',
|
||||||
|
'10. Keep it to a single realistic session (typically 3-8 exercises). Do NOT invent multiple days or weeks — this is ONE workout.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.hasHistoryContext) {
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'# USING THE HISTORY BLOCK',
|
||||||
|
'',
|
||||||
|
"The HISTORY block below summarizes the user's last 90 days. Use it to:",
|
||||||
|
'- Set `suggestedWeight` near their recent working weights for that exercise, NOT round numbers from nowhere.',
|
||||||
|
'- Nudge progressive overload where appropriate (small jump if a lift is moving; hold or deload if STAGNANT).',
|
||||||
|
'- Match the rep ranges and effort they tend to train at.',
|
||||||
|
"- If an exercise they named has no history, estimate conservatively and say so in `notes`.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'# WEIGHT GUIDANCE WITHOUT HISTORY',
|
||||||
|
'',
|
||||||
|
`Without prior data, set conservative \`suggestedWeight\` values (round gym increments; 5${opts.weightUnit} jumps, 2.5${opts.weightUnit} for small accessories) and add a coaching note like "adjust to leave 2-3 reps in reserve" so the user knows it's a starting estimate.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.priorWorkoutJson) {
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'# REVISION MODE',
|
||||||
|
'',
|
||||||
|
'The user already has the workout below and wants you to change it. Apply their requested change and re-emit the COMPLETE revised workout as one JSON object (not a diff). Keep everything they did not ask to change.',
|
||||||
|
'',
|
||||||
|
'CURRENT WORKOUT:',
|
||||||
|
opts.priorWorkoutJson,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.isLocalModel) {
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'# LOCAL MODEL REMINDER',
|
||||||
|
'',
|
||||||
|
'You are running locally with limited reasoning. Build the simplest valid single-session workout that matches the request. Do not overthink. JSON only.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { extractJson, looseInt } from './programSchema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shape we ask LLMs to produce for a SINGLE day's workout (the
|
||||||
|
* "generate today's workout" flow). Distinct from the multi-week
|
||||||
|
* AIProgram in programSchema.ts.
|
||||||
|
*
|
||||||
|
* This does NOT map onto a DB table directly: the user reviews/edits the
|
||||||
|
* suggestion, then it pre-populates the normal New Workout form (nothing
|
||||||
|
* is persisted until they save through the regular workout path). So the
|
||||||
|
* shape is optimized for "pre-fill a logger" not "INSERT a Program".
|
||||||
|
*
|
||||||
|
* Per exercise we ask for a working `sets` count plus a single target
|
||||||
|
* `reps` / `suggestedWeight` — the hand-off expands that into N identical
|
||||||
|
* pre-filled SetLogs. (No warmup/ramping distinction in v1.)
|
||||||
|
*
|
||||||
|
* `exerciseId` is nullable: the model picks from the user's library when
|
||||||
|
* it can, but may suggest something not in the library (the preview
|
||||||
|
* prompts the user to map it). `exerciseName` is REQUIRED as the display
|
||||||
|
* label + fuzzy-match fallback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const aiWorkoutExerciseSchema = z.object({
|
||||||
|
exerciseId: z.string().nullable(),
|
||||||
|
exerciseName: z.string().min(1),
|
||||||
|
order: looseInt(z.number().int().nonnegative()),
|
||||||
|
/// Number of working sets to pre-fill. Defaults to 3 in the hand-off
|
||||||
|
/// if the model omits it.
|
||||||
|
sets: looseInt(z.number().int().positive()).optional().nullable(),
|
||||||
|
/// Target reps per set (the user overwrites with what they actually
|
||||||
|
/// did). Omit for time/distance-based work.
|
||||||
|
reps: looseInt(z.number().int().positive()).optional().nullable(),
|
||||||
|
/// Suggested working weight. Null for cardio / bodyweight / stretching.
|
||||||
|
suggestedWeight: z.number().nonnegative().optional().nullable(),
|
||||||
|
/// "lbs" | "kg". Optional; hand-off falls back to the user's
|
||||||
|
/// defaultWeightUnit when null.
|
||||||
|
suggestedWeightUnit: z.enum(['lbs', 'kg']).optional().nullable(),
|
||||||
|
/// Strength effort (1-10). The hand-off keeps this only for non-cardio
|
||||||
|
/// exercises (cardio uses `gear`).
|
||||||
|
rpe: looseInt(z.number().int().min(1).max(10)).optional().nullable(),
|
||||||
|
/// Cardio breathing gear (1-5). The hand-off keeps this only for
|
||||||
|
/// cardio exercises (strength uses `rpe`).
|
||||||
|
gear: looseInt(z.number().int().min(1).max(5)).optional().nullable(),
|
||||||
|
/// Target duration in seconds for time-based work (e.g. a hold).
|
||||||
|
durationSeconds: looseInt(z.number().int().positive()).optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiWorkoutSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
exercises: z.array(aiWorkoutExerciseSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AIWorkout = z.infer<typeof aiWorkoutSchema>;
|
||||||
|
export type AIWorkoutExercise = z.infer<typeof aiWorkoutExerciseSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-schema-ish doc pasted into the system prompt so the model knows
|
||||||
|
* the exact shape to emit (same approach as PROGRAM_OUTPUT_SHAPE — not a
|
||||||
|
* provider "structured output" mode, since Ollama support is uneven).
|
||||||
|
*/
|
||||||
|
export const WORKOUT_OUTPUT_SHAPE = `{
|
||||||
|
"name": "<string, e.g. Upper Body — Shoulder Focus>",
|
||||||
|
"notes": "<string, optional, one-line session summary>",
|
||||||
|
"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 >= 1, number of working sets>,
|
||||||
|
"reps": <int, target reps per set; omit for time/distance work>,
|
||||||
|
"suggestedWeight": <number, working weight in the exercise's LIBRARY \`unit\`; omit/null for cardio, bodyweight, stretching>,
|
||||||
|
"suggestedWeightUnit": "<\\"lbs\\" | \\"kg\\", optional — match the exercise's \`unit\` from the LIBRARY>",
|
||||||
|
"rpe": <int 1-10, strength effort; use for NON-cardio exercises>,
|
||||||
|
"gear": <int 1-5, cardio breathing gear; use for CARDIO exercises instead of rpe>,
|
||||||
|
"durationSeconds": <int, optional, for timed holds/intervals>,
|
||||||
|
"notes": "<string, optional, short coaching cue>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse + validate a model's raw response into an AIWorkout. Returns a
|
||||||
|
* clean workout or a structured error. Mirrors parseAIProgram.
|
||||||
|
*/
|
||||||
|
export function parseAIWorkout(
|
||||||
|
raw: string,
|
||||||
|
):
|
||||||
|
| { ok: true; workout: AIWorkout }
|
||||||
|
| { 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 = aiWorkoutSchema.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, workout: result.data };
|
||||||
|
}
|
||||||
@@ -24,6 +24,33 @@ export async function verifyPassword(
|
|||||||
return bcrypt.compare(password, hash);
|
return bcrypt.compare(password, hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A valid bcrypt hash (cost 10, matching real password hashes) of a
|
||||||
|
* throwaway string. Not a secret — it verifies no real password. Its only
|
||||||
|
* job is to give the no-such-user login path something to bcrypt against.
|
||||||
|
*/
|
||||||
|
const DUMMY_PASSWORD_HASH =
|
||||||
|
"$2b$10$4Q3ukhdLWRqxvYHp4JezhuSPskBFVXvewuUhhfUML64nh4xBuYyPC";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a password against a user's hash, or against a fixed dummy hash
|
||||||
|
* when `hash` is null (no user matched the email). Either way exactly one
|
||||||
|
* bcrypt.compare runs, so an unknown-email attempt costs the same wall
|
||||||
|
* time as a real one — closing the timing oracle that would otherwise let
|
||||||
|
* an attacker enumerate which emails have accounts. The null-hash path
|
||||||
|
* always returns false.
|
||||||
|
*/
|
||||||
|
export async function verifyPasswordOrDummy(
|
||||||
|
password: string,
|
||||||
|
hash: string | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (hash === null) {
|
||||||
|
await bcrypt.compare(password, DUMMY_PASSWORD_HASH);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a session token for a user (30-day expiration).
|
* Create a session token for a user (30-day expiration).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { existsSync } from "fs";
|
|
||||||
|
|
||||||
export function resolveDatabasePath(): string {
|
export function resolveDatabasePath(): string {
|
||||||
const dbUrl = process.env.DATABASE_URL || "file:./data/app.db";
|
const dbUrl = process.env.DATABASE_URL || "file:./data/app.db";
|
||||||
@@ -14,14 +13,13 @@ export function resolveDatabasePath(): string {
|
|||||||
return rawPath;
|
return rawPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prisma resolves a relative `file:` URL against the schema directory
|
||||||
|
// (prisma/), NOT the cwd. We must resolve it the same way, otherwise we
|
||||||
|
// can hand back a stray empty ./data/app.db (created by a cwd-relative
|
||||||
|
// `prisma db push`) while the live DB Prisma actually uses sits under
|
||||||
|
// prisma/data/ — which made export-db stream a 0-byte file in dev.
|
||||||
const normalized = rawPath.replace(/^\.\//, "");
|
const normalized = rawPath.replace(/^\.\//, "");
|
||||||
const directPath = path.resolve(process.cwd(), normalized);
|
return path.resolve(process.cwd(), "prisma", normalized);
|
||||||
if (existsSync(directPath)) {
|
|
||||||
return directPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prismaPath = path.resolve(process.cwd(), "prisma", normalized);
|
|
||||||
return prismaPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTimestampFileSuffix(now: Date = new Date()): string {
|
export function getTimestampFileSuffix(now: Date = new Date()): string {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const BASE_TRACKING_FIELDS: Option[] = [
|
|||||||
{ value: "duration", label: "Time" },
|
{ value: "duration", label: "Time" },
|
||||||
{ value: "distance", label: "Distance" },
|
{ value: "distance", label: "Distance" },
|
||||||
{ value: "calories", label: "Calories" },
|
{ value: "calories", label: "Calories" },
|
||||||
|
{ value: "watts", label: "Avg. watts" },
|
||||||
{ value: "notes", label: "Notes" },
|
{ value: "notes", label: "Notes" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -130,3 +131,19 @@ export function deriveTrackingFieldOptions(exercises: Exercise[]): Option[] {
|
|||||||
export function displayLabel(value: string): string {
|
export function displayLabel(value: string): string {
|
||||||
return titleCaseToken(value);
|
return titleCaseToken(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cardio exercises log breathing "Gear" (1-5) instead of RPE (6-10) as their
|
||||||
|
* effort field. An exercise counts as cardio if its equipment type is "cardio"
|
||||||
|
* or it carries the "cardio" muscle group (e.g. Assault Bike, type
|
||||||
|
* "assault bike", is tagged cardio).
|
||||||
|
*/
|
||||||
|
export function isCardioExercise(exercise: {
|
||||||
|
type?: string | null;
|
||||||
|
muscleGroups?: string | null;
|
||||||
|
}): boolean {
|
||||||
|
if (normalizeValue(exercise.type || "") === "cardio") return true;
|
||||||
|
return parseJsonArray(exercise.muscleGroups ?? null).some(
|
||||||
|
(group) => normalizeValue(group) === "cardio"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the subset of `exerciseIds` that do NOT belong to `userId`.
|
||||||
|
*
|
||||||
|
* Exercises are per-user (`Exercise.userId`, `@@unique([userId, name])`) —
|
||||||
|
* even curated-library entries are copied per account. Routes that write
|
||||||
|
* SetLogs from a client-supplied exerciseId must check ownership first;
|
||||||
|
* otherwise a user could attach another user's exercise to their own
|
||||||
|
* workout, which leaks that exercise's name/notes back on fetch and wires
|
||||||
|
* up a cross-user `onDelete: Cascade` dependency.
|
||||||
|
*
|
||||||
|
* Unknown ids and ids owned by someone else are deliberately
|
||||||
|
* indistinguishable in the result, so a caller's 400 can't be used to
|
||||||
|
* probe which exerciseIds exist. An empty input returns an empty array
|
||||||
|
* (no query).
|
||||||
|
*/
|
||||||
|
export async function findUnownedExerciseIds(
|
||||||
|
userId: string,
|
||||||
|
exerciseIds: Iterable<string>,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const ids = Array.from(new Set(exerciseIds));
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
|
const owned = await prisma.exercise.findMany({
|
||||||
|
where: { userId, id: { in: ids } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const ownedIds = new Set(owned.map((e) => e.id));
|
||||||
|
|
||||||
|
return ids.filter((id) => !ownedIds.has(id));
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a JSON request body, turning a malformed or empty body into a
|
||||||
|
* `ZodError` rather than letting the raw `SyntaxError` from `request.json()`
|
||||||
|
* fall through a route's generic `catch` and become an HTTP 500.
|
||||||
|
*
|
||||||
|
* Why a `ZodError` specifically: every body-parsing route already maps
|
||||||
|
* `instanceof z.ZodError` to a 400. Throwing one here means a malformed body
|
||||||
|
* returns 400 across all of them with no per-route catch changes — the call
|
||||||
|
* site swaps `request.json()` for `readJsonBody(request)` and nothing else
|
||||||
|
* moves. (It is a genuine `z.ZodError`, so the `instanceof` checks hold.)
|
||||||
|
*/
|
||||||
|
export async function readJsonBody(request: Request): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return await request.json();
|
||||||
|
} catch {
|
||||||
|
throw new z.ZodError([
|
||||||
|
{
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: [],
|
||||||
|
message: "Request body must be valid JSON",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,19 +44,37 @@ export function rateLimit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Best-effort client IP extraction. In a StartOS deployment the Next.js
|
* Best-effort client IP extraction for rate-limit keys.
|
||||||
* server sits behind a single proxy hop, so the leftmost
|
*
|
||||||
* `x-forwarded-for` entry is the originating client. If headers are
|
* `X-Forwarded-For` is a client-appendable, comma-separated list: each proxy
|
||||||
* absent (direct access in dev), fall back to the literal "unknown" key
|
* APPENDS the address it observed. A direct client can therefore forge any
|
||||||
* so the limiter still applies as a global rate cap.
|
* number of leftmost entries — using `xff.split(',')[0]` (the leftmost) lets
|
||||||
|
* an attacker rotate a fake IP per request and defeat the limiter entirely.
|
||||||
|
*
|
||||||
|
* In a StartOS deployment the Next.js server sits behind exactly one trusted
|
||||||
|
* proxy hop, so the RIGHTMOST entry is the address that proxy actually saw —
|
||||||
|
* the only value the client cannot spoof. We key off that. (If the proxy
|
||||||
|
* overwrites rather than appends XFF, the list has a single entry and
|
||||||
|
* rightmost == leftmost, so this is also correct in that case.) If XFF is
|
||||||
|
* absent (direct access in dev), fall back to `x-real-ip`, then to the
|
||||||
|
* literal "unknown" key so the limiter still applies as a global cap.
|
||||||
|
*
|
||||||
|
* Assumes a single trusted hop; if the deployment ever grows additional
|
||||||
|
* trusted proxies, count that many entries in from the right instead.
|
||||||
*/
|
*/
|
||||||
export function clientIpFromHeaders(headers: Headers): string {
|
export function clientIpFromHeaders(headers: Headers): string {
|
||||||
const xff = headers.get('x-forwarded-for');
|
const xff = headers.get('x-forwarded-for');
|
||||||
if (xff) {
|
if (xff) {
|
||||||
const first = xff.split(',')[0]?.trim();
|
const parts = xff
|
||||||
if (first) return first;
|
.split(',')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (parts.length > 0) return parts[parts.length - 1];
|
||||||
}
|
}
|
||||||
const real = headers.get('x-real-ip');
|
const real = headers.get('x-real-ip');
|
||||||
if (real) return real;
|
if (real) {
|
||||||
|
const trimmed = real.trim();
|
||||||
|
if (trimmed) return trimmed;
|
||||||
|
}
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user