@civitai/blocks-react 0.64.0 → 0.65.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -12
- package/dist/hooks/useBuzzPurchase.d.ts +3 -1
- package/dist/hooks/useBuzzPurchase.js +3 -1
- package/dist/hooks/useBuzzWorkflow.d.ts +102 -24
- package/dist/hooks/useBuzzWorkflow.js +59 -33
- package/dist/hooks/useGoodPurchase.d.ts +7 -7
- package/dist/hooks/usePrepareTrainingDataset.d.ts +99 -0
- package/dist/hooks/usePrepareTrainingDataset.js +157 -0
- package/dist/hooks/useRunTraining.d.ts +151 -0
- package/dist/hooks/useRunTraining.js +212 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/internal/liveHost.d.ts +12 -1
- package/dist/internal/liveHost.js +54 -1
- package/dist/internal/mockHost.d.ts +118 -6
- package/dist/internal/mockHost.js +276 -1
- package/dist/transport/requestTimeouts.d.ts +19 -0
- package/dist/transport/requestTimeouts.js +30 -0
- package/dist/transport/validate.d.ts +27 -0
- package/dist/transport/validate.js +114 -11
- package/dist/ui/styles.d.ts +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -325,12 +325,10 @@ if (priced) {
|
|
|
325
325
|
try {
|
|
326
326
|
const snap = await submit(body); // status 'submitting' → 'polling'
|
|
327
327
|
if (snap.status === 'failed') {
|
|
328
|
-
// 🔴 A RESOLVED `failed` IS A
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
// unavailable" deny and a missing price quote are NOT. Selling Buzz for
|
|
333
|
-
// one of those takes money and fixes nothing, so branch before you offer.
|
|
328
|
+
// 🔴 A RESOLVED `failed` IS A SERVER OUTCOME, NEVER A TOP-UP CUE: a spend
|
|
329
|
+
// cap or limit buying Buzz does not raise, or a run that may already have
|
|
330
|
+
// spent. The complete list is on `useBuzzWorkflow`'s `submit` docs.
|
|
331
|
+
// Running OUT of Buzz rejects instead — see the catch below.
|
|
334
332
|
showError(submitOutcomeMessage(snap)); // YOUR app owns this copy
|
|
335
333
|
} else {
|
|
336
334
|
await poll(snap.workflowId); // you loop this on a backoff until terminal
|
|
@@ -375,11 +373,14 @@ if (priced) {
|
|
|
375
373
|
`status: 'failed'`, an `error` string, **and a numeric `cost.total`** — the
|
|
376
374
|
price the server refused to charge. That is a workflow *outcome*, not an error.
|
|
377
375
|
Check `snap.status`, not just `try/catch`.
|
|
378
|
-
🔴 **
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
376
|
+
🔴 **None of them is a top-up cue.** Each is a spend cap or limit that buying
|
|
377
|
+
Buzz does not raise — the per-call `buzzBudget` and the viewer's daily cap
|
|
378
|
+
included — or a reply that may already have spent. The complete list, and
|
|
379
|
+
which of them may say "nothing was charged", is in `useBuzzWorkflow`'s
|
|
380
|
+
`submit` docs. A viewer who is genuinely **out of Buzz** makes `submit`
|
|
381
|
+
**reject** (`WorkflowSubmitError` code `'exception'`, shared with other
|
|
382
|
+
thrown submits): decide a top-up from `useBuzzBalance()` — blue plus the
|
|
383
|
+
block's domain pool, never all three — against the quoted cost.
|
|
383
384
|
- **`submit` REJECTS when the reply carries no usable workflow outcome**
|
|
384
385
|
(`@civitai/blocks-react@0.44.0+`). Every failure-shaped reply reports
|
|
385
386
|
`status: 'failed'`, so `status` cannot tell them apart — **`cost` presence
|
|
@@ -468,7 +469,9 @@ if (priced) {
|
|
|
468
469
|
|
|
469
470
|
### `useBuzzPurchase()`
|
|
470
471
|
|
|
471
|
-
Open the Buzz purchase modal
|
|
472
|
+
Open the Buzz purchase modal. It raises the viewer's WALLET, never a spend cap:
|
|
473
|
+
offer it when the viewer's spendable Buzz is below a quoted cost, never on a
|
|
474
|
+
resolved `failed` submit (see `useBuzzWorkflow`'s `submit` docs).
|
|
472
475
|
|
|
473
476
|
```tsx
|
|
474
477
|
const { openPurchaseModal } = useBuzzPurchase();
|
|
@@ -735,6 +738,126 @@ options drive both arms (including `declined`). **`dev:live` refuses this bridge
|
|
|
735
738
|
it has no civitai chrome to render the server-resolved confirm in, and driving
|
|
736
739
|
the write without it would let dev prove out a flow production does not have.
|
|
737
740
|
|
|
741
|
+
### `usePrepareTrainingDataset()` and `useRunTraining()` — LoRA training
|
|
742
|
+
|
|
743
|
+
Train a LoRA with the ai-toolkit engine on the **viewer's own images**, from
|
|
744
|
+
inside a page app — the App Blocks `kind: 'training'` flow. Four steps, three
|
|
745
|
+
hooks:
|
|
746
|
+
|
|
747
|
+
1. **Dataset** — `usePrepareTrainingDataset().prepareDataset([{ imageId, caption }])`
|
|
748
|
+
(`PREPARE_TRAINING_DATASET`). Only the viewer's own scanned, unflagged images
|
|
749
|
+
within the page's maturity ceiling are admitted; the rest come back in
|
|
750
|
+
`rejected` with a reason (`unavailable`, `unsupported-media`, `not-eligible`,
|
|
751
|
+
`pending-scan`, `import-failed`, `import-unavailable`; `pending-scan` and
|
|
752
|
+
`import-unavailable` are retryable). You get an opaque `datasetId` and the
|
|
753
|
+
admitted `count`, always at least 1 — if **nothing** is admitted the call
|
|
754
|
+
rejects with the server's message instead. 1–50 images, captions up to 1,000 characters; captions are
|
|
755
|
+
moderated. No dialog, no charge.
|
|
756
|
+
2. **Quote** — `useBuzzWorkflow().estimate(body)` with a `WorkflowBodyTraining`
|
|
757
|
+
(`kind: 'training'`, no `quoteId`). The reply carries `trainingQuote:
|
|
758
|
+
{ quoteId, total, imageCount, expiresAt }` — the orchestrator's own price for
|
|
759
|
+
exactly this run, stored server-side for 15 minutes.
|
|
760
|
+
3. **Run** — `useRunTraining().runTraining({ ...body, quoteId })` (`RUN_TRAINING`).
|
|
761
|
+
**Civitai shows the viewer a consent dialog in its own chrome** — price, base
|
|
762
|
+
model, length and dataset size, all read back from the server, never from
|
|
763
|
+
your body — and submits only on their click. Change nothing in the body
|
|
764
|
+
between the estimate and the run; the server checks it against the quoted one.
|
|
765
|
+
4. **Follow** — `useBuzzWorkflow().watch(workflowId)`. Once the run's moderation
|
|
766
|
+
status is approved, `trainedEpochs` lists the epochs whose checkpoint is
|
|
767
|
+
ready; send the viewer to the publish wizard with one of them (see
|
|
768
|
+
`trainedEpochs` on `BlockWorkflowSnapshot`).
|
|
769
|
+
|
|
770
|
+
🔴 **Availability.** Behind the host flag **`app-blocks-training-kind`**, which
|
|
771
|
+
ships **off** and is evaluated per viewer. **Page apps only** — the model slot
|
|
772
|
+
answers both messages with an error. The manifest must declare
|
|
773
|
+
**`ai:write:budgeted`** and the viewer must be signed in and have granted it.
|
|
774
|
+
**Refused from `dev:live` and from review sessions** (the server refuses
|
|
775
|
+
development and review tokens); build the flow against the mock host. Requires a
|
|
776
|
+
civitai.com host carrying civitai/civitai#5434 (`kind: 'training'`,
|
|
777
|
+
`RUN_TRAINING`) and civitai/civitai#5438 (`PREPARE_TRAINING_DATASET`).
|
|
778
|
+
|
|
779
|
+
🔴 **Money.** There is no `maxBuzz` and no timeout knob — the price is the quote.
|
|
780
|
+
A confirmed run may cost more than the token's per-call `buzzBudget` (the viewer
|
|
781
|
+
confirms the exact price), up to `BLOCK_TRAINING_MAX_BUZZ_PER_RUN` (5,000 Buzz);
|
|
782
|
+
an estimate above that is refused. `useBuzzWorkflow().submit()` **refuses** a
|
|
783
|
+
training body before sending anything: only `RUN_TRAINING`'s dialog can confirm a
|
|
784
|
+
quote, so a training run starts nowhere else.
|
|
785
|
+
|
|
786
|
+
`runTraining` **rejects** with a `RunTrainingError`; read the flags before
|
|
787
|
+
saying anything about money:
|
|
788
|
+
|
|
789
|
+
| | meaning | what to do |
|
|
790
|
+
|---|---|---|
|
|
791
|
+
| `err.declined` | the viewer dismissed the dialog — **no run was submitted** | revert, say nothing |
|
|
792
|
+
| `err.unconfirmed` | `submission-unconfirmed`, or no reply within the 10-min bound (`err.timedOut`) — the run **may be running and charged** | 🔴 **never retry automatically**: check the viewer's trainings first (`useAppWorkflows()` lists this app's runs). Re-running the same body after the server did start it is a **second, separately charged run** |
|
|
793
|
+
| `err.refused` (`code: 'refused'`) | the server refused the submit after the viewer confirmed — a **spend cap** (their daily or private-run Buzz cap, the per-app consent budget, the app's daily spend or rate limit, a dev-session cap) or a **temporary-availability** deny. Refunded: **no run, nothing charged**; the quote is used up | show `err.message` (the server's reason, e.g. `daily Buzz cap reached: …`); estimate again before any retry — buying Buzz does not lift these caps |
|
|
794
|
+
| `err.signInRequired` | no session | route into `useRequestSignIn()` |
|
|
795
|
+
| `err.code` set otherwise | a host refusal (`review-mode` / `block is not ready` / `invalid training request` / `no block token`) | show or ignore per case |
|
|
796
|
+
| `err.code === undefined`, no flag | a server refusal before any submit (expired or used quote, changed body, ineligible image) | estimate again, then retry |
|
|
797
|
+
|
|
798
|
+
`prepareDataset` rejects with a `PrepareTrainingDatasetError` (`.code` for the
|
|
799
|
+
host's refusals — `review-mode`, `block is not ready`, `sign in to train`,
|
|
800
|
+
`invalid training dataset`, `no block token` — plus `.signInRequired` and
|
|
801
|
+
`.timedOut`). Preparing charges nothing, so none of them cost Buzz. It prompts
|
|
802
|
+
for `ai:write:budgeted` and retries once on a grant (see the table below);
|
|
803
|
+
`runTraining` deliberately does not — it carries no idempotency key and has an
|
|
804
|
+
outcome where a run may exist, so it never re-sends.
|
|
805
|
+
|
|
806
|
+
```tsx
|
|
807
|
+
const { prepareDataset } = usePrepareTrainingDataset();
|
|
808
|
+
const { estimate, watch } = useBuzzWorkflow();
|
|
809
|
+
const { runTraining } = useRunTraining();
|
|
810
|
+
|
|
811
|
+
async function train(images: Array<{ id: number; caption: string }>) {
|
|
812
|
+
// Rejects (no `.code`) when NOTHING is admitted, e.g. "none of the requested
|
|
813
|
+
// images can be used for training"; a resolved dataset always has count >= 1.
|
|
814
|
+
const dataset = await prepareDataset(images.map((i) => ({ imageId: i.id, caption: i.caption })));
|
|
815
|
+
if (dataset.rejected.length > 0) showNotice(`${dataset.rejected.length} image(s) left out`);
|
|
816
|
+
|
|
817
|
+
const body: WorkflowBodyTraining = {
|
|
818
|
+
kind: 'training',
|
|
819
|
+
datasetId: dataset.datasetId,
|
|
820
|
+
engine: 'ai-toolkit',
|
|
821
|
+
model: baseModelKey, // a key from Civitai's training catalog
|
|
822
|
+
params: aiToolkitParams, // AiToolkitTrainingParams
|
|
823
|
+
triggerWord: 'mystyle',
|
|
824
|
+
samplePrompts: ['mystyle, a lighthouse at dusk'],
|
|
825
|
+
};
|
|
826
|
+
const quote = (await estimate(body)).trainingQuote;
|
|
827
|
+
if (!quote) return showError('No training price came back.');
|
|
828
|
+
// Show quote.total; the host's dialog will show the same number.
|
|
829
|
+
|
|
830
|
+
try {
|
|
831
|
+
const started = await runTraining({ ...body, quoteId: quote.quoteId });
|
|
832
|
+
const done = await watch(started.workflowId, { onUpdate: render });
|
|
833
|
+
setEpochs(done.trainedEpochs ?? []);
|
|
834
|
+
} catch (err) {
|
|
835
|
+
if (!(err instanceof RunTrainingError)) throw err;
|
|
836
|
+
if (err.declined) return; // no run
|
|
837
|
+
if (err.unconfirmed) return showCheckYourTrainings(); // may be running — never auto-retry
|
|
838
|
+
if (err.refused) return showError(err.message); // a cap / availability refusal; no run, nothing charged
|
|
839
|
+
if (err.signInRequired) return requestSignIn();
|
|
840
|
+
showError('Could not start training. Get a new price and try again.');
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
With the mock host — `createMockHost` or `Harness` from
|
|
846
|
+
`@civitai/blocks-react/testing` — the training path is **kind-faithful**: it
|
|
847
|
+
holds the datasets it prepared and the quotes it stored, refuses an unknown
|
|
848
|
+
dataset, a dataset with nothing admitted, a quote above 5,000 and a quote run
|
|
849
|
+
twice, and needs
|
|
850
|
+
`ai:write:budgeted` on the token and a signed-in viewer. Its knobs are
|
|
851
|
+
`trainingDatasetRejected`, `trainingDatasetError`, `trainingQuoteTotal`,
|
|
852
|
+
`runTrainingError` (`'declined'`, `'submission-unconfirmed'`, or any server
|
|
853
|
+
message), `runTrainingCapRefusal` (the server's resolved cap / availability refusal, which consumes the quote →
|
|
854
|
+
`err.refused`) and `generation.trainedEpochs` for the finished run. It has no
|
|
855
|
+
consent dialog, so `runTraining` settles at once where the real host waits on a
|
|
856
|
+
click, and it checks a run's body against its quote by `datasetId` only — the
|
|
857
|
+
server compares the whole body.
|
|
858
|
+
**`dev:live` refuses both bridges**, because the server refuses every training
|
|
859
|
+
request from a dev token.
|
|
860
|
+
|
|
738
861
|
### `useAppWorkflows(params?)`
|
|
739
862
|
|
|
740
863
|
The calling app's **own** generator subqueue — the tag-scoped list of generations
|
|
@@ -1227,6 +1350,7 @@ had just worked.
|
|
|
1227
1350
|
|---|---|---|
|
|
1228
1351
|
| `useBuzzWorkflow()` | `submit()` | `ai:write:budgeted` |
|
|
1229
1352
|
| `useCreatePostFromApp()` | `createPost()` | `posts:write:self` |
|
|
1353
|
+
| `usePrepareTrainingDataset()` | `prepareDataset()` | `ai:write:budgeted` |
|
|
1230
1354
|
| `useGoodPurchase()` | `purchase()` | `goods:purchase:self` |
|
|
1231
1355
|
| `useTip()` | `tip()` | `social:tip:self` |
|
|
1232
1356
|
|
|
@@ -9,7 +9,9 @@ export interface UseBuzzPurchase {
|
|
|
9
9
|
* Opens the Civitai Buzz purchase modal on the host. Resolves with the
|
|
10
10
|
* outcome when the user closes the modal — `purchased: true` means the
|
|
11
11
|
* balance increased; the new balance is included if the host reports it.
|
|
12
|
-
*
|
|
12
|
+
* Raises the viewer's WALLET, never a spend cap: offer it when the viewer's
|
|
13
|
+
* spendable Buzz is below a quoted cost, never on a resolved `failed` submit
|
|
14
|
+
* from {@link useBuzzWorkflow} (see its `submit` docs for why).
|
|
13
15
|
*
|
|
14
16
|
* 🔴 HUMAN-GATED: the reply comes when the viewer closes the modal, and a
|
|
15
17
|
* payment flow is nowhere near a ~30s round-trip — so this passes
|
|
@@ -6,7 +6,9 @@ import { sendTypedRequest } from '../transport/transport.js';
|
|
|
6
6
|
* Opens the Civitai Buzz purchase modal on the host. Resolves with the
|
|
7
7
|
* outcome when the user closes the modal — `purchased: true` means the
|
|
8
8
|
* balance increased; the new balance is included if the host reports it.
|
|
9
|
-
*
|
|
9
|
+
* Raises the viewer's WALLET, never a spend cap: offer it when the viewer's
|
|
10
|
+
* spendable Buzz is below a quoted cost, never on a resolved `failed` submit
|
|
11
|
+
* from {@link useBuzzWorkflow} (see its `submit` docs for why).
|
|
10
12
|
*
|
|
11
13
|
* 🔴 HUMAN-GATED: the reply comes when the viewer closes the modal, and a
|
|
12
14
|
* payment flow is nowhere near a ~30s round-trip — so this passes
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import type { BlockWorkflowSnapshot, WorkflowBody, WorkflowStatus } from '@civitai/app-sdk/blocks';
|
|
2
2
|
import type { ConsentRetryOptions } from './consentRetryOptions.js';
|
|
3
|
+
/**
|
|
4
|
+
* Why {@link UseBuzzWorkflow.submit} refuses a `kind: 'training'` body.
|
|
5
|
+
*
|
|
6
|
+
* 🔴 REFUSED BEFORE THE SEND, NOT LEFT TO THE SERVER. The server charges a
|
|
7
|
+
* training run only against a quote the viewer confirmed from a signed-in
|
|
8
|
+
* session, and only `RUN_TRAINING`'s host-chrome dialog can record that — so a
|
|
9
|
+
* `SUBMIT_WORKFLOW` carrying this body can never start a run. Refusing here
|
|
10
|
+
* turns a server round-trip ending in a confusing refusal into an immediate
|
|
11
|
+
* error that names the hook to use. Nothing is sent and nothing is spent, which
|
|
12
|
+
* is why it is a plain `Error` and not a {@link WorkflowSubmitError} (every code
|
|
13
|
+
* on that class is money-ambiguous by design).
|
|
14
|
+
*/
|
|
15
|
+
export declare const TRAINING_BODY_SUBMIT_REFUSAL = "useBuzzWorkflow.submit() does not run a kind:'training' body \u2014 estimate it here, then run it with useRunTraining().runTraining({ ...body, quoteId }). Nothing was sent.";
|
|
3
16
|
/**
|
|
4
17
|
* Default orchestrator-side hold per {@link UseBuzzWorkflow.watch} poll,
|
|
5
18
|
* in SECONDS.
|
|
@@ -258,10 +271,11 @@ export type WorkflowSubmitErrorCode = 'exception' | 'workflow-failed';
|
|
|
258
271
|
*
|
|
259
272
|
* - **budget / spend-cap REJECTION** — a legitimate OUTCOME. The server quotes
|
|
260
273
|
* the price it refused to charge, so the snapshot carries a numeric
|
|
261
|
-
* `cost.total
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
274
|
+
* `cost.total`, and the block can tell the viewer what was refused and why,
|
|
275
|
+
* so it MUST keep RESOLVING. It is NOT a top-up cue: these are app and
|
|
276
|
+
* platform caps, and buying Buzz raises none of them. Every such exit on
|
|
277
|
+
* the server attaches a cost; the complete list of them, and of the other
|
|
278
|
+
* resolved `'failed'` replies, is on {@link UseBuzzWorkflow.submit}.
|
|
265
279
|
* - **caught server EXCEPTION** — `failureSnapshot(err)`, no `cost`, and the
|
|
266
280
|
* `'failed'` sentinel id. The host had no workflow to report — usually
|
|
267
281
|
* nothing was queued, but a lost response or an in-progress idempotency
|
|
@@ -309,8 +323,8 @@ export type WorkflowSubmitErrorCode = 'exception' | 'workflow-failed';
|
|
|
309
323
|
*
|
|
310
324
|
* 🔴 DO NOT "SIMPLIFY" THIS BY PUTTING `snapshot.error` BACK ON `message`, and do
|
|
311
325
|
* not widen the guard to reject a budget rejection. The first re-opens #253; the
|
|
312
|
-
* second
|
|
313
|
-
* not
|
|
326
|
+
* second turns a priced, explainable refusal into an opaque exception, which is
|
|
327
|
+
* the one thing #251 says must not happen.
|
|
314
328
|
*/
|
|
315
329
|
export declare class WorkflowSubmitError extends Error {
|
|
316
330
|
/**
|
|
@@ -465,6 +479,10 @@ export interface UseBuzzWorkflow {
|
|
|
465
479
|
* consent dialog with no user gesture behind it, once per edit. A missing
|
|
466
480
|
* scope surfaces as an ordinary rejection; show no price and let `submit()`
|
|
467
481
|
* do the asking.
|
|
482
|
+
*
|
|
483
|
+
* A `kind: 'training'` body's reply also carries
|
|
484
|
+
* {@link BlockWorkflowSnapshot.trainingQuote} — the `quoteId` to run it with
|
|
485
|
+
* through `useRunTraining()`.
|
|
468
486
|
*/
|
|
469
487
|
estimate: (body: WorkflowBody) => Promise<BlockWorkflowSnapshot>;
|
|
470
488
|
/**
|
|
@@ -486,14 +504,67 @@ export interface UseBuzzWorkflow {
|
|
|
486
504
|
* 🔴 A BUDGET / SPEND-CAP REJECTION STILL RESOLVES, and that is deliberate. It
|
|
487
505
|
* is a documented outcome, not an error: the server quotes what it refused to
|
|
488
506
|
* charge, so the resolved snapshot has `status === 'failed'` AND a numeric
|
|
489
|
-
* `cost.total`.
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
507
|
+
* `cost.total`.
|
|
508
|
+
*
|
|
509
|
+
* 🔴 WHAT A RESOLVED `status: 'failed'` CAN MEAN — THE ONE COMPLETE LIST. The
|
|
510
|
+
* `@civitai/blocks-react` README, the `civitai-block-starter` AGENTS.md and the
|
|
511
|
+
* changesets link here rather than restating it. Every case below RESOLVES
|
|
512
|
+
* (`blocks.submitWorkflow` returns it; the REST route answers it with HTTP
|
|
513
|
+
* 200):
|
|
514
|
+
*
|
|
515
|
+
* 1. **Refused before anything ran — the `'failed'` sentinel `workflowId`, a
|
|
516
|
+
* `cost.total`, nothing charged, every reservation released.**
|
|
517
|
+
* - the per-call budget, `token.buzzBudget` (`insufficient buzz budget: …`);
|
|
518
|
+
* - the viewer's daily Buzz cap — or, in its place, the review-session cap
|
|
519
|
+
* (a moderator's run-for-real) or the private-run cap;
|
|
520
|
+
* - the viewer's consent budget (the ceiling they set when they granted the
|
|
521
|
+
* scope);
|
|
522
|
+
* - the app's velocity limit or aggregate daily cap, or a fail-closed
|
|
523
|
+
* "temporarily unavailable" deny;
|
|
524
|
+
* - the dev-session cap (`dev:tunnel`);
|
|
525
|
+
* - a missing price quote for a step ("generation temporarily unavailable").
|
|
526
|
+
*
|
|
527
|
+
* A training step with no price quote is refused the same way but with NO
|
|
528
|
+
* `cost`, which on this hook REJECTS as `'exception'` instead.
|
|
529
|
+
* 2. **Not confirmed — Buzz may have been spent.** A training run whose
|
|
530
|
+
* submission the server could not confirm: the sentinel id, a `cost.total`,
|
|
531
|
+
* and `submissionUnconfirmed: true` on the REST reply. The reservation is NOT
|
|
532
|
+
* refunded and the run may be going. 🔴 The host bridge forwards only the
|
|
533
|
+
* snapshot, so on this hook that flag never arrives and the reply looks like
|
|
534
|
+
* case 1 — never tell a viewer "nothing was charged" for a resolved failed
|
|
535
|
+
* `kind: 'training'` submit; send them to their trainings instead.
|
|
536
|
+
* 3. **A run was created and came back failed — a REAL `workflowId`.** The
|
|
537
|
+
* reservation is kept and Buzz may have been spent. Priced, it resolves;
|
|
538
|
+
* unpriced, it rejects as `'workflow-failed'`.
|
|
539
|
+
*
|
|
540
|
+
* So only case 1 may be told "could not start, nothing was charged".
|
|
541
|
+
*
|
|
542
|
+
* 🔴 A RESOLVED `'failed'` IS NEVER A TOP-UP CUE — do not wire it to
|
|
543
|
+
* `useBuzzPurchase().openPurchaseModal()`. Case 1 is a set of caps that buying
|
|
544
|
+
* Buzz does not raise, and cases 2 and 3 are not about the wallet.
|
|
545
|
+
*
|
|
546
|
+
* 🔴 THE CASE A TOP-UP DOES FIX — THE VIEWER IS ACTUALLY OUT OF BUZZ — REJECTS.
|
|
547
|
+
* The orchestrator refuses the run, the server throws, and the host answers
|
|
548
|
+
* with its cost-less `failureSnapshot(err)`, so this rejects with
|
|
549
|
+
* {@link WorkflowSubmitError} code **`'exception'`** — the same code as every
|
|
550
|
+
* other thrown submit (a bad request, an outage, a rate limit, an idempotency
|
|
551
|
+
* conflict). Nothing structural marks it as insufficient funds: the code is
|
|
552
|
+
* shared, and `snapshot.error` is the upstream message, server-authored prose
|
|
553
|
+
* that is not a contract. So decide a top-up from the BALANCE, not from the
|
|
554
|
+
* rejection: on `'exception'`, read {@link useBuzzBalance} and offer
|
|
555
|
+
* `openPurchaseModal()` only if the viewer's SPENDABLE Buzz is below the quoted
|
|
556
|
+
* cost. Two things make that read correct:
|
|
557
|
+
* - `useBuzzBalance` needs `buzz:read:self` declared in `block.manifest.json`,
|
|
558
|
+
* and that scope is consent-gated (it is not consent-exempt), so request it
|
|
559
|
+
* like any other gated scope. An anonymous viewer is refused outright.
|
|
560
|
+
* - A block spends only blue plus ITS DOMAIN'S pool: blue + green under an SFW
|
|
561
|
+
* ceiling, blue + yellow under a mature one. The server keys this on the
|
|
562
|
+
* token's DOMAIN ceiling (`maxBrowsingLevel`), so compute it as
|
|
563
|
+
* `isSfwCeiling(useDomainMaturity().maxBrowsingLevel)` (from
|
|
564
|
+
* `@civitai/app-sdk/blocks`; an unknown ceiling counts as SFW) — NOT
|
|
565
|
+
* `useDomainMaturity().isSfw`, which is narrowed by the viewer's own setting —
|
|
566
|
+
* and compare `blue` plus that one pool, never blue + green + yellow, against
|
|
567
|
+
* the quoted cost.
|
|
497
568
|
*
|
|
498
569
|
* 🔴 THIS ALSO INCLUDES MODERATOR REVIEW PREVIEW. While an app is under review
|
|
499
570
|
* the host short-circuits every workflow request with
|
|
@@ -512,6 +583,9 @@ export interface UseBuzzWorkflow {
|
|
|
512
583
|
* `CONSENT_UNAVAILABLE` environment is never retried, and a second consent
|
|
513
584
|
* failure reaches you unchanged. Opt out with
|
|
514
585
|
* {@link ConsentRetryOptions.autoRequestConsent}`: false`.
|
|
586
|
+
*
|
|
587
|
+
* 🔴 A `kind: 'training'` BODY IS REFUSED before anything is sent — it runs
|
|
588
|
+
* only through `useRunTraining()`. See {@link TRAINING_BODY_SUBMIT_REFUSAL}.
|
|
515
589
|
*/
|
|
516
590
|
submit: (body: WorkflowBody, options?: SubmitWorkflowOptions) => Promise<BlockWorkflowSnapshot>;
|
|
517
591
|
/**
|
|
@@ -566,15 +640,18 @@ export interface UseBuzzWorkflow {
|
|
|
566
640
|
*
|
|
567
641
|
* 🔴 A BUDGET REFUSAL DOES **NOT** REJECT — IT RESOLVES. It comes back as a
|
|
568
642
|
* snapshot with `status: 'failed'`, an `error` string and the `cost` the server
|
|
569
|
-
* declined to charge
|
|
570
|
-
* `useBuzzPurchase().openPurchaseModal()
|
|
571
|
-
*
|
|
572
|
-
*
|
|
643
|
+
* declined to charge. It is NOT a cue to call
|
|
644
|
+
* `useBuzzPurchase().openPurchaseModal()`: every resolving refusal is a cap
|
|
645
|
+
* buying Buzz does not raise, or a reply that is not about the wallet at all —
|
|
646
|
+
* the complete list is on {@link UseBuzzWorkflow.submit}.
|
|
573
647
|
*
|
|
574
|
-
* 🔴
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
648
|
+
* 🔴 RUNNING OUT OF BUZZ REJECTS, and is not marked as such. The orchestrator's
|
|
649
|
+
* refusal reaches the block as {@link WorkflowSubmitError} code `'exception'`,
|
|
650
|
+
* shared with every other thrown submit. Offer a top-up only after checking the
|
|
651
|
+
* viewer's SPENDABLE Buzz ({@link useBuzzBalance}: blue plus the block's domain
|
|
652
|
+
* pool, `buzz:read:self` declared) against the quoted cost — see
|
|
653
|
+
* {@link UseBuzzWorkflow.submit}, which also holds the complete list of resolved
|
|
654
|
+
* refusals.
|
|
578
655
|
*
|
|
579
656
|
* AFTER `submit` FLIPS `status` TO `'polling'`, USE `watch(workflowId)`. It owns
|
|
580
657
|
* the loop, resolves on the terminal snapshot, and pushes every intermediate
|
|
@@ -652,8 +729,9 @@ export interface UseBuzzWorkflow {
|
|
|
652
729
|
* try {
|
|
653
730
|
* const snap = await submit(body); // status 'submitting' → 'polling'
|
|
654
731
|
* if (snap.status === 'failed') {
|
|
655
|
-
* // RESOLVED
|
|
656
|
-
* //
|
|
732
|
+
* // RESOLVED = a server outcome: a cap, a limit, a missing quote — none of
|
|
733
|
+
* // them fixed by buying Buzz — or a run that may have spent (see `submit`
|
|
734
|
+
* // for the complete list). Never a top-up cue.
|
|
657
735
|
* showError(submitOutcomeMessage(snap));
|
|
658
736
|
* } else {
|
|
659
737
|
* const done = await watch(snap.workflowId, { onUpdate: render }); // → terminal
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { BLOCK_SCOPES } from '@civitai/app-sdk/blocks';
|
|
3
3
|
import { withConsentRetry } from '../internal/withConsentRetry.js';
|
|
4
|
+
import { WORKFLOW_REQUEST_TIMEOUT_MS } from '../transport/requestTimeouts.js';
|
|
4
5
|
import { getTransport } from '../transport/singleton.js';
|
|
5
6
|
import { resolveIdempotencyKey, sendTypedRequest } from '../transport/transport.js';
|
|
6
7
|
/**
|
|
@@ -16,6 +17,19 @@ import { resolveIdempotencyKey, sendTypedRequest } from '../transport/transport.
|
|
|
16
17
|
* automatic prompt silently do nothing.
|
|
17
18
|
*/
|
|
18
19
|
const WORKFLOW_SCOPES = [BLOCK_SCOPES.AI_WRITE_BUDGETED];
|
|
20
|
+
/**
|
|
21
|
+
* Why {@link UseBuzzWorkflow.submit} refuses a `kind: 'training'` body.
|
|
22
|
+
*
|
|
23
|
+
* 🔴 REFUSED BEFORE THE SEND, NOT LEFT TO THE SERVER. The server charges a
|
|
24
|
+
* training run only against a quote the viewer confirmed from a signed-in
|
|
25
|
+
* session, and only `RUN_TRAINING`'s host-chrome dialog can record that — so a
|
|
26
|
+
* `SUBMIT_WORKFLOW` carrying this body can never start a run. Refusing here
|
|
27
|
+
* turns a server round-trip ending in a confusing refusal into an immediate
|
|
28
|
+
* error that names the hook to use. Nothing is sent and nothing is spent, which
|
|
29
|
+
* is why it is a plain `Error` and not a {@link WorkflowSubmitError} (every code
|
|
30
|
+
* on that class is money-ambiguous by design).
|
|
31
|
+
*/
|
|
32
|
+
export const TRAINING_BODY_SUBMIT_REFUSAL = "useBuzzWorkflow.submit() does not run a kind:'training' body — estimate it here, then run it with useRunTraining().runTraining({ ...body, quoteId }). Nothing was sent.";
|
|
19
33
|
/**
|
|
20
34
|
* Snapshot statuses that mean "no further polling is needed."
|
|
21
35
|
* Used by both `submit` (a host can return an instant-fail / cached result)
|
|
@@ -27,18 +41,16 @@ const TERMINAL_STATUSES = new Set([
|
|
|
27
41
|
'canceled',
|
|
28
42
|
'expired',
|
|
29
43
|
]);
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
*/
|
|
41
|
-
const WORKFLOW_REQUEST_TIMEOUT_MS = 120_000;
|
|
44
|
+
// The workflow requests (estimate / submit / poll) are orchestrator-bound:
|
|
45
|
+
// the host forwards them to the generation orchestrator and only replies
|
|
46
|
+
// once it answers. `submit` is the slowest — server-side it does a whatif
|
|
47
|
+
// cost-preflight AND the real submit (two orchestrator round-trips) plus a
|
|
48
|
+
// prompt audit before responding, which legitimately exceeds the
|
|
49
|
+
// transport's 30s `DEFAULT_REQUEST_TIMEOUT_MS` (tuned for fast bridge
|
|
50
|
+
// messages) when the orchestrator queue is busy. `WORKFLOW_REQUEST_TIMEOUT_MS`
|
|
51
|
+
// (imported above, from `transport/requestTimeouts.ts`) gives these calls a
|
|
52
|
+
// generous ceiling so a busy-but-healthy orchestrator doesn't surface as a
|
|
53
|
+
// spurious `request "SUBMIT_WORKFLOW" timed out` rejection.
|
|
42
54
|
/**
|
|
43
55
|
* Default orchestrator-side hold per {@link UseBuzzWorkflow.watch} poll,
|
|
44
56
|
* in SECONDS.
|
|
@@ -288,10 +300,11 @@ const HOST_SYNTHESISED_WORKFLOW_ID = 'failed';
|
|
|
288
300
|
*
|
|
289
301
|
* - **budget / spend-cap REJECTION** — a legitimate OUTCOME. The server quotes
|
|
290
302
|
* the price it refused to charge, so the snapshot carries a numeric
|
|
291
|
-
* `cost.total
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
303
|
+
* `cost.total`, and the block can tell the viewer what was refused and why,
|
|
304
|
+
* so it MUST keep RESOLVING. It is NOT a top-up cue: these are app and
|
|
305
|
+
* platform caps, and buying Buzz raises none of them. Every such exit on
|
|
306
|
+
* the server attaches a cost; the complete list of them, and of the other
|
|
307
|
+
* resolved `'failed'` replies, is on {@link UseBuzzWorkflow.submit}.
|
|
295
308
|
* - **caught server EXCEPTION** — `failureSnapshot(err)`, no `cost`, and the
|
|
296
309
|
* `'failed'` sentinel id. The host had no workflow to report — usually
|
|
297
310
|
* nothing was queued, but a lost response or an in-progress idempotency
|
|
@@ -339,8 +352,8 @@ const HOST_SYNTHESISED_WORKFLOW_ID = 'failed';
|
|
|
339
352
|
*
|
|
340
353
|
* 🔴 DO NOT "SIMPLIFY" THIS BY PUTTING `snapshot.error` BACK ON `message`, and do
|
|
341
354
|
* not widen the guard to reject a budget rejection. The first re-opens #253; the
|
|
342
|
-
* second
|
|
343
|
-
* not
|
|
355
|
+
* second turns a priced, explainable refusal into an opaque exception, which is
|
|
356
|
+
* the one thing #251 says must not happen.
|
|
344
357
|
*/
|
|
345
358
|
export class WorkflowSubmitError extends Error {
|
|
346
359
|
/**
|
|
@@ -452,15 +465,18 @@ export class WorkflowSubmitError extends Error {
|
|
|
452
465
|
*
|
|
453
466
|
* 🔴 A BUDGET REFUSAL DOES **NOT** REJECT — IT RESOLVES. It comes back as a
|
|
454
467
|
* snapshot with `status: 'failed'`, an `error` string and the `cost` the server
|
|
455
|
-
* declined to charge
|
|
456
|
-
* `useBuzzPurchase().openPurchaseModal()
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
* 🔴
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
468
|
+
* declined to charge. It is NOT a cue to call
|
|
469
|
+
* `useBuzzPurchase().openPurchaseModal()`: every resolving refusal is a cap
|
|
470
|
+
* buying Buzz does not raise, or a reply that is not about the wallet at all —
|
|
471
|
+
* the complete list is on {@link UseBuzzWorkflow.submit}.
|
|
472
|
+
*
|
|
473
|
+
* 🔴 RUNNING OUT OF BUZZ REJECTS, and is not marked as such. The orchestrator's
|
|
474
|
+
* refusal reaches the block as {@link WorkflowSubmitError} code `'exception'`,
|
|
475
|
+
* shared with every other thrown submit. Offer a top-up only after checking the
|
|
476
|
+
* viewer's SPENDABLE Buzz ({@link useBuzzBalance}: blue plus the block's domain
|
|
477
|
+
* pool, `buzz:read:self` declared) against the quoted cost — see
|
|
478
|
+
* {@link UseBuzzWorkflow.submit}, which also holds the complete list of resolved
|
|
479
|
+
* refusals.
|
|
464
480
|
*
|
|
465
481
|
* AFTER `submit` FLIPS `status` TO `'polling'`, USE `watch(workflowId)`. It owns
|
|
466
482
|
* the loop, resolves on the terminal snapshot, and pushes every intermediate
|
|
@@ -538,8 +554,9 @@ export class WorkflowSubmitError extends Error {
|
|
|
538
554
|
* try {
|
|
539
555
|
* const snap = await submit(body); // status 'submitting' → 'polling'
|
|
540
556
|
* if (snap.status === 'failed') {
|
|
541
|
-
* // RESOLVED
|
|
542
|
-
* //
|
|
557
|
+
* // RESOLVED = a server outcome: a cap, a limit, a missing quote — none of
|
|
558
|
+
* // them fixed by buying Buzz — or a run that may have spent (see `submit`
|
|
559
|
+
* // for the complete list). Never a top-up cue.
|
|
543
560
|
* showError(submitOutcomeMessage(snap));
|
|
544
561
|
* } else {
|
|
545
562
|
* const done = await watch(snap.workflowId, { onUpdate: render }); // → terminal
|
|
@@ -687,10 +704,11 @@ export function useBuzzWorkflow() {
|
|
|
687
704
|
// `submit` half of civitai/civitai#4159). Two producers report
|
|
688
705
|
// `status:'failed'` and `status` separates neither:
|
|
689
706
|
//
|
|
690
|
-
// - a budget / spend-cap REJECTION is an OUTCOME the block
|
|
691
|
-
//
|
|
692
|
-
// charge, so `cost.total` is present. It RESOLVES —
|
|
693
|
-
// into a throw
|
|
707
|
+
// - a budget / spend-cap REJECTION is an OUTCOME the block reports (the
|
|
708
|
+
// run did not start, at this quoted price). The server quotes the price
|
|
709
|
+
// it refused to charge, so `cost.total` is present. It RESOLVES —
|
|
710
|
+
// turning this arm into a throw would erase that. (It is not a top-up
|
|
711
|
+
// cue: buying Buzz raises none of these caps.)
|
|
694
712
|
// - a failure-shaped reply with NO `cost` is not a usable outcome. It
|
|
695
713
|
// REJECTS — and the `code` says which kind, because they differ on
|
|
696
714
|
// whether money moved (see WorkflowSubmitError.code).
|
|
@@ -720,6 +738,14 @@ export function useBuzzWorkflow() {
|
|
|
720
738
|
return snapshot;
|
|
721
739
|
}, []);
|
|
722
740
|
const submit = useCallback(async (body, options) => {
|
|
741
|
+
// See `TRAINING_BODY_SUBMIT_REFUSAL`. Before the status flip, the key
|
|
742
|
+
// mint and the consent retry: nothing about this call may reach the wire.
|
|
743
|
+
if (body.kind === 'training') {
|
|
744
|
+
const refused = new Error(TRAINING_BODY_SUBMIT_REFUSAL);
|
|
745
|
+
setError(refused);
|
|
746
|
+
setStatus('error');
|
|
747
|
+
throw refused;
|
|
748
|
+
}
|
|
723
749
|
setError(null);
|
|
724
750
|
setStatus('submitting');
|
|
725
751
|
// Idempotency: reuse a caller-supplied stable key across a retry (→ one Buzz
|
|
@@ -52,13 +52,13 @@ export interface GoodPurchaseOptions extends ConsentRetryOptions {
|
|
|
52
52
|
* fresh key here would work too, but it would make a lost response on the
|
|
53
53
|
* retry unrecoverable, which is the thing idempotency keys exist to prevent.
|
|
54
54
|
*
|
|
55
|
-
* ⚠️ RECONCILED WITH THE
|
|
56
|
-
* `useBuzzWorkflow`
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
55
|
+
* ⚠️ RECONCILED WITH THE POLICY NEXT DOOR, which a reader will hit.
|
|
56
|
+
* `useBuzzWorkflow` says a resolved-failed generation is never a top-up cue
|
|
57
|
+
* (those are caps Buzz does not raise) and that running out of Buzz there
|
|
58
|
+
* rejects without a structural marker, so the APP decides a top-up from the
|
|
59
|
+
* balance. This option does not contradict that: it defaults to `false`, and
|
|
60
|
+
* it keys on the REASON (`insufficient_funds`, which a goods purchase DOES
|
|
61
|
+
* report structurally) rather than on "the call failed", so it never offers
|
|
62
62
|
* Buzz for a failure Buzz cannot fix. What it buys, and the only reason it is
|
|
63
63
|
* in the library at all rather than left to each app, is the same-key retry
|
|
64
64
|
* above — a server contract an app gets wrong in the expensive direction.
|