diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..30651fb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +coverage +*.db +*.db-shm +*.db-wal +.git +.env +.tsbuildinfo diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c493cfe --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Copy to .env and adjust. Never commit a real MINIMAX_API_KEY. + +# Provider mode: "mock" (default, deterministic, no key) or "minimax" (real API). +PROVIDER_MODE=mock + +# Required only when PROVIDER_MODE=minimax. Real mode refuses to start without +# it and NEVER falls back silently to mock. +# MINIMAX_API_KEY= +# MINIMAX_GROUP_ID= +# MINIMAX_BASE_URL=https://api.minimaxi.com + +# Server / data +PORT=3001 +DB_PATH=./data/h3-studio.db +SEED_SAMPLES=true + +# Polling (in-process, single instance) +POLL_INTERVAL_MS=2000 +POLL_MAX_ATTEMPTS=120 + +# Production single-image client serving (set by Docker; leave unset for dev). +# CLIENT_DIST=/app/packages/client/dist diff --git a/.gitignore b/.gitignore index 41a3fa8..41746dc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,11 @@ node_modules/ dist/ coverage/ .env +.env.* +!.env.example *.db *.db-shm *.db-wal .DS_Store +*.log +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c6de659 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1.7 + +# ---- Build stage: install all deps and build shared, client, and server ---- +FROM node:22-bookworm-slim AS build +WORKDIR /app +RUN corepack enable +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./ +COPY packages/shared/package.json packages/shared/ +COPY packages/server/package.json packages/server/ +COPY packages/client/package.json packages/client/ +RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile + +# Copy sources and build (topological order: shared -> server/client). +COPY packages/shared packages/shared +COPY packages/server packages/server +COPY packages/client packages/client +RUN pnpm -r run build + +# ---- Runtime stage: production deps + built artifacts only ---- +FROM node:22-bookworm-slim AS runtime +WORKDIR /app +RUN corepack enable + +# Install only production dependencies for the workspace. +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./ +COPY packages/shared/package.json packages/shared/ +COPY packages/server/package.json packages/server/ +COPY packages/client/package.json packages/client/ +RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ + pnpm install --prod --frozen-lockfile + +# Overlay the built artifacts (sources are not needed at runtime). +COPY --from=build /app/packages/shared/dist ./packages/shared/dist +COPY --from=build /app/packages/server/dist ./packages/server/dist +COPY --from=build /app/packages/client/dist ./packages/client/dist + +ENV NODE_ENV=production \ + PORT=3001 \ + DB_PATH=/data/h3-studio.db \ + SEED_SAMPLES=true \ + PROVIDER_MODE=mock \ + CLIENT_DIST=/app/packages/client/dist \ + # node:sqlite is experimental in Node 22. + NODE_OPTIONS="--experimental-sqlite --disable-warning=ExperimentalWarning" + +EXPOSE 3001 +# Migrations run on startup; the in-process poller advances non-terminal jobs. +WORKDIR /app/packages/server +CMD ["node", "dist/server.js"] diff --git a/README.md b/README.md index ea6fac7..83833bd 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,361 @@ # H3 Prompt Studio -This repository is a Cloud dogfood project. The product requirements are in -[`docs/PRD.md`](docs/PRD.md); implementation is intentionally delegated to a -Cloud built-in coding agent. +A self-hostable, single-user workspace that combines a **versioned MiniMax H3 +video-prompt library** with **asynchronous H3 generation jobs**. Create reusable +prompt templates, declare `{{variable}}` placeholders, render and validate before +submitting, then watch jobs progress through `queued → running → succeeded` (or +`failed`/`expired`) — all while MiniMax credentials stay server-side. -No API keys belong in this repository. MiniMax credentials must be supplied to -the server at runtime. +The product is a complete vertical slice: strict-TypeScript React/Vite UI, a +Node/Express REST API, SQLite persistence, a deterministic mock provider **and** +a real MiniMax-H3 V2 adapter behind one `VideoProvider` interface, server-side +polling, validation, and idempotency. It runs end-to-end with **no MiniMax key** +in deterministic mock mode and switches to the real server-side API when a key is +configured. + +> Requirements live in [`docs/PRD.md`](docs/PRD.md). No API keys belong in this +> repository — MiniMax credentials are supplied to the server at runtime. + +--- + +## Highlights + +- **Versioned prompt library** — immutable versions, restore-as-new-head, + duplicate, archive (never delete), full-text + tag/status filters. +- **Pure template engine** — `{{variable}}` parsing/rendering with name + validation (letters, numbers, `_`, `.`, `-`), duplicate normalization, and + rejection of blank/unresolved variables. +- **Honest H3 policy** — durations 4–15s, explicit non-adaptive aspect ratios, + resolution represented as `2K` only, in one server-side policy module. +- **Two providers, one interface** — deterministic mock (success/failure/expired/ + provider_error/slow scenarios) and the real MiniMax H3 V2 adapter; selected by + configuration, never falling back silently. +- **Async + idempotent** — jobs return immediately with a local id, the server + polls the provider (not the browser), submissions are de-duplicated by an + idempotency key, and same-key/different-payload is a conflict. Idempotency is + **concurrency-safe**: a SQLite unique-key race resolves into reuse or a 409, + never a generic 500, and the client keeps the same key after transient + failures so a retry cannot create a paid duplicate. +- **Resilient restart** — on startup, queued/running jobs with no recorded + provider task are moved to an explicit recoverable `failed` state so the + poller never spins on them forever (see the *exactly-once boundary* note + below). +- **Production artifacts** — multi-stage Docker image, Docker Compose, and + Kubernetes manifests for namespace `jcode` with PVC, probes, and referenced + (never embedded) secrets. + +--- + +## Architecture + +A pnpm workspace with three packages and strict TypeScript throughout: + +``` +packages/ + shared/ # contract: types, zod schemas, template engine, H3 policy, errors + server/ # Node + Express REST API, SQLite, providers, poller + client/ # React + Vite SPA (imports shared types only — never server impl) +``` + +- **Data model** — `prompts` (identity) are separate from immutable + `prompt_versions`, and `generation_jobs` record the rendered prompt, params, + provider task id, and outcome. Migrations run on startup and via + `pnpm migrate`. +- **Provider isolation** — MiniMax lives behind a small `VideoProvider` + interface (`create`/`query`). The real adapter builds the multimodal `content` + array, sends `Authorization: Bearer …` server-side (never logged), and maps + provider states/errors into the local state machine. +- **Polling** — a simple in-process poller advances non-terminal jobs with + bounded retry/backoff; the browser polls the server's job endpoint, never the + provider directly. +- **Single image** — in production the server serves the built client at `/` and + the API at `/api`. + +``` +┌────────────┐ /api/* ┌─────────────────────┐ HTTPS ┌──────────────┐ +│ React UI │ ─────────▶ │ Express API │ ─────────▶ │ MiniMax H3 │ +│ (Vite) │ ◀───────── │ SQLite + poller │ ◀────────── │ (or mock) │ +└────────────┘ job state └─────────────────────┘ states └──────────────┘ +``` + +--- + +## Prerequisites + +- **Node.js ≥ 22** (uses the built-in experimental `node:sqlite` — no native + add-ons required). +- **pnpm 10** (enable via `corepack enable`). +- Optionally Docker / Kubernetes for containerized deployment. + +--- + +## Quick start (mock mode, no key) + +```bash +corepack enable +pnpm install +pnpm --filter @h3/shared build # build the shared contract once + +# Terminal 1 — API on :3001 (mock provider, sample prompts seeded) +pnpm dev:server + +# Terminal 2 — UI on :5173 (proxies /api to :3001) +pnpm dev:client +``` + +Open . The library is seeded with sample prompts. Open a +prompt, fill variables, save a new version, then **Generate from head** to submit +a mock generation and watch it poll to success. + +To exercise failure paths from the UI, pick a **Mock scenario** in the composer +(failure / expired / provider_error / slow), or call the mock-only control: + +```bash +curl -X PUT localhost:3001/api/debug/mock -H 'Content-Type: application/json' \ + -d '{"scenario":"failure"}' +``` + +--- + +## Using the real MiniMax H3 API + +Real mode is selected by configuration and **fails visibly** when the key is +absent — it never silently falls back to mock. + +```bash +export PROVIDER_MODE=minimax +export MINIMAX_API_KEY=sk-... # server-side only; never commit +# optional: export MINIMAX_GROUP_ID=... MINIMAX_BASE_URL=https://api.minimaxi.com +pnpm dev:server +``` + +The adapter targets the official H3 V2 contract: + +- `POST {MINIMAX_BASE_URL}/v2/video_generation` with model `MiniMax-H3`. +- The body uses top-level **`ratio`** (not `aspect_ratio`) and a multimodal + `content[]` array: one text item (max **7000** rendered characters) plus + independent media items, each tagged with a `role`: + `{type:"image_url",image_url:{url},role:"first_frame|last_frame|reference_image"}`, + `{type:"video_url",video_url:{url},role:"reference_video"}`, + `{type:"audio_url",audio_url:{url},role:"reference_audio"}`. +- Status is queried at `GET …/v2/query/video_generation/{task_id}` (the task id + is a path segment, URL-encoded). The nested `task.status` / + `task.content.url` / `task.error` are parsed; provider HTTP errors use the + OpenAI-style envelope `{error:{type,message,http_code}}` and are classified by + `http_code` (400/401/402/422/429/500/529). +- A trailing slash on `MINIMAX_BASE_URL` is normalized automatically. + +The H3 constraints are enforced **before** the request reaches the provider: +durations 4–15s, `resolution` 2K, and the conditional `ratio` + media rules +(text-to-video needs a concrete ratio; first/last-frame is `adaptive`; reference +mode may use either). Media cross-field rules are also enforced: first/last-frame +mode and reference media are mutually exclusive, `last_frame` requires +`first_frame`, and reference audio requires a reference image or video. See +`packages/shared/src/h3-policy.ts`. + +--- + +## Environment variables + +| Variable | Default | Description | +| -------------------- | ----------------------------- | -------------------------------------------------------------------- | +| `PROVIDER_MODE` | `mock` | `mock` or `minimax`. | +| `MINIMAX_API_KEY` | _(unset)_ | Required for `minimax`. Read server-side only. | +| `MINIMAX_GROUP_ID` | _(unset)_ | Optional MiniMax group id. | +| `MINIMAX_BASE_URL` | `https://api.minimaxi.com` | MiniMax API base URL. | +| `PORT` | `3001` | HTTP port. | +| `DB_PATH` | `./data/h3-studio.db` | SQLite file path (persist on a mounted volume in prod). | +| `SEED_SAMPLES` | `true` (mock) / `false` (minimax) | Seed sample prompts when the DB is empty. Defaults off in `minimax` mode unless explicitly enabled. | +| `POLL_INTERVAL_MS` | `2000` | Poller sweep interval. | +| `POLL_MAX_ATTEMPTS` | `120` | Consecutive provider failures before a job is marked failed. | +| `CLIENT_DIST` | _(unset in dev)_ | Built client dir to serve (set by Docker for the single image). | + +A starter template is in [`.env.example`](.env.example). + +--- + +## NPM scripts + +Run from the repository root: + +| Command | Description | +| -------------------- | ------------------------------------------------------------------ | +| `pnpm install` | Install all workspace dependencies. | +| `pnpm -r run build` | Production build of shared, server, and client. | +| `pnpm -r run typecheck` | Type-check every package (strict). | +| `pnpm lint` | ESLint across the workspace. | +| `pnpm -r run test` | Run the full test suite (Vitest). | +| `pnpm dev:server` | Run the API in dev mode (tsx watch). | +| `pnpm dev:client` | Run the Vite dev server. | +| `pnpm migrate` | Apply SQLite migrations (idempotent; also runs on startup). | + +> The server/test scripts set `NODE_OPTIONS=--experimental-sqlite` because +> `node:sqlite` is experimental in Node 22. + +> `pnpm typecheck` and `pnpm test` build `@h3/shared` first so they work on a +> clean checkout with no `dist/` present; `pnpm -r run build` builds in +> topological (deterministic) order: shared → server/client. + +--- + +## Testing + +The gate is: install → lint → type check → all tests → production build. + +```bash +pnpm -r run test +``` + +Coverage by intent (behavior and state transitions, not internals): + +- **Template engine & validation** — parsing/rendering, variable-name rules, + duplicate normalization, blank/unresolved rejection, and H3 duration (4/15 + boundaries) + all aspect ratios + http(s) URL validation. +- **MiniMax mapping** — request payload (`content` array), task-status → local + state, and HTTP error → category mapping through a **fake HTTP transport** + (no paid calls). +- **Mock provider** — deterministic queued→running→succeeded/failed/expired + transitions, stable result URLs, create-time `provider_error`. +- **Repositories** — SQLite create/search/version-restore/archived behavior and + the idempotency-key uniqueness constraint. +- **Generation service & poller** — idempotent reuse vs. conflict, unresolved + variables, and the queued→terminal job lifecycle. +- **API (supertest)** — the core path: create prompt → version → render → submit + mock generation → poll to success → history; plus validation, conflict, and + request-id envelopes. +- **Components (RTL)** — empty library, populated cards, missing-variable submit + protection, double-submit protection, and provider-failure rendering. + +--- + +## REST API + +All routes are under `/api` and return a consistent typed error envelope +`{ error: { code, message, status, requestId } }` on failure. + +| Method | Path | Purpose | +| ------ | ----------------------------------------------- | ---------------------------------------- | +| GET | `/api/health` | App health vs. provider configuration. | +| GET | `/api/prompts` | List/search/filter prompts. | +| POST | `/api/prompts` | Create a prompt + first version. | +| GET | `/api/prompts/:id` | Prompt detail + version history. | +| PATCH | `/api/prompts/:id` | Update name/description/tags/status. | +| POST | `/api/prompts/:id/duplicate` | Duplicate a prompt. | +| DELETE | `/api/prompts/:id` | Archive a prompt (never hard-delete). | +| POST | `/api/prompts/:id/versions` | Save a new immutable version. | +| POST | `/api/prompts/:id/versions/:vid/restore` | Restore a version as a new head. | +| POST | `/api/render-preview` | Render a template with values. | +| GET | `/api/generations` | List jobs (filter by status/prompt). | +| POST | `/api/generations` | Submit a generation (idempotent). | +| GET | `/api/generations/:id` | Job detail (rendered prompt, outcome). | +| POST | `/api/generations/:id/retry` | Retry a failed/expired job as new. | +| GET/PUT| `/api/debug/mock` | Mock-only scenario control. | + +Example submission: + +```bash +curl -X POST localhost:3001/api/generations -H 'Content-Type: application/json' -d '{ + "promptVersionId": "", + "values": { "subject": "a car" }, + "durationSeconds": 6, + "aspectRatio": "16:9", + "resolution": "2K", + "idempotencyKey": "unique-per-attempt" +}' +``` + +--- + +## Docker + +Build and run the single production image (API + client) with Docker Compose: + +```bash +docker compose up --build +# open http://localhost:3001 +``` + +SQLite data is persisted on the `h3-data` volume. To use the real provider with +Compose, set `PROVIDER_MODE=minimax` and inject `MINIMAX_API_KEY` via your secret +mechanism (not committed). + +--- + +## Kubernetes (namespace `jcode`) + +Manifests live in [`k8s/`](k8s/): Namespace, ConfigMap, Secret placeholder, PVC, +Deployment (readiness/liveness probes, `Recreate` strategy for single-instance +SQLite), and a ClusterIP Service. + +```bash +docker build -t h3-prompt-studio:latest . +kubectl apply -k k8s/ # or: kubectl apply -f k8s/ +kubectl -n jcode rollout status deploy/h3-prompt-studio +kubectl -n jcode port-forward svc/h3-prompt-studio 8080:80 # open http://localhost:8080 +``` + +Supply real credentials out-of-band: + +```bash +kubectl -n jcode create secret generic h3-prompt-studio-secrets \ + --from-literal=MINIMAX_API_KEY=sk-... -o yaml --dry-run=client | kubectl apply -f - +``` + +> **Note:** `secret.yaml` is an empty placeholder so the Deployment applies +> cleanly in mock mode. Never commit a real key. + +--- + +## Security notes + +- MiniMax credentials are read from the environment **server-side only**. They + never appear in client bundles, API responses, logs, or Git. Authorization + headers and rendered media payloads are never logged. +- External media URLs are validated as `http(s)`. Production deployments should + add **URL allowlists and server-side media ingestion** rather than passing + arbitrary provider-side fetches — this is the documented extension point for + SSRF/media scanning. +- Health (`/api/health`) distinguishes application health from provider + configuration: a missing paid key is reported as `degraded` (and + `providerConfigured: false`), not an outage. `/api/healthz` (liveness) is + always `200 ok`; `/api/health` (readiness) returns `200` so traffic still + flows while the key is absent. +- Inbound `X-Request-Id` headers are validated against a bounded, control-free + safe character set; an unsafe or overlong value is replaced with a generated + id (no header/log injection). In `minimax` mode, sample seeding defaults off + unless explicitly enabled. +- **Exactly-once boundary (documented).** This single-instance PoC stores a job + row *before* submitting to the provider. If the process is interrupted after + the provider accepted a request but before the task id was persisted, the + provider may have started a generation the local row does not know about. + Startup recovery moves such orphaned rows to a recoverable `failed` state; + retrying creates a new local job (and may create a second provider + generation). Closing this gap would require a durable outbox plus provider + idempotency — the documented extension point. + +## Extensibility points (out of scope for this PoC) + +- Replace the in-process poller with a **durable distributed queue** and + horizontal multi-instance polling. +- Add **webhooks** for provider callbacks. +- Add SSRF allowlists / media scanning before provider fetches. +- The provider seam (`packages/server/src/providers`) is where additional video + providers would be added. + +--- + +## Project layout + +``` +docs/PRD.md Product requirements +k8s/ Namespace jcode Kubernetes manifests +packages/shared Types, zod schemas, template engine, H3 policy, errors +packages/server Express API, SQLite, providers, poller, migrations +packages/client React/Vite SPA and typed API client +Dockerfile Multi-stage production image +docker-compose.yml Local containerized run +``` + +## License + +Provided for the Cloud dogfood exercise described in `docs/PRD.md`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..37eb1b5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +# Local development / dogfood via Docker Compose. +# +# Defaults to deterministic MOCK mode (no MiniMax key required). To run against +# the real MiniMax H3 API, set PROVIDER_MODE=minimax and provide MINIMAX_API_KEY +# (preferably via a secrets manager / .env that is never committed). +services: + h3-studio: + build: + context: . + dockerfile: Dockerfile + image: h3-prompt-studio:latest + container_name: h3-prompt-studio + ports: + - "3001:3001" + environment: + PROVIDER_MODE: ${PROVIDER_MODE:-mock} + SEED_SAMPLES: "true" + POLL_INTERVAL_MS: "2000" + # MINIMAX_API_KEY: set only when PROVIDER_MODE=minimax (via secrets). + volumes: + - h3-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3001/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + h3-data: diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..63fee2b --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,45 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: [ + '**/dist/**', + '**/node_modules/**', + '**/coverage/**', + '**/*.config.{js,cjs,mjs,ts}', + 'eslint.config.js', + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + console: 'readonly', + process: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + crypto: 'readonly', + URL: 'readonly', + fetch: 'readonly', + AbortController: 'readonly', + structuredClone: 'readonly', + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { prefer: 'type-imports', fixStyle: 'inline-type-imports' }, + ], + }, + }, +); diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 0000000..43129dd --- /dev/null +++ b/k8s/configmap.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: h3-prompt-studio-config + namespace: jcode + labels: + app.kubernetes.io/name: h3-prompt-studio +data: + # Default to deterministic mock mode. Set to "minimax" and supply the secret + # below to use the real MiniMax H3 API. + PROVIDER_MODE: "mock" + SEED_SAMPLES: "true" + POLL_INTERVAL_MS: "2000" + POLL_MAX_ATTEMPTS: "120" + DB_PATH: "/data/h3-studio.db" + CLIENT_DIST: "/app/packages/client/dist" diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..08b1834 --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,66 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: h3-prompt-studio + namespace: jcode + labels: + app.kubernetes.io/name: h3-prompt-studio +spec: + replicas: 1 + strategy: + type: Recreate # single-instance SQLite: avoid concurrent writers + selector: + matchLabels: + app.kubernetes.io/name: h3-prompt-studio + template: + metadata: + labels: + app.kubernetes.io/name: h3-prompt-studio + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + containers: + - name: h3-prompt-studio + image: h3-prompt-studio:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3001 + envFrom: + - configMapRef: + name: h3-prompt-studio-config + - secretRef: + name: h3-prompt-studio-secrets + env: + - name: NODE_OPTIONS + value: "--experimental-sqlite --disable-warning=ExperimentalWarning" + volumeMounts: + - name: data + mountPath: /data + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + failureThreshold: 6 + livenessProbe: + httpGet: + path: /api/healthz + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + failureThreshold: 3 + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "1000m" + memory: "512Mi" + volumes: + - name: data + persistentVolumeClaim: + claimName: h3-prompt-studio-data diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..9536b82 --- /dev/null +++ b/k8s/kustomization.yaml @@ -0,0 +1,12 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: jcode +resources: + - namespace.yaml + - configmap.yaml + - secret.yaml + - pvc.yaml + - deployment.yaml + - service.yaml +commonLabels: + app.kubernetes.io/part-of: h3-prompt-studio diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..24bbe2e --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: jcode + labels: + app.kubernetes.io/name: h3-prompt-studio diff --git a/k8s/pvc.yaml b/k8s/pvc.yaml new file mode 100644 index 0000000..e2d0b47 --- /dev/null +++ b/k8s/pvc.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: h3-prompt-studio-data + namespace: jcode + labels: + app.kubernetes.io/name: h3-prompt-studio +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/k8s/secret.yaml b/k8s/secret.yaml new file mode 100644 index 0000000..f9e5dc1 --- /dev/null +++ b/k8s/secret.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Secret +metadata: + name: h3-prompt-studio-secrets + namespace: jcode + labels: + app.kubernetes.io/name: h3-prompt-studio +type: Opaque +# IMPORTANT: this is an empty placeholder. Real MiniMax credentials must be +# supplied out-of-band (e.g. `kubectl create secret`, Sealed Secrets, or a +# managed secret store) and must NEVER be committed with a real value. +# Only required when PROVIDER_MODE=minimax. +stringData: + MINIMAX_API_KEY: "" diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..d226f77 --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: h3-prompt-studio + namespace: jcode + labels: + app.kubernetes.io/name: h3-prompt-studio +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: h3-prompt-studio + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP diff --git a/package.json b/package.json new file mode 100644 index 0000000..16078ef --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "h3-prompt-studio", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "H3 Prompt Studio — versioned MiniMax H3 prompt library and generation workspace.", + "packageManager": "pnpm@10.10.0", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "pnpm -r run build", + "test": "pnpm --filter @h3/shared run build && pnpm -r run test", + "typecheck": "pnpm --filter @h3/shared run build && pnpm -r run typecheck", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "migrate": "pnpm --filter @h3/server run migrate", + "dev": "pnpm --filter @h3/server run dev", + "dev:server": "pnpm --filter @h3/server run dev", + "dev:client": "pnpm --filter @h3/client run dev", + "start": "pnpm --filter @h3/server run start" + }, + "devDependencies": { + "@eslint/js": "^9.12.0", + "@types/node": "^22.7.4", + "eslint": "^9.12.0", + "typescript": "^5.6.2", + "typescript-eslint": "^8.8.0" + } +} diff --git a/packages/client/index.html b/packages/client/index.html new file mode 100644 index 0000000..51fbcc5 --- /dev/null +++ b/packages/client/index.html @@ -0,0 +1,13 @@ + + + + + + + H3 Prompt Studio + + +
+ + + diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..9b7e83c --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,31 @@ +{ + "name": "@h3/client", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.json --noEmit && vite build", + "typecheck": "tsc -p tsconfig.json --noEmit", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@h3/shared": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^25.0.1", + "typescript": "^5.6.2", + "vite": "^5.4.11", + "vitest": "^2.1.6" + } +} diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx new file mode 100644 index 0000000..462fb9f --- /dev/null +++ b/packages/client/src/App.tsx @@ -0,0 +1,116 @@ +/** Application shell: sidebar navigation, health/mode badge, view routing. */ + +import { useEffect, useState } from 'react'; +import type { HealthStatus, ProviderName } from '@h3/shared'; +import { api } from './api/client.js'; +import { NavProvider, useNav } from './nav.js'; +import { Library } from './features/Library.js'; +import { PromptEditor } from './features/PromptEditor.js'; +import { Composer } from './features/Composer.js'; +import { JobsList, JobDetail } from './features/Jobs.js'; +import { NewPrompt } from './features/NewPrompt.js'; + +function Shell() { + const { view } = useNav(); + const [creating, setCreating] = useState(false); + const [health, setHealth] = useState(null); + const [healthError, setHealthError] = useState(false); + + useEffect(() => { + let active = true; + (async () => { + try { + const h = await api.getHealth(); + if (active) setHealth(h); + } catch { + // Health is informational. Do NOT assume mock mode on failure — surface + // an explicit "unavailable" state instead of silently presenting mock. + if (active) setHealthError(true); + } + })(); + return () => { + active = false; + }; + }, []); + + const onNew = () => setCreating(true); + // Provider mode is only known once health is loaded. Do not default to mock. + const mode: ProviderName | undefined = health?.mode; + + return ( +
+ + +
+ {creating ? ( + setCreating(false)} /> + ) : view.name === 'editor' ? ( + + ) : view.name === 'composer' ? ( + + ) : view.name === 'jobs' ? ( + + ) : view.name === 'job' ? ( + + ) : ( + + )} +
+
+ ); +} + +export default function App() { + return ( + + + + ); +} diff --git a/packages/client/src/__tests__/client.test.ts b/packages/client/src/__tests__/client.test.ts new file mode 100644 index 0000000..1ca77a1 --- /dev/null +++ b/packages/client/src/__tests__/client.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { api, ApiClientError } from '../api/client.js'; + +function mockFetch(response: Response | { status: number; body: unknown }) { + const impl = + response instanceof Response + ? async () => response + : async () => + new Response(JSON.stringify(response.body), { + status: response.status, + headers: { 'content-type': 'application/json' }, + }); + vi.stubGlobal('fetch', vi.fn(impl)); +} + +beforeEach(() => { + mockFetch({ status: 200, body: { items: [], total: 0 } }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('api client', () => { + it('parses a successful list response', async () => { + const res = await api.listPrompts(); + expect(res).toEqual({ items: [], total: 0 }); + expect(fetch).toHaveBeenCalledTimes(1); + const [url, init] = (fetch as unknown as ReturnType).mock.calls[0] as [ + string, + RequestInit, + ]; + expect(url).toMatch(/^\/api\/prompts/); + expect(init.method).toBe('GET'); + }); + + it('sends a JSON body for POST', async () => { + await api.createPrompt({ name: 'x', content: 'c', description: '', tags: [], status: 'draft' }); + const init = (fetch as unknown as ReturnType).mock.calls[0]![1] as RequestInit; + expect(init.method).toBe('POST'); + expect(init.headers).toMatchObject({ 'Content-Type': 'application/json' }); + expect(JSON.parse(init.body as string).name).toBe('x'); + }); + + it('throws ApiClientError with code and request id on a failure envelope', async () => { + mockFetch({ + status: 404, + body: { + error: { + code: 'not_found', + message: 'nope', + status: 404, + requestId: 'rid-123', + }, + }, + }); + await expect(api.getPrompt('x')).rejects.toMatchObject({ + code: 'not_found', + status: 404, + requestId: 'rid-123', + message: 'nope', + }); + try { + await api.getJob('x'); + } catch (e) { + expect(e).toBeInstanceOf(ApiClientError); + } + }); + + it('builds query strings for filters', async () => { + await api.listJobs({ status: 'failed', limit: 5 }); + const url = (fetch as unknown as ReturnType).mock.calls[0]![0] as string; + expect(url).toContain('status=failed'); + expect(url).toContain('limit=5'); + }); +}); diff --git a/packages/client/src/api/client.ts b/packages/client/src/api/client.ts new file mode 100644 index 0000000..bfc584f --- /dev/null +++ b/packages/client/src/api/client.ts @@ -0,0 +1,161 @@ +/** + * Typed API client. A thin fetch wrapper over the REST surface. Imports shared + * contract types only — never the server implementation. Errors are normalized + * into `ApiClientError` carrying the stable error code and request id. + */ + +import type { + CreateGenerationRequest, + CreatePromptRequest, + DuplicatePromptRequest, + GenerationJob, + HealthStatus, + ListJobsQuery, + ListPromptsQuery, + Prompt, + PromptDetail, + PromptVersion, + UpdatePromptRequest, +} from '@h3/shared'; +import type { + ApiErrorBody, + CreateGenerationResponse, +} from '@h3/shared'; + +export class ApiClientError extends Error { + readonly code: string; + readonly status: number; + readonly requestId: string; + readonly details?: Record; + + constructor(body: ApiErrorBody) { + super(body.message); + this.name = 'ApiClientError'; + this.code = body.code; + this.status = body.status; + this.requestId = body.requestId; + this.details = body.details; + } +} + +export interface ListResponse { + items: T[]; + total: number; +} + +interface RequestOptions { + method: string; + body?: unknown; + query?: Record; +} + +async function request(path: string, options: RequestOptions): Promise { + const url = options.query + ? `${path}?${new URLSearchParams( + Object.entries(options.query).filter(([, v]) => v !== undefined) as [string, string][], + ).toString()}` + : path; + + const response = await fetch(url, { + method: options.method, + headers: + options.body !== undefined ? { 'Content-Type': 'application/json' } : undefined, + body: options.body !== undefined ? JSON.stringify(options.body) : undefined, + }); + + const text = await response.text(); + const parsed = text.length > 0 ? (JSON.parse(text) as unknown) : undefined; + + if (!response.ok) { + const errorBody = (parsed as { error?: ApiErrorBody } | undefined)?.error; + throw new ApiClientError( + errorBody ?? { + code: 'internal_error', + message: `Request failed with status ${response.status}.`, + status: response.status, + requestId: response.headers.get('x-request-id') ?? 'unknown', + }, + ); + } + return parsed as T; +} + +export const api = { + // Prompts + listPrompts(query: Partial = {}): Promise> { + return request('/api/prompts', { + method: 'GET', + query: { + q: query.q, + status: query.status, + tag: query.tag, + limit: query.limit?.toString(), + }, + }); + }, + createPrompt(body: CreatePromptRequest): Promise { + return request('/api/prompts', { method: 'POST', body }); + }, + getPrompt(id: string): Promise { + return request(`/api/prompts/${id}`, { method: 'GET' }); + }, + updatePrompt(id: string, body: UpdatePromptRequest): Promise { + return request(`/api/prompts/${id}`, { method: 'PATCH', body }); + }, + duplicatePrompt(id: string, body: DuplicatePromptRequest): Promise { + return request(`/api/prompts/${id}/duplicate`, { method: 'POST', body }); + }, + archivePrompt(id: string): Promise { + return request(`/api/prompts/${id}`, { method: 'DELETE' }); + }, + + // Versions + createVersion(promptId: string, content: string): Promise { + return request(`/api/prompts/${promptId}/versions`, { + method: 'POST', + body: { content }, + }); + }, + restoreVersion(promptId: string, versionId: string): Promise { + return request(`/api/prompts/${promptId}/versions/${versionId}/restore`, { + method: 'POST', + }); + }, + renderPreview(content: string, values: Record): Promise<{ rendered: string }> { + return request('/api/render-preview', { method: 'POST', body: { content, values } }); + }, + + // Generations + listJobs(query: Partial = {}): Promise> { + return request('/api/generations', { + method: 'GET', + query: { + status: query.status, + promptId: query.promptId, + limit: query.limit?.toString(), + }, + }); + }, + createGeneration(body: CreateGenerationRequest): Promise { + return request('/api/generations', { method: 'POST', body }); + }, + getJob(id: string): Promise { + return request(`/api/generations/${id}`, { method: 'GET' }); + }, + retryJob(id: string): Promise { + return request(`/api/generations/${id}/retry`, { method: 'POST' }); + }, + + // Health + mock scenario control + getHealth(): Promise { + return request('/api/health', { method: 'GET' }); + }, + getMockScenario(): Promise<{ scenario: string; mode: string }> { + return request('/api/debug/mock', { method: 'GET' }); + }, + setMockScenario(scenario: string): Promise<{ scenario: string; mode: string }> { + return request('/api/debug/mock', { method: 'PUT', body: { scenario } }); + }, +}; + +export type Api = typeof api; diff --git a/packages/client/src/components.tsx b/packages/client/src/components.tsx new file mode 100644 index 0000000..0b719bd --- /dev/null +++ b/packages/client/src/components.tsx @@ -0,0 +1,77 @@ +/** Shared presentational components. */ + +import type { ReactNode } from 'react'; +import type { JobStatus, PromptStatus } from '@h3/shared'; + +export function Spinner({ label }: { label?: string }) { + return ( + + + ); +} + +export function CenterState({ + icon, + title, + children, +}: { + icon: string; + title: string; + children?: ReactNode; +}) { + return ( +
+ +

{title}

+ {children ?
{children}
: null} +
+ ); +} + +export function ErrorBanner({ message, code }: { message: string; code?: string }) { + return ( +
+
{message}
+ {code ?
{code}
: null} +
+ ); +} + +export function Badge({ + status, + pulse, +}: { + status: JobStatus | PromptStatus; + pulse?: boolean; +}) { + const cls = pulse ? `badge ${status} dot` : `badge ${status}`; + return {status}; +} + +export function Tag({ name }: { name: string }) { + return #{name}; +} + +export function Field({ + label, + hint, + children, + htmlFor, +}: { + label: string; + hint?: string; + children: ReactNode; + htmlFor?: string; +}) { + return ( +
+ + {children} + {hint ? {hint} : null} +
+ ); +} diff --git a/packages/client/src/features/Composer.test.tsx b/packages/client/src/features/Composer.test.tsx new file mode 100644 index 0000000..1e1bb6a --- /dev/null +++ b/packages/client/src/features/Composer.test.tsx @@ -0,0 +1,130 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { NavProvider } from '../nav.js'; +import { Composer } from './Composer.js'; + +const getPrompt = vi.fn(); +const createGeneration = vi.fn(); + +vi.mock('../api/client.js', () => ({ + api: { + getPrompt: (...args: unknown[]) => getPrompt(...args), + createGeneration: (...args: unknown[]) => createGeneration(...args), + }, + // Mirror the real ApiClientError: constructed from an error body and exposing + // the stable code/status/requestId used by the component. + ApiClientError: class extends Error { + readonly code: string; + readonly status: number; + readonly requestId: string; + constructor(body: { message: string; code: string; status: number; requestId: string }) { + super(body.message); + this.name = 'ApiClientError'; + this.code = body.code; + this.status = body.status; + this.requestId = body.requestId; + } + }, +})); + +import type { PromptDetail } from '@h3/shared'; +import { ApiClientError } from '../api/client.js'; + +function versionDetail(): PromptDetail { + return { + prompt: { + id: 'p1', + name: 'P', + description: '', + tags: [], + status: 'active', + currentVersionId: 'v1', + createdAt: '', + updatedAt: '', + archivedAt: null, + }, + versions: [ + { + id: 'v1', + promptId: 'p1', + versionNumber: 1, + content: 'A film of {{subject}}', + variables: ['subject'], + createdAt: '', + }, + ], + }; +} + +function renderComposer() { + return render( + + + , + ); +} + +beforeEach(() => { + getPrompt.mockReset(); + createGeneration.mockReset(); + getPrompt.mockResolvedValue(versionDetail()); +}); + +describe('Composer', () => { + it('disables submit and warns while a variable is missing', async () => { + renderComposer(); + await waitFor(() => expect(screen.getByLabelText('subject')).toBeInTheDocument()); + expect( + screen.getByRole('button', { name: /Generate video/i }), + ).toBeDisabled(); + expect(screen.getByText(/Fill in all variables: subject/i)).toBeInTheDocument(); + }); + + it('enables submit once the variable is filled', async () => { + const user = userEvent.setup(); + renderComposer(); + const field = await screen.findByLabelText('subject'); + await user.type(field, 'a car'); + await waitFor(() => + expect(screen.getByRole('button', { name: /Generate video/i })).toBeEnabled(), + ); + }); + + it('protects against double submission (one createGeneration call)', async () => { + // Never resolves so the component stays in the submitting state. + createGeneration.mockReturnValue(new Promise(() => {})); + const user = userEvent.setup(); + renderComposer(); + await user.type(await screen.findByLabelText('subject'), 'a car'); + const submit = await screen.findByRole('button', { name: /Generate video/i }); + await waitFor(() => expect(submit).toBeEnabled()); + await user.click(submit); + await user.click(submit); + await waitFor(() => expect(createGeneration).toHaveBeenCalledTimes(1)); + // The submitted payload carries an idempotency key. + const payload = createGeneration.mock.calls[0]![0] as { idempotencyKey: string }; + expect(payload.idempotencyKey).toBeTruthy(); + }); + + it('renders a provider failure message', async () => { + createGeneration.mockRejectedValue( + new ApiClientError({ + code: 'provider_error', + message: 'Content rejected', + status: 500, + requestId: 'rid-test', + }), + ); + const user = userEvent.setup(); + renderComposer(); + await user.type(await screen.findByLabelText('subject'), 'a car'); + const submit = await screen.findByRole('button', { name: /Generate video/i }); + await waitFor(() => expect(submit).toBeEnabled()); + await user.click(submit); + // createGeneration returns the reused job envelope normally; here it rejected. + await waitFor(() => + expect(screen.getByText(/Content rejected/i)).toBeInTheDocument(), + ); + }); +}); diff --git a/packages/client/src/features/Composer.tsx b/packages/client/src/features/Composer.tsx new file mode 100644 index 0000000..7cf4040 --- /dev/null +++ b/packages/client/src/features/Composer.tsx @@ -0,0 +1,333 @@ +/** Generation composer: render variables, pick H3 parameters, and submit a + * protected generation request. Launched from a prompt version. */ + +import { useEffect, useMemo, useState } from 'react'; +import { + findMissingVariables, + H3_ADAPTIVE_RATIO, + H3_CONCRETE_RATIOS, + H3_MAX_DURATION_SECONDS, + H3_MIN_DURATION_SECONDS, + H3_RATIOS, + H3_RESOLUTION, + mediaMode, + renderTemplate, + UnresolvedVariableError, + type ProviderName, +} from '@h3/shared'; +import type { CreateGenerationRequest } from '@h3/shared'; +import { api, ApiClientError } from '../api/client.js'; +import { useNav } from '../nav.js'; +import { newRequestId } from '../util.js'; +import { Badge, ErrorBanner, Field, Spinner } from '../components.js'; + +const MOCK_SCENARIOS = ['success', 'failure', 'expired', 'provider_error', 'slow'] as const; + +export function Composer({ + promptId, + versionId, + mode, +}: { + promptId: string; + versionId: string; + /** Provider mode from health. Mock-scenario controls only render when 'mock'. */ + mode?: ProviderName; +}) { + const { go } = useNav(); + const [loading, setLoading] = useState(true); + const [content, setContent] = useState(''); + const [variables, setVariables] = useState([]); + const [values, setValues] = useState>({}); + + const [duration, setDuration] = useState(6); + const [aspectRatio, setAspectRatio] = useState(H3_CONCRETE_RATIOS[0]); + const [firstFrame, setFirstFrame] = useState(''); + const [lastFrame, setLastFrame] = useState(''); + const [refImage, setRefImage] = useState(''); + const [refVideo, setRefVideo] = useState(''); + const [refAudio, setRefAudio] = useState(''); + const [scenario, setScenario] = useState<(typeof MOCK_SCENARIOS)[number]>('success'); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [idempotencyKey, setIdempotencyKey] = useState(newRequestId); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const detail = await api.getPrompt(promptId); + const version = detail.versions.find((v) => v.id === versionId); + if (cancelled) return; + if (!version) { + setError('Prompt version not found.'); + setLoading(false); + return; + } + setContent(version.content); + setVariables(version.variables); + // Initialize values from variable names. + const init: Record = {}; + for (const v of version.variables) init[v] = ''; + setValues(init); + setLoading(false); + } catch (e) { + if (cancelled) return; + setError(e instanceof ApiClientError ? e.message : 'Failed to load version.'); + setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [promptId, versionId]); + + const missing = useMemo( + () => findMissingVariables(content, values), + [content, values], + ); + + const preview = useMemo(() => { + try { + return renderTemplate(content, values); + } catch (e) { + return e instanceof UnresolvedVariableError + ? `Missing variable: ${e.variable}` + : 'Preview unavailable.'; + } + }, [content, values]); + + // Conditional H3 ratio behavior, exposed honestly in the UI: + // - text-to-video requires a concrete ratio; + // - first/last-frame mode is adaptive; + // - reference mode may use adaptive or concrete. + const { mode: mediaModeValue, ratios: availableRatios, effectiveRatio } = useMemo(() => { + const m = mediaMode({ + firstFrameUrl: firstFrame, + lastFrameUrl: lastFrame, + referenceImageUrl: refImage, + referenceVideoUrl: refVideo, + referenceAudioUrl: refAudio, + }); + const ratios: readonly string[] = + m === 'frame' + ? [H3_ADAPTIVE_RATIO] + : m === 'reference' + ? H3_RATIOS + : H3_CONCRETE_RATIOS; + const ratio = ratios.includes(aspectRatio) ? aspectRatio : ratios[0]!; + return { mode: m, ratios, effectiveRatio: ratio }; + }, [firstFrame, lastFrame, refImage, refVideo, refAudio, aspectRatio]); + + const canSubmit = missing.length === 0 && !submitting; + + async function submit() { + if (!canSubmit) return; + setSubmitting(true); + setError(null); + const body: CreateGenerationRequest = { + promptVersionId: versionId, + values, + durationSeconds: duration, + aspectRatio: effectiveRatio as CreateGenerationRequest['aspectRatio'], + resolution: H3_RESOLUTION, + firstFrameUrl: firstFrame || undefined, + lastFrameUrl: lastFrame || undefined, + referenceImageUrl: refImage || undefined, + referenceVideoUrl: refVideo || undefined, + referenceAudioUrl: refAudio || undefined, + idempotencyKey, + ...(mode === 'mock' ? { mockScenario: scenario } : {}), + }; + try { + const result = await api.createGeneration(body); + go({ name: 'job', jobId: result.job.id }); + } catch (e) { + const message = + e instanceof ApiClientError + ? e.message + : e instanceof Error && e.message.length > 0 + ? e.message + : 'Failed to submit generation.'; + setError(message); + // Keep the SAME idempotency key after transient/unknown failures so a + // retry cannot create a paid duplicate at the provider. Rotate ONLY for a + // deliberate idempotency conflict (same key, different payload), where a + // fresh request is the intended next step. + const isConflict = + e instanceof ApiClientError && e.code === 'idempotency_conflict'; + if (isConflict) { + setIdempotencyKey(newRequestId()); + } + } finally { + setSubmitting(false); + } + } + + if (loading) return ; + + const ratioHint = + mediaModeValue === 'frame' + ? 'First/last-frame mode uses an adaptive ratio.' + : mediaModeValue === 'reference' + ? 'Reference mode may use adaptive or a concrete ratio.' + : 'Text-to-video requires an explicit (non-adaptive) ratio.'; + + return ( + <> +
+
+ +

Generation composer

+
+ + model MiniMax-H3 · resolution {H3_RESOLUTION} +
+
+
+ + {error ? : null} + +
+
+
+
Variables
+ {variables.length === 0 ? ( +

This template has no variables.

+ ) : ( + variables.map((v) => ( + + + setValues((s) => ({ ...s, [v]: e.target.value })) + } + className={missing.includes(v) ? 'invalid' : ''} + /> + + )) + )} + {missing.length > 0 ? ( + + ) : null} +
+ +
+
Rendered prompt
+
+              {preview}
+            
+
+
+ +
+
+
H3 parameters
+ + + + + + + + + + + {mode === 'mock' ? ( + + + + ) : null} +
+ +
+
Optional references (http(s) URLs)
+ + setFirstFrame(e.target.value)} placeholder="https://…" /> + + + setLastFrame(e.target.value)} placeholder="https://…" /> + + + setRefImage(e.target.value)} placeholder="https://…" /> + + + setRefVideo(e.target.value)} placeholder="https://…" /> + + + setRefAudio(e.target.value)} placeholder="https://…" /> + +
+ + +

