Compare commits

...
Author SHA1 Message Date
Tanish Manku b9ee643bf6 Stop the bootstrap scripts claiming the dev webapp targets the local backend
It never has: setDevelopment() is commented out in main.tsx, so the dev server
falls through to its /v1 proxy at api.storyteller.ai. Both bootstrap epilogues
and the seed script now say so and print the VITE_DEV_API_HOST needed to
actually reach the local backend.
2026-08-03 17:24:39 -07:00
Tanish Manku 07e9a35044 Add portable local MySQL/Redis bootstrap scripts for backend dev
Brings up a fully local storyteller-web: portable MySQL 8.4 and Redis under a
gitignored .devstack/ (plain user processes -- no admin, no services, no WSL,
no Docker), all 281 migrations, role/badge seeds, and a demo login with
credits. Windows and Linux variants, plus a doctor that reports what is and
isn't healthy.

Scripts and docs only. Nothing here is compiled into any binary, and no
application behaviour changes: the backend it starts is the one that ships.

Integrations that cannot be provided locally -- object storage, generation
providers, email -- get placeholder credentials, so those endpoints fail when
exercised. Swap in a real dev credential for the one you are working on. The
doc records exactly which areas that leaves working.

An earlier revision closed those gaps with development-only behaviour inside
storyteller-web (DEV_FAKE_GENERATION, a filesystem bucket mode, a CDN_BASE_URL
override). That is deliberately not here: a switch that exists in the shipped
binary can be flipped. Frontend work that wants a populated, working stack
should use frontend/apps/fake-storyteller-web instead.
2026-08-03 14:41:14 -07:00
hanashi 0eddb659fa fixup 3d on admin dash, add databox (#1823) 2026-07-30 20:20:44 -04:00
hanashi 17c12836ad seedream 5 plus (#1822) 2026-07-30 18:25:19 -04:00
BFlat 7654e95636 Desktop: drag & drop files onto the promptbox on create pages (#1821)
### What
- Drag files onto the promptbox on the Image, Video, and Audio create
pages to add them as references - same look and behavior as the webapp
- On those pages the promptbox is now the only drop target; dropping
elsewhere does nothing
- Every other page keeps the existing upload-modal behavior

### Notes
- The webapp's drop code doesn't work as-is on desktop - Tauri
intercepts file drags before the page sees them, so desktop needed its
own path
- Both now share one component, so the two apps stay in sync going
forward
- Model limits still apply: each box only accepts the reference types
its model supports, and existing slot/duration caps are unchanged
2026-07-30 22:37:34 +07:00
BFlat 1bb31f42d7 Bug fix: hand-typed mentions (#1819)
- **Hand-typed mentions always take the character's exact name** -
typing `@person` for a character named `Person` now converts to
`@Person` as you type, so the tag binds correctly instead of silently
failing at generation time.
2026-07-30 08:05:44 +07:00
BFlat bc4b3a6c1f Bug fix: mention textarea & reference deck (#1818)
- **Prompt input no longer freezes at a character tag** - the caret
could get stuck inside a chip (e.g. after deleting the space behind it),
blocking all typing.
- **Reference add-menu no longer hides behind the sidebar** - it now
renders above everything instead of getting clipped by the promptbox
layers.
- **Character autocomplete is scrollable in focus mode** - the
fullscreen prompt's scroll lock was blocking it; same fix for the chip
menu's Replace list.
2026-07-30 07:43:40 +07:00
BFlat 13b5e7b5c2 Mention textarea focus & dropdown fixes (#1817)
- Fix popovers (model selector etc.) closing instantly when the mention
textarea was focused
- Fix mention autocomplete not clickable in prompt focus mode
- Fix chip "Replace" reopening the autocomplete - now confirms
immediately
- Fix autocomplete appearing at top-left of screen when caret is next to
a leading chip
- Don't open the autocomplete when clicking the caret onto an
already-confirmed chip
2026-07-29 23:09:26 +07:00
BFlat 4ff5bdf475 Webapp: mobile audio ref fixes (#1816)
- **Fixed: picking an audio reference on mobile opened the photo/video
picker.** iPhones don't have an audio library, so the browser fell back
to Photos - the only thing you could "attach as audio" was a video, and
it silently uploaded as an audio ref. Audio pickers now open the Files
app filtered to real audio files (MP3, WAV, M4A, FLAC…).
- **Non-audio files are now rejected with a clear error toast** instead
of being silently attached (also covers drag & drop and Android's "any
file" picker).
- **Fixed: limit warnings never appeared in the web app.** Errors like
"Total audio duration cannot exceed 15s" from shared components were
firing into a toast system the web app doesn't render. They're now
routed to the app's own toasts, so length/limit errors actually show up.
- Applies to the create video, create audio, and desktop reference deck
flows
2026-07-29 22:17:04 +07:00
BFlat e1b16cf2de Character mention chips + mention fixes (#1815)
### New stuff
- Character `@mentions` in the video promptbox now show as **chips with
a thumbnail**, so you can see exactly which character is attached to the
prompt
- Clicking a chip opens a menu to **Replace** (pick another character),
**Preview** (view their image), or **Remove** the mention
- Chips behave like a single unit - one backspace deletes the whole
mention, and removing/replacing can be undone with Ctrl+Z
- Copying prompt text still gives clean plain text (`@name ...`), and
pasting it back restores the chips
- When two characters share a name, the one you actually picked is now
the one that gets sent

### Fixes
- Mobile: the `@` autocomplete no longer goes off-screen - it stays
inside the viewport, flips below the caret when needed, and taps select
reliably
- Focus mode: the autocomplete no longer pushes the prompt sideways /
causes overflow
- Enter-to-generate now works while the mention editor is active on the
webapp
2026-07-29 21:43:02 +07:00
98 changed files with 5206 additions and 1320 deletions
+5
View File
@@ -1,3 +1,8 @@
# Shell scripts and dotenv config files are executed/parsed on Linux (WSL2, CI)
# even when the repo is cloned on Windows — they must stay LF in the worktree.
*.sh text eol=lf
*.env text eol=lf
# Ignore certain code for Github language statistics
# https://github.com/github/linguist/blob/master/docs/overrides.md
old_code/trumpet-trumped.com/dictionary/cmudict-0.7b linguist-vendored
+3
View File
@@ -104,3 +104,6 @@ windows/sqlite/sqlite3.lib
crates/desktop/src-tauri/*.dll
windows_dlls/
# Portable local dev stack (script/bootstrap) - MySQL/Redis binaries, data, logs
.devstack/
+293
View File
@@ -0,0 +1,293 @@
# Local Dev Stack — Requirements & Bootstrap
How to run `storyteller-web` on your own machine against a local MySQL and
Redis — no shared dev database, no Docker, no admin rights. This is for **work
on the backend itself**.
> **Working on the frontend?** You probably want
> `frontend/apps/fake-storyteller-web` instead — an in-memory stand-in for this
> API that needs no database, no build, and no setup. Everything below is
> unnecessary for webapp work. Come back here when you need the real server.
These scripts install and supervise infrastructure. They change no application
behaviour, and nothing in them is compiled into any binary: the backend they
start is the same one that ships. Integrations they cannot provide locally
(object storage, generation providers, email) get placeholder credentials, so
the endpoints that use them fail when exercised. Swap in a real dev credential
for whichever one you're actually working on.
The automated path on **Windows** (primary platform, native — no WSL, no
Docker, no admin rights for the stack itself):
```powershell
.\script\bootstrap\windows\bootstrap_dev_stack.ps1 # one-time setup, idempotent, re-run anytime
.\script\bootstrap\windows\run_backend_dev.ps1 # terminal 1: API on http://localhost:12345
.\script\bootstrap\windows\seed_demo_user.ps1 # terminal 2: create demo login (localdev1 / localdev1pass)
$env:VITE_DEV_API_HOST = "http://localhost:12345" # terminal 2: aim the webapp at your backend
cd frontend; npx nx dev artcraft-webapp # webapp on http://localhost:4201
.\script\bootstrap\windows\dev_stack_doctor.ps1 # health check, any time
.\script\bootstrap\windows\stop_dev_services.ps1 # stop MySQL/Redis when done
```
The equivalent on **Linux** (Ubuntu 22.04+ — native, WSL2, or CI):
```bash
./script/bootstrap/bootstrap_dev_stack.sh # one-time setup, idempotent, re-run anytime
./script/bootstrap/run_backend_dev.sh # terminal 1: API on http://localhost:12345
./script/bootstrap/seed_demo_user.sh # terminal 2: create demo login (localdev1 / localdev1pass)
cd frontend && VITE_DEV_API_HOST=http://localhost:12345 npx nx dev artcraft-webapp # terminal 2: webapp on :4201
./script/bootstrap/dev_stack_doctor.sh # health check, any time
```
This document records the underlying requirements so the scripts aren't a
black box, and corrects some stale claims in older docs.
## Supported platforms
| Platform | Status |
|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Windows 10/11 | Primary. `script/bootstrap/windows/*.ps1` run a **portable stack**: MySQL 8.4 (official zip) and Redis live under the gitignored `.devstack/` and run as plain user processes — no admin, no services, no WSL, no Docker. Build tools (cmake, perl, nasm, llvm) are offered via winget |
| Ubuntu 22.04+ | Supported by `script/bootstrap/*.sh` (native, WSL2, or CI runner/container); services via apt |
| macOS | Not covered by the scripts; follow `_docs/dev_setup_server.md` manually |
### Windows specifics
- Everything stateful lives in `.devstack/` (gitignored): `mysql/` (extracted
zip), `mysql-data/`, `redis/`, `logs/`, `pids/`, `downloads/` (cached zips),
`my.ini`. Delete the directory to fully reset; re-run bootstrap to rebuild.
- Pinned services: MySQL **8.4.11** from `cdn.mysql.com` (the 8.x series is
required; only some patch versions exist on the CDN) and Redis **5.0.14.1**
(tporadowski build — old but sufficient for the backend's keepalive/TTL/
rate-limit usage; override `REDIS_ZIP_URL` to use Memurai/Garnet instead).
- The portable MySQL root user has **no password** (`--initialize-insecure`) —
local dev only, bound to 127.0.0.1.
- diesel-cli is compiled against the portable MySQL's own client library
(`MYSQLCLIENT_LIB_DIR`/`MYSQLCLIENT_VERSION`); `libmysql.dll` must be on
`PATH` to *run* it — the scripts handle both.
- Compiling the backend needs cmake, Strawberry Perl, NASM, and LLVM/libclang
(the `wreq` HTTP client builds BoringSSL, and `aws-lc-sys`/`ring` need
NASM/clang on MSVC). The bootstrap checks and offers winget installs.
- The generated secrets file additionally sets `TEMP_DIR` (the upload temp-dir
helper defaults to `/tmp`, which doesn't exist on Windows).
## Requirements matrix
| Requirement | Version / detail | Needed for |
|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------|
| MySQL server | 8.x series (9.x unsupported by tooling) | Backend boot (eager connect) + migrations |
| Redis server | any recent | Backend boot (r2d2 pool connects eagerly) |
| Rust (stable) | no pinned MSRV; Docker builds use 1.93.0 | Building `storyteller-web`, diesel_cli |
| diesel_cli | `cargo install diesel_cli --no-default-features --features mysql,sqlite` | Running the 281 migrations |
| Node.js | 20+ (enforced by `script/common/frontend_preflight.sh`) | Frontend (Nx 21 / Vite 6) |
| npm | ships with Node; **pnpm must not be used** | Frontend install (`frontend/package-lock.json`) |
| System libs (apt) | build-essential, cmake, libssl-dev, libclang-dev, libfontconfig1-dev, fontconfig, perl, pkg-config, ffmpeg, libmysqlclient-dev, libsqlite3-dev, curl, jq | Compiling the workspace + diesel_cli; seeding scripts |
| Elasticsearch | optional (7.x) | Only 4 search endpoints; server boots without it |
| Docker | **not required** — no docker-compose exists | — |
Database identity (used consistently by docs, `.env`, and defaults):
database `storyteller`, user `storyteller`, password `password`, host
`localhost`.
## Backend: what `storyteller-web` needs to boot
Binary: `storyteller-web` in `crates/service/web/storyteller_web`
(binaries: `storyteller-web`, `docs` — Swagger UI on :8989, `docs-cli` — emits
`api.json`). Binds `0.0.0.0:12345` (`BIND_ADDRESS`), plain HTTP.
**Run it from the repo root** — the config search path and `includes/` paths
are cwd-relative.
### Config loading (corrected facts)
- Mode is selected by the `SERVER_ENVIRONMENT` **process env var only**
(default: Development). Setting it inside an env file does nothing — it is
read before any file loads.
- In Development mode, files load from
`crates/service/web/storyteller_web/config/` in this order:
`storyteller-web.common.env` → `storyteller-web.development.env` →
`storyteller-web.development-secrets.env` (gitignored; the bootstrap
generates it).
- **Precedence is first-value-wins** (dotenv never overrides an existing key):
real process env > common.env > development.env > development-secrets.env.
The secrets file therefore cannot override anything already set in
`development.env`.
- The repo-root `.env` is **NOT loaded by the server** (it passes
`ignore_legacy_dot_env_file: true`). Root `.env`'s `DATABASE_URL` feeds
diesel-cli/sqlx-cli only; the server reads **`MYSQL_URL`** (default
`mysql://storyteller:password@localhost/storyteller`).
### Hard requirements at boot
| Requirement | Behavior if missing |
|-------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
| MySQL reachable via `MYSQL_URL` | Boot fails (sqlx pool connects eagerly; the DB must exist and be migrated — health-check and ban-poller threads query immediately) |
| Redis reachable via `REDIS_0_URL` (default `redis://localhost/0`) | Boot fails (r2d2 pool connects eagerly) |
| ~30 `get_env_string_required` vars present | Boot fails on the first absent var |
Of the required-present vars, 19 are supplied by **no** checked-in file and
live in the generated secrets file: `ACCESS_KEY`, `SECRET_KEY`, `REGION_NAME`,
`W2L_PRIVATE_DOWNLOAD_BUCKET_NAME`, `W2L_PUBLIC_DOWNLOAD_BUCKET_NAME`,
`RESEND_API_KEY`, `FAL_API_KEY`, `GMICLOUD_API_KEY`, `GROK_API_KEY`,
`BEEBLE_API_KEY`, `OPENAI_API_KEY`, `WORLDLABS_API_KEY`,
`SEEDANCE2PRO_COOKIES`, `SEEDANCE2PRO_WHITELIST_COOKIES`,
`SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES`, `STRIPE_FAKEYOU_ACCOUNT_ID`,
`STRIPE_ARTCRAFT_ACCOUNT_ID`, `STRIPE_ARTCRAFT_SECRET_KEY`,
`STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY`.
**Placeholder values are safe at boot**: none of these services is contacted
at boot (clients are constructed offline; Elasticsearch, S3/R2, Stripe,
Resend, and Google-cert fetches are all lazy). But two earlier claims here
were wrong and are corrected:
- **The omni_gen endpoints call providers synchronously in the HTTP handler**
(`/v1/omni_gen/generate/{image,video}` — the path the webapp uses). With
placeholder keys the provider call fails and the submit returns HTTP 500
*after* billing the wallet (the image pipeline has no refund; video refunds
only Kinovi). Only the legacy polling providers (GmiCloud, Grok, Kinovi
orders, WorldLabs) are worker-driven.
- **Uploads fail hard with placeholder S3 creds** — every media upload endpoint
uploads to the bucket *before* inserting the DB row, and `rust-s3` is built
with `fail-on-err`.
### What does and doesn't work locally
| Area | Works with placeholder credentials? |
|----------------------------------------------|-------------------------------------|
| Login, sessions, account, profile | Yes |
| Wallets, credits, billing reads | Yes |
| Folders, tags, prompts, characters, projects | Yes |
| Media listing (rows) | Yes — but the images 404, see below |
| Media uploads | No — needs real R2/S3 credentials |
| Image/video/mesh/splat generation | No — needs a real provider key |
| Transactional email | No — needs a real Resend key |
| Elasticsearch-backed search | No — needs a local ES + reindex |
The rows-but-no-images case is the one that surprises people: `media_files`
rows carry a bucket path, and the URL the API builds from it points at the
shared R2 bucket, which does not have your local objects. Put real dev R2
credentials in the secrets file if you need media to render, or use
`fake-storyteller-web`, which serves its own bytes.
### Auth on localhost
- `POST /v1/login` takes `{"username_or_email": "...", "password": "..."}`;
success returns `{"success": true, "signed_session": "<jwt>"}` **and** a
`session` cookie.
- With the checked-in dev config (`COOKIE_DOMAIN=.jungle.horse`), the session
cookie is issued non-Secure and host-only (the Domain attribute is never
set), so plain-HTTP localhost login works as-is. The frontend additionally
stores `signed_session` in localStorage and sends it as a `session` header.
- Dev-mode CORS allows any `localhost`/`127.0.0.1` origin on any port, with
credentials.
- Health endpoint for scripts/CI: `GET /_status`.
### Build
- Compile with `SQLX_OFFLINE=true` (uses the checked-in `.sqlx/` cache; 483
queries). Without it, the sqlx macros open a **live DB connection at compile
time** using `DATABASE_URL` from the root `.env`. `SQLX_OFFLINE` has no
runtime effect — ignore older notes saying not to set it when running.
- First build takes several minutes; the crate has ~370 pre-existing warnings.
## Database: migrations and seed data
- 281 Diesel migrations in `_database/sql/migrations/` (`diesel.toml` at repo
root points there). Run from the repo root: `diesel migration run`.
`"Encountered unknown type for Mysql: enum"` warnings are harmless.
`migrations_squashed/` is documentation only — not executable.
- Seed data: `_database/sql/seed/sql/system_roles.sql` (roles `user`, `mod`,
`admin`) and `user_badges.sql`. **The `user` role is mandatory** — account
creation hardcodes `user_role_slug='user'`. The legacy wrapper
`bootstrap_inserts_roles_etc.sh` must run with cwd `_database/sql/` and is
not idempotent; the bootstrap script applies the same SQL files with
idempotency guards instead.
- Demo user: created through **`POST /v1/create_account`** (no captcha, no
invite code, no email-verification gate; password just needs 6+ chars). This
exercises the real signup path instead of hand-rolled SQL. Gotcha: usernames
like `demo`, `admin`, `test`, `dev`, `artcraft` are reserved
(`includes/binary_includes/usernames/reserved_usernames.txt`) — the default
demo user is `localdev1`. The Rust seeder
(`cargo run --bin dev-database-seed`) exists but hard-requires a
`.env-secrets` file and also seeds model weights, so the HTTP path is
preferred.
- Demo credits: signup creates **no wallet**, and the webapp gates the
generate button on wallet balance — so `seed_demo_user` also seeds an
`artcraft`-namespace wallet with `DEMO_CREDITS` (default 100,000) banked
credits, plus matching ledger rows. Idempotent: re-runs top the balance
back up to `DEMO_CREDITS` but never lower it. (Raw SQL, because the only
real credit-granting paths are Stripe webhooks and moderator tooling.)
- Demo gallery: **not seeded**. Media rows without reachable bytes render as
broken tiles, so seeding them locally would be worse than an empty library.
Use `fake-storyteller-web` for a populated one.
## Frontend: `artcraft-webapp`
- Nx workspace at repo-root `frontend/` (npm workspaces; `npm install` from
`frontend/`). The FontAwesome Pro registry token is committed in
`frontend/.npmrc` — no manual auth step.
- Serve: `npx nx dev artcraft-webapp` → `http://localhost:4201`. No lib
pre-build is needed in dev (libs resolve to TS source via the
`"development"` export condition); production `nx build` does need lib
builds.
- **By default the dev webapp targets production.** `src/main.tsx` points the
API host at the Vite dev origin, and Vite proxies `/v1` to
`https://api.storyteller.ai`. The adjacent `setDevelopment()` call — which
would point it at `localhost:12345` — is commented out. To aim the webapp at
your local backend, set `VITE_DEV_API_HOST=http://localhost:12345` before
`nx dev` (that override ships with the `fake-storyteller-web` change; until
it lands, uncomment `setDevelopment()` locally instead).
- Optional: `VITE_GOOGLE_CLIENT_ID` in `frontend/apps/artcraft-webapp/.env`
enables the Google SSO button; email/password login needs nothing.
- Other dev ports if you run more apps: website 4200, dashboard 4202 (separate
pnpm app, excluded from Nx), video-info-website 4250, genhub 4300, Tauri
frontend 5173.
## What still touches production (known, deliberate seams)
The bootstrap changes no runtime behaviour, so these remain open. Each is
closed by supplying a real credential, not by a code path:
| Seam | Today |
|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Media/CDN URLs in API responses | The compiled-in `cdn_link.rs` constants point at the public `pub-….r2.dev` bucket, so a locally-created row's media URL resolves to an object that isn't there |
| Media uploads | 500 with placeholder credentials; supply real dev R2/S3 keys to exercise them |
| Generation | 500 with placeholder keys, *after* the wallet is billed; supply a real key for the provider under test |
| Vite `/v1` proxy in webapp | Targets `https://api.storyteller.ai`, so a dev webapp with no host override reads and writes production |
| Elasticsearch-backed search | `search_featured` / `search_session` media files, `weights/search`, legacy `tts/search` fail without a local ES + `elasticsearch-cli` reindex (optional) |
## CI usage
The same bootstrap works on a fresh Ubuntu runner/container:
```bash
export MYSQL_ROOT_PASSWORD=root # GitHub Actions' preinstalled MySQL uses password auth
./script/bootstrap/bootstrap_dev_stack.sh --yes
./script/bootstrap/run_backend_dev.sh &
./script/bootstrap/seed_demo_user.sh # waits for /_status
./script/bootstrap/dev_stack_doctor.sh
```
`--yes` answers all prompts; `--skip-frontend` / `--skip-rust-build` /
`--skip-packages` slice the work for focused jobs.
## Verification checklist
- `./script/bootstrap/dev_stack_doctor.sh` exits 0 with no FAILs.
- `POST /v1/login` with the demo user returns `success: true` plus a
`signed_session` (done automatically by `seed_demo_user.sh`).
- `GET /_status` returns 200, and MySQL has the full migration set applied.
- With `VITE_DEV_API_HOST` pointed at `localhost:12345`, the webapp logs in as
`localdev1` and shows its credits in the top bar. The library is empty and
generation 500s — both expected, see the table above.
## Corrections to older docs
- `crates/service/web/storyteller_web/CLAUDE.md`: the repo-root `.env` is
*not* auto-loaded by the server, and `SQLX_OFFLINE=true` is fine (and
recommended) when running — it only affects compile-time query checking.
- `_docs/dev_setup_server.md`: `./script/dev_mysql_connect.sh` doesn't exist —
it's `./script/dev_database_connect.sh`.
- `diesel.toml`'s comment about needing an unreleased diesel CLI is stale —
any current `diesel_cli` works.
- `TESTING_INFRA_HANDOFF.md` (untracked): the server env var is `MYSQL_URL`,
not `DATABASE_URL`; migration count is 281; crate path is
`crates/service/web/storyteller_web`.
@@ -43,6 +43,8 @@ pub enum RouterImageModel {
Seedream5Lite,
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
#[serde(rename = "seedream_5p0_pro_u")]
Seedream5p0ProUltra,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
@@ -80,4 +82,9 @@ mod tests {
fn seedream_5p0_pro_serializes_to_seedream_5p0_pro() {
assert_round_trip(RouterImageModel::Seedream5p0Pro, "seedream_5p0_pro");
}
#[test]
fn seedream_5p0_pro_ultra_serializes_to_seedream_5p0_pro_u() {
assert_round_trip(RouterImageModel::Seedream5p0ProUltra, "seedream_5p0_pro_u");
}
}
@@ -27,6 +27,7 @@ use crate::generate::generate_image::providers::artcraft::seedream_4::build::bui
use crate::generate::generate_image::providers::artcraft::seedream_4p5::build::build_artcraft_seedream_4p5;
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::build::build_artcraft_seedream_5_lite;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::build::build_artcraft_seedream_5p0_pro;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::build::build_artcraft_seedream_5p0_pro_ultra;
use crate::generate::generate_image::providers::fal::flux_1_dev::build::build_fal_flux_1_dev;
use crate::generate::generate_image::providers::fal::flux_1_schnell::build::build_fal_flux_1_schnell;
use crate::generate::generate_image::providers::fal::flux_2_lora_angles::build::build_fal_flux_2_lora_angles;
@@ -122,6 +123,7 @@ impl GenerateImageRequestBuilder {
(RouterProvider::Artcraft, RouterImageModel::Seedream4p5) => build_artcraft_seedream_4p5(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream5Lite) => build_artcraft_seedream_5_lite(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream5p0Pro) => build_artcraft_seedream_5p0_pro(self),
(RouterProvider::Artcraft, RouterImageModel::Seedream5p0ProUltra) => build_artcraft_seedream_5p0_pro_ultra(self),
(RouterProvider::Artcraft, RouterImageModel::QwenEdit2511Angles) => build_artcraft_qwen_edit_2511_angles(self),
(RouterProvider::Artcraft, RouterImageModel::Flux2LoraAngles) => build_artcraft_flux_2_lora_angles(self),
(RouterProvider::Artcraft, RouterImageModel::Midjourney7) => build_artcraft_midjourney_7(self),
@@ -149,6 +151,7 @@ impl GenerateImageRequestBuilder {
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney7Niji) => build_kinovi_midjourney_7_niji(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Midjourney8) => build_kinovi_midjourney_8(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Seedream5p0Pro) => build_kinovi_seedream_5p0_pro(self),
(RouterProvider::Seedance2Pro, RouterImageModel::Seedream5p0ProUltra) => build_kinovi_seedream_5p0_pro(self),
_ => self.unsupported_provider_and_model(),
}
@@ -41,6 +41,8 @@ use crate::generate::generate_image::providers::artcraft::seedream_5_lite::cost:
use crate::generate::generate_image::providers::artcraft::seedream_5_lite::request::ArtcraftSeedream5LiteRequestState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::cost::ArtcraftSeedream5p0ProCostState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::request::ArtcraftSeedream5p0ProRequestState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::cost::ArtcraftSeedream5p0ProUltraCostState;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::request::ArtcraftSeedream5p0ProUltraRequestState;
use crate::generate::generate_image::providers::fal::flux_1_dev::cost::FalFlux1DevCostState;
use crate::generate::generate_image::providers::fal::flux_1_dev::request::FalFlux1DevRequestState;
use crate::generate::generate_image::providers::fal::flux_1_schnell::cost::FalFlux1SchnellCostState;
@@ -97,6 +99,7 @@ pub enum ImageGenerationRequest {
ArtcraftSeedream4p5(ArtcraftSeedream4p5RequestState),
ArtcraftSeedream5Lite(ArtcraftSeedream5LiteRequestState),
ArtcraftSeedream5p0Pro(ArtcraftSeedream5p0ProRequestState),
ArtcraftSeedream5p0ProUltra(ArtcraftSeedream5p0ProUltraRequestState),
ArtcraftQwenEdit2511Angles(ArtcraftQwenEdit2511AnglesRequestState),
ArtcraftFlux2LoraAngles(ArtcraftFlux2LoraAnglesRequestState),
ArtcraftMidjourney7(ArtcraftMidjourney7RequestState),
@@ -144,6 +147,7 @@ impl ImageGenerationRequest {
Self::ArtcraftSeedream4p5(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream5Lite(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream5p0Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftSeedream5p0ProUltra(_) => RouterProvider::Artcraft,
Self::ArtcraftQwenEdit2511Angles(_) => RouterProvider::Artcraft,
Self::ArtcraftFlux2LoraAngles(_) => RouterProvider::Artcraft,
Self::ArtcraftMidjourney7(_) => RouterProvider::Artcraft,
@@ -218,6 +222,9 @@ impl ImageGenerationRequest {
Self::ArtcraftSeedream5p0Pro(request) => {
Ok(ArtcraftSeedream5p0ProCostState::from_request(request).estimate_cost())
}
Self::ArtcraftSeedream5p0ProUltra(request) => {
Ok(ArtcraftSeedream5p0ProUltraCostState::from_request(request).estimate_cost())
}
Self::ArtcraftQwenEdit2511Angles(request) => {
Ok(ArtcraftQwenEdit2511AnglesCostState::from_request(request).estimate_cost())
}
@@ -317,6 +324,10 @@ impl ImageGenerationRequest {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
Self::ArtcraftSeedream5p0ProUltra(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
}
Self::ArtcraftQwenEdit2511Angles(request) => {
let artcraft_client = client.get_artcraft_client_ref()?;
request.send(artcraft_client).await
@@ -20,3 +20,4 @@ pub mod seedream_4;
pub mod seedream_4p5;
pub mod seedream_5_lite;
pub mod seedream_5p0_pro;
pub mod seedream_5p0_pro_ultra;
@@ -0,0 +1,17 @@
use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image::providers::artcraft::build_common::build_artcraft_omni_image_request;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::request::ArtcraftSeedream5p0ProUltraRequestState;
pub fn build_artcraft_seedream_5p0_pro_ultra(
builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_image_request(builder, CommonImageModelEnum::Seedream5p0ProUltra)?;
Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::ArtcraftSeedream5p0ProUltra(ArtcraftSeedream5p0ProUltraRequestState { request }),
))
}
@@ -0,0 +1,208 @@
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro_ultra::request::ArtcraftSeedream5p0ProUltraRequestState;
/// Customer-facing price per image at 1k resolution (the default), in USD cents.
const USD_CENTS_PER_IMAGE_1K: u64 = 18;
/// Customer-facing price per image at 2k resolution, in USD cents.
const USD_CENTS_PER_IMAGE_2K: u64 = 30;
/// Cost state for Artcraft Seedream 5.0 Pro Ultra. Pricing is per image and
/// resolution-dependent:
///
/// 1K (default) → 18¢, 2K → 30¢. 3K/4K fall back to 2K pricing; 0.5K and
/// legacy video resolutions (480p/720p/1080p) fall back to 1K.
#[derive(Clone, Debug)]
pub struct ArtcraftSeedream5p0ProUltraCostState {
pub resolution: Option<CommonResolutionEnum>,
pub num_images: u16,
}
impl ArtcraftSeedream5p0ProUltraCostState {
pub fn from_request(request: &ArtcraftSeedream5p0ProUltraRequestState) -> Self {
Self {
resolution: request.request.resolution,
num_images: request.request.image_batch_count.unwrap_or(1),
}
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_per_image: u64 = cost_per_image_in_cents(self.resolution);
let cost_in_usd_cents = cost_per_image * self.num_images as u64;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
fn cost_per_image_in_cents(resolution: Option<CommonResolutionEnum>) -> u64 {
use CommonResolutionEnum as R;
match resolution {
None
| Some(R::HalfK)
| Some(R::OneK)
| Some(R::FourEightyP)
| Some(R::SevenTwentyP)
| Some(R::TenEightyP) => USD_CENTS_PER_IMAGE_1K,
Some(R::TwoK) | Some(R::ThreeK) | Some(R::FourK) => USD_CENTS_PER_IMAGE_2K,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::router_image_model::RouterImageModel;
use crate::api::router_provider::RouterProvider;
use crate::api::router_resolution::RouterResolution;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder;
fn estimate(resolution: Option<RouterResolution>, image_batch_count: u16) -> ImageGenerationCostEstimate {
let builder = GenerateImageRequestBuilder {
model: RouterImageModel::Seedream5p0ProUltra,
provider: RouterProvider::Artcraft,
prompt: Some("an anime girl riding a dinosaur".to_string()),
image_inputs: None,
resolution,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
};
builder.build2().unwrap().estimate_cost().unwrap()
}
fn cost_cents(resolution: Option<RouterResolution>, image_batch_count: u16) -> u64 {
estimate(resolution, image_batch_count).cost_in_usd_cents.unwrap()
}
// ── Default / 1K (18¢) ──
#[test]
fn default_resolution_one_image_is_18c() { assert_eq!(cost_cents(None, 1), 18); }
#[test]
fn one_k_one_image_is_18c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 1), 18); }
#[test]
fn one_k_four_images_is_72c() { assert_eq!(cost_cents(Some(RouterResolution::OneK), 4), 72); }
// ── 2K (30¢) ──
#[test]
fn two_k_one_image_is_30c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 1), 30); }
#[test]
fn two_k_four_images_is_120c() { assert_eq!(cost_cents(Some(RouterResolution::TwoK), 4), 120); }
// ── Fallbacks ──
#[test]
fn four_k_falls_back_to_two_k_pricing() {
assert_eq!(
ArtcraftSeedream5p0ProUltraCostState {
resolution: Some(CommonResolutionEnum::FourK),
num_images: 1,
}.estimate_cost().cost_in_usd_cents,
Some(30),
);
}
#[test]
fn half_k_falls_back_to_one_k_pricing() {
assert_eq!(
ArtcraftSeedream5p0ProUltraCostState {
resolution: Some(CommonResolutionEnum::HalfK),
num_images: 1,
}.estimate_cost().cost_in_usd_cents,
Some(18),
);
}
// ── Credits track USD cents exactly (1 credit = 1 cent) ──
#[test]
fn credits_equal_usd_cents() {
for batch in 1..=4u16 {
let one_k = estimate(Some(RouterResolution::OneK), batch);
assert_eq!(one_k.cost_in_credits, one_k.cost_in_usd_cents, "1k batch {}", batch);
let two_k = estimate(Some(RouterResolution::TwoK), batch);
assert_eq!(two_k.cost_in_credits, two_k.cost_in_usd_cents, "2k batch {}", batch);
}
}
// ── The price must exceed the base Seedream 5.0 Pro price ──
#[test]
fn price_exceeds_base_seedream_5p0_pro_price() {
use crate::generate::generate_image::providers::artcraft::seedream_5p0_pro::cost::ArtcraftSeedream5p0ProCostState;
for resolution in [None, Some(CommonResolutionEnum::OneK), Some(CommonResolutionEnum::TwoK)] {
for num_images in [1u16, 4] {
let ultra_cents = ArtcraftSeedream5p0ProUltraCostState { resolution, num_images }
.estimate_cost().cost_in_usd_cents.unwrap();
let base_cents = ArtcraftSeedream5p0ProCostState { resolution, num_images }
.estimate_cost().cost_in_usd_cents.unwrap();
assert!(
ultra_cents > base_cents,
"ultra price {}¢ must exceed base price {}¢ ({:?} batch {})",
ultra_cents, base_cents, resolution, num_images,
);
}
}
}
// ── The Artcraft price must cover the Kinovi provider cost ──
#[test]
fn price_covers_kinovi_cost() {
use seedance2pro_client::generate::image::generate_seedream_5p0_pro::{
KinoviSeedream5p0ProBatchCount, KinoviSeedream5p0ProResolution,
};
use crate::generate::generate_image::providers::kinovi::seedream_5p0_pro::cost::KinoviSeedream5p0ProCostState;
let cases = [
(RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 1u16, KinoviSeedream5p0ProBatchCount::One),
(RouterResolution::OneK, KinoviSeedream5p0ProResolution::OneK, 4u16, KinoviSeedream5p0ProBatchCount::Four),
(RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 1u16, KinoviSeedream5p0ProBatchCount::One),
(RouterResolution::TwoK, KinoviSeedream5p0ProResolution::TwoK, 4u16, KinoviSeedream5p0ProBatchCount::Four),
];
for (router_res, kinovi_res, batch, kinovi_batch) in cases {
let artcraft_cents = cost_cents(Some(router_res), batch);
let kinovi_cents = KinoviSeedream5p0ProCostState {
resolution: kinovi_res,
batch_count: kinovi_batch,
}.estimate_cost().cost_in_usd_cents.unwrap();
assert!(
artcraft_cents > kinovi_cents,
"artcraft price {}¢ must exceed kinovi cost {}¢ ({:?} batch {})",
artcraft_cents, kinovi_cents, router_res, batch,
);
}
}
// ── Flags ──
#[test]
fn cost_flags_are_correct() {
let cost = ArtcraftSeedream5p0ProUltraCostState { resolution: None, num_images: 1 }.estimate_cost();
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
assert_eq!(cost.failures_are_refunded, None);
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,17 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::providers::artcraft::request_common::send_artcraft_omni_image_request;
#[derive(Clone, Debug)]
pub struct ArtcraftSeedream5p0ProUltraRequestState {
pub request: OmniGenImageCostAndGenerateRequest,
}
impl ArtcraftSeedream5p0ProUltraRequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
send_artcraft_omni_image_request(&self.request, client).await
}
}
@@ -55,6 +55,7 @@ fn image_model_to_common_model_type(model: RouterImageModel) -> Option<CommonMod
RouterImageModel::Seedream4p5 => Some(CommonModelType::Seedream4p5),
RouterImageModel::Seedream5Lite => Some(CommonModelType::Seedream5Lite),
RouterImageModel::Seedream5p0Pro => Some(CommonModelType::Seedream5p0Pro),
RouterImageModel::Seedream5p0ProUltra => Some(CommonModelType::Seedream5p0ProUltra),
RouterImageModel::QwenEdit2511Angles => Some(CommonModelType::QwenEdit2511Angles),
RouterImageModel::Flux2LoraAngles => Some(CommonModelType::Flux2LoraAngles),
RouterImageModel::GrokImagineImage => Some(CommonModelType::GrokImagineImage),
@@ -83,7 +83,8 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo
Some(CommonModelType::Midjourney7) |
Some(CommonModelType::Midjourney7Niji) |
Some(CommonModelType::Midjourney8) |
Some(CommonModelType::Seedream5p0Pro) => true,
Some(CommonModelType::Seedream5p0Pro) |
Some(CommonModelType::Seedream5p0ProUltra) => true,
_ => false,
};
@@ -111,7 +112,12 @@ pub async fn insert_generic_inference_job_for_seedance2pro_queue_with_apriori_jo
InferenceJobExternalThirdParty::Seedance2ProAlt,
InferenceJobProductCategory::Seedance2ProVideoAlt,
),
(KinoviVersion::BytePlusUltra, _) => (
(KinoviVersion::BytePlusUltra, true) => (
InferenceJobType::Seedance2ProBytePlusUltraQueue,
InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra,
InferenceJobProductCategory::Seedance2ProImageBytePlusUltra,
),
(KinoviVersion::BytePlusUltra, false) => (
InferenceJobType::Seedance2ProBytePlusUltraQueue,
InferenceJobExternalThirdParty::Seedance2ProBytePlusUltra,
InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra,
@@ -72,6 +72,10 @@ pub enum InferenceJobProductCategory {
#[serde(rename = "seedance2pro_video_bpu")]
Seedance2ProVideoBytePlusUltra,
/// Seedance 2 Pro / Kinovi (BytePlus Ultra account): image generation.
#[serde(rename = "seedance2pro_image_bpu")]
Seedance2ProImageBytePlusUltra,
// =============== WORLD LABS ===============
/// World Labs: Gaussian Splat Generation
@@ -183,6 +187,7 @@ impl InferenceJobProductCategory {
Self::Seedance2ProAudio => "seedance2pro_audio",
Self::Seedance2ProVideoAlt => "seedance2pro_video_alt",
Self::Seedance2ProVideoBytePlusUltra => "seedance2pro_video_bpu",
Self::Seedance2ProImageBytePlusUltra => "seedance2pro_image_bpu",
Self::WorldlabsSplat => "worldlabs_splat",
Self::TtsGptSoVits => "tts_gpt_so_vits",
Self::TtsStyleTts2 => "tts_style_tts2",
@@ -226,6 +231,7 @@ impl InferenceJobProductCategory {
"seedance2pro_audio" => Ok(Self::Seedance2ProAudio),
"seedance2pro_video_alt" => Ok(Self::Seedance2ProVideoAlt),
"seedance2pro_video_bpu" => Ok(Self::Seedance2ProVideoBytePlusUltra),
"seedance2pro_image_bpu" => Ok(Self::Seedance2ProImageBytePlusUltra),
"worldlabs_splat" => Ok(Self::WorldlabsSplat),
"tts_gpt_so_vits" => Ok(Self::TtsGptSoVits),
"tts_style_tts2" => Ok(Self::TtsStyleTts2),
@@ -272,6 +278,7 @@ impl InferenceJobProductCategory {
Self::Seedance2ProAudio,
Self::Seedance2ProVideoAlt,
Self::Seedance2ProVideoBytePlusUltra,
Self::Seedance2ProImageBytePlusUltra,
Self::WorldlabsSplat,
Self::TtsGptSoVits,
Self::TtsStyleTts2,
@@ -324,6 +331,7 @@ mod tests {
assert_serialization(InferenceJobProductCategory::Seedance2ProAudio, "seedance2pro_audio");
assert_serialization(InferenceJobProductCategory::Seedance2ProVideoAlt, "seedance2pro_video_alt");
assert_serialization(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra, "seedance2pro_video_bpu");
assert_serialization(InferenceJobProductCategory::Seedance2ProImageBytePlusUltra, "seedance2pro_image_bpu");
assert_serialization(InferenceJobProductCategory::WorldlabsSplat, "worldlabs_splat");
assert_serialization(InferenceJobProductCategory::TtsGptSoVits, "tts_gpt_so_vits");
assert_serialization(InferenceJobProductCategory::TtsStyleTts2, "tts_style_tts2");
@@ -366,6 +374,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::Seedance2ProAudio.to_str(), "seedance2pro_audio");
assert_eq!(InferenceJobProductCategory::Seedance2ProVideoAlt.to_str(), "seedance2pro_video_alt");
assert_eq!(InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra.to_str(), "seedance2pro_video_bpu");
assert_eq!(InferenceJobProductCategory::Seedance2ProImageBytePlusUltra.to_str(), "seedance2pro_image_bpu");
assert_eq!(InferenceJobProductCategory::WorldlabsSplat.to_str(), "worldlabs_splat");
assert_eq!(InferenceJobProductCategory::TtsGptSoVits.to_str(), "tts_gpt_so_vits");
assert_eq!(InferenceJobProductCategory::TtsStyleTts2.to_str(), "tts_style_tts2");
@@ -407,6 +416,7 @@ mod tests {
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_audio").unwrap(), InferenceJobProductCategory::Seedance2ProAudio);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_alt").unwrap(), InferenceJobProductCategory::Seedance2ProVideoAlt);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_video_bpu").unwrap(), InferenceJobProductCategory::Seedance2ProVideoBytePlusUltra);
assert_eq!(InferenceJobProductCategory::from_str("seedance2pro_image_bpu").unwrap(), InferenceJobProductCategory::Seedance2ProImageBytePlusUltra);
assert_eq!(InferenceJobProductCategory::from_str("worldlabs_splat").unwrap(), InferenceJobProductCategory::WorldlabsSplat);
assert_eq!(InferenceJobProductCategory::from_str("tts_gpt_so_vits").unwrap(), InferenceJobProductCategory::TtsGptSoVits);
assert_eq!(InferenceJobProductCategory::from_str("tts_style_tts2").unwrap(), InferenceJobProductCategory::TtsStyleTts2);
@@ -434,7 +444,7 @@ mod tests {
#[test]
fn all_variants() {
// Static check
const EXPECTED_COUNT : usize = 38;
const EXPECTED_COUNT : usize = 39;
assert_eq!(InferenceJobProductCategory::all_variants().len(), EXPECTED_COUNT);
assert_eq!(InferenceJobProductCategory::iter().len(), EXPECTED_COUNT);
@@ -129,6 +129,8 @@ pub enum InferenceModelType {
Seedream5Lite,
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
#[serde(rename = "seedream_5p0_pro_u")]
Seedream5p0ProUltra,
#[serde(rename = "midjourney")]
Midjourney,
#[serde(rename = "midjourney_v6")]
@@ -337,6 +339,7 @@ impl InferenceModelType {
Self::Seedream4p5 => "seedream_4p5",
Self::Seedream5Lite => "seedream_5_lite",
Self::Seedream5p0Pro => "seedream_5p0_pro",
Self::Seedream5p0ProUltra => "seedream_5p0_pro_u",
Self::Midjourney => "midjourney",
Self::MidjourneyV6 => "midjourney_v6",
Self::MidjourneyV6p1 => "midjourney_v6p1",
@@ -466,6 +469,7 @@ impl InferenceModelType {
"seedream_4p5" => Ok(Self::Seedream4p5),
"seedream_5_lite" => Ok(Self::Seedream5Lite),
"seedream_5p0_pro" => Ok(Self::Seedream5p0Pro),
"seedream_5p0_pro_u" => Ok(Self::Seedream5p0ProUltra),
"midjourney" => Ok(Self::Midjourney),
"midjourney_v6" => Ok(Self::MidjourneyV6),
"midjourney_v6p1" => Ok(Self::MidjourneyV6p1),
@@ -599,6 +603,7 @@ impl InferenceModelType {
Self::Seedream4p5,
Self::Seedream5Lite,
Self::Seedream5p0Pro,
Self::Seedream5p0ProUltra,
Self::Midjourney,
Self::MidjourneyV6,
Self::MidjourneyV6p1,
@@ -715,6 +720,7 @@ impl InferenceModelType {
CommonModelType::Seedream4p5 => Self::Seedream4p5,
CommonModelType::Seedream5Lite => Self::Seedream5Lite,
CommonModelType::Seedream5p0Pro => Self::Seedream5p0Pro,
CommonModelType::Seedream5p0ProUltra => Self::Seedream5p0ProUltra,
CommonModelType::Midjourney => Self::Midjourney,
CommonModelType::MidjourneyV6 => Self::MidjourneyV6,
CommonModelType::MidjourneyV6p1 => Self::MidjourneyV6p1,
@@ -49,6 +49,8 @@ pub enum CommonImageModel {
Seedream5Lite,
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
#[serde(rename = "seedream_5p0_pro_u")]
Seedream5p0ProUltra,
#[serde(rename = "qwen_edit_2511_angles")]
QwenEdit2511Angles,
#[serde(rename = "flux_2_lora_angles")]
@@ -78,6 +80,7 @@ impl CommonImageModel {
Self::Seedream4p5 => CommonModelType::Seedream4p5,
Self::Seedream5Lite => CommonModelType::Seedream5Lite,
Self::Seedream5p0Pro => CommonModelType::Seedream5p0Pro,
Self::Seedream5p0ProUltra => CommonModelType::Seedream5p0ProUltra,
Self::QwenEdit2511Angles => CommonModelType::QwenEdit2511Angles,
Self::Flux2LoraAngles => CommonModelType::Flux2LoraAngles,
}
@@ -115,6 +118,7 @@ mod tests {
assert_serialization(CommonImageModel::Seedream4p5, "seedream_4p5");
assert_serialization(CommonImageModel::Seedream5Lite, "seedream_5_lite");
assert_serialization(CommonImageModel::Seedream5p0Pro, "seedream_5p0_pro");
assert_serialization(CommonImageModel::Seedream5p0ProUltra, "seedream_5p0_pro_u");
assert_serialization(CommonImageModel::QwenEdit2511Angles, "qwen_edit_2511_angles");
assert_serialization(CommonImageModel::Flux2LoraAngles, "flux_2_lora_angles");
}
@@ -141,6 +145,7 @@ mod tests {
(CommonImageModel::Seedream4p5, CommonModelType::Seedream4p5),
(CommonImageModel::Seedream5Lite, CommonModelType::Seedream5Lite),
(CommonImageModel::Seedream5p0Pro, CommonModelType::Seedream5p0Pro),
(CommonImageModel::Seedream5p0ProUltra, CommonModelType::Seedream5p0ProUltra),
(CommonImageModel::QwenEdit2511Angles, CommonModelType::QwenEdit2511Angles),
(CommonImageModel::Flux2LoraAngles, CommonModelType::Flux2LoraAngles),
];
@@ -169,7 +174,7 @@ mod tests {
#[test]
fn variant_count() {
use strum::IntoEnumIterator;
assert_eq!(CommonImageModel::iter().len(), 21);
assert_eq!(CommonImageModel::iter().len(), 22);
}
#[test]
@@ -85,6 +85,9 @@ pub enum CommonModelType {
/// Seedream 5.0 Pro as routed via kinovi
#[serde(rename = "seedream_5p0_pro")]
Seedream5p0Pro,
/// Seedream 5.0 Pro as routed via the kinovi ultra account
#[serde(rename = "seedream_5p0_pro_u")]
Seedream5p0ProUltra,
/// Midjourney without distinguishing a model type or version
#[serde(rename = "midjourney")]
@@ -291,6 +294,7 @@ impl CommonModelType {
Self::Seedream4p5 => "seedream_4p5",
Self::Seedream5Lite => "seedream_5_lite",
Self::Seedream5p0Pro => "seedream_5p0_pro",
Self::Seedream5p0ProUltra => "seedream_5p0_pro_u",
Self::Midjourney => "midjourney",
Self::MidjourneyV6 => "midjourney_v6",
Self::MidjourneyV6p1 => "midjourney_v6p1",
@@ -403,6 +407,7 @@ impl CommonModelType {
"seedream_4p5" => Ok(Self::Seedream4p5),
"seedream_5_lite" => Ok(Self::Seedream5Lite),
"seedream_5p0_pro" => Ok(Self::Seedream5p0Pro),
"seedream_5p0_pro_u" => Ok(Self::Seedream5p0ProUltra),
"midjourney" => Ok(Self::Midjourney),
"midjourney_v6" => Ok(Self::MidjourneyV6),
"midjourney_v6p1" => Ok(Self::MidjourneyV6p1),
@@ -519,6 +524,7 @@ impl CommonModelType {
Self::Seedream4p5,
Self::Seedream5Lite,
Self::Seedream5p0Pro,
Self::Seedream5p0ProUltra,
Self::Midjourney,
Self::MidjourneyV6,
Self::MidjourneyV6p1,
@@ -632,6 +638,7 @@ impl CommonModelType {
Self::Seedream4p5 => CommonModelClass::Image,
Self::Seedream5Lite => CommonModelClass::Image,
Self::Seedream5p0Pro => CommonModelClass::Image,
Self::Seedream5p0ProUltra => CommonModelClass::Image,
Self::Midjourney => CommonModelClass::Image,
Self::MidjourneyV6 => CommonModelClass::Image,
Self::MidjourneyV6p1 => CommonModelClass::Image,
@@ -755,6 +762,7 @@ mod tests {
assert_serialization(CommonModelType::Seedream4p5, "seedream_4p5");
assert_serialization(CommonModelType::Seedream5Lite, "seedream_5_lite");
assert_serialization(CommonModelType::Seedream5p0Pro, "seedream_5p0_pro");
assert_serialization(CommonModelType::Seedream5p0ProUltra, "seedream_5p0_pro_u");
assert_serialization(CommonModelType::Midjourney, "midjourney");
assert_serialization(CommonModelType::MidjourneyV6, "midjourney_v6");
assert_serialization(CommonModelType::MidjourneyV6p1, "midjourney_v6p1");
@@ -861,6 +869,7 @@ mod tests {
assert_eq!(CommonModelType::Seedream4p5.to_str(), "seedream_4p5");
assert_eq!(CommonModelType::Seedream5Lite.to_str(), "seedream_5_lite");
assert_eq!(CommonModelType::Seedream5p0Pro.to_str(), "seedream_5p0_pro");
assert_eq!(CommonModelType::Seedream5p0ProUltra.to_str(), "seedream_5p0_pro_u");
assert_eq!(CommonModelType::Midjourney.to_str(), "midjourney");
assert_eq!(CommonModelType::MidjourneyV6.to_str(), "midjourney_v6");
assert_eq!(CommonModelType::MidjourneyV6p1.to_str(), "midjourney_v6p1");
@@ -971,6 +980,7 @@ mod tests {
assert_eq!(CommonModelType::from_str("seedream_4p5").unwrap(), CommonModelType::Seedream4p5);
assert_eq!(CommonModelType::from_str("seedream_5_lite").unwrap(), CommonModelType::Seedream5Lite);
assert_eq!(CommonModelType::from_str("seedream_5p0_pro").unwrap(), CommonModelType::Seedream5p0Pro);
assert_eq!(CommonModelType::from_str("seedream_5p0_pro_u").unwrap(), CommonModelType::Seedream5p0ProUltra);
assert_eq!(CommonModelType::from_str("midjourney").unwrap(), CommonModelType::Midjourney);
assert_eq!(CommonModelType::from_str("midjourney_v6").unwrap(), CommonModelType::MidjourneyV6);
assert_eq!(CommonModelType::from_str("midjourney_v6p1").unwrap(), CommonModelType::MidjourneyV6p1);
@@ -1052,7 +1062,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = CommonModelType::all_variants();
assert_eq!(variants.len(), 97);
assert_eq!(variants.len(), 98);
// Image models
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
@@ -1080,6 +1090,7 @@ mod tests {
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream4p5));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5Lite));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5p0Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Seedream5p0ProUltra));
assert_eq!(variants.pop_first(), Some(CommonModelType::Midjourney));
assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV6));
assert_eq!(variants.pop_first(), Some(CommonModelType::MidjourneyV6p1));
@@ -470,6 +470,37 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
..Default::default()
});
models.push(OmniGenImageModelDetails {
model: CommonImageModel::Seedream5p0ProUltra,
model_creator: Some(ModelCreator::Bytedance),
full_name: Some("Seedream 5.0 Pro Ultra".to_string()),
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
image_refs_max: Some(14),
aspect_ratio_options: Some(vec![
CommonAspectRatio::Auto,
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::Square,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::Square),
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto),
resolution_options: Some(vec![
CommonResolution::OneK,
CommonResolution::TwoK,
]),
resolution_default: Some(CommonResolution::TwoK),
batch_size_min: Some(1),
batch_size_max: Some(8),
batch_size_default: Some(1),
..Default::default()
});
models
}
@@ -519,6 +550,10 @@ fn build_omni_gen_image_model_providers() -> Vec<OmniGenImageModelProviderDetail
model: CommonImageModel::Seedream5p0Pro,
overrides: None,
},
OmniGenImageProviderModelDetails {
model: CommonImageModel::Seedream5p0ProUltra,
overrides: None,
},
OmniGenImageProviderModelDetails {
model: CommonImageModel::NanoBanana,
overrides: None,
@@ -95,6 +95,7 @@ use artcraft_api_defs::moderation::debug_logs::debug_log_entry::*;
use artcraft_api_defs::moderation::debug_logs::moderation_list_all_debug_logs::*;
use artcraft_api_defs::moderation::debug_logs::moderation_list_debug_logs_for_token::*;
use artcraft_api_defs::moderation::debug_logs::moderation_list_debug_logs_for_user::*;
use crate::http_server::endpoints::moderation::dashboards::moderator_list_databox_dashboards_handler::*;
use crate::http_server::endpoints::moderation::jobs::moderation_get_job_by_token_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::*;
@@ -640,6 +641,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::omni_api::job_status::batch_get_job_status_handler::omni_api_batch_get_job_status_handler,
// Moderation
crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler,
crate::http_server::endpoints::moderation::dashboards::moderator_list_databox_dashboards_handler::moderator_list_databox_dashboards_handler,
crate::http_server::endpoints::moderation::info::moderator_token_info_handler::moderator_get_token_info_handler,
crate::http_server::endpoints::moderation::user::moderator_list_subscribing_users_by_signup_date::moderator_list_subscribing_users_by_signup_date_handler,
crate::http_server::endpoints::moderation::user::moderator_list_users_by_signup_date::moderator_list_users_by_signup_date_handler,
@@ -724,6 +726,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::update_gpt_image_job_status_handler,
),
components(schemas(
DataboxDashboardEntry,
ListDataboxDashboardsResponse,
ModeratorGetUserSpendSummaryResponse,
UserSpendSummaryView,
ModeratorReengagementListResponse,
@@ -142,6 +142,7 @@ fn map_image_model(model: CommonImageModel) -> Result<RouterImageModel, HandlerE
CommonImageModel::Seedream4p5 => RouterImageModel::Seedream4p5,
CommonImageModel::Seedream5Lite => RouterImageModel::Seedream5Lite,
CommonImageModel::Seedream5p0Pro => RouterImageModel::Seedream5p0Pro,
CommonImageModel::Seedream5p0ProUltra => RouterImageModel::Seedream5p0ProUltra,
CommonImageModel::GrokImagineImage => RouterImageModel::GrokImagineImage,
CommonImageModel::GrokImagineImageQuality => RouterImageModel::GrokImagineImageQuality,
CommonImageModel::QwenEdit2511Angles => RouterImageModel::QwenEdit2511Angles,
@@ -0,0 +1 @@
pub mod moderator_list_databox_dashboards_handler;
@@ -0,0 +1,67 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use serde::Serialize;
use utoipa::ToSchema;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::user_session::require_moderator::require_moderator;
use crate::state::server_state::ServerState;
#[derive(Serialize, ToSchema)]
pub struct ListDataboxDashboardsResponse {
pub success: bool,
/// Configured Databox datawalls, in display order.
pub databoards: Vec<DataboxDashboardEntry>,
}
#[derive(Serialize, ToSchema)]
pub struct DataboxDashboardEntry {
/// Human-readable dashboard name.
pub name: String,
/// Databox datawall id.
pub id: String,
}
/// List the Databox metrics dashboards configured for the admin dashboard.
/// Dashboards whose env vars aren't set are omitted.
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/dashboards/databox",
responses(
(status = 200, description = "Success", body = ListDataboxDashboardsResponse),
(status = 401, description = "Not authorized", body = CommonWebError),
(status = 500, description = "Server error", body = CommonWebError),
),
)]
pub async fn moderator_list_databox_dashboards_handler(
http_request: HttpRequest,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListDataboxDashboardsResponse>, CommonWebError> {
require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
let databox = &server_state.dashboards.databox;
let databoards = [
("DAUs", databox.daus_id.as_ref()),
("Daily Generations", databox.daily_generations_id.as_ref()),
]
.into_iter()
.filter_map(|(name, maybe_id)| {
maybe_id.map(|id| DataboxDashboardEntry {
name: name.to_string(),
id: id.clone(),
})
})
.collect();
Ok(Json(ListDataboxDashboardsResponse {
success: true,
databoards,
}))
}
@@ -1,4 +1,5 @@
pub mod alerts;
pub mod dashboards;
pub mod debug_logs;
pub mod info;
pub mod ip_bans;
@@ -13,6 +13,7 @@ use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::platform_type::PlatformType;
@@ -37,6 +38,7 @@ use crate::http_server::endpoints::omni_api::generate::image::insert_db_job::ins
use crate::http_server::endpoints::omni_api::generate::image::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
use crate::http_server::endpoints::omni_api::generate::image::insert_db_job::shared_job_args::SharedJobArgs;
use crate::http_server::endpoints::omni_api::generate::image::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
use crate::http_server::endpoints::omni_api::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::user_lookup::api_keys::require_api_key_user::require_api_key_user;
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
@@ -135,6 +137,14 @@ pub async fn omni_api_image_generate_handler(
let router_builder = hydrate_to_router_request(&request)?;
// ==================== PIPELINE DISPATCH ==================== //
let kinovi_account = match request.model {
Some(CommonImageModel::Seedream5p0ProUltra) => KinoviAccount::BytePlusUltra,
// Everything else goes through Volcengine
_ => KinoviAccount::Volcengine,
};
// ==================== DEBUG LOG: HTTP REQUEST ==================== //
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
@@ -170,6 +180,7 @@ pub async fn omni_api_image_generate_handler(
server_state: &server_state,
user_token,
resolved_media: &resolved_media,
kinovi_account,
debug_log_context: &debug_log_context,
mysql_connection,
}).await;
@@ -289,10 +300,11 @@ pub async fn omni_api_image_generate_handler(
GenerateImageResponse::Seedance2Pro(payload) => {
info!("Inserting seedance2pro image job(s) with token: {:?}", pipeline_result.apriori_job_token);
// The image-side omni pipeline always dispatches Midjourney via the
// Volcengine Kinovi account today. If we ever route to BytePlus /
// BytePlus Ultra here, mirror the video-side `kinovi_account` knob.
let kinovi_version = KinoviVersion::Volcengine;
let kinovi_version = match kinovi_account {
KinoviAccount::Volcengine => KinoviVersion::Volcengine,
KinoviAccount::BytePlus => KinoviVersion::BytePlus,
KinoviAccount::BytePlusUltra => KinoviVersion::BytePlusUltra,
};
let result = insert_seedance2pro_jobs(InsertSeedance2proJobsArgs {
primary_order_id: &payload.order_id,
@@ -21,6 +21,7 @@ use crate::http_server::endpoints::generate::common::generation_debug_logs::{
insert_provider_request_debug_log, provider_request_debug_log_type, GenerationDebugLogContext,
};
use crate::http_server::endpoints::omni_api::generate::image::pipeline_result::ImagePipelineResult;
use crate::http_server::endpoints::omni_api::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_api::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
use crate::state::server_state::ServerState;
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::MediaFilesAsCdnUrlListAndMap;
@@ -30,6 +31,7 @@ pub struct RunPipelineV2Args<'a> {
pub server_state: &'a ServerState,
pub user_token: &'a UserToken,
pub resolved_media: &'a MediaFilesAsCdnUrlListAndMap,
pub kinovi_account: KinoviAccount,
pub debug_log_context: &'a GenerationDebugLogContext<'a>,
/// The handler's open connection. The pipeline uses it for its remaining
/// pre-request DB writes (billing, outbound-request debug log) and releases
@@ -49,6 +51,7 @@ pub async fn run_pipeline_v2(
server_state,
user_token,
resolved_media,
kinovi_account,
debug_log_context,
mut mysql_connection,
} = args;
@@ -97,7 +100,7 @@ pub async fn run_pipeline_v2(
// across the external generation call below.
drop(mysql_connection);
let response = finalize_and_generate(draft_or_request, server_state, resolved_media).await?;
let response = finalize_and_generate(draft_or_request, server_state, resolved_media, kinovi_account).await?;
Ok(ImagePipelineResult {
apriori_job_token,
@@ -118,15 +121,17 @@ fn build_execution_request(
})
}
/// Route each image model to the provider that fulfils it. Midjourney is
/// served via the Seedance2Pro/Kinovi (Volcengine) backend; everything else
/// flows through Fal.
/// Route each image model to the provider that fulfils it. Midjourney and
/// Seedream 5.0 Pro (both variants) are served via the Seedance2Pro/Kinovi
/// backend; everything else flows through Fal. Which Kinovi account fulfils
/// a Kinovi request is the handler's `kinovi_account` choice.
fn provider_for_model(model: RouterImageModel) -> RouterProvider {
match model {
RouterImageModel::Midjourney7
| RouterImageModel::Midjourney7Niji
| RouterImageModel::Midjourney8
| RouterImageModel::Seedream5p0Pro => RouterProvider::Seedance2Pro,
| RouterImageModel::Seedream5p0Pro
| RouterImageModel::Seedream5p0ProUltra => RouterProvider::Seedance2Pro,
_ => RouterProvider::Fal,
}
}
@@ -172,9 +177,10 @@ async fn finalize_and_generate(
draft_or_request: ImageGenerationDraftOrRequest,
server_state: &ServerState,
resolved_media: &MediaFilesAsCdnUrlListAndMap,
kinovi_account: KinoviAccount,
) -> Result<GenerateImageResponse, CommonWebError> {
let provider = draft_or_request.get_provider();
let client = build_router_client(provider, server_state)?;
let client = build_router_client(provider, server_state, kinovi_account)?;
// Inline the draft finalization so the draft context can borrow `client`.
let request: ImageGenerationRequest = match draft_or_request {
@@ -204,6 +210,7 @@ async fn finalize_and_generate(
fn build_router_client(
provider: RouterProvider,
server_state: &ServerState,
kinovi_account: KinoviAccount,
) -> Result<RouterClient, CommonWebError> {
match provider {
RouterProvider::Fal => {
@@ -214,11 +221,12 @@ fn build_router_client(
Ok(RouterClient::Fal(fal_client))
},
RouterProvider::Seedance2Pro => {
// Midjourney image generation always uses the Volcengine account.
// Per-request account selection (like the video flow's
// `KinoviAccount` enum) isn't needed here yet — if it ever is, plumb
// a parallel knob through `RunPipelineV2Args`.
let cookies = server_state.inference_providers.seedance2pro.cookies_volcengine.clone();
let seedance2pro = &server_state.inference_providers.seedance2pro;
let cookies = match kinovi_account {
KinoviAccount::Volcengine => seedance2pro.cookies_volcengine.clone(),
KinoviAccount::BytePlus => seedance2pro.cookies_byteplus.clone(),
KinoviAccount::BytePlusUltra => seedance2pro.cookies_byteplus_ultra.clone(),
};
let session = Seedance2ProSession::from_cookies_string(cookies);
Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session)))
},
@@ -9,7 +9,7 @@ use artcraft_router::client::router_seedance2pro_client::RouterSeedance2ProClien
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::omni_api::generate::video::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_api::shared_utils::kinovi_account::KinoviAccount;
use crate::state::server_state::ServerState;
pub fn build_router_client(
@@ -4,4 +4,3 @@ pub mod helpers;
pub mod ingest_url_inputs;
pub mod pipeline_v2;
pub mod insert_db_job;
pub mod kinovi_account;
@@ -43,7 +43,7 @@ use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::ins
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_grok_api_job::{insert_grok_api_job, InsertGrokApiJobArgs};
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
use crate::http_server::endpoints::omni_api::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
use crate::http_server::endpoints::omni_api::generate::video::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_api::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_api::generate::video::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
use crate::http_server::endpoints::omni_api::shared_utils::video::validate_video_request::validate_video_request;
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
@@ -23,7 +23,7 @@ use crate::http_server::endpoints::omni_api::generate::video::helpers::build_rou
use crate::http_server::endpoints::omni_api::generate::video::helpers::pipeline_result::PipelineResult;
use crate::http_server::endpoints::omni_api::generate::video::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls;
use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates;
use crate::http_server::endpoints::omni_api::generate::video::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_api::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_api::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
use crate::state::server_state::ServerState;
@@ -1,4 +1,5 @@
pub mod check_url_inputs;
pub mod kinovi_account;
pub mod map_seedance2pro_router_error;
pub mod media_uploads;
pub mod video;
@@ -36,7 +36,7 @@ use crate::http_server::endpoints::omni_gen::generate::audio::insert_db_job::ins
use crate::http_server::endpoints::omni_gen::generate::audio::insert_db_job::insert_seedance2pro_job::{insert_seedance2pro_job, InsertSeedance2proJobArgs};
use crate::http_server::endpoints::omni_gen::generate::audio::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::shared_utils::audio::validate_audio_request::validate_audio_request;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
@@ -22,7 +22,7 @@ use crate::http_server::endpoints::omni_gen::generate::audio::helpers::pipeline_
use crate::http_server::endpoints::omni_gen::generate::audio::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::bill_wallet;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_router_client::build_router_client;
use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
use crate::state::server_state::ServerState;
@@ -12,6 +12,7 @@ use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use http_server_common::request::get_request_ip::get_request_ip;
@@ -34,6 +35,7 @@ use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::ins
use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::shared_job_args::SharedJobArgs;
use crate::http_server::endpoints::omni_gen::generate::image::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
@@ -130,6 +132,14 @@ pub async fn omni_gen_image_generate_handler(
let router_builder = hydrate_to_router_request(&request)?;
// ==================== PIPELINE DISPATCH ==================== //
let kinovi_account = match request.model {
Some(CommonImageModel::Seedream5p0ProUltra) => KinoviAccount::BytePlusUltra,
// Everything else goes through Volcengine
_ => KinoviAccount::Volcengine,
};
// ==================== DEBUG LOG: HTTP REQUEST ==================== //
let ip_address = get_request_ip(&http_request);
@@ -168,6 +178,7 @@ pub async fn omni_gen_image_generate_handler(
server_state: &server_state,
user_token,
resolved_media: &resolved_media,
kinovi_account,
debug_log_context: &debug_log_context,
mysql_connection,
}).await;
@@ -286,10 +297,11 @@ pub async fn omni_gen_image_generate_handler(
GenerateImageResponse::Seedance2Pro(payload) => {
info!("Inserting seedance2pro image job(s) with token: {:?}", pipeline_result.apriori_job_token);
// The image-side omni pipeline always dispatches Midjourney via the
// Volcengine Kinovi account today. If we ever route to BytePlus /
// BytePlus Ultra here, mirror the video-side `kinovi_account` knob.
let kinovi_version = KinoviVersion::Volcengine;
let kinovi_version = match kinovi_account {
KinoviAccount::Volcengine => KinoviVersion::Volcengine,
KinoviAccount::BytePlus => KinoviVersion::BytePlus,
KinoviAccount::BytePlusUltra => KinoviVersion::BytePlusUltra,
};
let result = insert_seedance2pro_jobs(InsertSeedance2proJobsArgs {
primary_order_id: &payload.order_id,
@@ -21,6 +21,7 @@ use crate::http_server::endpoints::generate::common::generation_debug_logs::{
insert_provider_request_debug_log, provider_request_debug_log_type, GenerationDebugLogContext,
};
use crate::http_server::endpoints::omni_gen::generate::image::pipeline_result::ImagePipelineResult;
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
use crate::state::server_state::ServerState;
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::MediaFilesAsCdnUrlListAndMap;
@@ -30,6 +31,7 @@ pub struct RunPipelineV2Args<'a> {
pub server_state: &'a ServerState,
pub user_token: &'a UserToken,
pub resolved_media: &'a MediaFilesAsCdnUrlListAndMap,
pub kinovi_account: KinoviAccount,
pub debug_log_context: &'a GenerationDebugLogContext<'a>,
/// The handler's open connection. The pipeline uses it for its remaining
/// pre-request DB writes (billing, outbound-request debug log) and releases
@@ -49,6 +51,7 @@ pub async fn run_pipeline_v2(
server_state,
user_token,
resolved_media,
kinovi_account,
debug_log_context,
mut mysql_connection,
} = args;
@@ -97,7 +100,7 @@ pub async fn run_pipeline_v2(
// across the external generation call below.
drop(mysql_connection);
let response = finalize_and_generate(draft_or_request, server_state, resolved_media).await?;
let response = finalize_and_generate(draft_or_request, server_state, resolved_media, kinovi_account).await?;
Ok(ImagePipelineResult {
apriori_job_token,
@@ -119,14 +122,16 @@ fn build_execution_request(
}
/// Route each image model to the provider that fulfils it. Midjourney and
/// Seedream 5.0 Pro are served via the Seedance2Pro/Kinovi (Volcengine)
/// backend; everything else flows through Fal.
/// Seedream 5.0 Pro (both variants) are served via the Seedance2Pro/Kinovi
/// backend; everything else flows through Fal. Which Kinovi account fulfils
/// a Kinovi request is the handler's `kinovi_account` choice.
fn provider_for_model(model: RouterImageModel) -> RouterProvider {
match model {
RouterImageModel::Midjourney7
| RouterImageModel::Midjourney7Niji
| RouterImageModel::Midjourney8
| RouterImageModel::Seedream5p0Pro => RouterProvider::Seedance2Pro,
| RouterImageModel::Seedream5p0Pro
| RouterImageModel::Seedream5p0ProUltra => RouterProvider::Seedance2Pro,
_ => RouterProvider::Fal,
}
}
@@ -172,9 +177,10 @@ async fn finalize_and_generate(
draft_or_request: ImageGenerationDraftOrRequest,
server_state: &ServerState,
resolved_media: &MediaFilesAsCdnUrlListAndMap,
kinovi_account: KinoviAccount,
) -> Result<GenerateImageResponse, CommonWebError> {
let provider = draft_or_request.get_provider();
let client = build_router_client(provider, server_state)?;
let client = build_router_client(provider, server_state, kinovi_account)?;
// Inline the draft finalization so the draft context can borrow `client`.
let request: ImageGenerationRequest = match draft_or_request {
@@ -204,6 +210,7 @@ async fn finalize_and_generate(
fn build_router_client(
provider: RouterProvider,
server_state: &ServerState,
kinovi_account: KinoviAccount,
) -> Result<RouterClient, CommonWebError> {
match provider {
RouterProvider::Fal => {
@@ -214,11 +221,12 @@ fn build_router_client(
Ok(RouterClient::Fal(fal_client))
},
RouterProvider::Seedance2Pro => {
// Midjourney image generation always uses the Volcengine account.
// Per-request account selection (like the video flow's
// `KinoviAccount` enum) isn't needed here yet — if it ever is, plumb
// a parallel knob through `RunPipelineV2Args`.
let cookies = server_state.inference_providers.seedance2pro.cookies_volcengine.clone();
let seedance2pro = &server_state.inference_providers.seedance2pro;
let cookies = match kinovi_account {
KinoviAccount::Volcengine => seedance2pro.cookies_volcengine.clone(),
KinoviAccount::BytePlus => seedance2pro.cookies_byteplus.clone(),
KinoviAccount::BytePlusUltra => seedance2pro.cookies_byteplus_ultra.clone(),
};
let session = Seedance2ProSession::from_cookies_string(cookies);
Ok(RouterClient::Seedance2Pro(RouterSeedance2ProClient::new(session)))
},
@@ -21,7 +21,7 @@ use crate::http_server::endpoints::omni_gen::generate::mesh::helpers::pipeline_r
use crate::http_server::endpoints::omni_gen::generate::mesh::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::bill_wallet;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_router_client::build_router_client;
use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
use crate::state::server_state::ServerState;
@@ -22,7 +22,7 @@ use crate::http_server::endpoints::omni_gen::generate::splat::helpers::pipeline_
use crate::http_server::endpoints::omni_gen::generate::splat::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::bill_wallet::bill_wallet;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_router_client::build_router_client;
use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::shared_utils::splat::map_worldlabs_router_error::map_worldlabs_router_error;
use crate::state::server_state::ServerState;
@@ -11,7 +11,7 @@ use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use worldlabs_api_client::credentials::world_labs_api_creds::WorldLabsApiCreds;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
use crate::state::server_state::ServerState;
pub fn build_router_client(
@@ -2,4 +2,3 @@ pub mod omni_gen_video_generate_handler;
pub mod helpers;
pub mod pipeline_v2;
pub mod insert_db_job;
pub mod kinovi_account;
@@ -40,7 +40,7 @@ use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::ins
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_grok_api_job::{insert_grok_api_job, InsertGrokApiJobArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_seedance2pro_jobs::{insert_seedance2pro_jobs, InsertSeedance2proJobsArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_video_request::validate_video_request;
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
@@ -23,7 +23,7 @@ use crate::http_server::endpoints::omni_gen::generate::video::helpers::build_rou
use crate::http_server::endpoints::omni_gen::generate::video::helpers::pipeline_result::PipelineResult;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::resolve_media_tokens_to_urls::resolve_media_tokens_to_urls;
use mysql_queries::queries::generic_inference::common::job_cost_estimates::JobCostEstimates;
use crate::http_server::endpoints::omni_gen::generate::video::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
use crate::http_server::endpoints::omni_gen::shared_utils::map_seedance2pro_router_error::map_router_error_to_web_error;
use crate::state::server_state::ServerState;
@@ -1,4 +1,5 @@
pub mod audio;
pub mod kinovi_account;
pub mod map_router_cost_error;
pub mod map_seedance2pro_router_error;
pub mod mesh;
@@ -35,6 +35,7 @@ use crate::http_server::endpoints::moderation::user::moderator_list_users_by_sig
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_by_stripe_customer_id_handler::moderator_user_lookup_by_stripe_customer_id_handler;
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_handler::moderator_user_lookup_handler;
use crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler;
use crate::http_server::endpoints::moderation::dashboards::moderator_list_databox_dashboards_handler::moderator_list_databox_dashboards_handler;
use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::moderator_list_staff_audit_logs_handler;
use crate::http_server::endpoints::moderation::debug_logs::moderation_list_all_debug_logs_handler::moderation_list_all_debug_logs_handler;
use crate::http_server::endpoints::moderation::debug_logs::moderation_list_debug_logs_for_token_handler::moderation_list_debug_logs_for_token_handler;
@@ -106,6 +107,12 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/dashboards")
.service(web::resource("/databox")
.route(web::get().to(moderator_list_databox_dashboards_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/debug_logs")
.service(web::resource("/list/{token}")
.route(web::get().to(moderation_list_debug_logs_for_token_handler))
@@ -34,6 +34,7 @@ use crate::startup::setup_bans::{
load_static_container_ip_bans, load_troll_user_token_bans,
};
use crate::startup::setup_inference_providers::setup_inference_providers;
use crate::startup::setup_dashboards::setup_dashboards;
use crate::startup::setup_seedance_video_bucket::setup_seedance_video_bucket;
use crate::startup::setup_static_feature_flags::setup_static_feature_flags;
use crate::startup::setup_stripe_artcraft::setup_stripe_artcraft;
@@ -262,6 +263,7 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
},
temp_dir_creator: ScopedTempDirCreator::auto_setup(),
google_sign_in_cert: GoogleSignInCert::new(),
dashboards: setup_dashboards(),
};
Ok(SetupResult {
@@ -1,5 +1,6 @@
pub mod build_dependencies;
pub mod setup_bans;
pub mod setup_dashboards;
pub mod setup_metrics;
pub mod setup_pager;
pub mod setup_seedance_video_bucket;
@@ -0,0 +1,38 @@
//! Optional setup for the admin dashboard's embedded metrics dashboards.
//!
//! Reads the `DATABOX_DASHBOARD_*` env vars. Each is independent and
//! optional — a missing var means that dashboard is simply not offered to
//! the admin dashboard. Never blocks startup.
use std::env;
use log::info;
use crate::state::server_state::{Dashboards, DataboxDashboards};
/// Build the dashboard configuration from the environment.
pub fn setup_dashboards() -> Dashboards {
let daus_id = read_non_empty_env("DATABOX_DASHBOARD_DAUS");
let daily_generations_id = read_non_empty_env("DATABOX_DASHBOARD_DAILY_GENERATIONS");
info!(
"Databox dashboards configured: daus={}, daily_generations={}",
daus_id.is_some(),
daily_generations_id.is_some(),
);
Dashboards {
databox: DataboxDashboards {
daus_id,
daily_generations_id,
},
}
}
/// Read an env var, treating unset or whitespace-only as absent.
fn read_non_empty_env(name: &str) -> Option<String> {
match env::var(name) {
Ok(value) if !value.trim().is_empty() => Some(value.trim().to_string()),
_ => None,
}
}
@@ -114,6 +114,10 @@ pub struct ServerState {
pub google_sign_in_cert: GoogleSignInCert,
pub temp_dir_creator: ScopedTempDirCreator,
/// Embedded metrics dashboards for the admin dashboard, configured from
/// optional env vars at startup.
pub dashboards: Dashboards,
}
#[derive(Clone)]
@@ -276,3 +280,17 @@ pub struct TrollBans {
pub struct ResendData {
pub api_key: String,
}
/// Embedded metrics dashboards surfaced to admins/mods.
#[derive(Clone)]
pub struct Dashboards {
pub databox: DataboxDashboards,
}
/// Databox datawall IDs. Each is `None` when its env var isn't configured,
/// in which case the dashboard is simply omitted from the moderation API.
#[derive(Clone)]
pub struct DataboxDashboards {
pub daus_id: Option<String>,
pub daily_generations_id: Option<String>,
}
@@ -57,6 +57,12 @@ export interface UserReferralListItem {
maybe_referral_code_token: string | null;
}
/** A Databox datawall configured server-side for the admin home page. */
export interface DataboxDashboard {
name: string;
id: string;
}
/** Which table the customer id was found in. */
export type ModeratorStripeCustomerIdSource = "customer_link" | "subscription";
@@ -475,6 +481,25 @@ export class ModerationApi extends ApiManager {
}));
}
// Databox metrics dashboards configured server-side, in display order.
// Dashboards without a configured id are omitted by the server.
public async ListDataboxDashboards(): Promise<
ApiResponse<DataboxDashboard[]>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/dashboards/databox`;
return await this.get<{
success: boolean;
databoards: DataboxDashboard[];
error_message?: string;
}>({ endpoint })
.then((response) => ({
success: response.success,
data: response.databoards || [],
errorMessage: response.error_message,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
public async SendAlert(params: {
title?: string | null;
description?: string | null;
@@ -120,9 +120,7 @@ export function MediaCard({
{/* Top-left: media class + upload indicator */}
<div className="absolute top-1.5 left-1.5 z-10 flex items-center gap-1 flex-wrap">
<span className="px-2 py-1 rounded-md bg-black/40 text-[10px] font-medium text-white backdrop-blur-sm uppercase">
{mediaClass === "dimensional" || mediaClass === "mesh" || mediaClass === "splat"
? "3D"
: mediaClass || "Unknown"}
{mediaClass === "dimensional" ? "3D" : mediaClass || "Unknown"}
</span>
{isUserUpload && (
<span
@@ -1,78 +1,31 @@
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { IconChartBar, IconDashboard, IconWorld } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { IconDashboard } from "@tabler/icons-react";
import { ModerationApi, type DataboxDashboard } from "@/api/ModerationApi";
import { Skeleton } from "@/components/ui/skeleton";
import { usePageTitle } from "@/hooks/usePageTitle";
const activeUsersData = [
{ date: "02/05", activeUsers: 812 },
{ date: "02/06", activeUsers: 945 },
{ date: "02/07", activeUsers: 1023 },
{ date: "02/08", activeUsers: 876 },
{ date: "02/09", activeUsers: 654 },
{ date: "02/10", activeUsers: 598 },
{ date: "02/11", activeUsers: 901 },
{ date: "02/12", activeUsers: 1102 },
{ date: "02/13", activeUsers: 1045 },
{ date: "02/14", activeUsers: 978 },
{ date: "02/15", activeUsers: 1134 },
{ date: "02/16", activeUsers: 756 },
{ date: "02/17", activeUsers: 689 },
{ date: "02/18", activeUsers: 1023 },
{ date: "02/19", activeUsers: 1187 },
{ date: "02/20", activeUsers: 1056 },
{ date: "02/21", activeUsers: 998 },
{ date: "02/22", activeUsers: 1210 },
{ date: "02/23", activeUsers: 834 },
{ date: "02/24", activeUsers: 712 },
{ date: "02/25", activeUsers: 967 },
{ date: "02/26", activeUsers: 1089 },
{ date: "02/27", activeUsers: 1145 },
{ date: "02/28", activeUsers: 1034 },
{ date: "03/01", activeUsers: 1267 },
{ date: "03/02", activeUsers: 890 },
{ date: "03/03", activeUsers: 756 },
{ date: "03/04", activeUsers: 1123 },
{ date: "03/05", activeUsers: 1198 },
{ date: "03/06", activeUsers: 704 },
];
const countryData = [
{ country: "United States", activeUsers: 704 },
{ country: "India", activeUsers: 105 },
{ country: "Canada", activeUsers: 100 },
{ country: "China", activeUsers: 74 },
{ country: "Brazil", activeUsers: 59 },
{ country: "Argentina", activeUsers: 57 },
{ country: "Australia", activeUsers: 51 },
];
const activeUsersConfig = {
activeUsers: {
label: "Active Users",
color: "hsl(221, 83%, 53%)",
},
} satisfies ChartConfig;
const countryConfig = {
activeUsers: {
label: "Active Users",
color: "hsl(142, 71%, 45%)",
},
} satisfies ChartConfig;
const totalActiveUsers = activeUsersData.reduce(
(sum, d) => sum + d.activeUsers,
0,
);
function databoxEmbedUrl(id: string): string {
return `https://app.databox.com/datawall/${encodeURIComponent(id)}?i`;
}
export function DashboardHome() {
usePageTitle("Dashboard");
const [databoards, setDataboards] = useState<DataboxDashboard[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const api = new ModerationApi();
api.ListDataboxDashboards().then((resp) => {
if (cancelled) return;
setDataboards(resp.success && resp.data ? resp.data : []);
setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
@@ -83,87 +36,27 @@ export function DashboardHome() {
<p className="text-muted-foreground">Welcome to the admin dashboard.</p>
</div>
<div className="grid gap-4 lg:grid-cols-5">
{/* Active Users Over Time */}
<Card className="lg:col-span-3">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="flex items-center gap-2">
<IconChartBar className="size-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">
Active Users (DUMMY CONTENT RIGHT NOW)
</CardTitle>
</div>
<span className="text-2xl font-bold tabular-nums">
{totalActiveUsers.toLocaleString()}
</span>
</CardHeader>
<CardContent>
<ChartContainer
config={activeUsersConfig}
className="aspect-auto h-[300px] w-full"
{isLoading ? (
<Skeleton className="w-full rounded-xl" style={{ paddingTop: "63%" }} />
) : (
databoards.map((databoard) => (
<div key={databoard.id} className="flex flex-col gap-2">
<h2 className="text-lg font-semibold">{databoard.name}</h2>
<div
className="relative w-full overflow-hidden rounded-xl border"
style={{ paddingTop: "63%" }}
>
<BarChart data={activeUsersData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
interval="preserveStartEnd"
/>
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip
content={<ChartTooltipContent hideLabel={false} />}
/>
<Bar
dataKey="activeUsers"
fill="var(--color-activeUsers)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
{/* Users by Country */}
<Card className="lg:col-span-2">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<IconWorld className="size-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">
Users by Country (DUMMY CONTENT RIGHT NOW)
</CardTitle>
<iframe
src={databoxEmbedUrl(databoard.id)}
title={databoard.name}
className="absolute inset-0 h-full w-full"
frameBorder="0"
allowFullScreen
/>
</div>
</CardHeader>
<CardContent>
<ChartContainer
config={countryConfig}
className="aspect-auto h-[300px] w-full"
>
<BarChart data={countryData} layout="vertical">
<CartesianGrid horizontal={false} />
<XAxis type="number" tickLine={false} axisLine={false} />
<YAxis
type="category"
dataKey="country"
tickLine={false}
axisLine={false}
tickMargin={8}
width={100}
/>
<ChartTooltip
content={<ChartTooltipContent hideLabel={false} />}
/>
<Bar
dataKey="activeUsers"
fill="var(--color-activeUsers)"
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
</div>
</div>
))
)}
</div>
);
}
@@ -26,9 +26,8 @@ const MEDIA_CLASS_OPTIONS = [
{ value: "", label: "All" },
{ value: "image", label: "Image" },
{ value: "video", label: "Video" },
// "dimensional" is the deprecated pre-split class; rows persist until the
// backfill lands, so the 3D filter matches all three classes.
{ value: "mesh,splat,dimensional", label: "3D" },
{ value: "mesh", label: "Meshes" },
{ value: "splat", label: "Splats" },
] as const;
const PAGE_SIZE = 24;
@@ -20,7 +20,7 @@ import {
import { toast } from "../toast/toast";
import { v4 as uuidv4 } from "uuid";
import { Button } from "@storyteller/ui-button";
import { useCharactersStore } from "./characters-store";
import { useCharactersStore } from "@storyteller/ui-promptbox";
interface CharactersModalProps {
isOpen: boolean;
@@ -10,6 +10,11 @@ import {
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { UploaderStates } from "@storyteller/common";
import {
AUDIO_FILE_ACCEPT,
AUDIO_FILE_TYPE_ERROR,
isAudioFile,
} from "@storyteller/ui-promptbox";
import { toast } from "../toast/toast";
import { AddButton } from "./ImagePromptRow";
import type { RefVideo, RefAudio } from "./types";
@@ -137,6 +142,10 @@ export const MediaReferenceRow = ({
const baseAudios = [...referenceAudios];
const file = files[0];
if (!isAudioFile(file)) {
toast.error(AUDIO_FILE_TYPE_ERROR);
return;
}
const duration = await getAudioDuration(file);
if (duration <= 0) {
@@ -208,7 +217,7 @@ export const MediaReferenceRow = ({
type="file"
ref={audioInputRef}
className="hidden"
accept="audio/*"
accept={AUDIO_FILE_ACCEPT}
onChange={handleAudioUpload}
/>
<div
@@ -1,693 +0,0 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { twMerge } from "tailwind-merge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUserGroup } from "@fortawesome/pro-solid-svg-icons";
import { faVideo, faMusic } from "@fortawesome/pro-regular-svg-icons";
import type { MentionItem } from "./types";
interface MentionTextareaProps {
value: string;
onChange: (value: string) => void;
mentionItems: MentionItem[];
placeholder?: string;
className?: string;
/** Inline style applied to the editable element (e.g. a maxHeight cap or
* resize override for the fullscreen modal). */
style?: React.CSSProperties;
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
onFocus?: () => void;
onBlur?: () => void;
disabled?: boolean;
colorMap: Record<string, string>;
}
interface MentionState {
isOpen: boolean;
triggerIndex: number;
query: string;
activeIndex: number;
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function escapeHTML(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ---------------------------------------------------------------------------
// Cursor helpers for contentEditable
// ---------------------------------------------------------------------------
function getCaretOffset(el: HTMLElement): number {
try {
const sel = window.getSelection();
if (!sel?.rangeCount || !sel.anchorNode || !el.contains(sel.anchorNode)) {
return 0;
}
const anchorNode = sel.anchorNode;
const anchorOffset = sel.anchorOffset;
let offset = 0;
if (anchorNode === el) {
for (let i = 0; i < anchorOffset; i++) {
const child = el.childNodes[i];
if (!child) break;
if (child.nodeType === Node.TEXT_NODE) {
offset += child.textContent?.length ?? 0;
} else if (child.nodeName === "BR") {
offset += 1;
} else {
offset += child.textContent?.length ?? 0;
}
}
return offset;
}
function countBefore(parent: Node): boolean {
for (const child of Array.from(parent.childNodes)) {
if (child === anchorNode) {
if (child.nodeType === Node.TEXT_NODE) {
offset += anchorOffset;
}
return true;
}
if (child.contains(anchorNode)) {
return countBefore(child);
}
if (child.nodeType === Node.TEXT_NODE) {
offset += child.textContent?.length ?? 0;
} else if (child.nodeName === "BR") {
offset += 1;
} else {
offset += child.textContent?.length ?? 0;
}
}
return false;
}
countBefore(el);
return offset;
} catch {
return 0;
}
}
function setCaretOffset(el: HTMLElement, offset: number) {
try {
let remaining = offset;
function findPosition(parent: Node): boolean {
for (let i = 0; i < parent.childNodes.length; i++) {
const child = parent.childNodes[i];
if (child.nodeType === Node.TEXT_NODE) {
const len = child.textContent?.length ?? 0;
if (remaining <= len) {
const sel = window.getSelection();
const range = document.createRange();
range.setStart(child, remaining);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
return true;
}
remaining -= len;
} else if (child.nodeName === "BR") {
if (remaining === 0) {
const sel = window.getSelection();
const range = document.createRange();
range.setStart(parent, i);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
return true;
}
remaining -= 1;
} else if (child.nodeType === Node.ELEMENT_NODE) {
if (findPosition(child)) {
return true;
}
}
}
return false;
}
if (!findPosition(el)) {
const sel = window.getSelection();
if (sel) {
sel.selectAllChildren(el);
sel.collapseToEnd();
}
}
} catch {
// DOM changed during caret restore
}
}
function scrollCaretIntoView(el: HTMLElement) {
try {
const sel = window.getSelection();
if (!sel?.rangeCount || !el.contains(sel.anchorNode)) return;
const range = sel.getRangeAt(0).cloneRange();
range.collapse(false);
const span = document.createElement("span");
span.textContent = "\u200B";
range.insertNode(span);
const spanRect = span.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
if (spanRect.bottom > elRect.bottom) {
el.scrollTop += spanRect.bottom - elRect.bottom;
} else if (spanRect.top < elRect.top) {
el.scrollTop -= elRect.top - spanRect.top;
}
const parent = span.parentNode;
if (parent) {
const next = span.nextSibling;
parent.removeChild(span);
const restored = document.createRange();
if (next) {
restored.setStartBefore(next);
} else if (parent.lastChild) {
restored.setStartAfter(parent.lastChild);
} else {
restored.selectNodeContents(parent);
}
restored.collapse(true);
sel.removeAllRanges();
sel.addRange(restored);
}
} catch {
// DOM changed during measurement
}
}
// ---------------------------------------------------------------------------
// Mention Dropdown
// ---------------------------------------------------------------------------
function MentionDropdown({
items,
activeIndex,
onSelect,
onHover,
position,
}: {
items: MentionItem[];
activeIndex: number;
onSelect: (item: MentionItem) => void;
onHover: (index: number) => void;
position: { left: number; bottom: number };
}) {
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const list = listRef.current;
if (!list) return;
const activeItem = list.children[1]?.children[activeIndex] as HTMLElement;
activeItem?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
return (
<div
ref={listRef}
className="absolute z-50 mb-1 w-64 max-h-72 overflow-y-auto rounded-lg border border-white/10 bg-[#2a2a2e] shadow-lg backdrop-blur-xl"
style={{ left: position.left, bottom: position.bottom }}
>
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-white/50">
Mentions
</div>
<div>
{items.map((item, i) => (
<button
key={item.label}
type="button"
className={twMerge(
"flex w-full items-center gap-2.5 px-3 py-2 text-sm text-white transition-colors cursor-pointer",
i === activeIndex ? "bg-white/10" : "hover:bg-white/5",
)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(item);
}}
onMouseEnter={() => onHover(i)}
>
<div className="h-8 w-8 flex-shrink-0 overflow-hidden rounded-md border border-white/20 flex items-center justify-center bg-black/20">
{(item.type === "character" || item.type === "image") && item.preview ? (
<img
src={item.preview}
alt={item.label}
className="h-full w-full object-cover"
/>
) : item.type === "character" ? (
<FontAwesomeIcon
icon={faUserGroup}
className="h-3.5 w-3.5 text-white/60"
/>
) : item.type === "video" && item.preview ? (
<video
src={item.preview}
muted
preload="metadata"
className="h-full w-full object-cover"
/>
) : (
<FontAwesomeIcon
icon={item.type === "video" ? faVideo : faMusic}
className="h-3.5 w-3.5 text-white/60"
/>
)}
</div>
<span className="font-medium">{item.label}</span>
</button>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// MentionTextarea Component
// ---------------------------------------------------------------------------
export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(
function MentionTextarea(
{
value,
onChange,
mentionItems,
placeholder,
className,
style,
onKeyDown: externalOnKeyDown,
onFocus,
onBlur,
disabled,
colorMap,
},
ref,
) {
const editorRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => editorRef.current!, []);
const isInternalUpdate = useRef(false);
const isComposing = useRef(false);
const pendingCaret = useRef<number | null>(null);
const [mentionState, setMentionState] = useState<MentionState>({
isOpen: false,
triggerIndex: -1,
query: "",
activeIndex: 0,
});
const [dropdownPos, setDropdownPos] = useState<{
left: number;
bottom: number;
}>({ left: 0, bottom: 0 });
const filteredItems = useMemo(() => {
if (!mentionState.isOpen) return [];
return mentionItems.filter((item) =>
mentionState.query
? item.label.toLowerCase().includes(mentionState.query.toLowerCase())
: true,
);
}, [mentionItems, mentionState.isOpen, mentionState.query]);
// Build a regex that matches any known mention label. Case-insensitive so
// a typed `@image1` still highlights when the canonical label is `@Image1`.
// Sort longest-first so `@Pumpkin Head` matches before `@Pumpkin`.
const mentionRegex = useMemo(() => {
const labels = mentionItems.map((item) => item.label);
if (labels.length === 0) return null;
const sorted = [...labels].sort((a, b) => b.length - a.length);
const pattern = sorted.map((l) => escapeRegex(l)).join("|");
return new RegExp(`(${pattern})`, "gi");
}, [mentionItems]);
// Case-insensitive colorMap lookup: "@image1" finds "@Image1".
const lowerColorMap = useMemo(() => {
const m: Record<string, string> = {};
for (const [k, v] of Object.entries(colorMap)) {
m[k.toLowerCase()] = v;
}
return m;
}, [colorMap]);
// Build innerHTML with colored @mentions inline
const buildHTML = useCallback(
(text: string): string => {
if (!text) return "";
if (!mentionRegex) {
let html = escapeHTML(text);
html = html.replace(/\n/g, "<br>");
if (html.endsWith("<br>")) html += "<br>";
return html;
}
let html = "";
let lastIndex = 0;
const regex = new RegExp(mentionRegex);
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
const fullMatch = match[0];
const color = lowerColorMap[fullMatch.toLowerCase()];
if (match.index > lastIndex) {
html += escapeHTML(text.slice(lastIndex, match.index));
}
if (color) {
html += `<span style="color:${color}">${escapeHTML(fullMatch)}</span>`;
} else {
html += escapeHTML(fullMatch);
}
lastIndex = match.index + fullMatch.length;
}
if (lastIndex < text.length) {
html += escapeHTML(text.slice(lastIndex));
}
html = html.replace(/\n/g, "<br>");
if (html.endsWith("<br>")) {
html += "<br>";
}
return html;
},
[lowerColorMap, mentionRegex],
);
// Sync DOM when value changes from parent
useEffect(() => {
if (isInternalUpdate.current) {
isInternalUpdate.current = false;
return;
}
const el = editorRef.current;
if (!el) return;
// This effect re-runs whenever `buildHTML`'s identity changes (the parent
// recomputing `colorMap`/`mentionItems`), not only when `value` genuinely
// changes from outside. If the DOM already shows this exact text while the
// user is focused, skip the innerHTML rewrite entirely: reassigning
// innerHTML under a live caret detaches it on iOS Safari, which makes the
// cursor disappear mid-typing. A pending caret (mention insert) must still
// resync, and an unfocused editor is safe to recolor.
if (pendingCaret.current === null && document.activeElement === el) {
let domText = el.innerText;
if (domText.endsWith("\n")) domText = domText.slice(0, -1);
if (domText === value) return;
}
const sel = window.getSelection();
if (document.activeElement === el && sel && !sel.isCollapsed) return;
try {
const caret = pendingCaret.current ?? getCaretOffset(el);
el.innerHTML = buildHTML(value);
if (pendingCaret.current !== null) {
setCaretOffset(el, pendingCaret.current);
pendingCaret.current = null;
} else if (document.activeElement === el) {
setCaretOffset(el, caret);
}
} catch {
el.innerHTML = buildHTML(value);
}
}, [value, buildHTML]);
// Get pixel coordinates of a text offset relative to the wrapper
const getOffsetRect = useCallback((charOffset: number) => {
try {
const el = editorRef.current;
if (!el) return null;
const saved = getCaretOffset(el);
setCaretOffset(el, charOffset);
const sel = window.getSelection();
if (!sel?.rangeCount) {
setCaretOffset(el, saved);
return null;
}
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
const wrapperRect = el.parentElement!.getBoundingClientRect();
setCaretOffset(el, saved);
return {
left: rect.left - wrapperRect.left,
bottom: wrapperRect.bottom - rect.top,
};
} catch {
return null;
}
}, []);
// Detect @mention trigger from cursor position. The `@` is a valid mention
// trigger when it follows anything that isn't a word character — that covers
// whitespace, punctuation, CJK/Chinese text, emoji, line starts, etc. Only
// reject it when it's sitting mid-word after an alphanumeric/underscore.
const detectMention = useCallback(
(text: string, cursorPos: number) => {
const textBefore = text.slice(0, cursorPos);
const lastAt = textBefore.lastIndexOf("@");
if (
lastAt !== -1 &&
(lastAt === 0 || !/[A-Za-z0-9_]/.test(text[lastAt - 1]))
) {
const query = text.slice(lastAt, cursorPos);
if (!query.includes("\n")) {
const pos = getOffsetRect(lastAt);
if (pos) setDropdownPos(pos);
setMentionState({
isOpen: true,
triggerIndex: lastAt,
query,
activeIndex: 0,
});
return;
}
}
setMentionState((prev) =>
prev.isOpen ? { ...prev, isOpen: false } : prev,
);
},
[getOffsetRect],
);
const handleInput = useCallback(() => {
if (isComposing.current) return;
const el = editorRef.current;
if (!el) return;
try {
let text = el.innerText;
if (text.endsWith("\n")) {
text = text.slice(0, -1);
}
const caret = getCaretOffset(el);
const html = buildHTML(text);
if (el.innerHTML !== html) {
el.innerHTML = html;
setCaretOffset(el, caret);
}
isInternalUpdate.current = true;
onChange(text);
detectMention(text, caret);
requestAnimationFrame(() => {
scrollCaretIntoView(el);
});
} catch {
const text = el.innerText?.replace(/\n$/, "") ?? "";
isInternalUpdate.current = true;
onChange(text);
}
}, [onChange, buildHTML, detectMention]);
const handleCompositionStart = useCallback(() => {
isComposing.current = true;
}, []);
const handleCompositionEnd = useCallback(() => {
isComposing.current = false;
handleInput();
}, [handleInput]);
// Select a mention from the dropdown
const handleSelect = useCallback(
(item: MentionItem) => {
const el = editorRef.current;
if (!el) return;
// Use the known mention bounds — `getCaretOffset` can return 0 when the
// dropdown click collapses the selection out of the editor, which would
// leave the typed query in the prompt alongside the inserted label.
const queryEnd =
mentionState.triggerIndex + mentionState.query.length;
const before = value.slice(0, mentionState.triggerIndex);
const after = value.slice(queryEnd);
const mention = `${item.label} `;
const newValue = before + mention + after;
pendingCaret.current = before.length + mention.length;
setMentionState({
isOpen: false,
triggerIndex: -1,
query: "",
activeIndex: 0,
});
onChange(newValue);
requestAnimationFrame(() => {
el.focus();
});
},
[value, mentionState.triggerIndex, mentionState.query, onChange],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (mentionState.isOpen && filteredItems.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setMentionState((prev) => ({
...prev,
activeIndex: Math.min(
prev.activeIndex + 1,
filteredItems.length - 1,
),
}));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setMentionState((prev) => ({
...prev,
activeIndex: Math.max(prev.activeIndex - 1, 0),
}));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
handleSelect(filteredItems[mentionState.activeIndex]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setMentionState((prev) => ({ ...prev, isOpen: false }));
return;
}
}
if (e.key === "Enter") {
e.preventDefault();
document.execCommand("insertLineBreak");
handleInput();
scrollCaretIntoView(editorRef.current!);
return;
}
externalOnKeyDown?.(e);
},
[
mentionState.isOpen,
mentionState.activeIndex,
filteredItems,
handleSelect,
externalOnKeyDown,
handleInput,
],
);
const handleClick = useCallback(() => {
const el = editorRef.current;
if (!el) return;
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return;
detectMention(value, getCaretOffset(el));
}, [value, detectMention]);
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLDivElement>) => {
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
document.execCommand("insertText", false, text);
},
[],
);
return (
<div className="relative flex-1 min-w-0 pb-[7px]">
{!value && placeholder && (
<div
className={twMerge(
className,
"absolute inset-0 pointer-events-none text-white/50 z-[1]",
)}
>
{placeholder}
</div>
)}
<div
ref={editorRef}
contentEditable={!disabled}
onInput={handleInput}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
onClick={handleClick}
onPaste={handlePaste}
onFocus={onFocus}
onBlur={onBlur}
style={style}
className={twMerge(
className,
"outline-none whitespace-pre-wrap break-words overflow-y-auto resize-y",
)}
/>
{mentionState.isOpen && filteredItems.length > 0 && (
<MentionDropdown
items={filteredItems}
activeIndex={mentionState.activeIndex}
onSelect={handleSelect}
onHover={(i) =>
setMentionState((prev) => ({ ...prev, activeIndex: i }))
}
position={dropdownPos}
/>
)}
</div>
);
},
);
@@ -20,21 +20,22 @@ import { GenerateIconButton } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
KeyframeCards,
MentionTextarea,
PromptClearAllButton,
ReferenceDeck,
buildMentionColorMap,
getMentionColor,
useDeckMedia,
type DeckAddAction,
type DeckItem,
} from "@storyteller/ui-promptbox";
import { arrayMove } from "@dnd-kit/sortable";
import { MentionTextarea } from "./MentionTextarea";
import {
PromptBoxDropOverlay,
usePromptBoxDrop,
type DroppedFiles,
} from "./PromptBoxDropZone";
import { toast } from "../toast/toast";
import { getMentionColor, buildMentionColorMap } from "./mention-colors";
import { uploadImage } from "./upload-image";
import { uploadVideo, uploadAudio } from "./upload-media";
import type { RefImage, RefVideo, RefAudio, MentionItem } from "./types";
@@ -114,6 +115,14 @@ interface PromptBoxProps {
// @-mention support (enables colored prompt overlay + autocomplete)
mentionItems?: MentionItem[];
// Records which character token a mention name refers to (dropdown pick or
// chip-menu replace) — needed because several characters can share a name.
onMentionSelect?: (item: MentionItem) => void;
// name (without "@") -> character token; picks which character's thumbnail
// renders in a mention chip when labels collide.
mentionSelections?: Record<string, string>;
// Soft prompt-length limit from the model API (`text_prompt_max_length`).
// Undefined = unlimited (no counter). The limit is not enforced here; the
// page's submit handler blocks generation when over.
@@ -162,6 +171,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
modelSelector,
secondaryPromptRow,
mentionItems,
onMentionSelect,
mentionSelections,
maxPromptLength,
},
ref,
@@ -789,10 +800,13 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
mentionItems={mentionItems}
placeholder={placeholder}
className={twMerge(
"promptbox-scrollbar min-h-[2.5em] w-full pr-8 text-base-fg placeholder-base-fg/60",
"promptbox-scrollbar min-h-[2.5em] w-full resize-y pr-8 text-base-fg placeholder-base-fg/60",
isExpanded ? "max-h-[500px]" : "max-h-[5.5em]",
)}
colorMap={mentionColorMap}
enterToGenerate={enterToGenerate}
onMentionSelect={onMentionSelect}
selectedTokens={mentionSelections}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
@@ -1009,6 +1023,9 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
className="promptbox-scrollbar h-full min-h-0 w-full overflow-y-auto text-base-fg placeholder-base-fg/60"
style={{ resize: "none" }}
colorMap={mentionColorMap}
enterToGenerate={enterToGenerate}
onMentionSelect={onMentionSelect}
selectedTokens={mentionSelections}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
@@ -1,33 +0,0 @@
import { create } from "zustand";
export interface StoredCharacter {
character_token: string;
name: string;
avatar_image_url?: string;
}
interface CharactersStore {
characters: StoredCharacter[];
loaded: boolean;
setCharacters: (characters: StoredCharacter[]) => void;
updateCharacter: (token: string, updates: Partial<StoredCharacter>) => void;
removeCharacter: (token: string) => void;
setLoaded: (loaded: boolean) => void;
}
export const useCharactersStore = create<CharactersStore>()((set) => ({
characters: [],
loaded: false,
setCharacters: (characters) => set({ characters }),
updateCharacter: (token, updates) =>
set((state) => ({
characters: state.characters.map((c) =>
c.character_token === token ? { ...c, ...updates } : c,
),
})),
removeCharacter: (token) =>
set((state) => ({
characters: state.characters.filter((c) => c.character_token !== token),
})),
setLoaded: (loaded) => set({ loaded }),
}));
@@ -2,8 +2,8 @@ export { PromptBox } from "./PromptBox";
export { ImagePromptRow, AddButton } from "./ImagePromptRow";
export { MediaReferenceRow } from "./MediaReferenceRow";
export { CharactersModal } from "./CharactersModal";
export { useCharactersStore } from "./characters-store";
export type { StoredCharacter } from "./characters-store";
export { useCharactersStore } from "@storyteller/ui-promptbox";
export type { StoredCharacter } from "@storyteller/ui-promptbox";
export type { RefImage, RefVideo, RefAudio, MentionItem } from "./types";
export {
getAudioDurationFromUrl,
@@ -1,9 +1,11 @@
import { type ReactNode, useMemo } from "react";
import { GenerateButton } from "@storyteller/ui-button";
import { MentionTextarea } from "../MentionTextarea";
import { buildMentionColorMap } from "../mention-colors";
import {
MentionTextarea,
buildMentionColorMap,
type MentionItem,
} from "@storyteller/ui-promptbox";
import { useMobileCreateTabs } from "../../generation-gallery/mobile-create-tabs";
import type { MentionItem } from "../types";
interface MobilePromptFormProps {
prompt: string;
@@ -14,6 +16,8 @@ interface MobilePromptFormProps {
submitLabel?: string;
credits?: number | null;
mentionItems?: MentionItem[];
onMentionSelect?: (item: MentionItem) => void;
mentionSelections?: Record<string, string>;
// Whether tapping Create will actually start a generation (logged in, prompt
// present, no missing required input). Gates the auto-switch to History.
autoAdvance?: boolean;
@@ -38,6 +42,8 @@ export function MobilePromptForm({
submitLabel = "Create",
credits,
mentionItems,
onMentionSelect,
mentionSelections,
autoAdvance = true,
banner,
inputModeSelector,
@@ -79,6 +85,10 @@ export function MobilePromptForm({
colorMap={colorMap}
placeholder={placeholder}
className="min-h-[120px] max-h-[40vh] w-full text-md text-base-fg"
// Mobile has no Enter-submit; Enter always inserts a newline.
enterToGenerate={false}
onMentionSelect={onMentionSelect}
selectedTokens={mentionSelections}
/>
</div>
@@ -23,8 +23,4 @@ export interface RefAudio {
duration: number;
}
export interface MentionItem {
label: string;
type: "image" | "video" | "audio" | "character";
preview?: string;
}
export type { MentionItem } from "@storyteller/ui-promptbox";
+11 -1
View File
@@ -6,6 +6,7 @@ import App from "./app/app";
import { StorytellerApiHostStore, UsersApi } from "@storyteller/api";
import { captureLandingContext, getReferrer } from "@storyteller/common";
import { setOmniGenErrorNotifier } from "@storyteller/omni-gen";
import { setToastDelegate } from "@storyteller/ui-toaster";
import { toast } from "./components/toast/toast";
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
@@ -24,7 +25,7 @@ if (import.meta.env.DEV) {
// (disabled — it overrides the origin above and points at localhost:12345,
// which breaks dev unless a local backend is running. Re-enable only when
// testing against a local storyteller-web.)
StorytellerApiHostStore.getInstance().setDevelopment();
//StorytellerApiHostStore.getInstance().setDevelopment();
} catch (e) {
console.warn("Failed to set dev API host override", e);
}
@@ -33,6 +34,15 @@ if (import.meta.env.DEV) {
// Surface omni model/generation outages through this app's toast component.
setOmniGenErrorNotifier((message) => toast.error(message));
// Shared libs (promptbox deck, gallery modal, …) fire react-hot-toast toasts,
// but this app renders its own ToastContainer and never mounts the
// react-hot-toast container — route those toasts here so limit/validation
// errors (e.g. "audio too long") actually show up.
setToastDelegate({
success: (message) => toast.success(message),
error: (message) => toast.error(message),
});
// Persist landing context (referral username, landing URL, referrer) to apex-
// domain cookies so attribution survives the getartcraft.com →
// app.getartcraft.com hop. First visit wins.
@@ -33,6 +33,7 @@ import {
type RefVideo,
type RefAudio,
type MentionItem,
type StoredCharacter,
} from "../../components/prompt-box";
import {
GenerationGallery,
@@ -443,6 +444,19 @@ export default function CreateVideo() {
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
// Mentions are plain text: with several characters sharing a name,
// "@Robot" alone can't identify one. Records which token the user actually
// picked (dropdown, modal, or chip-menu replace), keyed by name.
const [mentionSelections, setMentionSelections] = useState<
Record<string, string>
>({});
const handleMentionSelect = useCallback((item: MentionItem) => {
if (item.type !== "character" || !item.token) return;
const name = item.label.replace(/^@/, "");
setMentionSelections((prev) => ({ ...prev, [name]: item.token! }));
}, []);
// Load characters on mount if not already loaded
useEffect(() => {
if (charactersLoaded) return;
@@ -456,6 +470,7 @@ export default function CreateVideo() {
character_token: c.token,
name: c.name,
avatar_image_url: c.maybe_avatar?.cdn_url,
full_image_url: c.maybe_full_image?.cdn_url,
})),
);
}
@@ -601,6 +616,8 @@ export default function CreateVideo() {
label: `@${char.name}`,
type: "character" as const,
preview: char.avatar_image_url,
token: char.character_token,
fullPreview: char.full_image_url ?? char.avatar_image_url,
}));
return [...refItems, ...charItems];
}, [
@@ -1207,27 +1224,35 @@ export default function CreateVideo() {
.filter((t): t is string => typeof t === "string" && t.length > 0)
: undefined;
// Extract character tokens from @-mentions in the prompt. Match longest
// names first and require a non-word boundary after so `@Bob` doesn't
// false-match inside `@Bobby`, and only pick up characters that still
// exist in the current store (stale names are ignored).
const mentionedCharacters = (() => {
// Extract character tokens from @-mentions in the prompt, resolving to
// exactly one token per mentioned name. Match longest names first and
// require a non-word boundary after so `@Bob` doesn't false-match inside
// `@Bobby`, and only pick up characters that still exist in the current
// store (stale names are ignored). Several characters can share a name;
// prefer the user's explicit pick (mentionSelections), else the newest
// (store is newest-first).
const mentionedTokens = (() => {
if (activeCharacters.length === 0) return [];
const sorted = [...activeCharacters].sort(
(a, b) => b.name.length - a.name.length,
);
const matched = new Set<string>();
for (const c of sorted) {
const escaped = c.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`@${escaped}(?![\\w])`);
if (regex.test(prompt)) matched.add(c.character_token);
const byName = new Map<string, StoredCharacter[]>();
for (const c of activeCharacters) {
byName.set(c.name, [...(byName.get(c.name) ?? []), c]);
}
return activeCharacters.filter((c) => matched.has(c.character_token));
const names = [...byName.keys()].sort((a, b) => b.length - a.length);
const tokens: string[] = [];
for (const name of names) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (!new RegExp(`@${escaped}(?![\\w])`).test(prompt)) continue;
const candidates = byName.get(name)!;
const chosen =
candidates.find(
(c) => c.character_token === mentionSelections[name],
) ?? candidates[0];
tokens.push(chosen.character_token);
}
return tokens;
})();
const referenceCharacterTokens =
mentionedCharacters.length > 0
? mentionedCharacters.map((c) => c.character_token)
: undefined;
mentionedTokens.length > 0 ? mentionedTokens : undefined;
const baseParams = {
prompt: prompt.trim(),
@@ -1402,6 +1427,8 @@ export default function CreateVideo() {
credits={estimatedCredits}
placeholder={promptPlaceholder}
mentionItems={mentionItems.length > 0 ? mentionItems : undefined}
onMentionSelect={handleMentionSelect}
mentionSelections={mentionSelections}
autoAdvance={loggedIn && !!prompt.trim() && !isGenerating && !needsImage}
banner={
requiresImageInput ? (
@@ -1727,6 +1754,8 @@ export default function CreateVideo() {
})
}
mentionItems={mentionItems.length > 0 ? mentionItems : undefined}
onMentionSelect={handleMentionSelect}
mentionSelections={mentionSelections}
videoRefsSupported={supportsVideoRefs}
referenceVideos={referenceVideos}
onReferenceVideosChange={setReferenceVideos}
@@ -1956,6 +1985,10 @@ export default function CreateVideo() {
const spaceBefore =
prompt.length > 0 && !prompt.endsWith(" ") ? " " : "";
setPrompt(prompt + spaceBefore + mention + " ");
setMentionSelections((prev) => ({
...prev,
[character.name]: character.token,
}));
setIsCharactersModalOpen(false);
}}
/>
@@ -55,15 +55,15 @@
{
"path": "../../libs/components/slider-v2/tsconfig.lib.json"
},
{
"path": "../../libs/components/toaster/tsconfig.lib.json"
},
{
"path": "../../libs/omni-gen/tsconfig.lib.json"
},
{
"path": "../../libs/components/pricing-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/promptbox/tsconfig.lib.json"
},
{
"path": "../../libs/components/model-selector/tsconfig.lib.json"
},
@@ -73,15 +73,18 @@
{
"path": "../../libs/components/pagescene/tsconfig.lib.json"
},
{
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/lightbox-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/gallery-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/switch/tsconfig.lib.json"
},
{
"path": "../../libs/components/promptbox/tsconfig.lib.json"
},
{
"path": "../../libs/components/audio-player/tsconfig.lib.json"
},
+12 -9
View File
@@ -20,15 +20,15 @@
{
"path": "../../libs/components/slider-v2"
},
{
"path": "../../libs/components/toaster"
},
{
"path": "../../libs/omni-gen"
},
{
"path": "../../libs/components/pricing-modal"
},
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/components/model-selector"
},
@@ -38,15 +38,21 @@
{
"path": "../../libs/components/pagescene"
},
{
"path": "../../libs/components/gallery-modal"
},
{
"path": "../../libs/components/lightbox-modal"
},
{
"path": "../../libs/components/gallery-modal"
},
{
"path": "../../libs/components/switch"
},
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/components/audio-player"
},
{
"path": "../../libs/components/viewer-3d"
},
@@ -68,9 +74,6 @@
{
"path": "../../libs/components/generation-list"
},
{
"path": "../../libs/components/audio-player"
},
{
"path": "../../libs/components/tab-selector"
},
@@ -20,6 +20,48 @@ import {
getAudioDuration,
} from "./upload-media";
// iOS Safari has no system audio library, so a bare `accept="audio/*"` falls
// back to the Photos picker (photos and videos only). Concrete MIME types and
// extensions make iOS open the Files picker filtered to audio instead.
const AUDIO_FILE_ACCEPT = [
"audio/mpeg",
"audio/wav",
"audio/x-wav",
"audio/mp4",
"audio/x-m4a",
"audio/aac",
"audio/ogg",
"audio/opus",
"audio/flac",
"audio/webm",
".mp3",
".wav",
".m4a",
".aac",
".ogg",
".oga",
".opus",
".flac",
].join(",");
const AUDIO_FILE_EXTENSIONS = new Set([
"mp3",
"wav",
"m4a",
"aac",
"ogg",
"oga",
"opus",
"flac",
]);
// Some platforms report .m4a as `video/mp4`, so check the extension too.
const isAudioFile = (file: File): boolean => {
if (file.type.startsWith("audio/")) return true;
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
return AUDIO_FILE_EXTENSIONS.has(extension);
};
interface MediaReferenceRowProps {
referenceVideos: RefVideo[];
onReferenceVideosChange: (videos: RefVideo[]) => void;
@@ -133,6 +175,10 @@ export const MediaReferenceRow = ({
const baseAudios = [...referenceAudios];
const file = files[0];
if (!isAudioFile(file)) {
toast.error("Please choose an audio file (MP3, WAV, M4A, FLAC…)");
return;
}
const duration = await getAudioDuration(file);
if (duration <= 0) {
@@ -204,7 +250,7 @@ export const MediaReferenceRow = ({
type="file"
ref={audioInputRef}
className="hidden"
accept="audio/*"
accept={AUDIO_FILE_ACCEPT}
onChange={handleAudioUpload}
/>
<div
@@ -7,6 +7,7 @@ import {
UploadModalImage,
UploadModalSplat,
} from "@storyteller/ui-upload-modal";
import { isPromptBoxDropZoneActive } from "@storyteller/ui-promptbox";
import { FilterEngineCategories } from "../../enums";
type ModalType = "3d" | "image" | "splat" | null;
@@ -23,6 +24,15 @@ function isAnyModalOpen(): boolean {
return !!document.querySelector("[data-radix-dialog-content]");
}
/**
* Whole-app drops stand down while a prompt box owns them. On the create
* pages the prompt box is the only drop target (matching the webapp), so a
* drop anywhere else must do nothing rather than open the upload modal.
*/
function isGlobalDropSuppressed(): boolean {
return isAnyModalOpen() || isPromptBoxDropZoneActive();
}
const isTauri = typeof window !== "undefined" && "__TAURI__" in window;
export function GlobalFileDropHandler() {
@@ -50,12 +60,14 @@ export function GlobalFileDropHandler() {
const payload = event.payload;
if (payload.type === "enter") {
if (!isAnyModalOpen()) setIsDragging(true);
if (!isGlobalDropSuppressed()) setIsDragging(true);
} else if (payload.type === "over") {
// overlay stays visible
// The prompt box can mount mid-drag (or the pointer can move
// onto it), so keep re-checking rather than trusting "enter".
if (isGlobalDropSuppressed()) setIsDragging(false);
} else if (payload.type === "drop") {
setIsDragging(false);
if (isAnyModalOpen()) return;
if (isGlobalDropSuppressed()) return;
if (payload.paths.length === 0) return;
@@ -117,7 +129,7 @@ export function GlobalFileDropHandler() {
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
if (!e.dataTransfer?.types.includes("Files")) return;
if (isAnyModalOpen()) return;
if (isGlobalDropSuppressed()) return;
dragCounter.current++;
setIsDragging(true);
};
@@ -135,7 +147,7 @@ export function GlobalFileDropHandler() {
e.preventDefault();
setIsDragging(false);
dragCounter.current = 0;
if (isAnyModalOpen()) return;
if (isGlobalDropSuppressed()) return;
const allFiles = Array.from(e.dataTransfer.files);
if (allFiles.length === 0) return;
@@ -697,3 +697,36 @@ img {
background-size: 200% 100%;
animation: generation-shimmer 1.5s ease-in-out infinite;
}
/* Prompt box drag & drop overlay (mirrors the webapp's styles.css) */
@keyframes promptbox-drop-overlay-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.promptbox-drop-overlay {
animation: promptbox-drop-overlay-in 0.15s ease-out both;
}
@keyframes promptbox-drop-chip-in {
from {
opacity: 0;
translate: 0 6px;
scale: 0.9;
}
to {
opacity: 1;
translate: 0 0;
scale: 1;
}
}
.promptbox-drop-chip {
animation: promptbox-drop-chip-in 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@@ -105,7 +105,13 @@ export const GalleryCard = memo(function GalleryCard({
: isAudio
? faMusic
: faImage;
const mediaLabel = isVideo ? "Video" : is3D ? "3D" : isAudio ? "Audio" : "Image";
const mediaLabel = isVideo
? "Video"
: is3D
? "3D"
: isAudio
? "Audio"
: "Image";
const modelDisplayName = item.modelId
? getModelDisplayName(item.modelId)
: null;
@@ -172,7 +178,10 @@ export const GalleryCard = memo(function GalleryCard({
outside the card's rounded corners without being clipped. */}
<div
className="absolute inset-0 overflow-hidden rounded-[inherit]"
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 200px" }}
style={{
contentVisibility: "auto",
containIntrinsicSize: "auto 200px",
}}
>
{isAudio ? (
<div className="flex h-full flex-col bg-gradient-to-br from-white/[0.08] via-white/[0.04] to-white/[0.02] p-3">
@@ -222,7 +231,7 @@ export const GalleryCard = memo(function GalleryCard({
)}
{/* Hover overlay with media type + model badges and quick actions */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t from-black/70 to-transparent px-2 pb-2 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t rounded-b-lg from-black/70 to-transparent px-2 pb-2 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
<div className="pointer-events-auto flex min-w-0 flex-wrap items-center gap-1.5">
<div className="flex items-center gap-1.5 rounded-lg bg-black/60 px-2.5 py-1 text-xs font-medium text-white/90">
<FontAwesomeIcon icon={mediaIcon} className="text-[10px]" />
@@ -66,6 +66,8 @@ if (typeof window !== "undefined" && !window.__modalEscListenerInstalled) {
window.__modalEscListenerInstalled = true;
window.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key !== "Escape" && e.key !== "Esc") return;
// An open outside-safe panel (mention autocomplete etc.) claims Escape.
if (document.querySelector(OUTSIDE_SAFE_SELECTOR)) return;
const top = getTopEscapableModal();
if (top) {
e.preventDefault();
@@ -77,6 +79,12 @@ if (typeof window !== "undefined" && !window.__modalEscListenerInstalled) {
// Simple global z-index tracker for stacked modals
let modalZCounter = 70;
// Body-portaled companions of modal content (e.g. the prompt box's mention
// autocomplete, which portals to <body> for fixed positioning) mark their
// root with this attribute. Pointer-downs inside them must not count as
// outside clicks, and while one is open it claims Escape before the modal.
const OUTSIDE_SAFE_SELECTOR = "[data-modal-outside-safe]";
const AnimatedBackdrop = ({
styles,
backdropClassName,
@@ -1004,12 +1012,23 @@ export const Modal = ({
? {}
: ({ "aria-describedby": undefined } as any))}
onPointerDownOutside={(e) => {
if (!closeOnOutsideClick || allowBackgroundInteraction) {
const target = e.target as HTMLElement | null;
if (
!closeOnOutsideClick ||
allowBackgroundInteraction ||
target?.closest?.(OUTSIDE_SAFE_SELECTOR)
) {
e.preventDefault();
}
}}
onEscapeKeyDown={(e) => {
if (!closeOnEsc) {
// Radix listens on document in the capture phase, so an
// open outside-safe panel must be checked here too — its
// own Escape handling runs later in the bubble phase.
if (
!closeOnEsc ||
document.querySelector(OUTSIDE_SAFE_SELECTOR)
) {
e.preventDefault();
}
}}
@@ -1,4 +1,5 @@
export * from "./lib/common/AspectRatioIcon";
export * from "./lib/common/audioFiles";
export * from "./lib/PromptBox2D";
export * from "./lib/PromptBox3D";
export * from "./lib/PromptBoxEdit";
@@ -10,6 +11,9 @@ export * from "./lib/PromptFullscreenModal";
export * from "./lib/PromptFullscreenButton";
export * from "./lib/PromptClearAllButton";
export * from "./lib/promptStore";
export * from "./lib/MentionTextarea";
export * from "./lib/MentionChipMenu";
export * from "./lib/mentionColors";
export * from "./lib/PromptBoxErrorBoundary";
export * from "./lib/common/AudioTuningPopover";
export * from "./lib/common/SoundsSettingsPopover";
@@ -19,3 +23,4 @@ export * from "./lib/deck/DeckCard";
export * from "./lib/deck/ReferenceDeck";
export * from "./lib/deck/KeyframeCards";
export * from "./lib/deck/useDeckMedia";
export * from "./lib/deck/usePromptBoxDrop";
@@ -25,6 +25,11 @@ import { RefImage, RefVideo, RefAudio } from "./promptStore";
import { toast } from "@storyteller/ui-toaster";
import { twMerge } from "tailwind-merge";
import { UploaderStates } from "@storyteller/common";
import {
AUDIO_FILE_ACCEPT,
AUDIO_FILE_TYPE_ERROR,
isAudioFile,
} from "./common/audioFiles";
import {
DndContext,
closestCenter,
@@ -500,13 +505,18 @@ export const ImagePromptRow = ({
const files = Array.from(event.target.files || []);
if (files.length === 0) return;
const audioFiles = files.filter(isAudioFile);
if (audioFiles.length < files.length) {
toast.error(AUDIO_FILE_TYPE_ERROR);
}
const availableSlots = Math.max(0, maxAudioCount - referenceAudios.length);
if (availableSlots <= 0) {
if (audioFiles.length === 0 || availableSlots <= 0) {
if (audioFileInputRef.current) audioFileInputRef.current.value = "";
return;
}
const filesToProcess = files.slice(0, availableSlots);
const filesToProcess = audioFiles.slice(0, availableSlots);
for (const file of filesToProcess) {
const duration = await getAudioDuration(file);
@@ -819,7 +829,7 @@ export const ImagePromptRow = ({
type="file"
ref={audioFileInputRef}
className="hidden"
accept="audio/*"
accept={AUDIO_FILE_ACCEPT}
onChange={handleAudioFileUpload}
multiple={maxAudioCount > 1}
/>
@@ -0,0 +1,247 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { twMerge } from "tailwind-merge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowsRotate,
faChevronLeft,
faEye,
faTrashCan,
faUser,
} from "@fortawesome/pro-solid-svg-icons";
import type { MentionItem } from "./MentionTextarea";
const VIEWPORT_MARGIN = 8;
const ANCHOR_GAP = 4;
const MENU_ROW =
"flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm text-base-fg transition-colors hover:bg-ui-controls/60 cursor-pointer";
export interface MentionChipMenuProps {
/** Viewport-space rect of the clicked chip. */
anchorRect: DOMRect;
/** Canonical mention label including the "@", e.g. "@robot cartoon". */
currentLabel: string;
/** Avatar of the currently attached character, shown in the menu header. */
currentPreview?: string;
/** Characters offered as replacements (current one excluded by the caller). */
replaceItems: MentionItem[];
onReplace: (item: MentionItem) => void;
onPreview: () => void;
onRemove: () => void;
onClose: () => void;
}
/**
* Floating menu for an inline character-mention chip: Replace / Preview /
* Remove, with the Replace action swapping to a second "Back" panel listing
* the other available characters.
*
* Portaled to document.body with fixed positioning — the promptbox `.glass`
* container (backdrop-blur) is a containing block that would trap
* `position: fixed` descendants, so the menu must not render inside it.
*/
export function MentionChipMenu({
anchorRect,
currentLabel,
currentPreview,
replaceItems,
onReplace,
onPreview,
onRemove,
onClose,
}: MentionChipMenuProps) {
const panelRef = useRef<HTMLDivElement>(null);
const [view, setView] = useState<"menu" | "replace">("menu");
const [placement, setPlacement] = useState<{
left: number;
top: number;
flippedAbove: boolean;
} | null>(null);
// Drives the same 75ms fade/slide-in our PopoverMenu panels use.
const [entered, setEntered] = useState(false);
const currentName = currentLabel.replace(/^@/, "");
// Position below the chip, flipped above when there is no room, clamped
// horizontally into the viewport. Re-measured when the view swaps (the
// replace panel is taller than the menu).
useLayoutEffect(() => {
const panel = panelRef.current;
if (!panel) return;
const width = panel.offsetWidth;
const height = panel.offsetHeight;
let top = anchorRect.bottom + ANCHOR_GAP;
let flippedAbove = false;
if (top + height > window.innerHeight - VIEWPORT_MARGIN) {
top = anchorRect.top - height - ANCHOR_GAP;
flippedAbove = true;
}
top = Math.max(VIEWPORT_MARGIN, top);
const left = Math.max(
VIEWPORT_MARGIN,
Math.min(anchorRect.left, window.innerWidth - width - VIEWPORT_MARGIN),
);
setPlacement({ left, top, flippedAbove });
}, [anchorRect, view, replaceItems.length]);
useEffect(() => {
if (!placement || entered) return;
const raf = requestAnimationFrame(() => setEntered(true));
return () => cancelAnimationFrame(raf);
}, [placement, entered]);
useEffect(() => {
const handlePointerDown = (e: PointerEvent) => {
const panel = panelRef.current;
if (panel && !panel.contains(e.target as Node)) onClose();
};
const handleScroll = (e: Event) => {
const panel = panelRef.current;
if (panel && e.target instanceof Node && panel.contains(e.target)) return;
onClose();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("pointerdown", handlePointerDown, true);
window.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", onClose);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
window.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("resize", onClose);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose]);
const handleRowKeyActivate = useCallback(
(e: React.PointerEvent) => e.preventDefault(),
[],
);
return createPortal(
<div
ref={panelRef}
// Marks clicks in this body-portaled panel as not-outside for the
// focus-mode modal (see OUTSIDE_SAFE_SELECTOR in @storyteller/ui-modal).
data-modal-outside-safe=""
// Body-portaled, so the focus-mode modal's scroll lock
// (react-remove-scroll) would preventDefault wheel/touch events over
// this panel — stop propagation so the Replace list stays scrollable.
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
className={twMerge(
"fixed z-[9999] w-56 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl",
"transform-gpu transition duration-75 ease-out",
entered
? "translate-y-0 opacity-100"
: placement?.flippedAbove
? "-translate-y-1 opacity-0"
: "translate-y-1 opacity-0",
)}
style={{
// Re-enable interaction under the modal's body-wide pointer-events lock.
pointerEvents: "auto",
...(placement
? { left: placement.left, top: placement.top }
: {
left: anchorRect.left,
top: anchorRect.bottom + ANCHOR_GAP,
visibility: "hidden",
}),
}}
>
{view === "menu" ? (
<>
<div className="flex items-center gap-2 px-2 py-1.5">
<ChipAvatar preview={currentPreview} name={currentName} />
<div className="min-w-0">
<div className="truncate text-sm font-medium text-base-fg">
{currentName}
</div>
<div className="text-[11px] text-base-fg/50">Character</div>
</div>
</div>
<div className="my-1 border-t border-ui-panel-border" />
<button type="button" className={MENU_ROW} onClick={() => setView("replace")}>
<FontAwesomeIcon icon={faArrowsRotate} className="h-3.5 w-3.5 opacity-60" />
<span className="flex-1 text-left">Replace</span>
<span className="text-xs text-base-fg/40">{replaceItems.length}</span>
</button>
<button type="button" className={MENU_ROW} onClick={onPreview}>
<FontAwesomeIcon icon={faEye} className="h-3.5 w-3.5 opacity-60" />
<span className="flex-1 text-left">Preview</span>
</button>
<div className="my-1 border-t border-ui-panel-border" />
<button
type="button"
className={twMerge(MENU_ROW, "text-red-500 hover:bg-red-500/10")}
onClick={onRemove}
>
<FontAwesomeIcon icon={faTrashCan} className="h-3.5 w-3.5 opacity-60" />
<span className="flex-1 text-left">Remove</span>
</button>
</>
) : (
<>
<div className="flex items-center gap-2 px-2 py-1.5">
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded-md text-base-fg/60 transition-colors hover:bg-ui-controls/60 hover:text-base-fg"
onClick={() => setView("menu")}
aria-label="Back"
>
<FontAwesomeIcon icon={faChevronLeft} className="h-3 w-3" />
</button>
<div className="min-w-0">
<div className="text-sm font-medium text-base-fg">Replace</div>
<div className="truncate text-[11px] text-base-fg/50">
Currently {currentName}
</div>
</div>
</div>
<div className="my-1 border-t border-ui-panel-border" />
<div className="max-h-64 overflow-y-auto">
{replaceItems.length === 0 && (
<div className="px-2 py-3 text-center text-xs text-base-fg/50">
No other characters
</div>
)}
{replaceItems.map((item, i) => (
<button
key={item.token ?? `${item.label}-${i}`}
type="button"
className={MENU_ROW}
onPointerDown={handleRowKeyActivate}
onClick={() => onReplace(item)}
>
<ChipAvatar preview={item.preview} name={item.label} />
<span className="min-w-0 flex-1 truncate text-left">
{item.label.replace(/^@/, "")}
</span>
</button>
))}
</div>
</>
)}
</div>,
document.body,
);
}
function ChipAvatar({ preview, name }: { preview?: string; name: string }) {
return (
<div className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-md border border-white/10 bg-black/20">
{preview ? (
<img src={preview} alt={name} className="h-full w-full object-cover" />
) : (
<FontAwesomeIcon icon={faUser} className="h-3.5 w-3.5 text-base-fg/60" />
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -41,7 +41,15 @@ import { PromptFullscreenButton } from "./PromptFullscreenButton";
import { PromptClearAllButton } from "./PromptClearAllButton";
import { gtagEvent } from "@storyteller/google-analytics";
import { twMerge } from "tailwind-merge";
import { AudioReferenceRow } from "./common/AudioReferenceRow";
import {
AudioReferenceRow,
type AudioReferenceRowHandle,
} from "./common/AudioReferenceRow";
import {
PromptBoxDropOverlay,
usePromptBoxDrop,
type DroppedFiles,
} from "./deck/usePromptBoxDrop";
import { AudioTuningPopover } from "./common/AudioTuningPopover";
import { SoundsSettingsPopover } from "./common/SoundsSettingsPopover";
import { StylePromptRow } from "./common/StylePromptRow";
@@ -282,6 +290,29 @@ export const PromptBoxAudio = ({
setReferenceImages(images);
};
// Drag & drop / paste onto the box bounds. Uploads run through the
// reference row's own plumbing so limits, duration caps, and the
// audio/image mutual exclusion all behave exactly as they do for the
// row's own file pickers.
const referenceRowRef = useRef<AudioReferenceRowHandle>(null);
const handleDroppedFiles = ({ images, audios }: DroppedFiles) => {
if (audios.length > 0) {
void referenceRowRef.current?.addAudioFiles(audios);
} else if (images.length > 0) {
// One image ref max, and it can't coexist with audio — so a mixed drop
// resolves to the audio above and the image is ignored.
void referenceRowRef.current?.addImageFile(images[0]!);
}
};
const drop = usePromptBoxDrop({
acceptsImages: imageRefsSupported,
acceptsVideos: false,
acceptsAudio: audioRefsSupported,
onDropFiles: handleDroppedFiles,
});
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPrompt(e.target.value);
};
@@ -486,6 +517,7 @@ export const PromptBoxAudio = ({
const referenceRow: ReactNode =
audioRefsSupported || imageRefsSupported ? (
<AudioReferenceRow
ref={referenceRowRef}
referenceAudios={referenceAudios}
onReferenceAudiosChange={handleReferenceAudiosChange}
maxAudioCount={audioRefsSupported ? maxAudioRefs : 0}
@@ -512,7 +544,14 @@ export const PromptBoxAudio = ({
? "ring-1 ring-primary border-primary"
: "ring-1 ring-transparent",
)}
{...drop.dropZoneProps}
>
<PromptBoxDropOverlay
dragState={drop.dragState}
acceptsImages={imageRefsSupported}
acceptsVideos={false}
acceptsAudio={audioRefsSupported}
/>
{referenceRow}
<div className="flex justify-center gap-2">
@@ -34,6 +34,11 @@ import { ResolutionPicker } from "./common/ResolutionPicker";
import { QualityPicker } from "./common/QualityPicker";
import { ReferenceDeck } from "./deck/ReferenceDeck";
import { useDeckMedia } from "./deck/useDeckMedia";
import {
PromptBoxDropOverlay,
usePromptBoxDrop,
type DroppedFiles,
} from "./deck/usePromptBoxDrop";
import { DeckAddAction, DeckItem } from "./deck/deckTypes";
interface PromptBoxImageProps {
@@ -132,6 +137,28 @@ export const PromptBoxImage = ({
ownGalleryModal: true,
});
// Drag & drop / paste onto the box bounds. Images are the only reference
// kind this box takes, and only when the model can use them at all.
const dropAcceptsImages = !!selectedModel?.canUseImagePrompt;
const handleDroppedFiles = ({ images }: DroppedFiles) => {
if (images.length === 0) return;
if (deck.availableImageSlots <= 0) {
toast.error(
`Max ${maxImagePromptCount} image reference${maxImagePromptCount === 1 ? "" : "s"}`,
);
return;
}
deck.processImageFiles(images, "start");
};
const drop = usePromptBoxDrop({
acceptsImages: dropAcceptsImages,
acceptsVideos: false,
acceptsAudio: false,
onDropFiles: handleDroppedFiles,
});
const deckItems: DeckItem[] = useMemo(
() => [
...referenceImages.map((img, i) => ({
@@ -437,7 +464,14 @@ export const PromptBoxImage = ({
? "ring-1 ring-primary border-primary"
: "ring-1 ring-transparent",
)}
{...drop.dropZoneProps}
>
<PromptBoxDropOverlay
dragState={drop.dragState}
acceptsImages={dropAcceptsImages}
acceptsVideos={false}
acceptsAudio={false}
/>
<div className="flex justify-center gap-2">
{renderReferenceDeck()}
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useMemo } from "react";
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
import { useSignals } from "@preact/signals-react/runtime";
import { JobContextType } from "@storyteller/common";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
@@ -35,6 +35,11 @@ import type { UploadImageFn } from "./ImagePromptRow";
import { ReferenceDeck } from "./deck/ReferenceDeck";
import { KeyframeCards } from "./deck/KeyframeCards";
import { useDeckMedia } from "./deck/useDeckMedia";
import {
PromptBoxDropOverlay,
usePromptBoxDrop,
type DroppedFiles,
} from "./deck/usePromptBoxDrop";
import { DeckAddAction, DeckItem } from "./deck/deckTypes";
import { AspectRatioIcon } from "./common/AspectRatioIcon";
import { VideoGenerationCountPicker } from "./common/VideoGenerationCountPicker";
@@ -174,6 +179,13 @@ export const PromptBoxVideo = ({
useFullscreenPrompt();
const [isCharactersModalOpen, setIsCharactersModalOpen] = useState(false);
// Mentions are plain text: with several characters sharing a name,
// "@Robot" alone can't identify one. Records which token the user actually
// picked (dropdown, modal, or chip-menu replace), keyed by name.
const [mentionSelections, setMentionSelections] = useState<
Record<string, string>
>({});
// Characters store for @-mentions
const storedCharacters = useCharactersStore((s) => s.characters);
const charactersLoaded = useCharactersStore((s) => s.loaded);
@@ -193,6 +205,7 @@ export const PromptBoxVideo = ({
character_token: c.token,
name: c.name,
avatar_image_url: c.maybe_avatar?.cdn_url,
full_image_url: c.maybe_full_image?.cdn_url,
})),
);
}
@@ -498,6 +511,52 @@ export const PromptBoxVideo = ({
ownGalleryModal: true,
});
// Drag & drop / paste onto the box bounds: files route to the reference
// kind their MIME matches, gated on what the model supports. Keyframe mode
// renders no video/audio deck, so those kinds only land in reference mode
// where the user can see (and remove) them.
const dropAcceptsVideos = isReferenceMode && maxVideoCount > 0;
const dropAcceptsAudio = isReferenceMode && maxAudioCount > 0;
const handleDroppedFiles = ({ images, videos, audios }: DroppedFiles) => {
if (images.length > 0) {
if (!isReferenceMode) {
// Fill the empty keyframe slots in order: first frame, then last.
const queue = [...images];
const firstOpen =
referenceImages.length === 0 && deck.uploadingImages.length === 0;
const lastOpen =
!!selectedModel?.endFrame && !endFrameImage && !deck.uploadingEnd;
if (firstOpen) deck.processImageFiles([queue.shift()!], "start");
if (lastOpen && queue.length > 0) {
deck.processImageFiles([queue.shift()!], "end");
}
if (!firstOpen && !lastOpen) {
toast.error(
selectedModel?.endFrame
? "First and last frames are already set"
: "The first frame is already set",
);
}
} else if (deck.availableImageSlots <= 0) {
toast.error(
`Max ${maxImageCount} image reference${maxImageCount === 1 ? "" : "s"}`,
);
} else {
deck.processImageFiles(images, "start");
}
}
if (videos.length > 0) void deck.processVideoFiles(videos);
if (audios.length > 0) void deck.processAudioFiles(audios);
};
const drop = usePromptBoxDrop({
acceptsImages: maxImageCount > 0,
acceptsVideos: dropAcceptsVideos,
acceptsAudio: dropAcceptsAudio,
onDropFiles: handleDroppedFiles,
});
// Mixed deck items ordered images → videos → audios: the @ImageN/@VideoN/
// @AudioN mention labels are index-derived per type, so this ordering (and
// image-only reordering) is load-bearing.
@@ -837,6 +896,8 @@ export const PromptBoxVideo = ({
label: `@${char.name}`,
type: "character" as const,
preview: char.avatar_image_url,
token: char.character_token,
fullPreview: char.full_image_url ?? char.avatar_image_url,
})),
],
[
@@ -848,6 +909,14 @@ export const PromptBoxVideo = ({
],
);
// Record which token a mention name refers to when the user picks a
// character explicitly (dropdown pick or chip-menu replace).
const handleMentionSelect = useCallback((item: MentionItem) => {
if (item.type !== "character" || !item.token) return;
const name = item.label.replace(/^@/, "");
setMentionSelections((prev) => ({ ...prev, [name]: item.token! }));
}, []);
// Build label → color map for inline mention highlighting
const mentionColorMap = useMemo(() => {
const map: Record<string, string> = {};
@@ -1051,25 +1120,33 @@ export const PromptBoxVideo = ({
);
}
// Extract character tokens from @-mentions in prompt.
// Extract character tokens from @-mentions in prompt, resolving to
// exactly one token per mentioned name. Several characters can share a
// name; prefer the user's explicit pick (mentionSelections), else the
// newest (store is newest-first).
// Use a word-boundary regex so `@Bob` doesn't match inside `@Bob2`.
const mentionedCharacters = (() => {
const mentionedTokens = (() => {
if (activeCharacters.length === 0) return [];
const sorted = [...activeCharacters].sort(
(a, b) => b.name.length - a.name.length,
);
const matched = new Set<string>();
for (const c of sorted) {
const escaped = c.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`@${escaped}(?!\\w)`);
if (regex.test(prompt)) matched.add(c.character_token);
const byName = new Map<string, StoredCharacter[]>();
for (const c of activeCharacters) {
byName.set(c.name, [...(byName.get(c.name) ?? []), c]);
}
return activeCharacters.filter((c) => matched.has(c.character_token));
const names = [...byName.keys()].sort((a, b) => b.length - a.length);
const tokens: string[] = [];
for (const name of names) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (!new RegExp(`@${escaped}(?!\\w)`).test(prompt)) continue;
const candidates = byName.get(name)!;
const chosen =
candidates.find(
(c) => c.character_token === mentionSelections[name],
) ?? candidates[0];
tokens.push(chosen.character_token);
}
return tokens;
})();
if (mentionedCharacters.length > 0) {
request.reference_character_tokens = mentionedCharacters.map(
(c) => c.character_token,
);
if (mentionedTokens.length > 0) {
request.reference_character_tokens = mentionedTokens;
}
// Pass duration if model supports it
@@ -1265,7 +1342,15 @@ export const PromptBoxVideo = ({
? "ring-1 ring-primary border-primary"
: "ring-1 ring-transparent",
)}
{...drop.dropZoneProps}
>
<PromptBoxDropOverlay
dragState={drop.dragState}
acceptsImages={maxImageCount > 0}
acceptsVideos={dropAcceptsVideos}
acceptsAudio={dropAcceptsAudio}
keyframeMode={!isReferenceMode}
/>
{selectedModel?.textToVideoSupported === false && (
<div className="mb-2 flex items-center gap-1.5 rounded-md bg-ui-controls/60 px-2.5 py-1.5 text-xs text-base-fg/70">
<FontAwesomeIcon
@@ -1278,7 +1363,7 @@ export const PromptBoxVideo = ({
</span>
</div>
)}
<div className="relative flex justify-center gap-2">
<div className="relative flex justify-center gap-3">
{isReferenceMode ? renderReferenceDeck() : renderKeyframeCards()}
<div className="promptbox-resize-wrap relative flex-1 min-w-0">
{hasAnyMentionables ? (
@@ -1288,6 +1373,8 @@ export const PromptBoxVideo = ({
onChange={setPrompt}
mentionItems={allMentionItems}
colorMap={mentionColorMap}
onMentionSelect={handleMentionSelect}
selectedTokens={mentionSelections}
placeholder={
isReferenceMode
? "Use @Image1, @Video1, @Audio1... to reference uploads in prompt..."
@@ -1506,6 +1593,10 @@ export const PromptBoxVideo = ({
const spaceBefore =
prompt.length > 0 && !prompt.endsWith(" ") ? " " : "";
setPrompt(prompt + spaceBefore + mention + " ");
setMentionSelections((prev) => ({
...prev,
[character.name]: character.token,
}));
setIsCharactersModalOpen(false);
requestAnimationFrame(() => {
const el = mentionEditorRef.current;
@@ -1549,6 +1640,8 @@ export const PromptBoxVideo = ({
onChange={setPrompt}
mentionItems={allMentionItems}
colorMap={mentionColorMap}
onMentionSelect={handleMentionSelect}
selectedTokens={mentionSelections}
placeholder={
isReferenceMode
? "Use @Image1, @Video1, @Audio1... to reference uploads in prompt..."
@@ -1,4 +1,10 @@
import { useCallback, useRef, useState } from "react";
import {
forwardRef,
useCallback,
useImperativeHandle,
useRef,
useState,
} from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faFolderOpen,
@@ -13,6 +19,18 @@ import { toast } from "@storyteller/ui-toaster";
import { UploaderStates } from "@storyteller/common";
import type { UploadMediaFn } from "@storyteller/api";
import type { RefAudio, RefImage } from "../promptStore";
import {
AUDIO_FILE_ACCEPT,
AUDIO_FILE_TYPE_ERROR,
isAudioFile,
} from "./audioFiles";
/** Lets the prompt box push dropped/pasted files through the same upload
* path the row's own file inputs use. */
export interface AudioReferenceRowHandle {
addAudioFiles: (files: File[]) => Promise<void>;
addImageFile: (file: File) => Promise<void>;
}
export interface AudioReferenceRowProps {
referenceAudios: RefAudio[];
@@ -36,20 +54,26 @@ export interface AudioReferenceRowProps {
// remix/sample source or Seed Audio refs), plus an optional image reference
// for models that support one. Rendered inside the glass card, above the
// prompt textarea.
export function AudioReferenceRow({
referenceAudios,
onReferenceAudiosChange,
maxAudioCount,
maxAudioRefDuration,
uploadAudio,
onPickAudioFromLibrary,
audioRequired = false,
imageSupported = false,
referenceImages = [],
onReferenceImagesChange,
uploadImage,
className = "",
}: AudioReferenceRowProps) {
export const AudioReferenceRow = forwardRef<
AudioReferenceRowHandle,
AudioReferenceRowProps
>(function AudioReferenceRow(
{
referenceAudios,
onReferenceAudiosChange,
maxAudioCount,
maxAudioRefDuration,
uploadAudio,
onPickAudioFromLibrary,
audioRequired = false,
imageSupported = false,
referenceImages = [],
onReferenceImagesChange,
uploadImage,
className = "",
},
ref,
) {
const audioInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
@@ -61,14 +85,16 @@ export function AudioReferenceRow({
const referenceImagesRef = useRef(referenceImages);
referenceImagesRef.current = referenceImages;
const handleAudioFileUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const files = Array.from(event.target.files || []);
const processAudioFiles = async (files: File[]) => {
if (files.length === 0) return;
const audioFiles = files.filter(isAudioFile);
if (audioFiles.length < files.length) {
toast.error(AUDIO_FILE_TYPE_ERROR);
}
const availableSlots = Math.max(0, maxAudioCount - referenceAudios.length);
const filesToProcess = files.slice(0, availableSlots);
const filesToProcess = audioFiles.slice(0, availableSlots);
for (const file of filesToProcess) {
const duration = await getAudioFileDuration(file);
@@ -113,15 +139,17 @@ export function AudioReferenceRow({
});
}
}
};
const handleAudioFileUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
await processAudioFiles(Array.from(event.target.files || []));
if (audioInputRef.current) audioInputRef.current.value = "";
};
const handleImageFileUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = (event.target.files || [])[0];
if (!file || !uploadImage) return;
const processImageFile = async (file: File) => {
if (!uploadImage) return;
setIsUploadingImage(true);
await uploadImage({
@@ -146,10 +174,26 @@ export function AudioReferenceRow({
}
},
});
};
const handleImageFileUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = (event.target.files || [])[0];
if (file) await processImageFile(file);
if (imageInputRef.current) imageInputRef.current.value = "";
};
useImperativeHandle(
ref,
() => ({
addAudioFiles: processAudioFiles,
addImageFile: processImageFile,
}),
// Recreated each render so the handle always closes over fresh props
// (slot counts, current refs) rather than mount-time values.
);
const removeAudio = (id: string) => {
onReferenceAudiosChange(referenceAudios.filter((a) => a.id !== id));
if (audioInputRef.current) audioInputRef.current.value = "";
@@ -160,7 +204,7 @@ export function AudioReferenceRow({
<input
ref={audioInputRef}
type="file"
accept="audio/*"
accept={AUDIO_FILE_ACCEPT}
className="hidden"
multiple={maxAudioCount > 1}
onChange={handleAudioFileUpload}
@@ -270,7 +314,7 @@ export function AudioReferenceRow({
</div>
</div>
);
}
});
function AudioRefTile({
audio,
@@ -0,0 +1,56 @@
// iOS Safari has no system audio library, so a bare `accept="audio/*"` makes
// the file input fall back to the Photos picker (photos and videos only) —
// on a phone the only thing users could "attach as audio" was a video.
// Listing concrete audio MIME types and extensions instead makes iOS open the
// Files picker filtered to audio; desktop browsers filter identically.
export const AUDIO_FILE_ACCEPT = [
"audio/mpeg",
"audio/wav",
"audio/x-wav",
"audio/mp4",
"audio/x-m4a",
"audio/aac",
"audio/ogg",
"audio/opus",
"audio/flac",
"audio/webm",
"audio/aiff",
"audio/x-aiff",
".mp3",
".wav",
".m4a",
".aac",
".ogg",
".oga",
".opus",
".flac",
".weba",
".aiff",
".aif",
].join(",");
export const AUDIO_FILE_TYPE_ERROR =
"Please choose an audio file (MP3, WAV, M4A, FLAC…)";
const AUDIO_FILE_EXTENSIONS = new Set([
"mp3",
"wav",
"m4a",
"aac",
"ogg",
"oga",
"opus",
"flac",
"weba",
"aiff",
"aif",
]);
// Guards the audio-ref paths against non-audio picks (Android "any file"
// pickers, drag-and-drop, older iOS). Checks the extension as well as the
// MIME type because some platforms report .m4a as `video/mp4`.
export function isAudioFile(file: File): boolean {
if (file.type.startsWith("audio/")) return true;
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
return AUDIO_FILE_EXTENSIONS.has(extension);
}
@@ -174,6 +174,11 @@ export const ReferenceDeck = ({
const handleRootEnter = () => {
clearCloseTimer();
// The add menu is body-portaled, so hovering it counts as leaving the
// root and decays `hovered` (the panel stays up via addMenuOpen). Coming
// back onto the open panel must re-arm `hovered`, or it would collapse
// under the cursor the moment the menu closes.
if (expanded) setHovered(true);
};
const handleRootLeave = () => {
@@ -215,6 +220,11 @@ export const ReferenceDeck = ({
interactive={true}
position="top"
delay={100}
// Body-portaled: rendered inline, the menu is trapped in the
// promptbox's .glass stacking context and paints under the fixed
// z-30 sidebar no matter its own z-index.
portal
zIndex={9999}
className="-mb-0.5 border border-ui-controls-border bg-ui-controls p-1.5 text-base-fg"
closeOnClick={true}
onOpenChange={holdPanel ? setAddMenuOpen : undefined}
@@ -3,6 +3,11 @@ import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl, type UploadMediaFn } from "@storyteller/api";
import { toast } from "@storyteller/ui-toaster";
import { UploaderStates } from "@storyteller/common";
import {
AUDIO_FILE_ACCEPT,
AUDIO_FILE_TYPE_ERROR,
isAudioFile,
} from "../common/audioFiles";
/** Minimal structural shapes so both apps' ref types fit. */
export interface DeckRefLike {
@@ -368,12 +373,18 @@ export function useDeckMedia<
};
const processAudioFiles = async (files: File[]) => {
const audioFiles = files.filter(isAudioFile);
if (audioFiles.length < files.length) {
toast.error(AUDIO_FILE_TYPE_ERROR, { id: "audio-ref-type" });
}
if (audioFiles.length === 0) return;
const availableSlots = Math.max(0, maxAudios - referenceAudios.length);
if (availableSlots <= 0) {
return;
}
const filesToProcess = files.slice(0, availableSlots);
const filesToProcess = audioFiles.slice(0, availableSlots);
for (const file of filesToProcess) {
const duration = await getAudioDuration(file);
@@ -441,8 +452,12 @@ export function useDeckMedia<
if (files.length === 0) return;
const images = files.filter((f) => f.type.startsWith("image/"));
const videos = files.filter((f) => f.type.startsWith("video/"));
const audios = files.filter((f) => f.type.startsWith("audio/"));
// isAudioFile also claims .m4a files that platforms report as video/mp4,
// so exclude them from the video bucket.
const audios = files.filter(isAudioFile);
const videos = files.filter(
(f) => f.type.startsWith("video/") && !isAudioFile(f),
);
if (images.length > 0) processImageFiles(images, "start");
if (videos.length > 0 && setReferenceVideos) processVideoFiles(videos);
@@ -453,7 +468,7 @@ export function useDeckMedia<
const anyUploadAccept = [
...(maxImages > 0 ? ["image/*"] : []),
...(setReferenceVideos ? ["video/mp4", ".mp4"] : []),
...(setReferenceAudios ? ["audio/*"] : []),
...(setReferenceAudios ? [AUDIO_FILE_ACCEPT] : []),
].join(",");
const handleGalleryClose = () => {
@@ -681,7 +696,7 @@ export function useDeckMedia<
type="file"
ref={audioFileInputRef}
className="hidden"
accept="audio/*"
accept={AUDIO_FILE_ACCEPT}
onChange={handleAudioFileUpload}
multiple={maxAudios > 1}
/>
@@ -0,0 +1,488 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faImages, faMusic, faVideo } from "@fortawesome/pro-solid-svg-icons";
import { toast } from "@storyteller/ui-toaster";
import { IsDesktopApp } from "@storyteller/tauri-utils";
// ── Types ───────────────────────────────────────────────────────────────
type DropKind = "image" | "video" | "audio";
/** "accept" / "reject" reflect whether the hovered payload contains at
* least one file the current model can take as a reference. */
export type DropDragState = "idle" | "accept" | "reject";
export interface DroppedFiles {
images: File[];
videos: File[];
audios: File[];
}
export interface UsePromptBoxDropOptions {
acceptsImages: boolean;
acceptsVideos: boolean;
acceptsAudio: boolean;
onDropFiles: (files: DroppedFiles) => void;
}
interface PromptBoxDropOverlayProps {
dragState: DropDragState;
acceptsImages: boolean;
acceptsVideos: boolean;
acceptsAudio: boolean;
/** Video keyframe mode: images fill the start/end frame slots. */
keyframeMode?: boolean;
}
/**
* Live count of mounted prompt boxes that own file drops. The desktop's
* global drop handler reads this to stand down: on a create page the prompt
* box is the only drop target, so a drop anywhere else does nothing.
*/
let activeDropZoneCount = 0;
const dropZoneCountListeners = new Set<(count: number) => void>();
const adjustActiveDropZoneCount = (delta: number) => {
activeDropZoneCount = Math.max(0, activeDropZoneCount + delta);
dropZoneCountListeners.forEach((listener) => listener(activeDropZoneCount));
};
export const isPromptBoxDropZoneActive = () => activeDropZoneCount > 0;
export const subscribeToPromptBoxDropZones = (
listener: (count: number) => void,
) => {
dropZoneCountListeners.add(listener);
return () => {
dropZoneCountListeners.delete(listener);
};
};
// Tauri hands us OS paths, not File objects. Extensions are all we get to
// route by, so they mirror the MIME buckets the browser path uses.
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif", "bmp", "avif"];
const VIDEO_EXTENSIONS = ["mp4", "mov", "webm", "mkv", "avi", "m4v"];
const AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "flac", "aac", "m4a", "opus"];
const MIME_BY_EXTENSION: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
gif: "image/gif",
bmp: "image/bmp",
avif: "image/avif",
mp4: "video/mp4",
mov: "video/quicktime",
webm: "video/webm",
mkv: "video/x-matroska",
avi: "video/x-msvideo",
m4v: "video/x-m4v",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
flac: "audio/flac",
aac: "audio/aac",
m4a: "audio/mp4",
opus: "audio/opus",
};
const kindOfMime = (mime: string): DropKind | null =>
mime.startsWith("image/")
? "image"
: mime.startsWith("video/")
? "video"
: mime.startsWith("audio/")
? "audio"
: null;
const extensionOf = (path: string) =>
path.split(/[/\\]/).pop()?.split(".").pop()?.toLowerCase() ?? "";
const kindOfPath = (path: string): DropKind | null => {
const ext = extensionOf(path);
if (IMAGE_EXTENSIONS.includes(ext)) return "image";
if (VIDEO_EXTENSIONS.includes(ext)) return "video";
if (AUDIO_EXTENSIONS.includes(ext)) return "audio";
return null;
};
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const listKinds = (labels: string[]) =>
labels.length <= 1
? (labels[0] ?? "")
: `${labels.slice(0, -1).join(", ")} or ${labels[labels.length - 1]}`;
/**
* Makes the prompt box a drop target for reference media, and catches
* clipboard pastes of files anywhere on the page (focus-independent). Files
* are routed by MIME type; kinds the current model doesn't take are
* rejected with a toast instead of silently vanishing.
*
* Two transports, one behaviour. In the browser this is plain HTML5 drag
* events on the returned `dropZoneProps`. Under Tauri the webview never sees
* those events (the OS drag is captured natively), so the same states are
* driven from `onDragDropEvent` and the cursor position is hit-tested
* against the element `dropZoneProps.ref` is attached to.
*/
export function usePromptBoxDrop({
acceptsImages,
acceptsVideos,
acceptsAudio,
onDropFiles,
}: UsePromptBoxDropOptions) {
const [dragState, setDragState] = useState<DropDragState>("idle");
const zoneRef = useRef<HTMLDivElement>(null);
const enabled = acceptsImages || acceptsVideos || acceptsAudio;
const isDesktop = IsDesktopApp();
const acceptsKind = useCallback(
(kind: DropKind | null) =>
kind === "image"
? acceptsImages
: kind === "video"
? acceptsVideos
: kind === "audio"
? acceptsAudio
: false,
[acceptsImages, acceptsVideos, acceptsAudio],
);
// Announce ownership of drops for as long as an enabled box is mounted, so
// the desktop's global drop handler stays out of the way on this page.
useEffect(() => {
if (!enabled) return;
adjustActiveDropZoneCount(1);
return () => adjustActiveDropZoneCount(-1);
}, [enabled]);
const routeFiles = (files: File[]) => {
const accepted: DroppedFiles = { images: [], videos: [], audios: [] };
const rejectedKinds = new Set<DropKind>();
let unknownCount = 0;
for (const file of files) {
const kind = kindOfMime(file.type) ?? kindOfPath(file.name);
if (kind === null) {
unknownCount++;
} else if (!acceptsKind(kind)) {
rejectedKinds.add(kind);
} else if (kind === "image") {
accepted.images.push(file);
} else if (kind === "video") {
accepted.videos.push(file);
} else {
accepted.audios.push(file);
}
}
const anyAccepted =
accepted.images.length > 0 ||
accepted.videos.length > 0 ||
accepted.audios.length > 0;
if (anyAccepted) onDropFiles(accepted);
// Mode-neutral wording: a kind can be off because the model lacks it or
// because the current input mode (e.g. keyframes) doesn't show it.
if (rejectedKinds.size > 0) {
toast.error(
`${capitalize(listKinds([...rejectedKinds]))} references aren't available here`,
);
} else if (unknownCount > 0 && !anyAccepted) {
toast.error(
`Only ${listKinds(acceptedKindLabels(acceptsImages, acceptsVideos, acceptsAudio))} files can be added here`,
);
}
};
// Async listeners (Tauri, paste) re-read the freshest router through a ref
// so they can stay subscribed once instead of resubscribing per render.
const routeFilesRef = useRef(routeFiles);
useEffect(() => {
routeFilesRef.current = routeFiles;
});
const acceptsKindRef = useRef(acceptsKind);
useEffect(() => {
acceptsKindRef.current = acceptsKind;
}, [acceptsKind]);
// ── Browser transport ────────────────────────────────────────────────
// While the box is on screen, a stray drop outside it must not navigate
// the browser to the file (which would blow away the user's session).
useEffect(() => {
if (!enabled || isDesktop) return;
const preventNavigation = (e: DragEvent) => {
if (e.dataTransfer?.types.includes("Files")) e.preventDefault();
};
// Drops that land outside the box still need the overlay dismissed.
const clearDragState = () => setDragState("idle");
window.addEventListener("dragover", preventNavigation);
window.addEventListener("drop", preventNavigation);
window.addEventListener("drop", clearDragState);
window.addEventListener("dragend", clearDragState);
return () => {
window.removeEventListener("dragover", preventNavigation);
window.removeEventListener("drop", preventNavigation);
window.removeEventListener("drop", clearDragState);
window.removeEventListener("dragend", clearDragState);
};
}, [enabled, isDesktop]);
// MIME types are unreliable mid-drag (browsers may report empty strings),
// so unknown types count as acceptable until the drop resolves them.
const inspectDrag = useCallback(
(dt: DataTransfer): DropDragState => {
const fileItems = Array.from(dt.items).filter((i) => i.kind === "file");
if (fileItems.length === 0) return "accept";
const ok = fileItems.some(
(i) => i.type === "" || acceptsKind(kindOfMime(i.type)),
);
return ok ? "accept" : "reject";
},
[acceptsKind],
);
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
if (!enabled || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
setDragState(inspectDrag(e.dataTransfer));
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
if (dragState === "idle") return;
// Child elements fire enter/leave pairs constantly; only reset when the
// pointer actually exits the drop target's bounds.
const rect = e.currentTarget.getBoundingClientRect();
if (
e.clientX < rect.left ||
e.clientX >= rect.right ||
e.clientY < rect.top ||
e.clientY >= rect.bottom
) {
setDragState("idle");
}
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
if (!enabled || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.stopPropagation();
setDragState("idle");
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) routeFiles(files);
};
// ── Tauri transport ──────────────────────────────────────────────────
useEffect(() => {
if (!enabled || !isDesktop) return;
let unlisten: (() => void) | undefined;
let disposed = false;
// Tauri reports positions in physical pixels; CSS coordinates need the
// display's scale factor divided back out.
const isInsideZone = (position: { x: number; y: number }) => {
const zone = zoneRef.current;
if (!zone) return false;
const scale = window.devicePixelRatio || 1;
const x = position.x / scale;
const y = position.y / scale;
const rect = zone.getBoundingClientRect();
return (
x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom
);
};
const setup = async () => {
try {
const { getCurrentWebviewWindow } = await import(
"@tauri-apps/api/webviewWindow"
);
const { convertFileSrc } = await import("@tauri-apps/api/core");
const appWindow = getCurrentWebviewWindow();
const stop = await appWindow.onDragDropEvent(async (event) => {
const payload = event.payload;
if (payload.type === "enter" || payload.type === "over") {
// Paths aren't provided while hovering, so the overlay can only
// promise "accept" until the drop resolves the real files.
setDragState(isInsideZone(payload.position) ? "accept" : "idle");
return;
}
if (payload.type !== "drop") {
setDragState("idle");
return;
}
setDragState("idle");
if (!isInsideZone(payload.position)) return;
if (payload.paths.length === 0) return;
// Only read the files this box can actually take; unsupported ones
// still reach routeFiles (as zero-byte stand-ins) so it can raise
// the same "not available here" toast the browser path does.
const readable = payload.paths.filter(
(path) => kindOfPath(path) !== null,
);
const unsupported = payload.paths.filter(
(path) => kindOfPath(path) === null,
);
const files = await Promise.all(
readable.map(async (path) => {
const fileName = path.split(/[/\\]/).pop() ?? "file";
const type = MIME_BY_EXTENSION[extensionOf(path)] ?? "";
try {
const response = await fetch(convertFileSrc(path));
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
return new File([blob], fileName, { type });
} catch (err) {
console.error("[PromptBoxDrop] file read failed:", err);
return null;
}
}),
);
if (disposed) return;
const readFiles = files.filter((file): file is File => file !== null);
const failedCount = readable.length - readFiles.length;
if (failedCount > 0) {
toast.error(
`Couldn't read ${failedCount} file${failedCount > 1 ? "s" : ""}`,
);
}
const placeholders = unsupported.map(
(path) => new File([], path.split(/[/\\]/).pop() ?? "file"),
);
const allFiles = [...readFiles, ...placeholders];
if (allFiles.length > 0) routeFilesRef.current(allFiles);
});
if (disposed) stop();
else unlisten = stop;
} catch (err) {
console.error("[PromptBoxDrop] setup failed:", err);
}
};
void setup();
return () => {
disposed = true;
unlisten?.();
};
}, [enabled, isDesktop]);
// ── Paste-to-add ─────────────────────────────────────────────────────
// A copied image (or media file) pasted anywhere on the page lands in the
// deck, whether or not the textarea is focused.
useEffect(() => {
if (!enabled) return;
const handlePaste = (e: ClipboardEvent) => {
const files = Array.from(e.clipboardData?.files ?? []);
// Plain text pastes carry no files — leave them entirely alone.
if (files.length === 0) return;
// Guard against double-adding if more than one box ever mounts.
const marked = e as ClipboardEvent & { promptBoxHandled?: boolean };
if (marked.promptBoxHandled) return;
marked.promptBoxHandled = true;
// No preventDefault: any text alongside the file still pastes into
// whichever field is focused.
routeFilesRef.current(files);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [enabled]);
return {
dragState,
dropZoneProps: {
ref: zoneRef,
// Under Tauri these never fire, but leaving them attached keeps a
// single code path and costs nothing.
onDragEnter: handleDragOver,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
};
}
/**
* Full-bleed dashed overlay shown while files hover the prompt box, listing
* the reference kinds the current model takes (red when none match).
*/
export function PromptBoxDropOverlay({
dragState,
acceptsImages,
acceptsVideos,
acceptsAudio,
keyframeMode,
}: PromptBoxDropOverlayProps) {
if (dragState === "idle") return null;
const kinds = [
...(acceptsImages
? [{ icon: faImages, label: keyframeMode ? "Frames" : "Images" }]
: []),
...(acceptsVideos ? [{ icon: faVideo, label: "Video" }] : []),
...(acceptsAudio ? [{ icon: faMusic, label: "Audio" }] : []),
];
const rejected = dragState === "reject";
return (
<div
className={twMerge(
"promptbox-drop-overlay pointer-events-none absolute inset-0 z-40 flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed bg-[#161618]/85 backdrop-blur-sm",
rejected ? "border-red-400/80" : "border-primary",
)}
>
<div className="flex items-center gap-2">
{kinds.map((kind, i) => (
<div
key={kind.label}
className="promptbox-drop-chip flex h-9 w-9 items-center justify-center rounded-lg bg-white/10 text-base-fg/90"
style={{ animationDelay: `${i * 50}ms` }}
>
<FontAwesomeIcon icon={kind.icon} className="text-sm" />
</div>
))}
</div>
<div className="text-sm font-semibold text-base-fg">
{rejected
? "That file type isn't supported"
: keyframeMode && !acceptsVideos && !acceptsAudio
? "Drop to set your frames"
: "Drop to add references"}
</div>
<div className="-mt-1 text-xs text-base-fg/60">
{rejected
? `Accepts ${listKinds(acceptedKindLabels(acceptsImages, acceptsVideos, acceptsAudio))} files`
: kinds.map((kind) => kind.label).join(" · ")}
</div>
</div>
);
}
function acceptedKindLabels(
acceptsImages: boolean,
acceptsVideos: boolean,
acceptsAudio: boolean,
): string[] {
return [
...(acceptsImages ? ["image"] : []),
...(acceptsVideos ? ["video"] : []),
...(acceptsAudio ? ["audio"] : []),
];
}
@@ -1,4 +1,4 @@
import type { MentionItem } from "./types";
import type { MentionItem } from "./MentionTextarea";
// ── @-mention color palette ─────────────────────────────────────────────
@@ -340,6 +340,7 @@ export interface StoredCharacter {
character_token: string;
name: string;
avatar_image_url?: string;
full_image_url?: string;
}
interface CharactersStore {
@@ -1,4 +1,40 @@
import toast, { Toaster as RHToaster } from "react-hot-toast";
import rhToast, { Toaster as RHToaster } from "react-hot-toast";
// Host apps that render their own toast UI instead of mounting <Toaster />
// (e.g. the webapp) register a delegate here. Shared-lib `toast.success` /
// `toast.error` calls are forwarded to it — without this they dispatch into
// react-hot-toast, which renders nothing when its container isn't mounted.
export interface ToastDelegate {
success: (message: string) => void;
error: (message: string) => void;
}
let toastDelegate: ToastDelegate | null = null;
export function setToastDelegate(delegate: ToastDelegate | null) {
toastDelegate = delegate;
}
const toast: typeof rhToast = Object.assign(
((...args: Parameters<typeof rhToast>) => rhToast(...args)) as typeof rhToast,
rhToast,
{
success: ((message, opts) => {
if (toastDelegate && typeof message === "string") {
toastDelegate.success(message);
return "";
}
return rhToast.success(message, opts);
}) as typeof rhToast.success,
error: ((message, opts) => {
if (toastDelegate && typeof message === "string") {
toastDelegate.error(message);
return "";
}
return rhToast.error(message, opts);
}) as typeof rhToast.error,
},
);
interface ToasterProps {
position?: "top-right" | "top-left" | "bottom-right" | "bottom-left";
@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect } from "react";
import React, { useState, useRef, useEffect, useReducer } from "react";
import { createPortal } from "react-dom";
import { Transition } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
@@ -25,6 +26,16 @@ interface TooltipProps {
* when the tooltip is inside an overflow-hidden / overflow-auto container.
*/
strategy?: "absolute" | "fixed";
/**
* Render the tooltip in a document.body portal (implies fixed positioning).
* Use when the trigger sits inside its own stacking context (backdrop-blur
* "glass" surfaces, transformed ancestors) that would otherwise paint the
* tooltip below overlays like the sidebar — no zIndex can escape that from
* inside. Pair with a high `zIndex`; the portal root is marked
* `data-modal-outside-safe` so clicks in it don't count as outside clicks
* for the shared Modal.
*/
portal?: boolean;
}
export const Tooltip = ({
@@ -41,6 +52,7 @@ export const Tooltip = ({
zIndex = 10,
disabled = false,
strategy = "absolute",
portal = false,
}: TooltipProps) => {
const [isShowing, setIsShowing] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
@@ -184,16 +196,29 @@ export const Tooltip = ({
estWidth: number,
padding: number,
): React.CSSProperties => {
const offset = getContainingBlockOffset();
// A body portal has no transformed/filtered ancestor to compensate for.
const offset = portal ? { x: 0, y: 0 } : getContainingBlockOffset();
const GAP = 10;
switch (position) {
case "top":
case "top": {
const centerX = rect.left + rect.width / 2;
const halfWidth = estWidth / 2;
let translateX = "-50%";
if (centerX - halfWidth < padding) {
const diff = padding - (centerX - halfWidth);
translateX = `calc(-50% + ${diff}px)`;
} else if (centerX + halfWidth > vw - padding) {
const diff = centerX + halfWidth - (vw - padding);
translateX = `calc(-50% - ${diff}px)`;
}
return {
top: rect.top - offset.y - GAP,
left: rect.left + rect.width / 2 - offset.x,
transform: "translate(-50%, -100%)",
left: centerX - offset.x,
transform: `translate(${translateX}, -100%)`,
};
}
case "bottom": {
const centerX = rect.left + rect.width / 2;
const halfWidth = estWidth / 2;
@@ -240,7 +265,7 @@ export const Tooltip = ({
if (currentWidth > 0) estWidth = currentWidth;
}
return strategy === "fixed"
return strategy === "fixed" || portal
? getFixedStyle(rect, vw, estWidth, padding)
: getAbsoluteStyle(rect, vw, estWidth, padding);
};
@@ -288,51 +313,38 @@ export const Tooltip = ({
};
}, []);
const isFixed = strategy === "fixed";
const isFixed = strategy === "fixed" || portal;
return (
<div
ref={triggerRef}
onPointerEnter={() => {
setIsHoveringTrigger(true);
if (!checkForOpenPopovers() && !disabled) {
setIsShowing(true);
}
}}
onPointerLeave={() => {
setIsHoveringTrigger(false);
if (!interactive) {
setIsShowing(false);
}
}}
onPointerCancel={() => {
setIsHoveringTrigger(false);
setIsHoveringTooltip(false);
setIsShowing(false);
}}
onBlur={() => {
setIsHoveringTrigger(false);
setIsHoveringTooltip(false);
setIsShowing(false);
}}
onClick={handleClick}
className="relative"
// A portaled tooltip positions off a snapshot of the trigger's viewport
// rect — re-render on scroll/resize while showing so it tracks the trigger
// instead of floating where it used to be.
const [, bumpReposition] = useReducer((n: number) => n + 1, 0);
useEffect(() => {
if (!portal || !isShowing) return;
window.addEventListener("scroll", bumpReposition, true);
window.addEventListener("resize", bumpReposition);
return () => {
window.removeEventListener("scroll", bumpReposition, true);
window.removeEventListener("resize", bumpReposition);
};
}, [portal, isShowing]);
const tooltipPanel = (
<Transition
show={isShowing}
enter={twMerge(
"transition ease-out duration-200",
delay ? `delay-[${delay}ms]` : "delay-[300ms]",
)}
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
{children}
<Transition
show={isShowing}
enter={twMerge(
"transition ease-out duration-200",
delay ? `delay-[${delay}ms]` : "delay-[300ms]",
)}
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div
ref={setTooltipRef}
{...(portal ? { "data-modal-outside-safe": "" } : {})}
onPointerEnter={() => interactive && setIsHoveringTooltip(true)}
onPointerLeave={() => interactive && setIsHoveringTooltip(false)}
onClick={() => {
@@ -378,7 +390,39 @@ export const Tooltip = ({
</div>
)}
</div>
</Transition>
</Transition>
);
return (
<div
ref={triggerRef}
onPointerEnter={() => {
setIsHoveringTrigger(true);
if (!checkForOpenPopovers() && !disabled) {
setIsShowing(true);
}
}}
onPointerLeave={() => {
setIsHoveringTrigger(false);
if (!interactive) {
setIsShowing(false);
}
}}
onPointerCancel={() => {
setIsHoveringTrigger(false);
setIsHoveringTooltip(false);
setIsShowing(false);
}}
onBlur={() => {
setIsHoveringTrigger(false);
setIsHoveringTooltip(false);
setIsShowing(false);
}}
onClick={handleClick}
className="relative"
>
{children}
{portal ? createPortal(tooltipPanel, document.body) : tooltipPanel}
</div>
);
};
+88 -168
View File
File diff suppressed because it is too large Load Diff
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env bash
# Bootstrap the full local dev stack (backend + frontend) on a fresh clone.
#
# Target platform: Ubuntu 22.04+ — native, WSL2, or a CI runner/container.
# (Windows users: the native PowerShell variant in script/bootstrap/windows/
# is the primary path — no WSL needed. macOS users: follow
# _docs/dev_setup_server.md manually. See _docs/dev_setup_local_stack.md.)
#
# Idempotent: safe to re-run at any time; each step detects work already done.
#
# Usage:
# ./script/bootstrap/bootstrap_dev_stack.sh [options]
#
# Options:
# --yes, -y Non-interactive: assume "yes" for prompts (CI mode)
# --skip-packages Don't apt-install system packages
# --skip-rust-build Don't build the storyteller-web binary
# --skip-frontend Don't run the frontend npm install
#
# After it succeeds:
# ./script/bootstrap/run_backend_dev.sh # start the API on :12345
# ./script/bootstrap/seed_demo_user.sh # create the demo login
# cd frontend && npx nx dev artcraft-webapp # webapp on :4201
set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "${root_dir}/script/bootstrap/common.sh"
SECRETS_ENV_FILE="${root_dir}/crates/service/web/storyteller_web/config/storyteller-web.development-secrets.env"
# System packages: MySQL + Redis servers, the native libs needed to compile the
# Rust workspace (per build/service_cpu.Dockerfile) and diesel_cli
# (libmysqlclient-dev, libsqlite3-dev), plus curl/jq for the seeding scripts.
SYSTEM_PACKAGES=(
build-essential
ca-certificates
cmake
curl
ffmpeg
fontconfig
git
jq
libclang-dev
libfontconfig1-dev
libmysqlclient-dev
libsqlite3-dev
libssl-dev
mysql-server
perl
pkg-config
redis-server
)
ASSUME_YES=false
SKIP_PACKAGES=false
SKIP_RUST_BUILD=false
SKIP_FRONTEND=false
main() {
parse_args "$@"
ensure_supported_platform
install_system_packages
ensure_mysql_running
provision_mysql_database
ensure_redis_running
ensure_rust_toolchain
ensure_diesel_cli
run_migrations
seed_roles_and_badges
write_secrets_env_if_missing
build_backend
setup_frontend
print_next_steps
}
parse_args() {
for arg in "$@"; do
case "${arg}" in
--yes|-y) ASSUME_YES=true ;;
--skip-packages) SKIP_PACKAGES=true ;;
--skip-rust-build) SKIP_RUST_BUILD=true ;;
--skip-frontend) SKIP_FRONTEND=true ;;
--help|-h) sed -n '2,22p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "Unknown option: ${arg} (try --help)" ;;
esac
done
}
ensure_supported_platform() {
case "$(uname -s)" in
Linux) ;;
Darwin)
die "This script targets Ubuntu/WSL2. On macOS, follow _docs/dev_setup_server.md."
;;
*)
die "Unsupported platform '$(uname -s)'. On Windows use script/bootstrap/windows/bootstrap_dev_stack.ps1 (native), or WSL2 Ubuntu for this script."
;;
esac
if grep -qi microsoft /proc/version 2>/dev/null && [[ "${root_dir}" == /mnt/* ]]; then
warn "This checkout lives on the Windows filesystem (${root_dir})."
warn "Rust + npm builds are dramatically slower through /mnt. Consider cloning"
warn "into the WSL filesystem (e.g. ~/storyteller-rust) instead."
fi
}
install_system_packages() {
step "System packages"
if [ "${SKIP_PACKAGES}" = "true" ]; then
log "Skipped (--skip-packages)."
return
fi
local missing=()
for pkg in "${SYSTEM_PACKAGES[@]}"; do
dpkg -s "${pkg}" >/dev/null 2>&1 || missing+=("${pkg}")
done
if [ "${#missing[@]}" -eq 0 ]; then
log "All required apt packages are already installed."
return
fi
log "Missing packages: ${missing[*]}"
confirm "Install them with apt-get?" || die "Cannot continue without system packages (or pass --skip-packages if you manage them yourself)."
maybe_sudo env DEBIAN_FRONTEND=noninteractive apt-get update
maybe_sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing[@]}"
}
ensure_mysql_running() {
step "MySQL server"
if mysqladmin ping --silent 2>/dev/null; then
log "MySQL is running."
else
log "Starting MySQL..."
maybe_sudo service mysql start 2>/dev/null \
|| maybe_sudo systemctl start mysql 2>/dev/null \
|| die "Could not start MySQL (tried 'service mysql start' and 'systemctl start mysql')."
mysqladmin ping --silent 2>/dev/null || sleep 3
mysqladmin ping --silent 2>/dev/null || die "MySQL did not come up."
fi
# The tooling supports the MySQL 8.x series; 9.x is known-unsupported
# (_docs/dev_setup_server.md).
local version
version="$(mysqld --version 2>/dev/null || true)"
case "${version}" in
*" 8."*) ;;
"") warn "Could not determine MySQL server version." ;;
*) warn "MySQL 8.x is expected; found: ${version}" ;;
esac
}
provision_mysql_database() {
step "MySQL database + user (${DEV_MYSQL_DB} / ${DEV_MYSQL_USER}@localhost)"
if mysql_app_reachable; then
log "Database and app user already provisioned."
return
fi
# Same DDL as _docs/dev_setup_server.md, made idempotent.
mysql_admin <<SQL
CREATE DATABASE IF NOT EXISTS ${DEV_MYSQL_DB};
CREATE USER IF NOT EXISTS '${DEV_MYSQL_USER}'@'localhost' IDENTIFIED BY '${DEV_MYSQL_PASSWORD}';
GRANT ALL PRIVILEGES ON ${DEV_MYSQL_DB}.* TO '${DEV_MYSQL_USER}'@'localhost';
FLUSH PRIVILEGES;
SQL
mysql_app_reachable || die "Provisioning ran but '${DEV_MYSQL_USER}' still cannot reach '${DEV_MYSQL_DB}'."
log "Provisioned."
}
ensure_redis_running() {
step "Redis server"
if redis_reachable; then
log "Redis is running."
return
fi
log "Starting Redis..."
maybe_sudo service redis-server start 2>/dev/null \
|| maybe_sudo systemctl start redis-server 2>/dev/null \
|| die "Could not start Redis."
redis_reachable || die "Redis did not come up."
}
ensure_rust_toolchain() {
step "Rust toolchain"
ensure_cargo_on_path
if command -v cargo >/dev/null 2>&1; then
log "Found $(rustc --version 2>/dev/null || echo 'cargo (rustc missing?)')."
return
fi
confirm "Rust is not installed. Install stable via rustup?" || die "Rust is required to build the backend."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
ensure_cargo_on_path
command -v cargo >/dev/null 2>&1 || die "rustup install finished but cargo is not on PATH."
}
ensure_diesel_cli() {
step "diesel_cli (migration runner)"
if command -v diesel >/dev/null 2>&1; then
log "Found $(diesel --version)."
return
fi
# Same install command as _docs/dev_setup_server.md. Needs libmysqlclient-dev
# and libsqlite3-dev (installed above).
log "Installing diesel_cli (this compiles from source; a few minutes)..."
cargo install diesel_cli --no-default-features --features mysql,sqlite
}
run_migrations() {
step "Database migrations (_database/sql/migrations)"
# diesel-cli reads DATABASE_URL; the repo-root .env provides it, but export a
# default so this also works if that file is ever absent (e.g. sparse CI).
export DATABASE_URL="${DATABASE_URL:-mysql://${DEV_MYSQL_USER}:${DEV_MYSQL_PASSWORD}@${DEV_MYSQL_HOST}/${DEV_MYSQL_DB}}"
cd "${root_dir}"
# NB: "Encountered unknown type for Mysql: enum" warnings are harmless
# (_docs/dev_setup_server.md).
diesel migration run
log "Migrations up to date."
}
seed_roles_and_badges() {
step "Seed data (system roles + badges)"
# Same data as _database/sql/seed/bootstrap_inserts_roles_etc.sh, but guarded
# so re-runs don't hit duplicate-key errors. The 'user' role is mandatory:
# account creation hardcodes user_role_slug='user'.
local role_count badge_count
role_count="$(mysql_app -N -e "SELECT COUNT(*) FROM user_roles" 2>/dev/null || echo 0)"
if [ "${role_count}" -gt 0 ]; then
log "user_roles already seeded (${role_count} rows)."
else
mysql_app < "${root_dir}/_database/sql/seed/sql/system_roles.sql"
log "Inserted system roles (user, mod, admin)."
fi
badge_count="$(mysql_app -N -e "SELECT COUNT(*) FROM badges" 2>/dev/null || echo 0)"
if [ "${badge_count}" -gt 0 ]; then
log "badges already seeded (${badge_count} rows)."
else
mysql_app < "${root_dir}/_database/sql/seed/sql/user_badges.sql"
log "Inserted badges."
fi
}
write_secrets_env_if_missing() {
step "Development secrets file"
if [ -f "${SECRETS_ENV_FILE}" ]; then
log "Already exists: ${SECRETS_ENV_FILE} (leaving it untouched)."
return
fi
# Every env var below is read with get_env_string_required at server startup
# (build_dependencies.rs / setup_inference_providers.rs / setup_stripe_*.rs)
# but is NOT supplied by the checked-in config files. None of them is
# contacted at boot — placeholder values are enough to run the server; the
# endpoints that would use them fail lazily if exercised.
#
# NOTE: config loading is FIRST-VALUE-WINS (dotenv never overrides an
# already-set key), so values here cannot override anything already set in
# storyteller-web.development.env — only real process env vars can.
cat > "${SECRETS_ENV_FILE}" <<'EOF'
# Local-development secrets for storyteller-web. Generated by
# script/bootstrap/bootstrap_dev_stack.sh — gitignored; edit freely.
#
# Placeholders below satisfy required-at-boot checks without enabling any
# external integration. Replace individual values with real dev credentials
# only if you need that specific integration locally.
# R2/S3 object storage (clients are constructed offline)
ACCESS_KEY=dummy-local-dev
SECRET_KEY=dummy-local-dev
REGION_NAME=us-east-1
W2L_PRIVATE_DOWNLOAD_BUCKET_NAME=dummy-local-dev-private
W2L_PUBLIC_DOWNLOAD_BUCKET_NAME=dummy-local-dev-public
# Email (Resend)
RESEND_API_KEY=dummy-local-dev
# Generation providers. NB: the omni_gen endpoints (webapp image/video
# generation) contact providers SYNCHRONOUSLY in the request handler, so a real
# submit 500s until you put a real key in for the provider you are testing.
# The legacy polling providers are only contacted by worker binaries, which
# local dev does not run.
FAL_API_KEY=dummy-local-dev
GMICLOUD_API_KEY=dummy-local-dev
GROK_API_KEY=dummy-local-dev
BEEBLE_API_KEY=dummy-local-dev
OPENAI_API_KEY=dummy-local-dev
WORLDLABS_API_KEY=dummy-local-dev
SEEDANCE2PRO_COOKIES=dummy-local-dev
SEEDANCE2PRO_WHITELIST_COOKIES=dummy-local-dev
SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES=dummy-local-dev
# Stripe account ids (test-mode secret keys are already checked into
# storyteller-web.development.env; only the account ids are missing there)
STRIPE_FAKEYOU_ACCOUNT_ID=acct_dummylocaldev
STRIPE_ARTCRAFT_ACCOUNT_ID=acct_dummylocaldev
STRIPE_ARTCRAFT_SECRET_KEY=sk_test_dummylocaldev
STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY=whsec_dummylocaldev
EOF
log "Wrote ${SECRETS_ENV_FILE}"
}
build_backend() {
step "Backend build (storyteller-web)"
if [ "${SKIP_RUST_BUILD}" = "true" ]; then
log "Skipped (--skip-rust-build)."
return
fi
cd "${root_dir}"
# SQLX_OFFLINE only affects compile-time query verification (uses the
# checked-in .sqlx/ cache instead of a live DB); it has no runtime effect.
log "Building (first build takes several minutes)..."
SQLX_OFFLINE=true cargo build -p storyteller-web --bin storyteller-web
log "Built target/debug/storyteller-web."
}
setup_frontend() {
step "Frontend install (frontend/)"
if [ "${SKIP_FRONTEND}" = "true" ]; then
log "Skipped (--skip-frontend)."
return
fi
source "${root_dir}/script/common/frontend_preflight.sh"
frontend_preflight "${root_dir}/frontend"
pushd "${root_dir}/frontend" >/dev/null
frontend_npm_install
popd >/dev/null
log "Frontend dependencies installed."
}
print_next_steps() {
step "Done"
cat <<EOF
The dev stack is bootstrapped. Next steps:
1. Start the backend (binds ${DEV_BACKEND_URL}):
./script/bootstrap/run_backend_dev.sh
2. In another terminal, create the demo login (idempotent):
./script/bootstrap/seed_demo_user.sh
Credentials: ${DEMO_USERNAME} / ${DEMO_PASSWORD}
3. Start the webapp (binds http://localhost:4201). NB: by default the dev
webapp proxies to PRODUCTION — aim it at your backend explicitly:
cd frontend && VITE_DEV_API_HOST=${DEV_BACKEND_URL} npx nx dev artcraft-webapp
Health checks at any time:
./script/bootstrap/dev_stack_doctor.sh
EOF
}
main "$@"
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Shared helpers for the dev-stack bootstrap scripts.
#
# Usage (from a script that has set ${root_dir}):
#
# source "${root_dir}/script/bootstrap/common.sh"
#
# See _docs/dev_setup_local_stack.md for the full picture.
# Canonical local-dev database identity. These match _docs/dev_setup_server.md,
# the diesel DATABASE_URL in the repo-root .env, and the server's MYSQL_URL
# default (crates/schema/config/shared_env_var_config/src/mysql.rs). Override
# via environment only if your local MySQL differs — most tooling assumes them.
DEV_MYSQL_DB="${DEV_MYSQL_DB:-storyteller}"
DEV_MYSQL_USER="${DEV_MYSQL_USER:-storyteller}"
DEV_MYSQL_PASSWORD="${DEV_MYSQL_PASSWORD:-password}"
DEV_MYSQL_HOST="${DEV_MYSQL_HOST:-localhost}"
# Where the backend listens (BIND_ADDRESS default in
# storyteller_web/src/startup/build_dependencies.rs).
DEV_BACKEND_URL="${DEV_BACKEND_URL:-http://localhost:12345}"
# Default demo account. The username must NOT be on the reserved-usernames list
# (includes/binary_includes/usernames/reserved_usernames.txt — "demo", "admin",
# "test", and "dev" are all reserved) and must match ^[A-Za-z0-9_\-]{3,16}$.
DEMO_USERNAME="${DEMO_USERNAME:-localdev1}"
DEMO_PASSWORD="${DEMO_PASSWORD:-localdev1pass}"
DEMO_EMAIL="${DEMO_EMAIL:-localdev1@example.com}"
# Banked credits the demo user's artcraft wallet is seeded/topped-up to.
# Generation submits are gated on wallet balance, so 0 credits means the
# generate button silently refuses in the webapp.
DEMO_CREDITS="${DEMO_CREDITS:-100000}"
log() { echo "[bootstrap] $*"; }
warn() { echo "[bootstrap] WARNING: $*" >&2; }
die() { echo "[bootstrap] ERROR: $*" >&2; exit 1; }
step() {
echo ""
echo "=== $* ==="
}
# sudo unless we already are root (CI containers often run as root without sudo).
maybe_sudo() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
else
sudo "$@"
fi
}
confirm() {
local prompt="$1"
if [ "${ASSUME_YES:-false}" = "true" ]; then
return 0
fi
local reply
read -r -p "${prompt} [y/N] " reply
[[ "${reply}" =~ ^[Yy] ]]
}
# Generate a token in the backend's format: {prefix}{crockford-lower entropy}
# padded to the same TOTAL length the Rust generators use (tokens crate,
# impl_crockford_generator!). Alphabet excludes i/l/o/u like Crockford base32.
new_dev_token() {
local prefix="$1" total_length="$2"
local charset="0123456789abcdefghjkmnpqrstvwxyz"
local count=$((total_length - ${#prefix}))
local entropy=""
local i
for ((i = 0; i < count; i++)); do
entropy+="${charset:RANDOM%32:1}"
done
echo "${prefix}${entropy}"
}
# Run a statement (or stdin) as the storyteller app user against the dev DB.
# MYSQL_PWD keeps the password off the process list.
mysql_app() {
MYSQL_PWD="${DEV_MYSQL_PASSWORD}" mysql \
-u "${DEV_MYSQL_USER}" -h "${DEV_MYSQL_HOST}" -D "${DEV_MYSQL_DB}" "$@"
}
# Run a statement as an administrator, for provisioning the DB and user.
# On a fresh Ubuntu/WSL install, root uses auth_socket, so `sudo mysql` works.
# On GitHub Actions runners the preinstalled MySQL root password is "root" —
# export MYSQL_ROOT_PASSWORD to use password auth instead.
mysql_admin() {
if [ -n "${MYSQL_ROOT_PASSWORD:-}" ]; then
MYSQL_PWD="${MYSQL_ROOT_PASSWORD}" mysql -u root -h "${DEV_MYSQL_HOST}" "$@"
else
maybe_sudo mysql "$@"
fi
}
mysql_app_reachable() {
mysql_app -e "SELECT 1" >/dev/null 2>&1
}
redis_reachable() {
redis-cli ping 2>/dev/null | grep -q PONG
}
backend_reachable() {
curl -sf -o /dev/null --max-time 3 "${DEV_BACKEND_URL}/_status"
}
# Make ~/.cargo/bin visible when rustup was installed but the shell profile
# hasn't been re-sourced (fresh installs, CI).
ensure_cargo_on_path() {
if ! command -v cargo >/dev/null 2>&1 && [ -x "${HOME}/.cargo/bin/cargo" ]; then
export PATH="${HOME}/.cargo/bin:${PATH}"
fi
}
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Diagnose the local dev stack: checks every prerequisite the bootstrap sets up
# and reports PASS / WARN / FAIL per item.
#
# FAIL = the stack cannot work until fixed (exit code 1).
# WARN = optional or run-time-only (e.g. backend not currently running).
set -uo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "${root_dir}/script/bootstrap/common.sh"
SECRETS_ENV_FILE="${root_dir}/crates/service/web/storyteller_web/config/storyteller-web.development-secrets.env"
failures=0
warnings=0
pass() { printf ' PASS %s\n' "$1"; }
fail() { printf ' FAIL %s\n' "$1"; failures=$((failures + 1)); }
warn_check() { printf ' WARN %s\n' "$1"; warnings=$((warnings + 1)); }
echo "Dev stack doctor (${root_dir})"
echo ""
echo "--- Toolchains ---"
ensure_cargo_on_path
if command -v cargo >/dev/null 2>&1; then
pass "rust: $(rustc --version 2>/dev/null | head -1)"
else
fail "rust: cargo not on PATH (bootstrap installs via rustup)"
fi
if command -v diesel >/dev/null 2>&1; then
pass "diesel_cli: $(diesel --version 2>/dev/null | head -1)"
else
fail "diesel_cli: not installed (cargo install diesel_cli --no-default-features --features mysql,sqlite)"
fi
if command -v node >/dev/null 2>&1; then
node_major="$(node --version | sed -E 's/^v([0-9]+).*/\1/')"
if [ "${node_major}" -ge 20 ]; then
pass "node: $(node --version) (>= 20 required by Nx 21 / Vite 6)"
else
fail "node: $(node --version) is too old — Node 20+ required"
fi
else
fail "node: not installed (Node 20+ required for the frontend)"
fi
echo ""
echo "--- Services ---"
if mysql_app_reachable; then
pass "mysql: reachable as '${DEV_MYSQL_USER}' on database '${DEV_MYSQL_DB}'"
migration_dirs="$(find "${root_dir}/_database/sql/migrations" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
applied="$(mysql_app -N -e "SELECT COUNT(*) FROM __diesel_schema_migrations" 2>/dev/null || echo 0)"
if [ "${applied}" -ge "${migration_dirs}" ] && [ "${applied}" -gt 0 ]; then
pass "migrations: ${applied} applied (${migration_dirs} in _database/sql/migrations)"
elif [ "${applied}" -gt 0 ]; then
fail "migrations: only ${applied}/${migration_dirs} applied — run 'diesel migration run'"
else
fail "migrations: none applied — run 'diesel migration run'"
fi
role_count="$(mysql_app -N -e "SELECT COUNT(*) FROM user_roles" 2>/dev/null || echo 0)"
if [ "${role_count}" -ge 3 ]; then
pass "seed: user_roles has ${role_count} rows (user/mod/admin present)"
else
fail "seed: user_roles has ${role_count} rows — account creation needs the 'user' role (re-run bootstrap)"
fi
badge_count="$(mysql_app -N -e "SELECT COUNT(*) FROM badges" 2>/dev/null || echo 0)"
if [ "${badge_count}" -gt 0 ]; then
pass "seed: badges has ${badge_count} rows"
else
warn_check "seed: badges table is empty (cosmetic; re-run bootstrap to fill)"
fi
demo_count="$(mysql_app -N -e "SELECT COUNT(*) FROM users WHERE username='${DEMO_USERNAME}'" 2>/dev/null || echo 0)"
if [ "${demo_count}" -gt 0 ]; then
pass "demo user: '${DEMO_USERNAME}' exists"
credits="$(mysql_app -N -e "SELECT COALESCE(MAX(banked_credits + monthly_credits), 0) FROM wallets WHERE wallet_namespace='artcraft' AND owner_user_token=(SELECT token FROM users WHERE username='${DEMO_USERNAME}')" 2>/dev/null || echo 0)"
if [ "${credits}" -gt 0 ]; then
pass "demo wallet: '${DEMO_USERNAME}' has ${credits} credits"
else
warn_check "demo wallet: '${DEMO_USERNAME}' has no credits — generation flows are blocked (re-run seed_demo_user.sh)"
fi
else
warn_check "demo user: '${DEMO_USERNAME}' not created yet (./script/bootstrap/seed_demo_user.sh, backend must be running)"
fi
else
fail "mysql: cannot connect as '${DEV_MYSQL_USER}'@'${DEV_MYSQL_HOST}' to '${DEV_MYSQL_DB}' (is MySQL running? run bootstrap)"
fi
if redis_reachable; then
pass "redis: PONG"
else
fail "redis: not reachable on localhost (the backend's r2d2 pool connects eagerly at boot)"
fi
echo ""
echo "--- Backend ---"
if [ -f "${SECRETS_ENV_FILE}" ]; then
pass "secrets env: ${SECRETS_ENV_FILE#"${root_dir}"/} exists"
else
fail "secrets env: missing — the server aborts at boot on ~19 required vars (re-run bootstrap)"
fi
if [ -x "${root_dir}/target/debug/storyteller-web" ] || [ -x "${root_dir}/target/release/storyteller-web" ]; then
pass "binary: storyteller-web is built"
else
warn_check "binary: storyteller-web not built yet (bootstrap builds it; cargo run will build on demand)"
fi
if backend_reachable; then
pass "server: ${DEV_BACKEND_URL}/_status responds"
else
warn_check "server: not running (./script/bootstrap/run_backend_dev.sh)"
fi
echo ""
echo "--- Frontend ---"
if [ -d "${root_dir}/frontend/node_modules" ]; then
pass "frontend: node_modules present"
else
warn_check "frontend: node_modules missing (cd frontend && npm install)"
fi
if [ -e "${root_dir}/frontend/pnpm-lock.yaml" ] || [ -d "${root_dir}/frontend/node_modules/.pnpm" ]; then
fail "frontend: stale pnpm artifacts detected (see frontend/README.md — the workspace uses npm)"
else
pass "frontend: no stale pnpm artifacts"
fi
echo ""
echo "Summary: ${failures} failure(s), ${warnings} warning(s)."
if [ "${failures}" -gt 0 ]; then
echo "Fix failures with: ./script/bootstrap/bootstrap_dev_stack.sh (safe to re-run)"
exit 1
fi
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Run the storyteller-web backend for local development (binds 0.0.0.0:12345).
#
# Must run from a bootstrapped environment (./script/bootstrap/bootstrap_dev_stack.sh).
# Always runs from the repo root: the server's config search path
# (crates/service/web/storyteller_web/config/*.env) and its includes/ defaults
# are cwd-relative.
set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "${root_dir}/script/bootstrap/common.sh"
SECRETS_ENV_FILE="${root_dir}/crates/service/web/storyteller_web/config/storyteller-web.development-secrets.env"
mysql_app_reachable || die "MySQL is not reachable as '${DEV_MYSQL_USER}'. Run ./script/bootstrap/bootstrap_dev_stack.sh first."
redis_reachable || die "Redis is not reachable. Run ./script/bootstrap/bootstrap_dev_stack.sh first."
[ -f "${SECRETS_ENV_FILE}" ] || die "Missing ${SECRETS_ENV_FILE}. Run ./script/bootstrap/bootstrap_dev_stack.sh first."
ensure_cargo_on_path
cd "${root_dir}"
# SERVER_ENVIRONMENT defaults to Development when unset (bootstrap.rs), and the
# dev config files are picked up from the search path automatically.
# SQLX_OFFLINE only affects compile-time query checking, never runtime.
log "Starting storyteller-web on ${DEV_BACKEND_URL} (Ctrl-C to stop)..."
exec env SQLX_OFFLINE=true cargo run -p storyteller-web --bin storyteller-web
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# Create (or verify) the local demo user via the running backend, then verify
# that login works. Idempotent: an already-existing user is a success.
#
# Requires the backend to be up (./script/bootstrap/run_backend_dev.sh) — this
# script waits up to --wait-seconds (default 60) for it.
#
# The account is created through POST /v1/create_account rather than raw SQL so
# it exercises the real signup path (bcrypt hash, session, firehose row) and
# stays correct if the users schema evolves.
#
# Override the credentials via DEMO_USERNAME / DEMO_PASSWORD / DEMO_EMAIL.
# Username rules: 3-16 chars of [A-Za-z0-9_-], not on the reserved list
# (includes/binary_includes/usernames/reserved_usernames.txt); password >= 6 chars.
set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "${root_dir}/script/bootstrap/common.sh"
WAIT_SECONDS=60
for arg in "$@"; do
case "${arg}" in
--wait-seconds=*) WAIT_SECONDS="${arg#*=}" ;;
--help|-h) sed -n '2,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "Unknown option: ${arg} (try --help)" ;;
esac
done
command -v jq >/dev/null 2>&1 || die "jq is required (apt-get install jq)."
log "Waiting for backend at ${DEV_BACKEND_URL} (up to ${WAIT_SECONDS}s)..."
deadline=$((SECONDS + WAIT_SECONDS))
until backend_reachable; do
if [ "${SECONDS}" -ge "${deadline}" ]; then
die "Backend not reachable at ${DEV_BACKEND_URL}/_status. Start it with ./script/bootstrap/run_backend_dev.sh"
fi
sleep 2
done
log "Backend is up."
log "Creating account '${DEMO_USERNAME}'..."
create_response="$(curl -sS -X POST "${DEV_BACKEND_URL}/v1/create_account" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg u "${DEMO_USERNAME}" \
--arg p "${DEMO_PASSWORD}" \
--arg e "${DEMO_EMAIL}" \
'{username: $u, password: $p, password_confirmation: $p, email_address: $e}')")"
if [ "$(echo "${create_response}" | jq -r '.success // false')" = "true" ]; then
log "Account created."
else
error_code="$(echo "${create_response}" | jq -r '.error_type // .error_code // .error_code_str // empty')"
case "${error_code}" in
*[Uu]sername[Tt]aken*|*[Ee]mail[Tt]aken*)
log "Account already exists (${error_code}) — continuing."
;;
*)
die "Account creation failed: ${create_response}"
;;
esac
fi
log "Verifying login..."
login_response="$(curl -sS -X POST "${DEV_BACKEND_URL}/v1/login" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg u "${DEMO_USERNAME}" \
--arg p "${DEMO_PASSWORD}" \
'{username_or_email: $u, password: $p}')")"
if [ "$(echo "${login_response}" | jq -r '.success // false')" != "true" ]; then
die "Login verification failed: ${login_response}"
fi
if [ -z "$(echo "${login_response}" | jq -r '.signed_session // empty')" ]; then
die "Login succeeded but returned no signed_session: ${login_response}"
fi
# --- Credits wallet ---------------------------------------------------------
# Generation submits deduct from the user's artcraft wallet and the webapp
# gates the generate button on the balance, so a user without credits cannot
# exercise any generation flow. Signup does not create a wallet; seed one.
# Raw SQL (not an endpoint): the only credit-granting paths are Stripe
# webhooks and moderator tooling, neither of which works locally.
log "Seeding credits wallet (${DEMO_CREDITS} banked credits)..."
user_token="$(mysql_app -N -e "SELECT token FROM users WHERE username = '${DEMO_USERNAME}' LIMIT 1")"
[ -n "${user_token}" ] || die "Could not find users row for '${DEMO_USERNAME}'."
wallet_row="$(mysql_app -N -e "SELECT token, banked_credits FROM wallets WHERE wallet_namespace = 'artcraft' AND owner_user_token = '${user_token}' LIMIT 1")"
if [ -z "${wallet_row}" ]; then
wallet_token="$(new_dev_token "wallet_" 32)"
create_ledger_token="$(new_dev_token "wle_" 32)"
credit_ledger_token="$(new_dev_token "wle_" 32)"
mysql_app -e "
INSERT INTO wallets SET token='${wallet_token}', wallet_namespace='artcraft', owner_user_token='${user_token}', banked_credits=${DEMO_CREDITS}, monthly_credits=0;
INSERT INTO wallet_ledger_entries SET token='${create_ledger_token}', wallet_token='${wallet_token}', entry_type='create', credits_delta=0, banked_credits_before=0, banked_credits_after=0, monthly_credits_before=0, monthly_credits_after=0;
INSERT INTO wallet_ledger_entries SET token='${credit_ledger_token}', wallet_token='${wallet_token}', entry_type='credit_banked', maybe_entity_ref='dev_bootstrap_seed', credits_delta=${DEMO_CREDITS}, banked_credits_before=0, banked_credits_after=${DEMO_CREDITS}, monthly_credits_before=0, monthly_credits_after=0;
" || die "Failed to insert the demo wallet."
log "Wallet created with ${DEMO_CREDITS} banked credits."
else
wallet_token="$(echo "${wallet_row}" | cut -f1)"
balance="$(echo "${wallet_row}" | cut -f2)"
if [ "${balance}" -lt "${DEMO_CREDITS}" ]; then
credit_ledger_token="$(new_dev_token "wle_" 32)"
delta=$((DEMO_CREDITS - balance))
mysql_app -e "
UPDATE wallets SET banked_credits=${DEMO_CREDITS}, version=version+1 WHERE token='${wallet_token}' LIMIT 1;
INSERT INTO wallet_ledger_entries SET token='${credit_ledger_token}', wallet_token='${wallet_token}', entry_type='credit_banked', maybe_entity_ref='dev_bootstrap_topup', credits_delta=${delta}, banked_credits_before=${balance}, banked_credits_after=${DEMO_CREDITS}, monthly_credits_before=0, monthly_credits_after=0;
" || die "Failed to top up the demo wallet."
log "Wallet topped up: ${balance} -> ${DEMO_CREDITS} banked credits."
else
log "Wallet already has ${balance} banked credits — leaving as-is."
fi
fi
# NB: no gallery media is seeded. A media_files row is only useful if the bytes
# behind it are reachable, and serving those locally needs real R2 credentials
# in the secrets env — rows on their own render as broken tiles. For a populated
# library with no backend at all, run frontend/apps/fake-storyteller-web.
log "Demo user is ready."
log " Username: ${DEMO_USERNAME}"
log " Password: ${DEMO_PASSWORD}"
log "Log in at the webapp /login page, or via curl:"
log " curl -c /tmp/cookies.txt -X POST ${DEV_BACKEND_URL}/v1/login -H 'Content-Type: application/json' -d '{\"username_or_email\":\"${DEMO_USERNAME}\",\"password\":\"${DEMO_PASSWORD}\"}'"
@@ -0,0 +1,473 @@
# Bootstrap the full local dev stack (backend + frontend) on native Windows.
#
# Everything service-shaped is PORTABLE and lives under the gitignored
# .devstack\ directory: MySQL 8.4 (official zip) and Redis run as plain user
# processes - no admin rights, no Windows services, no WSL, no Docker.
# Build tools (cmake, perl, nasm, llvm) are checked and offered via winget.
#
# Idempotent: safe to re-run at any time; each step detects work already done.
#
# Usage (from anywhere; paths are self-resolving):
# .\script\bootstrap\windows\bootstrap_dev_stack.ps1 [options]
#
# Options:
# -Yes Non-interactive: assume "yes" for prompts (CI mode)
# -SkipTools Don't check/install build tools via winget
# -SkipRustBuild Don't build the storyteller-web binary
# -SkipFrontend Don't run the frontend npm install
#
# After it succeeds:
# .\script\bootstrap\windows\run_backend_dev.ps1 # API on :12345
# .\script\bootstrap\windows\seed_demo_user.ps1 # demo login
# cd frontend; npx nx dev artcraft-webapp # webapp on :4201, but see
# # VITE_DEV_API_HOST below
param(
[switch]$Yes,
[switch]$SkipTools,
[switch]$SkipRustBuild,
[switch]$SkipFrontend
)
. (Join-Path $PSScriptRoot "common.ps1")
. (Join-Path $PSScriptRoot "start_dev_services.ps1")
$script:AssumeYes = [bool]$Yes
# Tools needed to compile the Rust workspace on Windows (wreq's BoringSSL build
# needs cmake + nasm + perl + libclang; see the workspace Cargo.toml notes).
# Each entry: name, exe to probe, winget id, dirs the installer typically uses
# (appended to this session's PATH after install).
$BuildTools = @(
@{ Name = "CMake"; Exe = "cmake"; WingetId = "Kitware.CMake"; PathDirs = @("$env:ProgramFiles\CMake\bin") },
@{ Name = "Strawberry Perl"; Exe = "perl"; WingetId = "StrawberryPerl.StrawberryPerl"; PathDirs = @("C:\Strawberry\perl\bin") },
@{ Name = "NASM"; Exe = "nasm"; WingetId = "NASM.NASM"; PathDirs = @("$env:ProgramFiles\NASM", "$env:LOCALAPPDATA\bin\NASM") },
@{ Name = "LLVM (libclang)"; Exe = "clang"; WingetId = "LLVM.LLVM"; PathDirs = @("$env:ProgramFiles\LLVM\bin") }
)
function Main {
Step "Preflight"
Ensure-CoreTools
if (-not $SkipTools) { Ensure-BuildTools } else { Write-Log "Build-tool check skipped (-SkipTools)." }
Step "Portable MySQL $MySqlVersion"
Install-PortableMySql
Start-DevMySql
Provision-MySqlDatabase
Step "Portable Redis"
Install-PortableRedis
Start-DevRedis
Step "diesel_cli (migration runner)"
Ensure-DieselCli
Step "Database migrations (_database/sql/migrations)"
Run-Migrations
Step "Seed data (system roles + badges)"
Seed-RolesAndBadges
Step "Development secrets file"
Write-SecretsEnvIfMissing
Step "Backend build (storyteller-web)"
Build-Backend
Step "Frontend install (frontend/)"
Setup-Frontend
Print-NextSteps
}
function Ensure-CoreTools {
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
Add-SessionPath @($cargoBin)
}
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
Die "Rust is not installed. Install via winget (winget install Rustlang.Rustup) or https://rustup.rs, then re-run."
}
Write-Log "rust: $(rustc --version)"
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
Die "Node.js is not installed. Install Node 20+ (winget install OpenJS.NodeJS.LTS), then re-run."
}
$nodeMajor = [int]((node --version) -replace '^v(\d+).*', '$1')
if ($nodeMajor -lt 20) {
Die "Node.js 20+ is required (found $(node --version)) - Nx 21 / Vite 6 requirement."
}
Write-Log "node: $(node --version)"
}
function Ensure-BuildTools {
# Tools installed by a previous run land in dirs this shell may not have on
# PATH yet - map them in before probing, so we don't re-invoke winget.
Add-BuildToolsToSession
$missing = @($BuildTools | Where-Object { -not (Get-Command $_.Exe -ErrorAction SilentlyContinue) })
if ($missing.Count -eq 0) {
Write-Log "All native build tools present (cmake, perl, nasm, clang)."
return
}
$names = ($missing | ForEach-Object { $_.Name }) -join ", "
Write-Log "Missing build tools: $names"
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
Die "winget is unavailable; install the tools manually: $names"
}
if (-not (Confirm-Or-Skip "Install them via winget? (installers may raise a UAC prompt)")) {
Die "Cannot build the backend without: $names (or re-run with -SkipTools if you manage them yourself)."
}
foreach ($tool in $missing) {
Write-Log "winget install $($tool.WingetId)..."
Invoke-Native "winget install --id $($tool.WingetId) -e --accept-source-agreements --accept-package-agreements --disable-interactivity"
if ($LASTEXITCODE -ne 0) {
Write-Warn2 "winget exited with $LASTEXITCODE for $($tool.Name); checking whether the tool landed anyway..."
}
Add-SessionPath $tool.PathDirs
if (-not (Get-Command $tool.Exe -ErrorAction SilentlyContinue)) {
Die "$($tool.Name) still not found after install. Open a new terminal (PATH refresh) and re-run, or install manually."
}
Write-Log "$($tool.Name) OK."
}
}
function Install-PortableMySql {
if (Test-Path (Join-Path $MySqlBaseDir "bin\mysqld.exe")) {
Write-Log "Portable MySQL already extracted."
} elseif ((Test-TcpPort 3306)) {
# Something else already serves 3306 (e.g. a system-wide MySQL install).
# Use it rather than fighting over the port; provisioning below needs root
# credentials via MYSQL_ROOT_PASSWORD if that server has a root password.
Write-Warn2 "Port 3306 is already in use by an existing MySQL server - using it instead of the portable install."
if (-not (Get-Command mysql -ErrorAction SilentlyContinue)) {
Die "A server owns port 3306 but no 'mysql' client is on PATH. Add the client or stop that server."
}
return
} else {
New-Item -ItemType Directory -Force $DownloadsDir | Out-Null
$zipPath = Join-Path $DownloadsDir "mysql-$MySqlVersion-winx64.zip"
if (-not (Test-Path $zipPath)) {
Write-Log "Downloading MySQL $MySqlVersion (~250 MB, official cdn.mysql.com)..."
Download-File $MySqlZipUrl $zipPath
}
Write-Log "Extracting..."
Expand-Archive -Path $zipPath -DestinationPath (Join-Path $DevStackDir "mysql") -Force
if (-not (Test-Path (Join-Path $MySqlBaseDir "bin\mysqld.exe"))) {
Die "Extraction finished but $MySqlBaseDir\bin\mysqld.exe is missing."
}
}
if (-not (Test-Path $MySqlIniPath)) {
# Minimal config; utf8mb4 collation comes from each migration's DDL.
# MySQL's ini parser escape-processes backslashes - use forward slashes,
# and quote the values in case the clone path contains spaces.
$baseForward = $MySqlBaseDir -replace '\\', '/'
$dataForward = $MySqlDataDir -replace '\\', '/'
$ini = @"
[mysqld]
basedir="$baseForward"
datadir="$dataForward"
port=3306
bind-address=127.0.0.1
"@
New-Item -ItemType Directory -Force $DevStackDir | Out-Null
Set-Content -Path $MySqlIniPath -Value $ini -Encoding ascii
}
if (-not (Test-Path (Join-Path $MySqlDataDir "mysql"))) {
Write-Log "Initializing MySQL data directory (root without password, local dev only)..."
$mysqld = Join-Path $MySqlBaseDir "bin\mysqld.exe"
Invoke-Native "`"$mysqld`" --defaults-file=`"$MySqlIniPath`" --initialize-insecure --console"
if ($LASTEXITCODE -ne 0) { Die "mysqld --initialize-insecure failed (exit $LASTEXITCODE). See output above." }
}
}
function Provision-MySqlDatabase {
if (Test-MySqlApp) {
Write-Log "Database '$DevMySqlDb' and user '$DevMySqlUser' already provisioned."
return
}
$mysql = Get-MySqlTool "mysql"
if (-not $mysql) { Die "mysql client not found (portable install missing and none on PATH)." }
# Portable install: root has no password. External server: honor MYSQL_ROOT_PASSWORD.
$prev = $env:MYSQL_PWD
if ($env:MYSQL_ROOT_PASSWORD) { $env:MYSQL_PWD = $env:MYSQL_ROOT_PASSWORD } else { $env:MYSQL_PWD = "" }
try {
# Same DDL as _docs/dev_setup_server.md, made idempotent. The extra
# '127.0.0.1' account covers hosts where reverse-DNS of 127.0.0.1 doesn't
# resolve to 'localhost'. Fed through a temp file + `source` so mysql's
# stderr can never become a terminating PowerShell error mid-provision.
$ddl = @"
CREATE DATABASE IF NOT EXISTS $DevMySqlDb;
CREATE USER IF NOT EXISTS '$DevMySqlUser'@'localhost' IDENTIFIED BY '$DevMySqlPassword';
CREATE USER IF NOT EXISTS '$DevMySqlUser'@'127.0.0.1' IDENTIFIED BY '$DevMySqlPassword';
GRANT ALL PRIVILEGES ON $DevMySqlDb.* TO '$DevMySqlUser'@'localhost';
GRANT ALL PRIVILEGES ON $DevMySqlDb.* TO '$DevMySqlUser'@'127.0.0.1';
FLUSH PRIVILEGES;
"@
$ddlFile = Join-Path $env:TEMP "artcraft_provision_ddl.sql"
Set-Content -Path $ddlFile -Value $ddl -Encoding ascii
$ddlForward = $ddlFile -replace '\\', '/'
$out = Invoke-Native "`"$mysql`" -u root -h 127.0.0.1 -e `"source $ddlForward`""
Remove-Item $ddlFile -Force -Confirm:$false -ErrorAction SilentlyContinue
if ($LASTEXITCODE -ne 0) {
if ($out) { Write-Host ($out -join "`n") }
Die "Provisioning failed. For an external MySQL server, set MYSQL_ROOT_PASSWORD and re-run."
}
} finally {
$env:MYSQL_PWD = $prev
}
if (-not (Test-MySqlApp)) { Die "Provisioning ran but '$DevMySqlUser' still cannot reach '$DevMySqlDb'." }
Write-Log "Provisioned."
}
function Install-PortableRedis {
if (Test-Path (Join-Path $RedisDir "redis-server.exe")) {
Write-Log "Portable Redis already extracted."
return
}
if (Test-TcpPort 6379) {
Write-Warn2 "Port 6379 is already served (existing Redis/Memurai?) - using it."
return
}
New-Item -ItemType Directory -Force $DownloadsDir | Out-Null
$zipPath = Join-Path $DownloadsDir "redis-win-x64.zip"
if (-not (Test-Path $zipPath)) {
Write-Log "Downloading Redis for Windows (~12 MB)..."
Download-File $RedisZipUrl $zipPath
}
Write-Log "Extracting..."
Expand-Archive -Path $zipPath -DestinationPath $RedisDir -Force
if (-not (Test-Path (Join-Path $RedisDir "redis-server.exe"))) {
Die "Extraction finished but $RedisDir\redis-server.exe is missing."
}
}
function Ensure-DieselCli {
Add-MySqlClientToSession
$needInstall = $true
$forceFlag = ""
if (Get-Command diesel -ErrorAction SilentlyContinue) {
# A diesel.exe built against a client lib that has since moved fails at
# load time - probe it rather than trusting its presence.
$version = Invoke-Native "diesel --version"
if ($LASTEXITCODE -eq 0) {
Write-Log "Found $version."
$needInstall = $false
} else {
Write-Warn2 "diesel is on PATH but broken (missing libmysql.dll?) - reinstalling."
$forceFlag = " --force"
}
}
if (-not $needInstall) { return }
# diesel_cli's mysql feature links against libmysqlclient. The portable MySQL
# ships its own client lib - point mysqlclient-sys at it instead of requiring
# vcpkg. sqlite is bundled so no system sqlite is needed.
if (-not (Test-Path (Join-Path $MySqlBaseDir "lib\libmysql.lib"))) {
Die "MySQL client lib not found under $MySqlBaseDir\lib (needed to compile diesel_cli)."
}
Write-Log "Installing diesel_cli (compiles from source; a few minutes)..."
$env:MYSQLCLIENT_LIB_DIR = Join-Path $MySqlBaseDir "lib"
$env:MYSQLCLIENT_VERSION = $MySqlVersion
Invoke-Native "cargo install diesel_cli --no-default-features --features mysql,sqlite-bundled$forceFlag"
if ($LASTEXITCODE -ne 0) { Die "cargo install diesel_cli failed (exit $LASTEXITCODE)." }
Write-Log "Installed $(Invoke-Native 'diesel --version')."
}
function Run-Migrations {
# diesel-cli reads DATABASE_URL (also provided by the repo-root .env, which
# diesel auto-loads from the cwd). diesel.exe needs libmysql.dll at runtime -
# Add-MySqlClientToSession put the portable lib dirs on PATH.
if (-not $env:DATABASE_URL) {
$env:DATABASE_URL = "mysql://${DevMySqlUser}:${DevMySqlPassword}@localhost/${DevMySqlDb}"
}
Push-Location $RootDir
try {
# NB: "Encountered unknown type for Mysql: enum" warnings are harmless.
Invoke-Native "diesel migration run"
if ($LASTEXITCODE -ne 0) { Die "diesel migration run failed (exit $LASTEXITCODE)." }
} finally {
Pop-Location
}
Write-Log "Migrations up to date."
}
function Seed-RolesAndBadges {
# Same data as _database/sql/seed/bootstrap_inserts_roles_etc.sh, but guarded
# so re-runs don't hit duplicate-key errors. The 'user' role is mandatory:
# account creation hardcodes user_role_slug='user'.
$roleCount = Get-MySqlCount "SELECT COUNT(*) FROM user_roles"
if ($roleCount -lt 0) { Die "Could not query user_roles - are migrations applied? (re-run bootstrap)" }
if ($roleCount -gt 0) {
Write-Log "user_roles already seeded ($roleCount rows)."
} else {
Invoke-MySqlApp -SourceFile (Join-Path $RootDir "_database\sql\seed\sql\system_roles.sql")
if ($LASTEXITCODE -ne 0) { Die "Seeding system_roles.sql failed." }
Write-Log "Inserted system roles (user, mod, admin)."
}
$badgeCount = Get-MySqlCount "SELECT COUNT(*) FROM badges"
if ($badgeCount -lt 0) { Die "Could not query badges - are migrations applied? (re-run bootstrap)" }
if ($badgeCount -gt 0) {
Write-Log "badges already seeded ($badgeCount rows)."
} else {
Invoke-MySqlApp -SourceFile (Join-Path $RootDir "_database\sql\seed\sql\user_badges.sql")
if ($LASTEXITCODE -ne 0) { Die "Seeding user_badges.sql failed." }
Write-Log "Inserted badges."
}
}
function Write-SecretsEnvIfMissing {
if (Test-Path $SecretsEnvFile) {
Write-Log "Already exists: $SecretsEnvFile (leaving it untouched)."
return
}
# Every var below is read with get_env_string_required at server startup but
# is NOT supplied by the checked-in config files. None is contacted at boot -
# placeholders are enough to run the server.
#
# NOTE: config loading is FIRST-VALUE-WINS (dotenv never overrides a set
# key), so values here cannot override storyteller-web.development.env.
#
# TEMP_DIR: the upload temp-dir helper defaults to /tmp, which doesn't exist
# on Windows - point it at the user temp dir (forward slashes work fine).
$tempDirForward = $env:TEMP -replace '\\', '/'
$content = @"
# Local-development secrets for storyteller-web. Generated by
# script/bootstrap/windows/bootstrap_dev_stack.ps1 - gitignored; edit freely.
#
# Placeholders below satisfy required-at-boot checks without enabling any
# external integration. Replace individual values with real dev credentials
# only if you need that specific integration locally.
# Windows: scoped upload temp dirs (code default is /tmp)
TEMP_DIR=$tempDirForward
# R2/S3 object storage (clients are constructed offline)
ACCESS_KEY=dummy-local-dev
SECRET_KEY=dummy-local-dev
REGION_NAME=us-east-1
W2L_PRIVATE_DOWNLOAD_BUCKET_NAME=dummy-local-dev-private
W2L_PUBLIC_DOWNLOAD_BUCKET_NAME=dummy-local-dev-public
# Email (Resend)
RESEND_API_KEY=dummy-local-dev
# Generation providers. NB: the omni_gen endpoints (webapp image/video
# generation) contact providers SYNCHRONOUSLY in the request handler, so a real
# submit 500s until you put a real key in for the provider you are testing.
# The legacy polling providers are only contacted by worker binaries, which
# local dev does not run.
FAL_API_KEY=dummy-local-dev
GMICLOUD_API_KEY=dummy-local-dev
GROK_API_KEY=dummy-local-dev
BEEBLE_API_KEY=dummy-local-dev
OPENAI_API_KEY=dummy-local-dev
WORLDLABS_API_KEY=dummy-local-dev
SEEDANCE2PRO_COOKIES=dummy-local-dev
SEEDANCE2PRO_WHITELIST_COOKIES=dummy-local-dev
SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES=dummy-local-dev
# Stripe account ids (test-mode secret keys are already checked into
# storyteller-web.development.env; only the account ids are missing there)
STRIPE_FAKEYOU_ACCOUNT_ID=acct_dummylocaldev
STRIPE_ARTCRAFT_ACCOUNT_ID=acct_dummylocaldev
STRIPE_ARTCRAFT_SECRET_KEY=sk_test_dummylocaldev
STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY=whsec_dummylocaldev
"@
Set-Content -Path $SecretsEnvFile -Value $content -Encoding ascii
Write-Log "Wrote $SecretsEnvFile"
}
function Build-Backend {
if ($SkipRustBuild) {
Write-Log "Skipped (-SkipRustBuild)."
return
}
Add-BuildToolsToSession
Push-Location $RootDir
try {
# SQLX_OFFLINE only affects compile-time query verification (checked-in
# .sqlx/ cache instead of a live DB); it has no runtime effect.
$env:SQLX_OFFLINE = "true"
Write-Log "Building (first build takes several minutes)..."
Invoke-Native "cargo build -p storyteller-web --bin storyteller-web"
if ($LASTEXITCODE -ne 0) { Die "cargo build failed (exit $LASTEXITCODE)." }
} finally {
Pop-Location
}
Write-Log "Built target\debug\storyteller-web.exe."
}
function Setup-Frontend {
if ($SkipFrontend) {
Write-Log "Skipped (-SkipFrontend)."
return
}
$frontendPath = Join-Path $RootDir "frontend"
# Mirror script/common/frontend_preflight.sh: the workspace uses npm; stale
# pnpm artifacts break installs with ENOTEMPTY.
if ((Test-Path (Join-Path $frontendPath "pnpm-lock.yaml")) -or
(Test-Path (Join-Path $frontendPath "pnpm-workspace.yaml")) -or
(Test-Path (Join-Path $frontendPath "node_modules\.pnpm"))) {
Die "Stale pnpm artifacts detected in frontend\. Clean up (see frontend/README.md): remove node_modules, pnpm-lock.yaml, pnpm-workspace.yaml, then re-run."
}
Push-Location $frontendPath
try {
Invoke-Native "npm install"
if ($LASTEXITCODE -ne 0) {
Write-Warn2 "npm install failed; wiping node_modules + .nx and retrying once..."
Remove-Item -Recurse -Force -Confirm:$false node_modules, .nx -ErrorAction SilentlyContinue
Invoke-Native "npm install"
if ($LASTEXITCODE -ne 0) { Die "npm install failed again. See frontend/README.md troubleshooting." }
}
} finally {
Pop-Location
}
Write-Log "Frontend dependencies installed."
}
function Download-File([string]$Url, [string]$Destination) {
# curl.exe ships with Windows 10+ and handles redirects/resume better than
# Invoke-WebRequest (which is also painfully slow on large files in PS 5.1).
# -sS: no progress meter (it goes to stderr, which pollutes captured logs),
# but errors still print.
$out = Invoke-Native "curl.exe -fsSL --retry 3 -o `"$Destination.partial`" `"$Url`""
if ($LASTEXITCODE -ne 0) {
if ($out) { Write-Host ($out -join "`n") }
Remove-Item "$Destination.partial" -Force -Confirm:$false -ErrorAction SilentlyContinue
Die "Download failed: $Url"
}
Move-Item "$Destination.partial" $Destination -Force
}
function Print-NextSteps {
Step "Done"
Write-Host @"
The dev stack is bootstrapped. Next steps:
1. Start the backend (binds $DevBackendUrl):
.\script\bootstrap\windows\run_backend_dev.ps1
2. In another terminal, create the demo login (idempotent):
.\script\bootstrap\windows\seed_demo_user.ps1
Credentials: $DemoUsername / $DemoPassword
3. Start the webapp (binds http://localhost:4201). NB: by default the dev
webapp proxies to PRODUCTION - aim it at your backend explicitly:
`$env:VITE_DEV_API_HOST = "$DevBackendUrl"
cd frontend; npx nx dev artcraft-webapp
Health checks at any time:
.\script\bootstrap\windows\dev_stack_doctor.ps1
Services (MySQL/Redis) start on demand and persist; stop them with:
.\script\bootstrap\windows\stop_dev_services.ps1
"@
}
Main
+234
View File
@@ -0,0 +1,234 @@
# Shared helpers for the Windows dev-stack bootstrap scripts.
#
# Dot-source from a sibling script:
# . (Join-Path $PSScriptRoot "common.ps1")
#
# Design: the stack is PORTABLE - MySQL and Redis live under the gitignored
# .devstack/ directory and run as plain user processes. No admin rights, no
# Windows services, no system-wide installs (build tools excepted).
# See _docs/dev_setup_local_stack.md.
$ErrorActionPreference = "Stop"
$script:RootDir = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path
# Canonical local-dev database identity - must match _docs/dev_setup_server.md,
# the repo-root .env DATABASE_URL, and the server's MYSQL_URL default.
$script:DevMySqlDb = if ($env:DEV_MYSQL_DB) { $env:DEV_MYSQL_DB } else { "storyteller" }
$script:DevMySqlUser = if ($env:DEV_MYSQL_USER) { $env:DEV_MYSQL_USER } else { "storyteller" }
$script:DevMySqlPassword = if ($env:DEV_MYSQL_PASSWORD) { $env:DEV_MYSQL_PASSWORD } else { "password" }
$script:DevBackendUrl = if ($env:DEV_BACKEND_URL) { $env:DEV_BACKEND_URL } else { "http://localhost:12345" }
# Default demo account (see seed_demo_user.ps1). "demo"/"admin"/"test"/"dev"
# are on the backend's reserved-usernames list - don't use those.
$script:DemoUsername = if ($env:DEMO_USERNAME) { $env:DEMO_USERNAME } else { "localdev1" }
$script:DemoPassword = if ($env:DEMO_PASSWORD) { $env:DEMO_PASSWORD } else { "localdev1pass" }
$script:DemoEmail = if ($env:DEMO_EMAIL) { $env:DEMO_EMAIL } else { "localdev1@example.com" }
# Banked credits the demo user's artcraft wallet is seeded/topped-up to.
# Generation submits are gated on wallet balance, so 0 credits means the
# generate button silently refuses in the webapp.
$script:DemoCredits = if ($env:DEMO_CREDITS) { [uint32]$env:DEMO_CREDITS } else { 100000 }
# --- Portable service pins -------------------------------------------------
# MySQL 8.4 LTS (the 8.x series is required; 9.x is unsupported by tooling).
# cdn.mysql.com hosts only some patch versions; 8.4.11 verified available.
$script:MySqlVersion = "8.4.11"
$script:MySqlZipUrl = "https://cdn.mysql.com/Downloads/MySQL-8.4/mysql-$MySqlVersion-winx64.zip"
# Redis for Windows (tporadowski build). Old (5.0) but stable and sufficient
# for the backend's usage (keepalive/TTL/rate-limiter commands). Swap to
# Memurai or Microsoft Garnet by pointing REDIS_ZIP_URL elsewhere.
$script:RedisZipUrl = if ($env:REDIS_ZIP_URL) { $env:REDIS_ZIP_URL } else {
"https://github.com/tporadowski/redis/releases/download/v5.0.14.1/Redis-x64-5.0.14.1.zip"
}
$script:DevStackDir = Join-Path $RootDir ".devstack"
$script:DownloadsDir = Join-Path $DevStackDir "downloads"
$script:MySqlBaseDir = Join-Path $DevStackDir "mysql\mysql-$MySqlVersion-winx64"
$script:MySqlDataDir = Join-Path $DevStackDir "mysql-data"
$script:MySqlIniPath = Join-Path $DevStackDir "my.ini"
$script:RedisDir = Join-Path $DevStackDir "redis"
$script:LogsDir = Join-Path $DevStackDir "logs"
$script:PidsDir = Join-Path $DevStackDir "pids"
$script:SecretsEnvFile = Join-Path $RootDir "crates\service\web\storyteller_web\config\storyteller-web.development-secrets.env"
function Write-Log([string]$Message) { Write-Host "[bootstrap] $Message" }
function Write-Warn2([string]$Message) { Write-Host "[bootstrap] WARNING: $Message" -ForegroundColor Yellow }
function Die([string]$Message) { Write-Host "[bootstrap] ERROR: $Message" -ForegroundColor Red; exit 1 }
function Step([string]$Title) { Write-Host ""; Write-Host "=== $Title ===" -ForegroundColor Cyan }
function Confirm-Or-Skip([string]$Prompt) {
if ($script:AssumeYes) { return $true }
$reply = Read-Host "$Prompt [y/N]"
return ($reply -match '^[Yy]')
}
function Test-TcpPort([int]$Port) {
$client = New-Object System.Net.Sockets.TcpClient
try {
$async = $client.BeginConnect("127.0.0.1", $Port, $null, $null)
if (-not $async.AsyncWaitHandle.WaitOne(1500)) { return $false }
$client.EndConnect($async)
return $true
} catch {
return $false
} finally {
$client.Close()
}
}
# Resolve a client/server exe: prefer the portable install, fall back to PATH.
function Get-MySqlTool([string]$Name) {
$portable = Join-Path $MySqlBaseDir "bin\$Name.exe"
if (Test-Path $portable) { return $portable }
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
return $null
}
function Get-RedisTool([string]$Name) {
$portable = Join-Path $RedisDir "$Name.exe"
if (Test-Path $portable) { return $portable }
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
return $null
}
# Generate a token in the backend's format: {prefix}{crockford-lower entropy}
# padded to the same TOTAL length the Rust generators use (tokens crate,
# impl_crockford_generator!). Alphabet excludes i/l/o/u like Crockford base32.
function New-DevToken([string]$Prefix, [int]$TotalLength) {
$charset = "0123456789abcdefghjkmnpqrstvwxyz"
$count = $TotalLength - $Prefix.Length
$chars = 1..$count | ForEach-Object { $charset[(Get-Random -Maximum $charset.Length)] }
return "$Prefix$(-join $chars)"
}
# Run SQL as the app user against the dev database. Returns output lines
# (stdout and stderr merged via Invoke-Native, so a caller capturing our
# streams can never turn a mysql warning into a terminating error).
# Password goes via MYSQL_PWD so it stays off the command line.
# -Quiet discards output (for probes that are expected to fail, e.g.
# Test-MySqlApp before provisioning). Check $LASTEXITCODE for success.
function Invoke-MySqlApp([string]$Sql, [string]$SourceFile, [switch]$Quiet) {
$mysql = Get-MySqlTool "mysql"
if (-not $mysql) { Die "mysql client not found (run bootstrap_dev_stack.ps1 first)." }
$prev = $env:MYSQL_PWD
$env:MYSQL_PWD = $DevMySqlPassword
try {
if ($SourceFile) {
$forward = $SourceFile -replace '\\', '/'
$out = Invoke-Native "`"$mysql`" -u $DevMySqlUser -h 127.0.0.1 -D $DevMySqlDb -e `"source $forward`""
} else {
$out = Invoke-Native "`"$mysql`" -u $DevMySqlUser -h 127.0.0.1 -D $DevMySqlDb -N -e `"$Sql`""
}
if (-not $Quiet) { return $out }
} finally {
$env:MYSQL_PWD = $prev
}
}
# Run a query expected to return a single non-negative integer (e.g. COUNT(*)).
# Returns -1 if the query failed or produced no numeric line, so callers can
# distinguish "table is empty" (0) from "query failed" (-1).
function Get-MySqlCount([string]$Sql) {
$out = @(Invoke-MySqlApp -Sql $Sql)
if ($LASTEXITCODE -ne 0) { return -1 }
$line = $out | Where-Object { "$_" -match '^\d+$' } | Select-Object -First 1
if ($null -eq $line) { return -1 }
return [int]"$line"
}
function Test-MySqlApp {
try {
Invoke-MySqlApp -Sql "SELECT 1" -Quiet
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
function Test-Redis {
$cli = Get-RedisTool "redis-cli"
if (-not $cli) { return $false }
try {
$pong = Invoke-Native "`"$cli`" -h 127.0.0.1 ping"
return ("$pong" -match "PONG")
} catch {
return $false
}
}
function Test-Backend {
try {
$r = Invoke-WebRequest -Uri "$DevBackendUrl/_status" -UseBasicParsing -TimeoutSec 3
return ($r.StatusCode -eq 200)
} catch {
return $false
}
}
# Start a detached background process, remembering its PID for stop_dev_services.
function Start-DevProcess([string]$Name, [string]$Exe, [string[]]$Arguments) {
New-Item -ItemType Directory -Force $PidsDir | Out-Null
New-Item -ItemType Directory -Force $LogsDir | Out-Null
$out = Join-Path $LogsDir "$Name.out.log"
$err = Join-Path $LogsDir "$Name.err.log"
$proc = Start-Process -FilePath $Exe -ArgumentList $Arguments -WindowStyle Hidden `
-RedirectStandardOutput $out -RedirectStandardError $err -PassThru
Set-Content -Path (Join-Path $PidsDir "$Name.pid") -Value $proc.Id -Encoding ascii
return $proc
}
function Wait-ForCondition([scriptblock]$Condition, [int]$TimeoutSeconds, [string]$What) {
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if (& $Condition) { return $true }
Start-Sleep -Seconds 1
}
Die "Timed out after ${TimeoutSeconds}s waiting for: $What"
}
# Run a native command line via cmd so its stderr is merged OUTSIDE PowerShell.
# PS 5.1 wraps captured native stderr lines in ErrorRecords; combined with our
# $ErrorActionPreference = "Stop", a harmless warning (e.g. from cargo) would
# otherwise abort the script whenever a caller captures/redirects our output.
# Quote embedded paths: Invoke-Native "`"$exe`" --flag value"
function Invoke-Native([string]$CommandLine) {
cmd /s /c "$CommandLine 2>&1"
}
# Make freshly-installed tools visible in this session without a new shell.
function Add-SessionPath([string[]]$Dirs) {
foreach ($d in $Dirs) {
if ((Test-Path $d) -and (($env:Path -split ';') -notcontains $d)) {
$env:Path = "$d;$env:Path"
}
}
}
# The portable MySQL's client DLL + import lib, needed to build AND run
# diesel-cli with the mysql feature.
function Add-MySqlClientToSession {
Add-SessionPath @((Join-Path $MySqlBaseDir "bin"), (Join-Path $MySqlBaseDir "lib"))
}
# Native build tools (cmake/perl/nasm/llvm) land in these dirs when installed
# via winget; a shell opened before the install won't have them on PATH yet.
# bindgen (used by boring2 / aws-lc-sys) additionally wants LIBCLANG_PATH.
function Add-BuildToolsToSession {
Add-SessionPath @(
"$env:ProgramFiles\CMake\bin",
"C:\Strawberry\perl\bin",
"$env:ProgramFiles\NASM",
"$env:LOCALAPPDATA\bin\NASM",
"$env:ProgramFiles\LLVM\bin"
)
if (-not $env:LIBCLANG_PATH) {
$clang = Get-Command clang -ErrorAction SilentlyContinue
if ($clang) { $env:LIBCLANG_PATH = Split-Path $clang.Source }
}
}
@@ -0,0 +1,160 @@
# Diagnose the local dev stack on Windows: checks every prerequisite the
# bootstrap sets up and reports PASS / WARN / FAIL per item.
#
# FAIL = the stack cannot work until fixed (exit code 1).
# WARN = optional or run-time-only (e.g. backend not currently running).
param()
. (Join-Path $PSScriptRoot "common.ps1")
$ErrorActionPreference = "Continue"
$script:Failures = 0
$script:Warnings = 0
function Pass([string]$Message) { Write-Host " PASS $Message" }
function Fail([string]$Message) { Write-Host " FAIL $Message" -ForegroundColor Red; $script:Failures++ }
function WarnCheck([string]$Message) { Write-Host " WARN $Message" -ForegroundColor Yellow; $script:Warnings++ }
Write-Host "Dev stack doctor ($RootDir)"
Write-Host ""
Write-Host "--- Toolchains ---"
Add-SessionPath @((Join-Path $env:USERPROFILE ".cargo\bin"))
Add-MySqlClientToSession
# Freshly-installed build tools may not be on this session's PATH yet.
Add-BuildToolsToSession
if (Get-Command cargo -ErrorAction SilentlyContinue) {
Pass "rust: $(rustc --version)"
} else {
Fail "rust: cargo not on PATH (winget install Rustlang.Rustup)"
}
if (Get-Command diesel -ErrorAction SilentlyContinue) {
$dv = Invoke-Native "diesel --version"
if ($LASTEXITCODE -eq 0) { Pass "diesel_cli: $dv" }
else { Fail "diesel_cli: present but broken (libmysql.dll missing from PATH? re-run bootstrap)" }
} else {
Fail "diesel_cli: not installed (bootstrap installs it against the portable MySQL client lib)"
}
if (Get-Command node -ErrorAction SilentlyContinue) {
$nodeMajor = [int]((node --version) -replace '^v(\d+).*', '$1')
if ($nodeMajor -ge 20) { Pass "node: $(node --version) (>= 20 required by Nx 21 / Vite 6)" }
else { Fail "node: $(node --version) is too old - Node 20+ required" }
} else {
Fail "node: not installed (Node 20+ required for the frontend)"
}
foreach ($tool in @("cmake", "perl", "nasm", "clang")) {
if (Get-Command $tool -ErrorAction SilentlyContinue) {
Pass "${tool}: found"
} else {
WarnCheck "${tool}: not on PATH (needed to COMPILE the backend; bootstrap offers winget installs)"
}
}
Write-Host ""
Write-Host "--- Services (portable, under .devstack\) ---"
if (Test-Path (Join-Path $MySqlBaseDir "bin\mysqld.exe")) {
Pass "mysql: portable install present ($MySqlVersion)"
} elseif (Test-TcpPort 3306) {
WarnCheck "mysql: no portable install, but something serves port 3306 (external server assumed)"
} else {
Fail "mysql: not installed (run bootstrap_dev_stack.ps1)"
}
if (Test-TcpPort 3306) {
if (Test-MySqlApp) {
Pass "mysql: reachable as '$DevMySqlUser' on database '$DevMySqlDb'"
$migrationDirs = (Get-ChildItem -Directory (Join-Path $RootDir "_database\sql\migrations")).Count
$applied = Get-MySqlCount "SELECT COUNT(*) FROM __diesel_schema_migrations"
if (($applied -ge $migrationDirs) -and ($applied -gt 0)) {
Pass "migrations: $applied applied ($migrationDirs in _database\sql\migrations)"
} elseif ($applied -gt 0) {
Fail "migrations: only $applied/$migrationDirs applied - run 'diesel migration run'"
} else {
Fail "migrations: none applied - run 'diesel migration run' (or re-run bootstrap)"
}
$roleCount = Get-MySqlCount "SELECT COUNT(*) FROM user_roles"
if ($roleCount -ge 3) {
Pass "seed: user_roles has $roleCount rows (user/mod/admin present)"
} else {
Fail "seed: user_roles has $roleCount rows - account creation needs the 'user' role (re-run bootstrap)"
}
$badgeCount = Get-MySqlCount "SELECT COUNT(*) FROM badges"
if ($badgeCount -gt 0) { Pass "seed: badges has $badgeCount rows" }
else { WarnCheck "seed: badges table is empty (cosmetic; re-run bootstrap to fill)" }
$demoCount = Get-MySqlCount "SELECT COUNT(*) FROM users WHERE username='$DemoUsername'"
if ($demoCount -gt 0) { Pass "demo user: '$DemoUsername' exists" }
else { WarnCheck "demo user: '$DemoUsername' not created yet (seed_demo_user.ps1, backend must be running)" }
if ($demoCount -gt 0) {
$credits = Get-MySqlCount "SELECT COALESCE(MAX(banked_credits + monthly_credits), 0) FROM wallets WHERE wallet_namespace='artcraft' AND owner_user_token=(SELECT token FROM users WHERE username='$DemoUsername')"
if ($credits -gt 0) { Pass "demo wallet: '$DemoUsername' has $credits credits" }
else { WarnCheck "demo wallet: '$DemoUsername' has no credits - generation flows are blocked (re-run seed_demo_user.ps1)" }
}
} else {
Fail "mysql: port 3306 is up but '$DevMySqlUser' cannot access '$DevMySqlDb' (re-run bootstrap provisioning)"
}
} else {
Fail "mysql: nothing listening on 3306 (start_dev_services.ps1)"
}
if (Test-Redis) {
Pass "redis: PONG"
} elseif (Test-TcpPort 6379) {
WarnCheck "redis: port 6379 open but redis-cli not found to verify"
} else {
Fail "redis: not reachable on 6379 (the backend's r2d2 pool connects eagerly at boot; start_dev_services.ps1)"
}
Write-Host ""
Write-Host "--- Backend ---"
if (Test-Path $SecretsEnvFile) {
Pass "secrets env: storyteller-web.development-secrets.env exists"
} else {
Fail "secrets env: missing - the server aborts at boot on ~19 required vars (re-run bootstrap)"
}
if ((Test-Path (Join-Path $RootDir "target\debug\storyteller-web.exe")) -or
(Test-Path (Join-Path $RootDir "target\release\storyteller-web.exe"))) {
Pass "binary: storyteller-web.exe is built"
} else {
WarnCheck "binary: storyteller-web not built yet (bootstrap builds it; cargo run builds on demand)"
}
if (Test-Backend) {
Pass "server: $DevBackendUrl/_status responds"
} else {
WarnCheck "server: not running (run_backend_dev.ps1)"
}
Write-Host ""
Write-Host "--- Frontend ---"
if (Test-Path (Join-Path $RootDir "frontend\node_modules")) {
Pass "frontend: node_modules present"
} else {
WarnCheck "frontend: node_modules missing (cd frontend; npm install)"
}
if ((Test-Path (Join-Path $RootDir "frontend\pnpm-lock.yaml")) -or
(Test-Path (Join-Path $RootDir "frontend\node_modules\.pnpm"))) {
Fail "frontend: stale pnpm artifacts detected (see frontend/README.md - the workspace uses npm)"
} else {
Pass "frontend: no stale pnpm artifacts"
}
Write-Host ""
Write-Host "Summary: $Failures failure(s), $Warnings warning(s)."
if ($Failures -gt 0) {
Write-Host "Fix failures with: .\script\bootstrap\windows\bootstrap_dev_stack.ps1 (safe to re-run)"
exit 1
}
@@ -0,0 +1,33 @@
# Run the storyteller-web backend for local development (binds 0.0.0.0:12345).
#
# Requires a bootstrapped environment (bootstrap_dev_stack.ps1). Starts the
# portable MySQL/Redis if they aren't running. Always runs from the repo root:
# the server's config search path (crates/service/web/storyteller_web/config)
# and its includes/ defaults are cwd-relative.
param()
. (Join-Path $PSScriptRoot "common.ps1")
. (Join-Path $PSScriptRoot "start_dev_services.ps1")
Start-DevMySql
Start-DevRedis
if (-not (Test-MySqlApp)) { Die "MySQL is up but '$DevMySqlUser' cannot reach '$DevMySqlDb'. Run bootstrap_dev_stack.ps1." }
if (-not (Test-Path $SecretsEnvFile)) { Die "Missing $SecretsEnvFile. Run bootstrap_dev_stack.ps1." }
Add-SessionPath @((Join-Path $env:USERPROFILE ".cargo\bin"))
# cargo run may need to (re)compile, which needs the native build toolchain.
Add-BuildToolsToSession
Push-Location $RootDir
try {
# SERVER_ENVIRONMENT defaults to Development when unset; dev config files are
# picked up from the search path automatically. SQLX_OFFLINE only affects
# compile-time query checking, never runtime. TEMP_DIR belt-and-suspenders in
# case an older generated secrets file predates the Windows TEMP_DIR line.
$env:SQLX_OFFLINE = "true"
if (-not $env:TEMP_DIR) { $env:TEMP_DIR = $env:TEMP -replace '\\', '/' }
Write-Log "Starting storyteller-web on $DevBackendUrl (Ctrl-C to stop)..."
Invoke-Native "cargo run -p storyteller-web --bin storyteller-web"
} finally {
Pop-Location
}
+124
View File
@@ -0,0 +1,124 @@
# Create (or verify) the local demo user via the running backend, then verify
# that login works. Idempotent: an already-existing user is a success.
#
# Requires the backend to be up (run_backend_dev.ps1) - waits up to
# -WaitSeconds (default 60) for it.
#
# The account is created through POST /v1/create_account rather than raw SQL so
# it exercises the real signup path (bcrypt hash, session, firehose row) and
# stays correct if the users schema evolves.
#
# Override credentials via DEMO_USERNAME / DEMO_PASSWORD / DEMO_EMAIL env vars.
# Username rules: 3-16 chars of [A-Za-z0-9_-], not on the reserved list
# (includes/binary_includes/usernames/reserved_usernames.txt); password >= 6.
param(
[int]$WaitSeconds = 60
)
. (Join-Path $PSScriptRoot "common.ps1")
Write-Log "Waiting for backend at $DevBackendUrl (up to ${WaitSeconds}s)..."
Wait-ForCondition { Test-Backend } $WaitSeconds "backend /_status to respond (start it with run_backend_dev.ps1)" | Out-Null
Write-Log "Backend is up."
# curl.exe + ConvertFrom-Json: Invoke-RestMethod in PS 5.1 throws on non-2xx
# and hides the response body, which we need for the already-exists case.
# Invoke-Native keeps curl's stderr from becoming a terminating PS error.
function Invoke-JsonPost([string]$Url, [hashtable]$Body) {
$json = $Body | ConvertTo-Json -Compress
$tmp = Join-Path $env:TEMP "bootstrap_post_body.json"
Set-Content -Path $tmp -Value $json -Encoding ascii
$response = Invoke-Native "curl.exe -sS -X POST `"$Url`" -H `"Content-Type: application/json`" --data `"@$tmp`""
$curlExit = $LASTEXITCODE
Remove-Item $tmp -Force -Confirm:$false -ErrorAction SilentlyContinue
$joined = ($response -join "`n")
if ($curlExit -ne 0) { Die "Request to $Url failed (curl exit $curlExit): $joined" }
try {
return $joined | ConvertFrom-Json
} catch {
Die "Non-JSON response from ${Url}: $joined"
}
}
Write-Log "Creating account '$DemoUsername'..."
$create = Invoke-JsonPost "$DevBackendUrl/v1/create_account" @{
username = $DemoUsername
password = $DemoPassword
password_confirmation = $DemoPassword
email_address = $DemoEmail
}
if ($create.success -eq $true) {
Write-Log "Account created."
} else {
$errorCode = "$($create.error_type)$($create.error_code)$($create.error_code_str)"
if ($errorCode -match "(?i)username_?taken|email_?taken") {
Write-Log "Account already exists ($errorCode) - continuing."
} else {
Die "Account creation failed: $($create | ConvertTo-Json -Compress)"
}
}
Write-Log "Verifying login..."
$login = Invoke-JsonPost "$DevBackendUrl/v1/login" @{
username_or_email = $DemoUsername
password = $DemoPassword
}
if ($login.success -ne $true) { Die "Login verification failed: $($login | ConvertTo-Json -Compress)" }
if (-not $login.signed_session) { Die "Login succeeded but returned no signed_session." }
# --- Credits wallet ---------------------------------------------------------
# Generation submits deduct from the user's artcraft wallet and the webapp
# gates the generate button on the balance, so a user without credits cannot
# exercise any generation flow. Signup does not create a wallet; seed one.
# Raw SQL (not an endpoint): the only credit-granting paths are Stripe
# webhooks and moderator tooling, neither of which works locally.
Write-Log "Seeding credits wallet ($DemoCredits banked credits)..."
$userToken = @(Invoke-MySqlApp -Sql "SELECT token FROM users WHERE username = '$DemoUsername' LIMIT 1") |
Where-Object { "$_" -match '^user_' } | Select-Object -First 1
if (-not $userToken) { Die "Could not find users row for '$DemoUsername'." }
$walletRow = @(Invoke-MySqlApp -Sql "SELECT token, banked_credits FROM wallets WHERE wallet_namespace = 'artcraft' AND owner_user_token = '$userToken' LIMIT 1") |
Where-Object { "$_" -match '^wallet_' } | Select-Object -First 1
if (-not $walletRow) {
$walletToken = New-DevToken "wallet_" 32
$createLedgerToken = New-DevToken "wle_" 32
$creditLedgerToken = New-DevToken "wle_" 32
Invoke-MySqlApp -Sql (
"INSERT INTO wallets SET token='$walletToken', wallet_namespace='artcraft', owner_user_token='$userToken', banked_credits=$DemoCredits, monthly_credits=0; " +
"INSERT INTO wallet_ledger_entries SET token='$createLedgerToken', wallet_token='$walletToken', entry_type='create', credits_delta=0, banked_credits_before=0, banked_credits_after=0, monthly_credits_before=0, monthly_credits_after=0; " +
"INSERT INTO wallet_ledger_entries SET token='$creditLedgerToken', wallet_token='$walletToken', entry_type='credit_banked', maybe_entity_ref='dev_bootstrap_seed', credits_delta=$DemoCredits, banked_credits_before=0, banked_credits_after=$DemoCredits, monthly_credits_before=0, monthly_credits_after=0;"
) | Out-Null
if ($LASTEXITCODE -ne 0) { Die "Failed to insert the demo wallet." }
Write-Log "Wallet created with $DemoCredits banked credits."
} else {
$parts = "$walletRow" -split "`t"
$walletToken = $parts[0]
$balance = [uint64]$parts[1]
if ($balance -lt $DemoCredits) {
$creditLedgerToken = New-DevToken "wle_" 32
$delta = [uint64]$DemoCredits - $balance
Invoke-MySqlApp -Sql (
"UPDATE wallets SET banked_credits=$DemoCredits, version=version+1 WHERE token='$walletToken' LIMIT 1; " +
"INSERT INTO wallet_ledger_entries SET token='$creditLedgerToken', wallet_token='$walletToken', entry_type='credit_banked', maybe_entity_ref='dev_bootstrap_topup', credits_delta=$delta, banked_credits_before=$balance, banked_credits_after=$DemoCredits, monthly_credits_before=0, monthly_credits_after=0;"
) | Out-Null
if ($LASTEXITCODE -ne 0) { Die "Failed to top up the demo wallet." }
Write-Log "Wallet topped up: $balance -> $DemoCredits banked credits."
} else {
Write-Log "Wallet already has $balance banked credits - leaving as-is."
}
}
# NB: no gallery media is seeded. A media_files row is only useful if the bytes
# behind it are reachable, and serving those locally needs real R2 credentials
# in the secrets env - rows on their own render as broken tiles. For a populated
# library with no backend at all, run frontend/apps/fake-storyteller-web.
Write-Log "Demo user is ready."
Write-Log " Username: $DemoUsername"
Write-Log " Password: $DemoPassword"
Write-Log "Log in at http://localhost:4201/login once the webapp is running - start it with"
Write-Log "VITE_DEV_API_HOST=$DevBackendUrl set, or it will talk to production instead."
@@ -0,0 +1,52 @@
# Start (or verify) the portable MySQL and Redis processes under .devstack/.
# Idempotent: already-running services are left alone. Safe to call from other
# scripts via dot-sourcing (defines Start-DevMySql / Start-DevRedis) or run
# directly.
param()
. (Join-Path $PSScriptRoot "common.ps1")
function Start-DevMySql {
if (Test-TcpPort 3306) {
Write-Log "MySQL already listening on 3306."
return
}
$mysqld = Join-Path $MySqlBaseDir "bin\mysqld.exe"
if (-not (Test-Path $mysqld)) {
Die "Portable MySQL not installed at $MySqlBaseDir. Run bootstrap_dev_stack.ps1 first."
}
if (-not (Test-Path (Join-Path $MySqlDataDir "mysql"))) {
Die "MySQL data directory not initialized at $MySqlDataDir. Run bootstrap_dev_stack.ps1 first."
}
Write-Log "Starting mysqld (portable, data in .devstack\mysql-data)..."
Start-DevProcess -Name "mysqld" -Exe $mysqld -Arguments @("--defaults-file=`"$MySqlIniPath`"") | Out-Null
Wait-ForCondition { Test-TcpPort 3306 } 60 "MySQL to listen on 3306 (see $LogsDir\mysqld.err.log and the .err file in $MySqlDataDir)" | Out-Null
Write-Log "MySQL is up."
}
function Start-DevRedis {
if (Test-TcpPort 6379) {
Write-Log "Redis already listening on 6379."
return
}
$redis = Join-Path $RedisDir "redis-server.exe"
if (-not (Test-Path $redis)) {
Die "Portable Redis not installed at $RedisDir. Run bootstrap_dev_stack.ps1 first."
}
Write-Log "Starting redis-server (portable)..."
# Keep the working dir (dump.rdb etc.) inside .devstack. NB: Start-Process
# rejects empty-string ArgumentList elements, so pass `--save ""` as one
# token to disable snapshotting.
Start-DevProcess -Name "redis" -Exe $redis -Arguments @(
"--port", "6379",
"--save", "`"`"",
"--dir", "`"$RedisDir`""
) | Out-Null
Wait-ForCondition { Test-TcpPort 6379 } 30 "Redis to listen on 6379 (see $LogsDir\redis.err.log)" | Out-Null
Write-Log "Redis is up."
}
if ($MyInvocation.InvocationName -ne '.') {
Start-DevMySql
Start-DevRedis
}
@@ -0,0 +1,41 @@
# Stop the portable MySQL and Redis processes started by start_dev_services.ps1.
param()
. (Join-Path $PSScriptRoot "common.ps1")
# Graceful MySQL shutdown (flushes InnoDB) - root has no password in the
# portable install (initialized with --initialize-insecure).
if (Test-TcpPort 3306) {
$mysqladmin = Get-MySqlTool "mysqladmin"
if ($mysqladmin) {
Write-Log "Shutting down MySQL..."
try { & $mysqladmin -u root -h 127.0.0.1 shutdown 2>$null } catch {}
$deadline = (Get-Date).AddSeconds(30)
while ((Test-TcpPort 3306) -and ((Get-Date) -lt $deadline)) { Start-Sleep -Seconds 1 }
}
}
if (Test-TcpPort 6379) {
$cli = Get-RedisTool "redis-cli"
if ($cli) {
Write-Log "Shutting down Redis..."
try { & $cli -h 127.0.0.1 shutdown nosave 2>$null } catch {}
}
}
# Last resort: kill anything left via recorded PIDs.
if (Test-Path $PidsDir) {
foreach ($pidFile in Get-ChildItem $PidsDir -Filter "*.pid" -ErrorAction SilentlyContinue) {
$procId = Get-Content $pidFile.FullName -ErrorAction SilentlyContinue
if ($procId) {
$proc = Get-Process -Id $procId -ErrorAction SilentlyContinue
if ($proc -and -not $proc.HasExited) {
Write-Log "Force-stopping $($pidFile.BaseName) (pid $procId)..."
try { Stop-Process -Id $procId -Force -Confirm:$false -ErrorAction Stop } catch {}
}
}
Remove-Item $pidFile.FullName -Force -Confirm:$false -ErrorAction SilentlyContinue
}
}
Write-Log "Dev services stopped."