mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 01:12:40 -04:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9ee643bf6 | ||
|
|
07e9a35044 | ||
|
|
0eddb659fa | ||
|
|
17c12836ad | ||
|
|
7654e95636 | ||
|
|
1bb31f42d7 | ||
|
|
bc4b3a6c1f | ||
|
|
13b5e7b5c2 | ||
|
|
4ff5bdf475 | ||
|
|
e1b16cf2de |
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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(),
|
||||
}
|
||||
|
||||
+11
@@ -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
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+17
@@ -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 }),
|
||||
))
|
||||
}
|
||||
+208
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub mod build;
|
||||
pub mod cost;
|
||||
pub mod request;
|
||||
+17
@@ -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
|
||||
}
|
||||
}
|
||||
+1
@@ -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),
|
||||
|
||||
+8
-2
@@ -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,
|
||||
|
||||
+11
-1
@@ -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,
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod moderator_list_databox_dashboards_handler;
|
||||
+67
@@ -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;
|
||||
|
||||
+16
-4
@@ -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,
|
||||
|
||||
+19
-11
@@ -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)))
|
||||
},
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+16
-4
@@ -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,
|
||||
|
||||
+18
-10
@@ -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)))
|
||||
},
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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
@@ -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;
|
||||
|
||||
+7
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
|
||||
+13
-3
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
+17
-5
@@ -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
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Generated
+88
-168
File diff suppressed because it is too large
Load Diff
Executable
+356
@@ -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 "$@"
|
||||
Executable
+115
@@ -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
|
||||
}
|
||||
Executable
+144
@@ -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
|
||||
Executable
+26
@@ -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
|
||||
Executable
+127
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
Reference in New Issue
Block a user