+ Double-clicks are protected by an idempotency key. +

+
+
+ + ); +} diff --git a/packages/client/src/features/Jobs.tsx b/packages/client/src/features/Jobs.tsx new file mode 100644 index 0000000..6c86b93 --- /dev/null +++ b/packages/client/src/features/Jobs.tsx @@ -0,0 +1,252 @@ +/** Job history list and job detail view. The client polls the server's job + * endpoint (never the provider) to reflect the server-side poller's updates. */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { GenerationJob, JobStatus } from '@h3/shared'; +import { api, ApiClientError } from '../api/client.js'; +import { useNav } from '../nav.js'; +import { Badge, CenterState, ErrorBanner, Spinner } from '../components.js'; + +const TERMINAL: JobStatus[] = ['succeeded', 'failed', 'expired']; +const POLL_MS = 2000; + +export function JobsList() { + const { go } = useNav(); + const [jobs, setJobs] = useState(null); + const [error, setError] = useState(null); + const [filter, setFilter] = useState<'' | JobStatus>(''); + + const load = useCallback(async () => { + try { + const res = await api.listJobs({ status: filter || undefined }); + setJobs(res.items); + setError(null); + } catch (e) { + setError(e instanceof ApiClientError ? e.message : 'Failed to load jobs.'); + } + }, [filter]); + + useEffect(() => { + void load(); + const t = setInterval(() => void load(), POLL_MS); + return () => clearInterval(t); + }, [load]); + + return ( + <> +
+

Generation history

+
+ +
+ + + Auto-refreshing every {POLL_MS / 1000}s + +
+ + {error ? : null} + + {jobs === null ? ( + + ) : jobs.length === 0 ? ( + +

Submit a generation from a prompt to see it here.

+
+ ) : ( +
+ {jobs.map((j) => ( + + ))} +
+ )} + + ); +} + +export function JobDetail({ jobId }: { jobId: string }) { + const { go } = useNav(); + const [job, setJob] = useState(null); + const [error, setError] = useState(null); + const [retrying, setRetrying] = useState(false); + const timer = useRef | null>(null); + + // Single polling lifecycle keyed on jobId: load once, poll while non-terminal, + // and on cleanup (jobId change or unmount) both clear AND null the timer so it + // can never leak or double-fire across renders. + useEffect(() => { + let cancelled = false; + timer.current = null; + + const load = async () => { + try { + const j = await api.getJob(jobId); + if (cancelled) return; + setJob(j); + setError(null); + if (TERMINAL.includes(j.status) && timer.current) { + clearInterval(timer.current); + timer.current = null; + } + } catch (e) { + if (cancelled) return; + setError(e instanceof ApiClientError ? e.message : 'Failed to load job.'); + } + }; + + void load(); + timer.current = setInterval(() => void load(), POLL_MS); + + return () => { + cancelled = true; + if (timer.current) { + clearInterval(timer.current); + timer.current = null; + } + }; + }, [jobId]); + + async function retry() { + setRetrying(true); + try { + const result = await api.retryJob(jobId); + go({ name: 'job', jobId: result.job.id }); + } catch (e) { + setError(e instanceof ApiClientError ? e.message : 'Failed to retry job.'); + } finally { + setRetrying(false); + } + } + + if (error && !job) return ; + if (!job) return ; + + const isTerminal = TERMINAL.includes(job.status); + + return ( + <> +
+
+ +

+ Generation +

+
+
+ {(job.status === 'failed' || job.status === 'expired') && ( + + )} +
+
+ + {error ? : null} + + {!isTerminal ? ( +
+ +
+ ) : null} + + {job.status === 'succeeded' && job.resultUrl ? ( +
+
Result
+
+ ) : null} + + {(job.status === 'failed' || job.status === 'expired') && job.errorMessage ? ( +
+
Outcome
+ +
+ ) : null} + +
+
Details
+
+
Job ID
+
{job.id}
+
Provider
+
{job.provider}
+
Provider task
+
{job.providerTaskId ?? '—'}
+
Model
+
{job.model}
+
Duration
+
{job.durationSeconds}s
+
Aspect ratio
+
{job.aspectRatio}
+
Resolution
+
{job.resolution}
+
Created
+
{new Date(job.createdAt).toLocaleString()}
+
Updated
+
{new Date(job.updatedAt).toLocaleString()}
+ {job.completedAt ? ( + <> +
Completed
+
{new Date(job.completedAt).toLocaleString()}
+ + ) : null} +
Idempotency key
+
{job.idempotencyKey}
+
+ +
Rendered prompt
+
+          {job.renderedPrompt}
+        
+
+ + ); +} diff --git a/packages/client/src/features/Library.test.tsx b/packages/client/src/features/Library.test.tsx new file mode 100644 index 0000000..0dd3d43 --- /dev/null +++ b/packages/client/src/features/Library.test.tsx @@ -0,0 +1,86 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { NavProvider } from '../nav.js'; +import { Library } from './Library.js'; + +vi.mock('../api/client.js', () => ({ + api: { + listPrompts: vi.fn(), + getHealth: vi.fn(), + }, + ApiClientError: class extends Error {}, +})); + +import { api } from '../api/client.js'; + +const listPrompts = vi.mocked(api.listPrompts); + +function renderLibrary() { + return render( + + + , + ); +} + +beforeEach(() => { + listPrompts.mockReset(); +}); + +describe('Library', () => { + it('shows an empty state when there are no prompts', async () => { + listPrompts.mockResolvedValue({ items: [], total: 0 }); + renderLibrary(); + await waitFor(() => + expect(screen.getByText(/No prompts yet/i)).toBeInTheDocument(), + ); + expect( + screen.getAllByRole('button', { name: /New prompt/i }).length, + ).toBeGreaterThan(0); + }); + + it('renders prompt cards when prompts exist', async () => { + listPrompts.mockResolvedValue({ + items: [ + { + id: 'p1', + name: 'Cinematic Reveal', + description: 'A hero shot', + tags: ['product'], + status: 'active', + currentVersionId: 'v1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + archivedAt: null, + }, + ], + total: 1, + }); + renderLibrary(); + await waitFor(() => + expect(screen.getByText('Cinematic Reveal')).toBeInTheDocument(), + ); + expect(screen.getByText('#product')).toBeInTheDocument(); + }); + + it('shows an error banner when loading fails', async () => { + listPrompts.mockRejectedValue(new Error('boom')); + renderLibrary(); + await waitFor(() => + expect(screen.getByText(/Failed to load prompts/i)).toBeInTheDocument(), + ); + }); + + it('invokes search with the query term', async () => { + listPrompts.mockResolvedValue({ items: [], total: 0 }); + const user = userEvent.setup(); + renderLibrary(); + await waitFor(() => expect(listPrompts).toHaveBeenCalled()); + const search = screen.getByLabelText(/Search prompts/i); + await user.type(search, 'hero'); + await waitFor(() => + expect(listPrompts).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'hero' })), + ); + }); +}); diff --git a/packages/client/src/features/Library.tsx b/packages/client/src/features/Library.tsx new file mode 100644 index 0000000..ca97911 --- /dev/null +++ b/packages/client/src/features/Library.tsx @@ -0,0 +1,110 @@ +/** Library view: search, filter, and the prompt grid. */ + +import { useEffect, useState } from 'react'; +import type { Prompt } from '@h3/shared'; +import { api, ApiClientError } from '../api/client.js'; +import { useNav } from '../nav.js'; +import { Badge, CenterState, ErrorBanner, Spinner, Tag } from '../components.js'; + +type StatusFilter = '' | 'draft' | 'active' | 'archived'; + +export function Library({ onNew }: { onNew: () => void }) { + const { go } = useNav(); + const [items, setItems] = useState(null); + const [error, setError] = useState(null); + const [q, setQ] = useState(''); + const [status, setStatus] = useState(''); + + async function load() { + setError(null); + try { + const res = await api.listPrompts({ q: q || undefined, status: status || undefined }); + setItems(res.items); + } catch (e) { + setError(e instanceof ApiClientError ? e.message : 'Failed to load prompts.'); + setItems([]); + } + } + + useEffect(() => { + void load(); + }, [q, status]); + + return ( + <> +
+

Prompt Library

+
+ +
+
+ +
+ setQ(e.target.value)} + aria-label="Search prompts" + className="grow" + /> + +
+ + {error ? : null} + + {items === null ? ( + + + + ) : items.length === 0 ? ( + +

Create your first prompt template to start generating H3 videos.

+ +
+ ) : ( +
+ {items.map((p) => ( +
go({ name: 'editor', promptId: p.id })} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + go({ name: 'editor', promptId: p.id }); + } + }} + > +
+

{p.name}

+ +
+

{p.description || 'No description.'}

+
+ {p.tags.map((t) => ( + + ))} +
+
+ ))} +
+ )} + + ); +} diff --git a/packages/client/src/features/NewPrompt.tsx b/packages/client/src/features/NewPrompt.tsx new file mode 100644 index 0000000..944acbe --- /dev/null +++ b/packages/client/src/features/NewPrompt.tsx @@ -0,0 +1,109 @@ +/** Inline form to create a new prompt with its first version. */ + +import { useMemo, useState } from 'react'; +import { parseTemplate, TemplateSyntaxError } from '@h3/shared'; +import { api, ApiClientError } from '../api/client.js'; +import { useNav } from '../nav.js'; +import { ErrorBanner, Field, Spinner } from '../components.js'; + +export function NewPrompt({ onCancel }: { onCancel: () => void }) { + const { go } = useNav(); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [tagsCsv, setTagsCsv] = useState(''); + const [content, setContent] = useState(''); + const [status, setStatus] = useState<'draft' | 'active'>('draft'); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const templateError = useMemo(() => { + try { + parseTemplate(content); + return null; + } catch (e) { + return e instanceof TemplateSyntaxError ? e.message : 'Invalid template.'; + } + }, [content]); + + async function submit() { + if (!name.trim() || !content.trim() || templateError) return; + setSubmitting(true); + setError(null); + let created = false; + try { + const detail = await api.createPrompt({ + name: name.trim(), + description: description.trim(), + tags: tagsCsv + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0), + content: content.trim(), + status, + }); + created = true; + go({ name: 'editor', promptId: detail.prompt.id }); + } catch (e) { + setError(e instanceof ApiClientError ? e.message : 'Failed to create prompt.'); + } finally { + // Only flip back to the form on failure. On success, stay in the + // "creating" state until navigation unmounts this component so the form + // is never briefly re-rendered. + if (!created) setSubmitting(false); + } + } + + if (submitting) return ; + + return ( + <> +
+

New prompt

+ +
+ + {error ? : null} + +
+ + setName(e.target.value)} /> + + +