@civitai/blocks-react 0.70.0 → 0.72.0
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 +408 -2
- package/dist/hooks/useBatchEstimate.d.ts +4 -0
- package/dist/hooks/useBatchEstimate.js +4 -0
- package/dist/hooks/useBuzzWorkflow.d.ts +48 -0
- package/dist/hooks/useBuzzWorkflow.js +43 -0
- package/dist/hooks/useCheckpointPicker.d.ts +2 -0
- package/dist/hooks/useCheckpointPicker.js +2 -0
- package/dist/hooks/useRunConfirmedStep.d.ts +209 -0
- package/dist/hooks/useRunConfirmedStep.js +287 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/internal/hostSurface.d.ts +37 -0
- package/dist/internal/hostSurface.js +38 -0
- package/dist/internal/liveHost.d.ts +15 -6
- package/dist/internal/liveHost.js +151 -69
- package/dist/internal/mockHost.d.ts +178 -7
- package/dist/internal/mockHost.js +549 -12
- package/dist/internal/mockWorkflowGraph.d.ts +117 -0
- package/dist/internal/mockWorkflowGraph.js +447 -0
- package/dist/testing.d.ts +1 -1
- package/dist/transport/requestTimeouts.js +6 -0
- package/dist/transport/validate.d.ts +13 -0
- package/dist/transport/validate.js +111 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -482,7 +482,7 @@ if (quote.cost) showPrice(priceLine(quote.cost));
|
|
|
482
482
|
|
|
483
483
|
| `cost.authorFee` | Means | Where |
|
|
484
484
|
|---|---|---|
|
|
485
|
-
| a number `> 0` | that much of `total` is the app fee | `estimate()` for a `textToImage
|
|
485
|
+
| a number `> 0` | that much of `total` is the app fee | `estimate()` for a `textToImage`, registered-`step` or `workflow` body (one fee for the whole workflow), and the refusal `submit()` resolves for those kinds when a budget, spend cap or rate limit refuses the quoted price |
|
|
486
486
|
| `0` | a fee was looked up and none applies to this request | the same replies |
|
|
487
487
|
| absent | the total is **not itemised** | a host that predates the field; every snapshot of a submitted workflow (`submit` success, `poll`, `cancel`), whose `total` is the generation's realized cost with the fee charged separately; the registered-`step` `submit()` refusal for a missing orchestrator price quote, which refuses before any fee is looked up; and `customComfy`, pass-through `step` and `training` quotes, which price no fee |
|
|
488
488
|
|
|
@@ -535,12 +535,197 @@ try {
|
|
|
535
535
|
| `'training-quote-rejected'` | the orchestrator refused the request itself; resending the same body will not help. `error` reads `Training could not be priced: <reason>` when the reason is safe to show |
|
|
536
536
|
| `'training-quote-unavailable'` | no usable answer (an outage, a timeout, a rate limit, insufficient funds, …); a retry may succeed |
|
|
537
537
|
| `'training-quote-unpriced'` | the orchestrator answered without a price |
|
|
538
|
+
| `'daily-limit-exceeded'` | a viewer-confirmed job (a training run or a [confirmed step](#userunconfirmedstep--one-job-above-the-per-call-budget)) would exceed what remains today of the daily Buzz limit **the viewer** set for this app. Nothing was charged, no job started, and only the viewer can raise the limit. Arrives on a confirmed submit's failed snapshot, never on an estimate; `useRunConfirmedStep()` surfaces it as `err.dailyLimitExceeded` |
|
|
538
539
|
| absent | any other reply, and every reply from a host that predates the field |
|
|
539
540
|
|
|
540
541
|
- **Treat an unknown value as possible.** The field is typed as an open string
|
|
541
542
|
(`KnownBlockWorkflowErrorCode | (string & {})`), the host may add codes, and
|
|
542
543
|
the inbound validator accepts any string. Keep a `default` branch.
|
|
543
|
-
-
|
|
544
|
+
- A `kind: 'training'` **estimate** does not set it; the three `training-quote-*`
|
|
545
|
+
codes belong to the pass-through arm.
|
|
546
|
+
- A refused `kind: 'workflow'` body sets its own codes, in the same shape — see
|
|
547
|
+
the next section.
|
|
548
|
+
|
|
549
|
+
#### Several steps as one workflow: `kind: 'workflow'`
|
|
550
|
+
|
|
551
|
+
A `kind: 'workflow'` body submits **several orchestrator steps as one
|
|
552
|
+
workflow**, where a later step can consume an earlier step's result. It goes
|
|
553
|
+
through the hooks you already use: `useBuzzWorkflow().estimate / submit /
|
|
554
|
+
watch / cancel`, a `useBatchEstimate()` cell, and `useWorkflowBatch()`. There
|
|
555
|
+
is no new hook and no new message.
|
|
556
|
+
|
|
557
|
+
```tsx
|
|
558
|
+
import { useBuzzWorkflow } from '@civitai/blocks-react';
|
|
559
|
+
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
|
|
560
|
+
|
|
561
|
+
const { estimate, submit, watch } = useBuzzWorkflow();
|
|
562
|
+
|
|
563
|
+
// Step 1 draws an image; step 2 removes the background from step 1's result.
|
|
564
|
+
const body: WorkflowBody = {
|
|
565
|
+
kind: 'workflow',
|
|
566
|
+
arguments: { prompt: 'a lighthouse at dusk' },
|
|
567
|
+
steps: [
|
|
568
|
+
{
|
|
569
|
+
name: 'draw',
|
|
570
|
+
$type: 'imageGen',
|
|
571
|
+
input: {
|
|
572
|
+
engine: 'google',
|
|
573
|
+
model: 'nano-banana-2',
|
|
574
|
+
// Read from `arguments` above.
|
|
575
|
+
prompt: { $ref: '$arguments', path: 'prompt' },
|
|
576
|
+
},
|
|
577
|
+
// An intermediate step: its image is not surfaced.
|
|
578
|
+
suppressOutput: true,
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
name: 'cutout',
|
|
582
|
+
$type: 'imageBackgroundRemoval',
|
|
583
|
+
// Read from the EARLIER step named `draw`.
|
|
584
|
+
input: { image: { $ref: 'draw', path: 'output.images[0].url' } },
|
|
585
|
+
},
|
|
586
|
+
],
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const quote = await estimate(body);
|
|
590
|
+
showPrice(`About ${quote.cost?.total} Buzz`); // an estimate, not a maximum
|
|
591
|
+
|
|
592
|
+
const submitted = await submit(body);
|
|
593
|
+
const done = await watch(submitted.workflowId);
|
|
594
|
+
|
|
595
|
+
for (const step of done.steps ?? []) {
|
|
596
|
+
if (step.status === 'failed') showError(`${step.name} failed`);
|
|
597
|
+
}
|
|
598
|
+
// Even when `done.status === 'failed'`, earlier steps may have left images.
|
|
599
|
+
showImages(done.imageUrls ?? []);
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
**The body**
|
|
603
|
+
|
|
604
|
+
- `steps`: 1 to 8 (`BLOCK_WORKFLOW_MAX_STEPS`). Each is `{ name?, $type, input,
|
|
605
|
+
suppressOutput? }`, where `$type` is an orchestrator step type and `input` is
|
|
606
|
+
that step's own input.
|
|
607
|
+
- **A step `name`** is optional. It is a lowercase letter followed by up to 31
|
|
608
|
+
of `a-z`, `0-9`, `_` or `-`, unique within the body, and not `block-step`
|
|
609
|
+
(reserved). A step you do not name is named by its position: `$0`, `$1`, ….
|
|
610
|
+
- **A reference** is `{ $ref, path, default?, format? }` and may stand in for
|
|
611
|
+
any value in `input`, at any depth. `$ref` is `'$arguments'`, the `name` of an
|
|
612
|
+
**earlier** step, or — for an earlier step you did not name — its position
|
|
613
|
+
(`'$0'` is the first step). A step can never reference itself or a later
|
|
614
|
+
step. An object is a reference only when it has both `$ref` and `path` and no
|
|
615
|
+
other key but `default` / `format`.
|
|
616
|
+
- **Limits**: 64 references per body, inputs nested at most 32 levels, `repeat`
|
|
617
|
+
templates nested at most 4 deep, a `$type` of at most 64 characters, and 256
|
|
618
|
+
KB (262144 bytes) for every step's `input` plus `arguments` together. An
|
|
619
|
+
unknown key on the body or on a step is rejected. The app's velocity limit
|
|
620
|
+
counts one unit per step, and a `repeat` step one per iteration instead.
|
|
621
|
+
- `suppressOutput: true` marks an intermediate step. Its images are left out of
|
|
622
|
+
`snapshot.imageUrls` and out of its own `steps[i].imageUrls`. It still runs
|
|
623
|
+
and is still charged.
|
|
624
|
+
- There is **no `maxBuzz`**. The spend ceiling is the app's per-generation
|
|
625
|
+
budget × the number of steps, enforced by the host.
|
|
626
|
+
- Any orchestrator `$type` is accepted except the platform-internal ones:
|
|
627
|
+
scanners, moderation classifiers, hashing, web scrape/search, and the retired
|
|
628
|
+
`textToImage` step type.
|
|
629
|
+
- **`repeat` over `arguments` only, at most 16 iterations**
|
|
630
|
+
(`BLOCK_WORKFLOW_REPEAT_MAX_ITERATIONS`, nested repeats multiplied). Its input
|
|
631
|
+
is exactly `{ for: { $ref: '$arguments', path, as }, template: { $type,
|
|
632
|
+
name?, input? } }`, where `path` names an array in `arguments`. Inside the
|
|
633
|
+
template, `as` is a source a reference may name; it may not equal a step
|
|
634
|
+
name. The template's `$type` is held to the denylist too. A `repeat` over
|
|
635
|
+
another step's output is refused, because its length is unknown until that
|
|
636
|
+
step has run. The single-step pass-through `kind: 'step'` arm refuses
|
|
637
|
+
`repeat` outright.
|
|
638
|
+
- **`merge`**: every entry of `input.fields` needs a string `$ref` (`$arguments`
|
|
639
|
+
or an earlier step, never a repeat variable) and a `reduce`; each counts
|
|
640
|
+
toward the 64-reference limit.
|
|
641
|
+
- `WithWorkflowStepRefs<T>` (from `@civitai/app-sdk/blocks`) types an input you
|
|
642
|
+
already have a type for while still allowing references in it.
|
|
643
|
+
|
|
644
|
+
**🔴 Three things about the price**
|
|
645
|
+
|
|
646
|
+
1. **The estimate is not a maximum.** When a step reads from an earlier step,
|
|
647
|
+
the orchestrator re-prices it once those values are known and charges the
|
|
648
|
+
difference. Never show the estimate as "you will pay at most".
|
|
649
|
+
2. **A workflow the host cannot price has no estimate, but its submit still
|
|
650
|
+
proceeds.** `estimate()` rejects with a `workflow_quote_*` `errorCode` and
|
|
651
|
+
no `cost` (see the second table under Refusals), and a batch estimate
|
|
652
|
+
returns that as the cell. `submit()` of the same body is **not** refused: it
|
|
653
|
+
reserves the ceiling above instead of a quote. So a failed estimate does not
|
|
654
|
+
predict a refused submit. Two exceptions are refused on both, before any
|
|
655
|
+
reservation: a workflow with a training step (a `training-quote-*`
|
|
656
|
+
`errorCode`, as on the single-step arm), and a workflow with a `repeat` step
|
|
657
|
+
at any depth (`workflow_repeat_unbounded`). Their `error` states why there
|
|
658
|
+
is no quote, and only `none could be obtained; try again` is a cause a retry
|
|
659
|
+
of the same body can clear; for the others the body has to change.
|
|
660
|
+
An older host returns the ceiling as the estimate's `cost.total` instead of
|
|
661
|
+
failing, so there a total equal to the ceiling may be unpriced.
|
|
662
|
+
3. **One author fee per workflow**, not one per step, on the quoted base. The
|
|
663
|
+
estimate's `cost.authorFee` is that single fee, already inside `cost.total`.
|
|
664
|
+
If the workflow bills more than the quote, the fee charged is held to the
|
|
665
|
+
fee shown. A workflow that does not succeed has its **whole** fee reversed,
|
|
666
|
+
even if earlier steps delivered output.
|
|
667
|
+
|
|
668
|
+
**The result**
|
|
669
|
+
|
|
670
|
+
`snapshot.steps` lists every step in order, as `{ name, $type, status,
|
|
671
|
+
imageUrls?, output?, error? }`. Read it from `poll()`, `watch()` and
|
|
672
|
+
`cancel()`; do not rely on it being on the `submit()` reply. The steps a
|
|
673
|
+
`repeat` step generates follow it, named `<repeat name>.<n>`. The top-level
|
|
674
|
+
fields keep their meaning: `status` and `cost` are the whole workflow's, and
|
|
675
|
+
`imageUrls` is the union of the steps that did not set `suppressOutput`.
|
|
676
|
+
|
|
677
|
+
🔴 **A `failed` workflow is not an empty one.** Steps that succeeded before the
|
|
678
|
+
failing step stay charged and keep their results, so a snapshot with `status:
|
|
679
|
+
'failed'` can carry `cost.total > 0` and usable `imageUrls`. Show what did
|
|
680
|
+
finish, and do not tell the viewer nothing was charged — though the app fee is
|
|
681
|
+
reversed for any workflow that does not succeed.
|
|
682
|
+
|
|
683
|
+
**Refusals**
|
|
684
|
+
|
|
685
|
+
A body the host will not run is refused before anything is priced or reserved:
|
|
686
|
+
`estimate()` rejects with `WorkflowEstimateError`, `submit()` with
|
|
687
|
+
`WorkflowSubmitError`, and a batch estimate returns it as that cell. Branch on
|
|
688
|
+
`err.snapshot.errorCode`:
|
|
689
|
+
|
|
690
|
+
| `errorCode` | Means |
|
|
691
|
+
|---|---|
|
|
692
|
+
| `'workflow_too_many_steps'` | more than 8 steps |
|
|
693
|
+
| `'workflow_step_denied'` | a step names a platform-internal `$type` |
|
|
694
|
+
| `'workflow_ref_invalid'` | a reference points at the step itself, a later step, or nothing; a step name breaks the rules above; a `repeat` or `merge` is malformed; or a repeat `as` name is also a step name |
|
|
695
|
+
| `'workflow_repeat_unbounded'` | a `repeat` iterates something other than an array in `arguments`, or more than 16 times (nested repeats multiplied); or the workflow has a `repeat` and the host has no price quote for it. `error` then reads `workflow step N: a workflow with a repeat needs a price quote and <cause>`; a retry of the same body can work only when `<cause>` is `none could be obtained; try again` |
|
|
696
|
+
|
|
697
|
+
A body the host WOULD run, but has no price quote for, fails on `estimate()`
|
|
698
|
+
and as a batch-estimate cell only, with no `cost`. `submit()` of the same body
|
|
699
|
+
still proceeds (point 2 above):
|
|
700
|
+
|
|
701
|
+
| `errorCode` | Means | `error` | Retry the same body? |
|
|
702
|
+
|---|---|---|---|
|
|
703
|
+
| `'workflow_quote_rejected'` | the orchestrator refused the body | `No price quote for this workflow: the orchestrator rejected the request: <reason>` when the reason is safe to show, else `No price quote for this workflow: the orchestrator rejected the request; check the step inputs and references (retrying the same body will not help)` | no — change the body |
|
|
704
|
+
| `'workflow_quote_unavailable'` | no usable answer (an outage, a timeout, a rate limit, …) | `No price quote for this workflow: none could be obtained; try again` | yes |
|
|
705
|
+
| `'workflow_quote_unpriced'` | the orchestrator answered without a price | `No price quote for this workflow: the orchestrator answered without one` | not expected to help |
|
|
706
|
+
|
|
707
|
+
An older host has none of the three: it resolves the estimate with the ceiling
|
|
708
|
+
as `cost.total`.
|
|
709
|
+
|
|
710
|
+
- **Requires a host that knows the kind.** An older host refuses the body.
|
|
711
|
+
- **Test it locally.** The mock host prices each step with
|
|
712
|
+
`generation.costPerGen` (a function gets the step as its second argument) and
|
|
713
|
+
multiplies it by a `repeat` step's iteration count, then adds
|
|
714
|
+
`generation.authorFee` once; refuses with the four codes of the first table,
|
|
715
|
+
in the host's order; reports
|
|
716
|
+
`steps` on every poll, finishing them in order; and
|
|
717
|
+
`generation.failWorkflowStep: '<name>'` (`'$<index>'` for an unnamed step)
|
|
718
|
+
makes one step fail, so you can render a partial failure. The mock checks
|
|
719
|
+
where a reference points, not whether its `path` exists; it never re-prices;
|
|
720
|
+
and it does not expand a `repeat` step into generated `<name>.<n>` steps. It
|
|
721
|
+
quotes every workflow unless you set `generation.workflowUnpriced` to
|
|
722
|
+
`'rejected'`, `'unavailable'` or `'unpriced'` (`true` means `'unavailable'`),
|
|
723
|
+
which simulates no quote for that cause: a workflow with a training step or a
|
|
724
|
+
`repeat` is refused on both `estimate()` and `submit()`; any other fails its
|
|
725
|
+
`estimate()` with `workflow_quote_<cause>` and no `cost`, while its
|
|
726
|
+
`submit()` is reserved at `buzzBudget` × steps with no fee. The mock has no
|
|
727
|
+
orchestrator reason to pass on, so `'rejected'` always gives the reason-less
|
|
728
|
+
message, never the `…rejected the request: <reason>` form.
|
|
544
729
|
|
|
545
730
|
> **Estimate must mirror submit** (gotcha #59): build the params for `estimate`
|
|
546
731
|
> with the *exact* same logic as `submit` — same seed decision especially. The
|
|
@@ -575,6 +760,11 @@ resolves with one cell per body, in order, plus the run total. Returns
|
|
|
575
760
|
`WorkflowEstimateError`, and the other cells still price.
|
|
576
761
|
- **`kind: 'training'` bodies are refused per cell.** Estimate a training run
|
|
577
762
|
with `useBuzzWorkflow().estimate()`.
|
|
763
|
+
- **A cell never carries a `stepQuote`.** A batch stores nothing, so a
|
|
764
|
+
pass-through step priced above the per-call budget comes back with its cost and
|
|
765
|
+
no confirmable quote. To run it with
|
|
766
|
+
[`useRunConfirmedStep()`](#userunconfirmedstep--one-job-above-the-per-call-budget),
|
|
767
|
+
estimate that one body with `useBuzzWorkflow().estimate()`.
|
|
578
768
|
- **Estimate only.** Nothing is submitted and no Buzz is held or spent. There is
|
|
579
769
|
no batch submit. Submit each cell with
|
|
580
770
|
`useBuzzWorkflow().submit()`, which asks the viewer and checks the budget per
|
|
@@ -1222,6 +1412,191 @@ server compares the whole body.
|
|
|
1222
1412
|
**`dev:live` refuses both bridges**, because the server refuses every training
|
|
1223
1413
|
request from a dev token.
|
|
1224
1414
|
|
|
1415
|
+
### `useRunConfirmedStep()` — one job above the per-call budget
|
|
1416
|
+
|
|
1417
|
+
A pass-through `step` job (`{ kind: 'step', $type, input, maxBuzz }`) that costs
|
|
1418
|
+
more than the app may spend without asking is refused by `submit()` — unless the
|
|
1419
|
+
**viewer confirms that exact price** in Civitai's own dialog. This hook is that
|
|
1420
|
+
path. It is the training run's confirm applied to any pass-through `$type`
|
|
1421
|
+
(`polyGen`, a long video job, …). Three steps, two hooks:
|
|
1422
|
+
|
|
1423
|
+
1. **Quote** — `useBuzzWorkflow().estimate(body)`, the body you would submit. When
|
|
1424
|
+
the job qualifies the reply carries `stepQuote: { quoteId, total, stepType,
|
|
1425
|
+
expiresAt }`. `total` is the price the viewer will confirm and be charged.
|
|
1426
|
+
2. **Run** — `useRunConfirmedStep().runConfirmedStep({ ...body, quoteId })`
|
|
1427
|
+
(`RUN_CONFIRMED_STEP`). Civitai reads the quote back from the server, shows
|
|
1428
|
+
the viewer the price in **its** chrome and submits on their click. Resolves
|
|
1429
|
+
with the submitted workflow's snapshot.
|
|
1430
|
+
3. **Watch** — `useBuzzWorkflow().watch(snapshot.workflowId)`, as for any job.
|
|
1431
|
+
|
|
1432
|
+
No `stepQuote` on the estimate means the ordinary rules apply: submit it if it
|
|
1433
|
+
fits the budget, and otherwise it cannot run from an app. Change nothing in the
|
|
1434
|
+
body between the estimate and the run — the server checks it against the quoted
|
|
1435
|
+
one.
|
|
1436
|
+
|
|
1437
|
+
🔴 **Only a fixed price is confirmable.** `stepQuote` is present when the
|
|
1438
|
+
orchestrator quoted a **fixed** price (positive, not variable or metered) that is
|
|
1439
|
+
above the token's per-call `buzzBudget` and at most
|
|
1440
|
+
`BLOCK_CONFIRMED_STEP_MAX_BUZZ_PER_JOB` (5,000 Buzz). The viewer confirms what
|
|
1441
|
+
they are charged, so a price that may move, or no price at all, gets no quote.
|
|
1442
|
+
`stepQuote.total` can differ from `cost.total` on the same snapshot (which stays
|
|
1443
|
+
what an unconfirmed submit would reserve) — put `stepQuote.total` on your button.
|
|
1444
|
+
|
|
1445
|
+
🔴 **Estimate it on its own.** Only a single `estimate()` returns a `stepQuote`.
|
|
1446
|
+
A batch estimate (`useBatchEstimate()`) stores nothing, so its cells never carry
|
|
1447
|
+
one: price the grid in a batch, then estimate the one body you want to run.
|
|
1448
|
+
|
|
1449
|
+
🔴 **Two bodies make the estimate reject instead of returning a price.** Only
|
|
1450
|
+
where a quote would otherwise be granted, so not below the budget and not in a
|
|
1451
|
+
batch. `estimate()` rejects with a `WorkflowEstimateError` whose
|
|
1452
|
+
`snapshot.error` is the server's message; the same text is the
|
|
1453
|
+
`RunConfirmedStepError`'s `message` if such a body is run. Neither has a code or
|
|
1454
|
+
a flag — they are server messages, so match on the text:
|
|
1455
|
+
|
|
1456
|
+
| `snapshot.error` / `err.message` | cause | fix |
|
|
1457
|
+
|---|---|---|
|
|
1458
|
+
| `a training step type cannot run as a confirmed step — use kind:'training'` | `$type` is `training` or `imageResourceTraining`, in any casing | train with [`kind: 'training'`](#usepreparetrainingdataset-and-useruntraining--lora-training), which has its own dataset and checks |
|
|
1459
|
+
| starts with `a confirmed step needs plain JSON input (objects, arrays, strings, finite numbers, booleans, null) — ` and ends with the offending path, e.g. `body.input.when is a Date` | `input` holds something that is not plain JSON: a `Date`, `Map`, `Set`, class instance, `BigInt`, `NaN`/`Infinity`, an `undefined` array element, or an empty slot in a sparse array (`… is an empty array slot`). The quote is bound to the exact body, so the body must be exactly representable | send plain JSON (an ISO string for a date, an array for a set). The suffix varies, so match on the part **before the dash** |
|
|
1460
|
+
|
|
1461
|
+
🔴 **Single-use, and it expires.** A quote is good for one run and for **15
|
|
1462
|
+
minutes** (`stepQuote.expiresAt`). The server prices the job again at submit and
|
|
1463
|
+
refuses a higher price. Estimate again to get a new one.
|
|
1464
|
+
|
|
1465
|
+
🔴 **Availability.** Behind the host flag **`app-blocks-confirmed-step`**, which
|
|
1466
|
+
ships **off** and is evaluated per viewer; requires a civitai.com host carrying
|
|
1467
|
+
civitai/civitai#5786. **Page apps only** — the model slot answers with an error.
|
|
1468
|
+
The manifest must declare **`ai:write:budgeted`** and the viewer must be signed
|
|
1469
|
+
in and have granted it. **Refused for development, dev-tunnel and review
|
|
1470
|
+
tokens**, so **`dev:live` cannot exercise it**: the estimate there never carries
|
|
1471
|
+
a `stepQuote` and the bridge replies with a refusal. Build the flow against the
|
|
1472
|
+
mock host (below). Where the feature is unavailable nothing errors — the estimate
|
|
1473
|
+
simply has no `stepQuote`, which is also what an older host sends.
|
|
1474
|
+
|
|
1475
|
+
🔴 **No retry, ever.** Every confirmation is its own job: there is no idempotency
|
|
1476
|
+
key, and confirming the same body twice starts and charges two jobs. The hook
|
|
1477
|
+
never re-sends and never prompts for consent, and `useBuzzWorkflow().submit()`
|
|
1478
|
+
**refuses** a step body that carries a `quoteId` before sending anything — only
|
|
1479
|
+
`RUN_CONFIRMED_STEP`'s dialog can confirm a quote. `runConfirmedStep` also
|
|
1480
|
+
refuses, before sending, a body whose `quoteId` is missing or is not an
|
|
1481
|
+
`sq_`-shaped id (a training `tq_…` quote, or `undefined` from an estimate that
|
|
1482
|
+
returned no `stepQuote`).
|
|
1483
|
+
|
|
1484
|
+
`runConfirmedStep` **rejects** with a `RunConfirmedStepError`; read the flags
|
|
1485
|
+
before saying anything about money:
|
|
1486
|
+
|
|
1487
|
+
| | meaning | what to do |
|
|
1488
|
+
|---|---|---|
|
|
1489
|
+
| `err.declined` | the viewer dismissed the confirm — **no job was submitted**; the quote is still good until it expires | revert, say nothing |
|
|
1490
|
+
| `err.dailyLimitExceeded` | the job would exceed what remains today of the daily Buzz limit **the viewer** set for this app — **no job, nothing charged**. Civitai has already told the viewer and linked to where the limit is raised | say nothing more; do **not** retry and do **not** offer a Buzz purchase (more Buzz does not lift a limit) |
|
|
1491
|
+
| `err.unconfirmed` | `submission-unconfirmed`, or no reply within the 10-min bound (`err.timedOut`) — the job **may be running and charged** | 🔴 **never retry automatically**: check the viewer's generations first (`useAppWorkflows()` lists this app's runs). A retry needs a new estimate and a new confirmation, and is a **second, separately charged job** if the first one started |
|
|
1492
|
+
| `err.refused` (`code: 'refused'`) | the server refused the submit after the viewer confirmed — a **spend cap** (their daily or private-run Buzz cap, the app's daily spend or rate limit). Refunded: **no job, nothing charged**; the quote is used up | show `err.message` (the server's reason); a retry needs a new estimate and usually a later time |
|
|
1493
|
+
| `err.signInRequired` | no session | route into `useRequestSignIn()` |
|
|
1494
|
+
| `err.code` set otherwise | a host refusal (`review-mode` / `block is not ready` / `invalid confirmed step request` / `no block token`) | show or ignore per case |
|
|
1495
|
+
| `err.code === undefined`, no flag | a server refusal with no job started (expired or used quote, changed body, changed price, a training `$type`, non-plain-JSON `input`, the flag is off) | fix the body if the message names it, estimate again, then retry |
|
|
1496
|
+
|
|
1497
|
+
```tsx
|
|
1498
|
+
const { estimate, submit, watch } = useBuzzWorkflow();
|
|
1499
|
+
const { runConfirmedStep } = useRunConfirmedStep();
|
|
1500
|
+
const { requestSignIn } = useRequestSignIn();
|
|
1501
|
+
|
|
1502
|
+
async function generate3d(prompt: string) {
|
|
1503
|
+
const body: WorkflowBodyPassThroughStep = {
|
|
1504
|
+
kind: 'step',
|
|
1505
|
+
$type: 'polyGen',
|
|
1506
|
+
input: { prompt },
|
|
1507
|
+
maxBuzz: 250,
|
|
1508
|
+
};
|
|
1509
|
+
const quote = (await estimate(body)).stepQuote;
|
|
1510
|
+
|
|
1511
|
+
try {
|
|
1512
|
+
const started = quote
|
|
1513
|
+
? // Over the per-call budget: the viewer confirms `quote.total` in Civitai's dialog.
|
|
1514
|
+
await runConfirmedStep({ ...body, quoteId: quote.quoteId })
|
|
1515
|
+
: // Within it (or not confirmable): the ordinary path.
|
|
1516
|
+
await submit(body);
|
|
1517
|
+
return await watch(started.workflowId);
|
|
1518
|
+
} catch (err) {
|
|
1519
|
+
if (!(err instanceof RunConfirmedStepError)) throw err;
|
|
1520
|
+
if (err.declined) return; // no job — say nothing
|
|
1521
|
+
if (err.dailyLimitExceeded) return; // no job — Civitai already told the viewer
|
|
1522
|
+
if (err.signInRequired) return requestSignIn();
|
|
1523
|
+
if (err.unconfirmed) return showCheckYourGenerations(); // may be running — never auto-retry
|
|
1524
|
+
if (err.refused) return showError(err.message); // a cap refusal; no job, nothing charged
|
|
1525
|
+
showError('Could not start this job. Get a new price and try again.');
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
```
|
|
1529
|
+
|
|
1530
|
+
**With the mock host** — `createMockHost` or `Harness` from
|
|
1531
|
+
`@civitai/blocks-react/testing` — this is the only place the flow can be
|
|
1532
|
+
exercised before a production host has it. A pass-through `step` estimate carries
|
|
1533
|
+
a `stepQuote` whenever the mock's price for it (`generation.costPerGen`, or
|
|
1534
|
+
`cost`) is above the token's per-call budget (`buzzBudget`, default 200) and at
|
|
1535
|
+
most 5,000, for a signed-in viewer; there is no separate switch. As on the
|
|
1536
|
+
server, a batch-estimate cell never carries one, and where a quote would be
|
|
1537
|
+
granted the estimate fails instead, with the server's message, for a training
|
|
1538
|
+
`$type` and for non-plain-JSON `input`. The mock holds the quotes it stored and
|
|
1539
|
+
refuses an unknown, expired or already-run one, a body that differs from the
|
|
1540
|
+
quoted one, and a plain `SUBMIT_WORKFLOW` carrying a `quoteId`. How the run
|
|
1541
|
+
settles is `runConfirmedStepError`:
|
|
1542
|
+
|
|
1543
|
+
| `runConfirmedStepError` | URL | outcome |
|
|
1544
|
+
|---|---|---|
|
|
1545
|
+
| unset | — | the viewer confirms; the job starts, charged `stepQuote.total` |
|
|
1546
|
+
| `'declined'` | `?confirmedStep=declined` | `err.declined`; the quote survives |
|
|
1547
|
+
| `'daily-limit-exceeded'` | `?confirmedStep=daily-limit` | `err.dailyLimitExceeded`; the quote survives |
|
|
1548
|
+
| `'submission-unconfirmed'` | `?confirmedStep=unconfirmed` | `err.unconfirmed`; the quote is used up |
|
|
1549
|
+
| any other string | — | a server message; the quote is used up |
|
|
1550
|
+
|
|
1551
|
+
`runConfirmedStepCapRefusal` resolves the server's refunded cap refusal
|
|
1552
|
+
(`err.refused`), and both knobs are live-tunable with `host.setScenario(...)`.
|
|
1553
|
+
|
|
1554
|
+
```tsx
|
|
1555
|
+
import { createMockHost } from '@civitai/blocks-react/testing';
|
|
1556
|
+
|
|
1557
|
+
const host = createMockHost({
|
|
1558
|
+
consentGranted: true,
|
|
1559
|
+
generation: { costPerGen: 800 }, // above the default 200 per-call budget → quoted
|
|
1560
|
+
runConfirmedStepError: 'declined',
|
|
1561
|
+
});
|
|
1562
|
+
const uninstall = host.install();
|
|
1563
|
+
// …later: the viewer confirms this time.
|
|
1564
|
+
host.setScenario({ runConfirmedStepError: undefined });
|
|
1565
|
+
```
|
|
1566
|
+
|
|
1567
|
+
What the mock does **not** model: it has no dialog, so the run settles at once
|
|
1568
|
+
where the real host waits on a click; it prices every estimate as fixed, where
|
|
1569
|
+
the real host gives a variable or metered price no `stepQuote`; and the flag, the
|
|
1570
|
+
page-only rule and the submit-time re-price are server-side.
|
|
1571
|
+
|
|
1572
|
+
#### 3D outputs: `model3dOutputs`
|
|
1573
|
+
|
|
1574
|
+
A step that produces 3D models (`polyGen`) lists every model url in
|
|
1575
|
+
`snapshot.imageUrls`, as before. `snapshot.model3dOutputs` says which is which:
|
|
1576
|
+
one `{ url, format, variant }` per model, where `format` is the model's own file
|
|
1577
|
+
format (`'glb'`, `'fbx'`, …) and `variant` is the output it came from (`'model'`,
|
|
1578
|
+
`'riggedFbxModel'`, `'basicAnimations.walkingModel'`, …). Both are open sets
|
|
1579
|
+
named by the orchestrator. The field is **omitted** when the workflow produced no
|
|
1580
|
+
3D model and on hosts that predate it, and the same field is on each
|
|
1581
|
+
`useAppWorkflows()` row.
|
|
1582
|
+
|
|
1583
|
+
```tsx
|
|
1584
|
+
function modelsOf(snapshot: BlockWorkflowSnapshot) {
|
|
1585
|
+
const models = snapshot.model3dOutputs ?? [];
|
|
1586
|
+
return {
|
|
1587
|
+
// The plain mesh, in the format a web viewer loads.
|
|
1588
|
+
glb: models.find((m) => m.format === 'glb' && m.variant === 'model')?.url,
|
|
1589
|
+
// The rigged export, for a download button.
|
|
1590
|
+
riggedFbx: models.find((m) => m.variant === 'riggedFbxModel')?.url,
|
|
1591
|
+
// Every animation clip, whatever the host named them.
|
|
1592
|
+
animations: models.filter((m) => m.variant.startsWith('basicAnimations.')),
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
```
|
|
1596
|
+
|
|
1597
|
+
In the mock host, `generation.model3dOutputs` puts labels (and their urls in
|
|
1598
|
+
`imageUrls`) on a succeeded pass-through step.
|
|
1599
|
+
|
|
1225
1600
|
### `useAppWorkflows(params?)`
|
|
1226
1601
|
|
|
1227
1602
|
The calling app's **own** generator subqueue — the tag-scoped list of generations
|
|
@@ -1560,6 +1935,20 @@ if (isModelSlotContext(context) && context.checkpoint) {
|
|
|
1560
1935
|
}
|
|
1561
1936
|
```
|
|
1562
1937
|
|
|
1938
|
+
🔴 **`persist()` works on a model slot only. On a page it always rejects.** A
|
|
1939
|
+
full-page app (`app.page`) is bound to no model, so the host has nowhere to write
|
|
1940
|
+
the override: it answers every `persist()`, including `persist(null)`, with
|
|
1941
|
+
`ok: false`, and the promise rejects with `page blocks cannot persist a
|
|
1942
|
+
checkpoint override (no model binding)`. A page app keeps the checkpoint `open()`
|
|
1943
|
+
returned in its own state (or in `useAppStorage()`).
|
|
1944
|
+
|
|
1945
|
+
Both dev hosts (`createMockHost` / `Harness` and `dev:live`) refuse it the same
|
|
1946
|
+
way, and both default to a page context. Pass a complete `ModelSlotContext` as
|
|
1947
|
+
`context` to exercise a block that persists. A test that expected `persist()` to
|
|
1948
|
+
resolve under the default mock context was asserting something a page app never
|
|
1949
|
+
gets: give it the model slot the block really runs in, or, for a page app, drop
|
|
1950
|
+
the `persist()` call.
|
|
1951
|
+
|
|
1563
1952
|
### `useResourcePicker()`
|
|
1564
1953
|
|
|
1565
1954
|
Drive the platform resource picker for page blocks — `'Checkpoint' | 'LORA'`.
|
|
@@ -2665,6 +3054,8 @@ MockHostScenarioPatch
|
|
|
2665
3054
|
MockSharedScenario
|
|
2666
3055
|
MockSharedSeed
|
|
2667
3056
|
MockStorageScenario
|
|
3057
|
+
MockWorkflowQuoteFailure
|
|
3058
|
+
MockWorkflowStepInfo
|
|
2668
3059
|
PopoverShimHandle
|
|
2669
3060
|
PopoverShimOptions
|
|
2670
3061
|
```
|
|
@@ -2863,6 +3254,21 @@ const host = createLiveHost(options);
|
|
|
2863
3254
|
const uninstall = host.install();
|
|
2864
3255
|
```
|
|
2865
3256
|
|
|
3257
|
+
What `dev:live` forwards, and how it answers the one call it does not:
|
|
3258
|
+
|
|
3259
|
+
- **`useCheckpointPicker().persist()` on a page** is refused with no backend
|
|
3260
|
+
call, as the page host refuses it (see
|
|
3261
|
+
[`useCheckpointPicker()`](#usecheckpointpicker)). The default `context` is a
|
|
3262
|
+
page, because a dev token is a page token.
|
|
3263
|
+
- **`submit()`'s `idempotencyKey` reaches the server**, as it does from the
|
|
3264
|
+
production hosts. A retry with the same key is deduplicated there, and a key in
|
|
3265
|
+
a format the server refuses fails the submit with the server's message.
|
|
3266
|
+
- **`watch()`'s `waitSeconds` reaches the server**, so a poll is held open
|
|
3267
|
+
instead of answered at once, and the server decides the actual hold. This
|
|
3268
|
+
matches the production hosts as of
|
|
3269
|
+
[civitai/civitai#5798](https://github.com/civitai/civitai/pull/5798); a
|
|
3270
|
+
production host without that change drops the field and answers at once.
|
|
3271
|
+
|
|
2866
3272
|
### Stability of `/live`
|
|
2867
3273
|
|
|
2868
3274
|
The same terms as `./testing`: a normal subpath of a `0.x` package where a minor
|
|
@@ -162,6 +162,10 @@ export interface UseBatchEstimate {
|
|
|
162
162
|
* - **At most {@link BATCH_ESTIMATE_MAX_CELLS} bodies per call.**
|
|
163
163
|
* - **`kind: 'training'` bodies are refused per cell.** Estimate a training run
|
|
164
164
|
* with `useBuzzWorkflow().estimate()`.
|
|
165
|
+
* - **A cell never carries a `stepQuote`.** A batch stores nothing, so a
|
|
166
|
+
* pass-through step priced above the per-call budget comes back with its cost
|
|
167
|
+
* and no confirmable quote. To run it with `useRunConfirmedStep()`, estimate
|
|
168
|
+
* that one body with `useBuzzWorkflow().estimate()`.
|
|
165
169
|
*
|
|
166
170
|
* 🔴 ESTIMATE ONLY. There is no batch submit and no server-side id for the
|
|
167
171
|
* list. To watch and cancel the submitted cells together, use
|
|
@@ -104,6 +104,10 @@ function toBatchEstimateError(err) {
|
|
|
104
104
|
* - **At most {@link BATCH_ESTIMATE_MAX_CELLS} bodies per call.**
|
|
105
105
|
* - **`kind: 'training'` bodies are refused per cell.** Estimate a training run
|
|
106
106
|
* with `useBuzzWorkflow().estimate()`.
|
|
107
|
+
* - **A cell never carries a `stepQuote`.** A batch stores nothing, so a
|
|
108
|
+
* pass-through step priced above the per-call budget comes back with its cost
|
|
109
|
+
* and no confirmable quote. To run it with `useRunConfirmedStep()`, estimate
|
|
110
|
+
* that one body with `useBuzzWorkflow().estimate()`.
|
|
107
111
|
*
|
|
108
112
|
* 🔴 ESTIMATE ONLY. There is no batch submit and no server-side id for the
|
|
109
113
|
* list. To watch and cancel the submitted cells together, use
|
|
@@ -13,6 +13,22 @@ import type { ConsentRetryOptions } from './consentRetryOptions.js';
|
|
|
13
13
|
* on that class is money-ambiguous by design).
|
|
14
14
|
*/
|
|
15
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.";
|
|
16
|
+
/**
|
|
17
|
+
* Why {@link UseBuzzWorkflow.submit} refuses a pass-through `step` body that
|
|
18
|
+
* carries a `quoteId`.
|
|
19
|
+
*
|
|
20
|
+
* 🔴 REFUSED BEFORE THE SEND, for the same reason as
|
|
21
|
+
* {@link TRAINING_BODY_SUBMIT_REFUSAL}. A `quoteId` on a pass-through body means
|
|
22
|
+
* "charge this job against the price the viewer confirmed", and only
|
|
23
|
+
* `RUN_CONFIRMED_STEP`'s host-chrome dialog can record that confirmation — so a
|
|
24
|
+
* `SUBMIT_WORKFLOW` carrying one can never start the job; the server refuses it
|
|
25
|
+
* as unconfirmed. Nothing is sent and nothing is spent, which is why this too is
|
|
26
|
+
* a plain `Error` and not a {@link WorkflowSubmitError}.
|
|
27
|
+
*
|
|
28
|
+
* The same body WITHOUT `quoteId` is an ordinary pass-through submit and is not
|
|
29
|
+
* affected.
|
|
30
|
+
*/
|
|
31
|
+
export declare const CONFIRMED_STEP_BODY_SUBMIT_REFUSAL = "useBuzzWorkflow.submit() does not run a step body that carries a quoteId \u2014 run it with useRunConfirmedStep().runConfirmedStep(body), or drop quoteId to submit it as an ordinary job. Nothing was sent.";
|
|
16
32
|
/**
|
|
17
33
|
* Default orchestrator-side hold per {@link UseBuzzWorkflow.watch} poll,
|
|
18
34
|
* in SECONDS.
|
|
@@ -486,6 +502,17 @@ export interface UseBuzzWorkflow {
|
|
|
486
502
|
* A `kind: 'training'` body's reply also carries
|
|
487
503
|
* {@link BlockWorkflowSnapshot.trainingQuote} — the `quoteId` to run it with
|
|
488
504
|
* through `useRunTraining()`.
|
|
505
|
+
*
|
|
506
|
+
* A pass-through `step` body's reply carries
|
|
507
|
+
* {@link BlockWorkflowSnapshot.stepQuote} when its FIXED price is above the
|
|
508
|
+
* token's per-call budget (and within the per-job ceiling). That job cannot
|
|
509
|
+
* run through {@link UseBuzzWorkflow.submit}; run it with
|
|
510
|
+
* `useRunConfirmedStep().runConfirmedStep({ ...body, quoteId:
|
|
511
|
+
* stepQuote.quoteId })`, which has the viewer confirm `stepQuote.total`. No
|
|
512
|
+
* `stepQuote` means the ordinary rules apply — it is also what a host without
|
|
513
|
+
* the feature sends. Where a quote would be granted, this REJECTS instead for
|
|
514
|
+
* a training `$type` or an `input` that is not plain JSON (see
|
|
515
|
+
* {@link BlockWorkflowSnapshot.stepQuote} for the two messages).
|
|
489
516
|
*/
|
|
490
517
|
estimate: (body: WorkflowBody) => Promise<BlockWorkflowSnapshot>;
|
|
491
518
|
/**
|
|
@@ -589,6 +616,9 @@ export interface UseBuzzWorkflow {
|
|
|
589
616
|
*
|
|
590
617
|
* 🔴 A `kind: 'training'` BODY IS REFUSED before anything is sent — it runs
|
|
591
618
|
* only through `useRunTraining()`. See {@link TRAINING_BODY_SUBMIT_REFUSAL}.
|
|
619
|
+
* So is a pass-through `step` body that carries a `quoteId` — it runs only
|
|
620
|
+
* through `useRunConfirmedStep()`. See
|
|
621
|
+
* {@link CONFIRMED_STEP_BODY_SUBMIT_REFUSAL}.
|
|
592
622
|
*/
|
|
593
623
|
submit: (body: WorkflowBody, options?: SubmitWorkflowOptions) => Promise<BlockWorkflowSnapshot>;
|
|
594
624
|
/**
|
|
@@ -698,6 +728,24 @@ export interface UseBuzzWorkflow {
|
|
|
698
728
|
* the paragraph above records, one member over: an incomplete description of a
|
|
699
729
|
* union, trusted because it read as authoritative.
|
|
700
730
|
*
|
|
731
|
+
* 🔴 A MULTI-STEP `kind: 'workflow'` BODY CHANGES WHAT THE PRICE MEANS — again a
|
|
732
|
+
* capability claim, so it is stated here. `WorkflowBodyWorkflow` submits several
|
|
733
|
+
* orchestrator steps as one workflow through these same calls, and:
|
|
734
|
+
* - `estimate()`'s `cost.total` is NOT a maximum: the orchestrator re-prices a
|
|
735
|
+
* step once the values it references are known and charges the difference;
|
|
736
|
+
* - a workflow the host cannot price FAILS `estimate()` with a
|
|
737
|
+
* `workflow_quote_*` `errorCode` and no `cost`, yet still proceeds on
|
|
738
|
+
* `submit()`, reserved at the app's per-generation budget × the number of
|
|
739
|
+
* steps (one with a training step, or with a `repeat` step at any depth, is
|
|
740
|
+
* refused on both); an older host returns that ceiling as the estimate;
|
|
741
|
+
* - one author fee applies to the whole workflow, not one per step, and it is
|
|
742
|
+
* reversed whole if the workflow does not succeed;
|
|
743
|
+
* - `repeat` iterates only an array in the body's `arguments`, at most 16
|
|
744
|
+
* times, nested repeats multiplied;
|
|
745
|
+
* - a `failed` result can carry `cost.total > 0` and usable `imageUrls`, because
|
|
746
|
+
* steps that succeeded before the failing one stay charged — read
|
|
747
|
+
* `result.steps`.
|
|
748
|
+
*
|
|
701
749
|
* @returns `{ estimate, submit, poll, watch, cancel, status, result, error }`.
|
|
702
750
|
*
|
|
703
751
|
* @example
|
|
@@ -30,6 +30,22 @@ const WORKFLOW_SCOPES = [BLOCK_SCOPES.AI_WRITE_BUDGETED];
|
|
|
30
30
|
* on that class is money-ambiguous by design).
|
|
31
31
|
*/
|
|
32
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.";
|
|
33
|
+
/**
|
|
34
|
+
* Why {@link UseBuzzWorkflow.submit} refuses a pass-through `step` body that
|
|
35
|
+
* carries a `quoteId`.
|
|
36
|
+
*
|
|
37
|
+
* 🔴 REFUSED BEFORE THE SEND, for the same reason as
|
|
38
|
+
* {@link TRAINING_BODY_SUBMIT_REFUSAL}. A `quoteId` on a pass-through body means
|
|
39
|
+
* "charge this job against the price the viewer confirmed", and only
|
|
40
|
+
* `RUN_CONFIRMED_STEP`'s host-chrome dialog can record that confirmation — so a
|
|
41
|
+
* `SUBMIT_WORKFLOW` carrying one can never start the job; the server refuses it
|
|
42
|
+
* as unconfirmed. Nothing is sent and nothing is spent, which is why this too is
|
|
43
|
+
* a plain `Error` and not a {@link WorkflowSubmitError}.
|
|
44
|
+
*
|
|
45
|
+
* The same body WITHOUT `quoteId` is an ordinary pass-through submit and is not
|
|
46
|
+
* affected.
|
|
47
|
+
*/
|
|
48
|
+
export const CONFIRMED_STEP_BODY_SUBMIT_REFUSAL = "useBuzzWorkflow.submit() does not run a step body that carries a quoteId — run it with useRunConfirmedStep().runConfirmedStep(body), or drop quoteId to submit it as an ordinary job. Nothing was sent.";
|
|
33
49
|
/**
|
|
34
50
|
* Snapshot statuses that mean "no further polling is needed."
|
|
35
51
|
* Used by both `submit` (a host can return an instant-fail / cached result)
|
|
@@ -523,6 +539,24 @@ export class WorkflowSubmitError extends Error {
|
|
|
523
539
|
* the paragraph above records, one member over: an incomplete description of a
|
|
524
540
|
* union, trusted because it read as authoritative.
|
|
525
541
|
*
|
|
542
|
+
* 🔴 A MULTI-STEP `kind: 'workflow'` BODY CHANGES WHAT THE PRICE MEANS — again a
|
|
543
|
+
* capability claim, so it is stated here. `WorkflowBodyWorkflow` submits several
|
|
544
|
+
* orchestrator steps as one workflow through these same calls, and:
|
|
545
|
+
* - `estimate()`'s `cost.total` is NOT a maximum: the orchestrator re-prices a
|
|
546
|
+
* step once the values it references are known and charges the difference;
|
|
547
|
+
* - a workflow the host cannot price FAILS `estimate()` with a
|
|
548
|
+
* `workflow_quote_*` `errorCode` and no `cost`, yet still proceeds on
|
|
549
|
+
* `submit()`, reserved at the app's per-generation budget × the number of
|
|
550
|
+
* steps (one with a training step, or with a `repeat` step at any depth, is
|
|
551
|
+
* refused on both); an older host returns that ceiling as the estimate;
|
|
552
|
+
* - one author fee applies to the whole workflow, not one per step, and it is
|
|
553
|
+
* reversed whole if the workflow does not succeed;
|
|
554
|
+
* - `repeat` iterates only an array in the body's `arguments`, at most 16
|
|
555
|
+
* times, nested repeats multiplied;
|
|
556
|
+
* - a `failed` result can carry `cost.total > 0` and usable `imageUrls`, because
|
|
557
|
+
* steps that succeeded before the failing one stay charged — read
|
|
558
|
+
* `result.steps`.
|
|
559
|
+
*
|
|
526
560
|
* @returns `{ estimate, submit, poll, watch, cancel, status, result, error }`.
|
|
527
561
|
*
|
|
528
562
|
* @example
|
|
@@ -749,6 +783,15 @@ export function useBuzzWorkflow() {
|
|
|
749
783
|
setStatus('error');
|
|
750
784
|
throw refused;
|
|
751
785
|
}
|
|
786
|
+
// See `CONFIRMED_STEP_BODY_SUBMIT_REFUSAL`. Keyed on the PRESENCE of a
|
|
787
|
+
// `quoteId`, whatever its shape: a malformed one is no more submittable
|
|
788
|
+
// than a well-formed one, and the server would refuse both.
|
|
789
|
+
if (body.kind === 'step' && body.quoteId !== undefined) {
|
|
790
|
+
const refused = new Error(CONFIRMED_STEP_BODY_SUBMIT_REFUSAL);
|
|
791
|
+
setError(refused);
|
|
792
|
+
setStatus('error');
|
|
793
|
+
throw refused;
|
|
794
|
+
}
|
|
752
795
|
setError(null);
|
|
753
796
|
setStatus('submitting');
|
|
754
797
|
// Idempotency: reuse a caller-supplied stable key across a retry (→ one Buzz
|
|
@@ -54,6 +54,8 @@ export interface UseCheckpointPicker {
|
|
|
54
54
|
* host. Pass `null` to clear the override and fall back to the publisher
|
|
55
55
|
* default at next mount. Throws on host-side validation failure (e.g.
|
|
56
56
|
* "wrong-ecosystem") — surface the message to the user; don't retry blindly.
|
|
57
|
+
* MODEL SLOTS ONLY: on a page (`app.page`) the host refuses every `persist`,
|
|
58
|
+
* so it always throws there; a page app keeps the pick in its own state.
|
|
57
59
|
*
|
|
58
60
|
* Both flows are host-mediated: the block never touches the picker UI or
|
|
59
61
|
* the user-settings table directly. Same trust model as useBuzzPurchase /
|
|
@@ -15,6 +15,8 @@ import { sendTypedRequest } from '../transport/transport.js';
|
|
|
15
15
|
* host. Pass `null` to clear the override and fall back to the publisher
|
|
16
16
|
* default at next mount. Throws on host-side validation failure (e.g.
|
|
17
17
|
* "wrong-ecosystem") — surface the message to the user; don't retry blindly.
|
|
18
|
+
* MODEL SLOTS ONLY: on a page (`app.page`) the host refuses every `persist`,
|
|
19
|
+
* so it always throws there; a page app keeps the pick in its own state.
|
|
18
20
|
*
|
|
19
21
|
* Both flows are host-mediated: the block never touches the picker UI or
|
|
20
22
|
* the user-settings table directly. Same trust model as useBuzzPurchase /
|