@civitai/blocks-react 0.71.0 → 0.72.2

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.
Files changed (71) hide show
  1. package/README.md +308 -15
  2. package/dist/hooks/useAppStorage.js +2 -0
  3. package/dist/hooks/useAppWorkflows.js +2 -0
  4. package/dist/hooks/useBatchEstimate.d.ts +4 -0
  5. package/dist/hooks/useBatchEstimate.js +6 -0
  6. package/dist/hooks/useBlockAnalytics.js +2 -0
  7. package/dist/hooks/useBlockBreakpoint.js +2 -0
  8. package/dist/hooks/useBlockContext.js +2 -0
  9. package/dist/hooks/useBlockResize.js +2 -0
  10. package/dist/hooks/useBlockSettings.js +2 -0
  11. package/dist/hooks/useBlockTheme.js +2 -0
  12. package/dist/hooks/useBlockToken.js +2 -0
  13. package/dist/hooks/useBuzzAccounts.js +2 -0
  14. package/dist/hooks/useBuzzBalance.js +2 -0
  15. package/dist/hooks/useBuzzPurchase.js +2 -0
  16. package/dist/hooks/useBuzzTransactions.js +2 -0
  17. package/dist/hooks/useBuzzWorkflow.d.ts +35 -3
  18. package/dist/hooks/useBuzzWorkflow.js +32 -3
  19. package/dist/hooks/useCheckpointPicker.d.ts +2 -0
  20. package/dist/hooks/useCheckpointPicker.js +4 -0
  21. package/dist/hooks/useCivitaiNavigate.js +2 -0
  22. package/dist/hooks/useCivitaiRoute.js +2 -0
  23. package/dist/hooks/useCollectionFollow.js +2 -0
  24. package/dist/hooks/useConsentUnavailable.js +2 -0
  25. package/dist/hooks/useCreatePostFromApp.js +2 -0
  26. package/dist/hooks/useDailyCompensation.js +2 -0
  27. package/dist/hooks/useDirectLoad.js +2 -0
  28. package/dist/hooks/useDomainMaturity.js +2 -0
  29. package/dist/hooks/useEntitlements.js +2 -0
  30. package/dist/hooks/useGatedImages.js +2 -0
  31. package/dist/hooks/useGenerationResources.js +2 -0
  32. package/dist/hooks/useGoodPurchase.js +2 -0
  33. package/dist/hooks/useHostOrigin.js +2 -0
  34. package/dist/hooks/useImageUpload.js +2 -0
  35. package/dist/hooks/useNestedDocument.js +2 -0
  36. package/dist/hooks/usePrepareTrainingDataset.js +2 -0
  37. package/dist/hooks/usePublishGenerationOutputs.js +2 -0
  38. package/dist/hooks/useRequestConsent.js +2 -0
  39. package/dist/hooks/useRequestSignIn.js +2 -0
  40. package/dist/hooks/useResourcePicker.js +2 -0
  41. package/dist/hooks/useRunConfirmedStep.d.ts +209 -0
  42. package/dist/hooks/useRunConfirmedStep.js +289 -0
  43. package/dist/hooks/useRunTraining.js +2 -0
  44. package/dist/hooks/useSaveImage.js +2 -0
  45. package/dist/hooks/useSharedStorage.js +2 -0
  46. package/dist/hooks/useTip.js +2 -0
  47. package/dist/hooks/useTipAllowance.js +2 -0
  48. package/dist/hooks/useTransportConnection.d.ts +43 -0
  49. package/dist/hooks/useTransportConnection.js +59 -0
  50. package/dist/hooks/useUploadImageBytes.js +2 -0
  51. package/dist/hooks/useViewer.js +2 -0
  52. package/dist/hooks/useWildcardPack.js +2 -0
  53. package/dist/hooks/useWorkflowBatch.js +2 -0
  54. package/dist/index.d.ts +2 -0
  55. package/dist/index.js +1 -0
  56. package/dist/internal/hostSurface.d.ts +37 -0
  57. package/dist/internal/hostSurface.js +38 -0
  58. package/dist/internal/liveHost.d.ts +15 -6
  59. package/dist/internal/liveHost.js +151 -69
  60. package/dist/internal/mockHost.d.ts +123 -10
  61. package/dist/internal/mockHost.js +377 -14
  62. package/dist/internal/mockWorkflowGraph.d.ts +23 -10
  63. package/dist/internal/mockWorkflowGraph.js +34 -11
  64. package/dist/testing.d.ts +1 -1
  65. package/dist/transport/iframeTransport.js +13 -0
  66. package/dist/transport/requestTimeouts.js +6 -0
  67. package/dist/transport/singleton.d.ts +6 -0
  68. package/dist/transport/singleton.js +20 -0
  69. package/dist/transport/validate.d.ts +13 -0
  70. package/dist/transport/validate.js +76 -0
  71. package/package.json +4 -4
package/README.md CHANGED
@@ -117,6 +117,33 @@ viewer's signed-in civitai.com session, which the local harness does not have, s
117
117
  host — `createMockHost` or `Harness` from `@civitai/blocks-react/testing`, with
118
118
  the `publishImageIds` / `publishError` options.
119
119
 
120
+ ### Required at mount: the host handshake
121
+
122
+ The SDK must be **connected at mount**. When the transport is created it posts
123
+ `BLOCK_HELLO` to the host, and it answers the host's first valid `BLOCK_INIT`
124
+ with `BLOCK_READY`. The host waits **10 seconds** for that answer; without it,
125
+ the viewer sees "This app didn't load in time", the host retries twice, and then
126
+ gives up. `"bootSkeleton": true` only removes the host's loading cover — it does
127
+ **not** extend or lift that deadline.
128
+
129
+ You do not have to do anything for this: **every hook exported from the package
130
+ root connects the transport when its component mounts**, so a block that calls
131
+ any one of them — even only `useBuzzWorkflow()`, whose requests all happen in
132
+ callbacks — answers the host before anyone clicks. (In earlier releases, request-only
133
+ hooks connected lazily on their first call, so a block whose only SDK use was
134
+ such a hook rendered fine and was then timed out by the host.)
135
+
136
+ What this means for you:
137
+
138
+ - A block that renders but uses **no** hook from this package never connects.
139
+ Call `useBlockContext()` at your root (you will want its `ready` gate anyway).
140
+ - A dev harness that injects options with `getTransport({ allowedParentOrigins })`
141
+ must still do so **before** the first component that uses a hook mounts — only
142
+ the first call's options are honoured.
143
+ - If no parent allowlist is configured, the mount-time connect logs one
144
+ `console.error` and the block keeps rendering; the first bridge call then
145
+ throws the same configuration error it always did.
146
+
120
147
  ### `useBlockContext()`
121
148
 
122
149
  The primary hook. Returns everything the host delivered in `BLOCK_INIT` plus a
@@ -535,14 +562,35 @@ try {
535
562
  | `'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
563
  | `'training-quote-unavailable'` | no usable answer (an outage, a timeout, a rate limit, insufficient funds, …); a retry may succeed |
537
564
  | `'training-quote-unpriced'` | the orchestrator answered without a price |
565
+ | `'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
566
  | absent | any other reply, and every reply from a host that predates the field |
539
567
 
540
568
  - **Treat an unknown value as possible.** The field is typed as an open string
541
569
  (`KnownBlockWorkflowErrorCode | (string & {})`), the host may add codes, and
542
570
  the inbound validator accepts any string. Keep a `default` branch.
543
- - The `kind: 'training'` arm does not set it.
544
- - A refused `kind: 'workflow'` body sets its own three codes, in the same
545
- shape — see the next section.
571
+ - A `kind: 'training'` **estimate** does not set it; the three `training-quote-*`
572
+ codes belong to the pass-through arm.
573
+ - A refused `kind: 'workflow'` body sets its own codes, in the same shape — see
574
+ the next section.
575
+
576
+ #### A registered step with no price quote: `step_quote_*`
577
+
578
+ A **registered** `kind: 'step'` body (one with a `step` id, such as
579
+ `'chat-completion'` or `'convert-image'`) that the host has no price quote for
580
+ fails on `estimate()` (`WorkflowEstimateError`) and as a batch-estimate cell,
581
+ as `{ workflowId: 'failed', status: 'failed', error, errorCode }` with no
582
+ `cost`. `submit()` of the same body is unchanged. The pass-through arm (a
583
+ `$type`, no `step`) does not set these codes.
584
+
585
+ | `errorCode` | Means | `error` | Retry the same body? |
586
+ |---|---|---|---|
587
+ | `'step_quote_rejected'` | the orchestrator refused the request | `No price quote for this step: the orchestrator rejected the request: <reason>` when the reason is safe to show, else `No price quote for this step: the orchestrator rejected the request; check the step inputs and references (retrying the same body will not help)` | no — change the body |
588
+ | `'step_quote_unavailable'` | no usable answer (an outage, a timeout, a rate limit, …) | `No price quote for this step: none could be obtained; try again` | yes |
589
+ | `'step_quote_unpriced'` | the orchestrator answered without a price | `No price quote for this step: the orchestrator answered without one` | not expected to help |
590
+
591
+ An older host has none of the three: it resolves the estimate priced at the
592
+ step's declared estimate. The mock host always quotes a registered step (at
593
+ `generation.costPerGen`), so it never sends these codes.
546
594
 
547
595
  #### Several steps as one workflow: `kind: 'workflow'`
548
596
 
@@ -644,12 +692,19 @@ showImages(done.imageUrls ?? []);
644
692
  1. **The estimate is not a maximum.** When a step reads from an earlier step,
645
693
  the orchestrator re-prices it once those values are known and charges the
646
694
  difference. Never show the estimate as "you will pay at most".
647
- 2. **A workflow the host cannot price still proceeds.** `submit()` reserves the
648
- ceiling above instead of a quote. Two exceptions are refused without a
649
- quote, before any reservation: a workflow with a training step (a
650
- `training-quote-*` `errorCode`, as on the single-step arm), and a workflow
651
- with a `repeat` step at any depth (`workflow_repeat_unbounded`; a retry may
652
- succeed).
695
+ 2. **A workflow the host cannot price has no estimate, but its submit still
696
+ proceeds.** `estimate()` rejects with a `workflow_quote_*` `errorCode` and
697
+ no `cost` (see the second table under Refusals), and a batch estimate
698
+ returns that as the cell. `submit()` of the same body is **not** refused: it
699
+ reserves the ceiling above instead of a quote. So a failed estimate does not
700
+ predict a refused submit. Two exceptions are refused on both, before any
701
+ reservation: a workflow with a training step (a `training-quote-*`
702
+ `errorCode`, as on the single-step arm), and a workflow with a `repeat` step
703
+ at any depth (`workflow_repeat_unbounded`). Their `error` states why there
704
+ is no quote, and only `none could be obtained; try again` is a cause a retry
705
+ of the same body can clear; for the others the body has to change.
706
+ An older host returns the ceiling as the estimate's `cost.total` instead of
707
+ failing, so there a total equal to the ceiling may be unpriced.
653
708
  3. **One author fee per workflow**, not one per step, on the quoted base. The
654
709
  estimate's `cost.authorFee` is that single fee, already inside `cost.total`.
655
710
  If the workflow bills more than the quote, the fee charged is held to the
@@ -683,22 +738,40 @@ A body the host will not run is refused before anything is priced or reserved:
683
738
  | `'workflow_too_many_steps'` | more than 8 steps |
684
739
  | `'workflow_step_denied'` | a step names a platform-internal `$type` |
685
740
  | `'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 |
686
- | `'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 no price quote could be had (retrying may work) |
741
+ | `'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` |
742
+
743
+ A body the host WOULD run, but has no price quote for, fails on `estimate()`
744
+ and as a batch-estimate cell only, with no `cost`. `submit()` of the same body
745
+ still proceeds (point 2 above):
746
+
747
+ | `errorCode` | Means | `error` | Retry the same body? |
748
+ |---|---|---|---|
749
+ | `'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 |
750
+ | `'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 |
751
+ | `'workflow_quote_unpriced'` | the orchestrator answered without a price | `No price quote for this workflow: the orchestrator answered without one` | not expected to help |
752
+
753
+ An older host has none of the three: it resolves the estimate with the ceiling
754
+ as `cost.total`.
687
755
 
688
756
  - **Requires a host that knows the kind.** An older host refuses the body.
689
757
  - **Test it locally.** The mock host prices each step with
690
758
  `generation.costPerGen` (a function gets the step as its second argument) and
691
759
  multiplies it by a `repeat` step's iteration count, then adds
692
- `generation.authorFee` once; refuses with the four codes above, in the host's
693
- order; reports
760
+ `generation.authorFee` once; refuses with the four codes of the first table,
761
+ in the host's order; reports
694
762
  `steps` on every poll, finishing them in order; and
695
763
  `generation.failWorkflowStep: '<name>'` (`'$<index>'` for an unnamed step)
696
764
  makes one step fail, so you can render a partial failure. The mock checks
697
765
  where a reference points, not whether its `path` exists; it never re-prices;
698
766
  and it does not expand a `repeat` step into generated `<name>.<n>` steps. It
699
- quotes every workflow unless you set `generation.workflowUnpriced: true`,
700
- which simulates no quote: a workflow with a training step or a `repeat` is
701
- refused, and any other is reserved at `buzzBudget` × steps with no fee.
767
+ quotes every workflow unless you set `generation.workflowUnpriced` to
768
+ `'rejected'`, `'unavailable'` or `'unpriced'` (`true` means `'unavailable'`),
769
+ which simulates no quote for that cause: a workflow with a training step or a
770
+ `repeat` is refused on both `estimate()` and `submit()`; any other fails its
771
+ `estimate()` with `workflow_quote_<cause>` and no `cost`, while its
772
+ `submit()` is reserved at `buzzBudget` × steps with no fee. The mock has no
773
+ orchestrator reason to pass on, so `'rejected'` always gives the reason-less
774
+ message, never the `…rejected the request: <reason>` form.
702
775
 
703
776
  > **Estimate must mirror submit** (gotcha #59): build the params for `estimate`
704
777
  > with the *exact* same logic as `submit` — same seed decision especially. The
@@ -733,6 +806,11 @@ resolves with one cell per body, in order, plus the run total. Returns
733
806
  `WorkflowEstimateError`, and the other cells still price.
734
807
  - **`kind: 'training'` bodies are refused per cell.** Estimate a training run
735
808
  with `useBuzzWorkflow().estimate()`.
809
+ - **A cell never carries a `stepQuote`.** A batch stores nothing, so a
810
+ pass-through step priced above the per-call budget comes back with its cost and
811
+ no confirmable quote. To run it with
812
+ [`useRunConfirmedStep()`](#userunconfirmedstep--one-job-above-the-per-call-budget),
813
+ estimate that one body with `useBuzzWorkflow().estimate()`.
736
814
  - **Estimate only.** Nothing is submitted and no Buzz is held or spent. There is
737
815
  no batch submit. Submit each cell with
738
816
  `useBuzzWorkflow().submit()`, which asks the viewer and checks the budget per
@@ -1380,6 +1458,191 @@ server compares the whole body.
1380
1458
  **`dev:live` refuses both bridges**, because the server refuses every training
1381
1459
  request from a dev token.
1382
1460
 
1461
+ ### `useRunConfirmedStep()` — one job above the per-call budget
1462
+
1463
+ A pass-through `step` job (`{ kind: 'step', $type, input, maxBuzz }`) that costs
1464
+ more than the app may spend without asking is refused by `submit()` — unless the
1465
+ **viewer confirms that exact price** in Civitai's own dialog. This hook is that
1466
+ path. It is the training run's confirm applied to any pass-through `$type`
1467
+ (`polyGen`, a long video job, …). Three steps, two hooks:
1468
+
1469
+ 1. **Quote** — `useBuzzWorkflow().estimate(body)`, the body you would submit. When
1470
+ the job qualifies the reply carries `stepQuote: { quoteId, total, stepType,
1471
+ expiresAt }`. `total` is the price the viewer will confirm and be charged.
1472
+ 2. **Run** — `useRunConfirmedStep().runConfirmedStep({ ...body, quoteId })`
1473
+ (`RUN_CONFIRMED_STEP`). Civitai reads the quote back from the server, shows
1474
+ the viewer the price in **its** chrome and submits on their click. Resolves
1475
+ with the submitted workflow's snapshot.
1476
+ 3. **Watch** — `useBuzzWorkflow().watch(snapshot.workflowId)`, as for any job.
1477
+
1478
+ No `stepQuote` on the estimate means the ordinary rules apply: submit it if it
1479
+ fits the budget, and otherwise it cannot run from an app. Change nothing in the
1480
+ body between the estimate and the run — the server checks it against the quoted
1481
+ one.
1482
+
1483
+ 🔴 **Only a fixed price is confirmable.** `stepQuote` is present when the
1484
+ orchestrator quoted a **fixed** price (positive, not variable or metered) that is
1485
+ above the token's per-call `buzzBudget` and at most
1486
+ `BLOCK_CONFIRMED_STEP_MAX_BUZZ_PER_JOB` (5,000 Buzz). The viewer confirms what
1487
+ they are charged, so a price that may move, or no price at all, gets no quote.
1488
+ `stepQuote.total` can differ from `cost.total` on the same snapshot (which stays
1489
+ what an unconfirmed submit would reserve) — put `stepQuote.total` on your button.
1490
+
1491
+ 🔴 **Estimate it on its own.** Only a single `estimate()` returns a `stepQuote`.
1492
+ A batch estimate (`useBatchEstimate()`) stores nothing, so its cells never carry
1493
+ one: price the grid in a batch, then estimate the one body you want to run.
1494
+
1495
+ 🔴 **Two bodies make the estimate reject instead of returning a price.** Only
1496
+ where a quote would otherwise be granted, so not below the budget and not in a
1497
+ batch. `estimate()` rejects with a `WorkflowEstimateError` whose
1498
+ `snapshot.error` is the server's message; the same text is the
1499
+ `RunConfirmedStepError`'s `message` if such a body is run. Neither has a code or
1500
+ a flag — they are server messages, so match on the text:
1501
+
1502
+ | `snapshot.error` / `err.message` | cause | fix |
1503
+ |---|---|---|
1504
+ | `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 |
1505
+ | 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** |
1506
+
1507
+ 🔴 **Single-use, and it expires.** A quote is good for one run and for **15
1508
+ minutes** (`stepQuote.expiresAt`). The server prices the job again at submit and
1509
+ refuses a higher price. Estimate again to get a new one.
1510
+
1511
+ 🔴 **Availability.** Behind the host flag **`app-blocks-confirmed-step`**, which
1512
+ ships **off** and is evaluated per viewer; requires a civitai.com host carrying
1513
+ civitai/civitai#5786. **Page apps only** — the model slot answers with an error.
1514
+ The manifest must declare **`ai:write:budgeted`** and the viewer must be signed
1515
+ in and have granted it. **Refused for development, dev-tunnel and review
1516
+ tokens**, so **`dev:live` cannot exercise it**: the estimate there never carries
1517
+ a `stepQuote` and the bridge replies with a refusal. Build the flow against the
1518
+ mock host (below). Where the feature is unavailable nothing errors — the estimate
1519
+ simply has no `stepQuote`, which is also what an older host sends.
1520
+
1521
+ 🔴 **No retry, ever.** Every confirmation is its own job: there is no idempotency
1522
+ key, and confirming the same body twice starts and charges two jobs. The hook
1523
+ never re-sends and never prompts for consent, and `useBuzzWorkflow().submit()`
1524
+ **refuses** a step body that carries a `quoteId` before sending anything — only
1525
+ `RUN_CONFIRMED_STEP`'s dialog can confirm a quote. `runConfirmedStep` also
1526
+ refuses, before sending, a body whose `quoteId` is missing or is not an
1527
+ `sq_`-shaped id (a training `tq_…` quote, or `undefined` from an estimate that
1528
+ returned no `stepQuote`).
1529
+
1530
+ `runConfirmedStep` **rejects** with a `RunConfirmedStepError`; read the flags
1531
+ before saying anything about money:
1532
+
1533
+ | | meaning | what to do |
1534
+ |---|---|---|
1535
+ | `err.declined` | the viewer dismissed the confirm — **no job was submitted**; the quote is still good until it expires | revert, say nothing |
1536
+ | `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) |
1537
+ | `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 |
1538
+ | `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 |
1539
+ | `err.signInRequired` | no session | route into `useRequestSignIn()` |
1540
+ | `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 |
1541
+ | `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 |
1542
+
1543
+ ```tsx
1544
+ const { estimate, submit, watch } = useBuzzWorkflow();
1545
+ const { runConfirmedStep } = useRunConfirmedStep();
1546
+ const { requestSignIn } = useRequestSignIn();
1547
+
1548
+ async function generate3d(prompt: string) {
1549
+ const body: WorkflowBodyPassThroughStep = {
1550
+ kind: 'step',
1551
+ $type: 'polyGen',
1552
+ input: { prompt },
1553
+ maxBuzz: 250,
1554
+ };
1555
+ const quote = (await estimate(body)).stepQuote;
1556
+
1557
+ try {
1558
+ const started = quote
1559
+ ? // Over the per-call budget: the viewer confirms `quote.total` in Civitai's dialog.
1560
+ await runConfirmedStep({ ...body, quoteId: quote.quoteId })
1561
+ : // Within it (or not confirmable): the ordinary path.
1562
+ await submit(body);
1563
+ return await watch(started.workflowId);
1564
+ } catch (err) {
1565
+ if (!(err instanceof RunConfirmedStepError)) throw err;
1566
+ if (err.declined) return; // no job — say nothing
1567
+ if (err.dailyLimitExceeded) return; // no job — Civitai already told the viewer
1568
+ if (err.signInRequired) return requestSignIn();
1569
+ if (err.unconfirmed) return showCheckYourGenerations(); // may be running — never auto-retry
1570
+ if (err.refused) return showError(err.message); // a cap refusal; no job, nothing charged
1571
+ showError('Could not start this job. Get a new price and try again.');
1572
+ }
1573
+ }
1574
+ ```
1575
+
1576
+ **With the mock host** — `createMockHost` or `Harness` from
1577
+ `@civitai/blocks-react/testing` — this is the only place the flow can be
1578
+ exercised before a production host has it. A pass-through `step` estimate carries
1579
+ a `stepQuote` whenever the mock's price for it (`generation.costPerGen`, or
1580
+ `cost`) is above the token's per-call budget (`buzzBudget`, default 200) and at
1581
+ most 5,000, for a signed-in viewer; there is no separate switch. As on the
1582
+ server, a batch-estimate cell never carries one, and where a quote would be
1583
+ granted the estimate fails instead, with the server's message, for a training
1584
+ `$type` and for non-plain-JSON `input`. The mock holds the quotes it stored and
1585
+ refuses an unknown, expired or already-run one, a body that differs from the
1586
+ quoted one, and a plain `SUBMIT_WORKFLOW` carrying a `quoteId`. How the run
1587
+ settles is `runConfirmedStepError`:
1588
+
1589
+ | `runConfirmedStepError` | URL | outcome |
1590
+ |---|---|---|
1591
+ | unset | — | the viewer confirms; the job starts, charged `stepQuote.total` |
1592
+ | `'declined'` | `?confirmedStep=declined` | `err.declined`; the quote survives |
1593
+ | `'daily-limit-exceeded'` | `?confirmedStep=daily-limit` | `err.dailyLimitExceeded`; the quote survives |
1594
+ | `'submission-unconfirmed'` | `?confirmedStep=unconfirmed` | `err.unconfirmed`; the quote is used up |
1595
+ | any other string | — | a server message; the quote is used up |
1596
+
1597
+ `runConfirmedStepCapRefusal` resolves the server's refunded cap refusal
1598
+ (`err.refused`), and both knobs are live-tunable with `host.setScenario(...)`.
1599
+
1600
+ ```tsx
1601
+ import { createMockHost } from '@civitai/blocks-react/testing';
1602
+
1603
+ const host = createMockHost({
1604
+ consentGranted: true,
1605
+ generation: { costPerGen: 800 }, // above the default 200 per-call budget → quoted
1606
+ runConfirmedStepError: 'declined',
1607
+ });
1608
+ const uninstall = host.install();
1609
+ // …later: the viewer confirms this time.
1610
+ host.setScenario({ runConfirmedStepError: undefined });
1611
+ ```
1612
+
1613
+ What the mock does **not** model: it has no dialog, so the run settles at once
1614
+ where the real host waits on a click; it prices every estimate as fixed, where
1615
+ the real host gives a variable or metered price no `stepQuote`; and the flag, the
1616
+ page-only rule and the submit-time re-price are server-side.
1617
+
1618
+ #### 3D outputs: `model3dOutputs`
1619
+
1620
+ A step that produces 3D models (`polyGen`) lists every model url in
1621
+ `snapshot.imageUrls`, as before. `snapshot.model3dOutputs` says which is which:
1622
+ one `{ url, format, variant }` per model, where `format` is the model's own file
1623
+ format (`'glb'`, `'fbx'`, …) and `variant` is the output it came from (`'model'`,
1624
+ `'riggedFbxModel'`, `'basicAnimations.walkingModel'`, …). Both are open sets
1625
+ named by the orchestrator. The field is **omitted** when the workflow produced no
1626
+ 3D model and on hosts that predate it, and the same field is on each
1627
+ `useAppWorkflows()` row.
1628
+
1629
+ ```tsx
1630
+ function modelsOf(snapshot: BlockWorkflowSnapshot) {
1631
+ const models = snapshot.model3dOutputs ?? [];
1632
+ return {
1633
+ // The plain mesh, in the format a web viewer loads.
1634
+ glb: models.find((m) => m.format === 'glb' && m.variant === 'model')?.url,
1635
+ // The rigged export, for a download button.
1636
+ riggedFbx: models.find((m) => m.variant === 'riggedFbxModel')?.url,
1637
+ // Every animation clip, whatever the host named them.
1638
+ animations: models.filter((m) => m.variant.startsWith('basicAnimations.')),
1639
+ };
1640
+ }
1641
+ ```
1642
+
1643
+ In the mock host, `generation.model3dOutputs` puts labels (and their urls in
1644
+ `imageUrls`) on a succeeded pass-through step.
1645
+
1383
1646
  ### `useAppWorkflows(params?)`
1384
1647
 
1385
1648
  The calling app's **own** generator subqueue — the tag-scoped list of generations
@@ -1718,6 +1981,20 @@ if (isModelSlotContext(context) && context.checkpoint) {
1718
1981
  }
1719
1982
  ```
1720
1983
 
1984
+ 🔴 **`persist()` works on a model slot only. On a page it always rejects.** A
1985
+ full-page app (`app.page`) is bound to no model, so the host has nowhere to write
1986
+ the override: it answers every `persist()`, including `persist(null)`, with
1987
+ `ok: false`, and the promise rejects with `page blocks cannot persist a
1988
+ checkpoint override (no model binding)`. A page app keeps the checkpoint `open()`
1989
+ returned in its own state (or in `useAppStorage()`).
1990
+
1991
+ Both dev hosts (`createMockHost` / `Harness` and `dev:live`) refuse it the same
1992
+ way, and both default to a page context. Pass a complete `ModelSlotContext` as
1993
+ `context` to exercise a block that persists. A test that expected `persist()` to
1994
+ resolve under the default mock context was asserting something a page app never
1995
+ gets: give it the model slot the block really runs in, or, for a page app, drop
1996
+ the `persist()` call.
1997
+
1721
1998
  ### `useResourcePicker()`
1722
1999
 
1723
2000
  Drive the platform resource picker for page blocks — `'Checkpoint' | 'LORA'`.
@@ -2823,6 +3100,7 @@ MockHostScenarioPatch
2823
3100
  MockSharedScenario
2824
3101
  MockSharedSeed
2825
3102
  MockStorageScenario
3103
+ MockWorkflowQuoteFailure
2826
3104
  MockWorkflowStepInfo
2827
3105
  PopoverShimHandle
2828
3106
  PopoverShimOptions
@@ -3022,6 +3300,21 @@ const host = createLiveHost(options);
3022
3300
  const uninstall = host.install();
3023
3301
  ```
3024
3302
 
3303
+ What `dev:live` forwards, and how it answers the one call it does not:
3304
+
3305
+ - **`useCheckpointPicker().persist()` on a page** is refused with no backend
3306
+ call, as the page host refuses it (see
3307
+ [`useCheckpointPicker()`](#usecheckpointpicker)). The default `context` is a
3308
+ page, because a dev token is a page token.
3309
+ - **`submit()`'s `idempotencyKey` reaches the server**, as it does from the
3310
+ production hosts. A retry with the same key is deduplicated there, and a key in
3311
+ a format the server refuses fails the submit with the server's message.
3312
+ - **`watch()`'s `waitSeconds` reaches the server**, so a poll is held open
3313
+ instead of answered at once, and the server decides the actual hold. This
3314
+ matches the production hosts as of
3315
+ [civitai/civitai#5798](https://github.com/civitai/civitai/pull/5798); a
3316
+ production host without that change drops the field and answers at once.
3317
+
3025
3318
  ### Stability of `/live`
3026
3319
 
3027
3320
  The same terms as `./testing`: a normal subpath of a `0.x` package where a minor
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
2
2
  import { getTransport } from '../transport/singleton.js';
3
3
  import { throwOnFailedReply, throwOnReplyError } from '../internal/replyError.js';
4
4
  import { sendTypedRequest } from '../transport/transport.js';
5
+ import { useTransportConnection } from './useTransportConnection.js';
5
6
  /**
6
7
  * Per-(block instance, viewer) KV datastore. Calls flow through the host's
7
8
  * postMessage bridge — the block never sees the apps DB credentials.
@@ -47,6 +48,7 @@ import { sendTypedRequest } from '../transport/transport.js';
47
48
  * const quota = await storage.getQuota(); // { usedBytes, rowCount, limitBytes, limitRows }
48
49
  */
49
50
  export function useAppStorage() {
51
+ useTransportConnection();
50
52
  return useMemo(() => {
51
53
  const transport = getTransport();
52
54
  return {
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { getTransport } from '../transport/singleton.js';
3
3
  import { sendTypedRequest } from '../transport/transport.js';
4
4
  import { useRequestSequencer } from './useRequestSequencer.js';
5
+ import { useTransportConnection } from './useTransportConnection.js';
5
6
  /**
6
7
  * Read (and cancel within) the calling app's OWN generator SUBQUEUE — the
7
8
  * tag-scoped list of generations THIS app produced for the viewer, newest-first —
@@ -24,6 +25,7 @@ import { useRequestSequencer } from './useRequestSequencer.js';
24
25
  * // …render each workflow's images; await cancel(w.workflowId) on a Cancel click.
25
26
  */
26
27
  export function useAppWorkflows(params) {
28
+ useTransportConnection();
27
29
  const [workflows, setWorkflows] = useState([]);
28
30
  const [cursor, setCursor] = useState(null);
29
31
  const [loading, setLoading] = useState(true);
@@ -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
@@ -4,6 +4,7 @@ import { getTransport } from '../transport/singleton.js';
4
4
  import { RequestTimeoutError, sendTypedRequest } from '../transport/transport.js';
5
5
  import { WorkflowEstimateError } from './useBuzzWorkflow.js';
6
6
  import { useRequestSequencer } from './useRequestSequencer.js';
7
+ import { useTransportConnection } from './useTransportConnection.js';
7
8
  /**
8
9
  * The most bodies one {@link UseBatchEstimate.estimateBatch} call may carry.
9
10
  *
@@ -104,6 +105,10 @@ function toBatchEstimateError(err) {
104
105
  * - **At most {@link BATCH_ESTIMATE_MAX_CELLS} bodies per call.**
105
106
  * - **`kind: 'training'` bodies are refused per cell.** Estimate a training run
106
107
  * with `useBuzzWorkflow().estimate()`.
108
+ * - **A cell never carries a `stepQuote`.** A batch stores nothing, so a
109
+ * pass-through step priced above the per-call budget comes back with its cost
110
+ * and no confirmable quote. To run it with `useRunConfirmedStep()`, estimate
111
+ * that one body with `useBuzzWorkflow().estimate()`.
107
112
  *
108
113
  * 🔴 ESTIMATE ONLY. There is no batch submit and no server-side id for the
109
114
  * list. To watch and cancel the submitted cells together, use
@@ -130,6 +135,7 @@ function toBatchEstimateError(err) {
130
135
  * }
131
136
  */
132
137
  export function useBatchEstimate() {
138
+ useTransportConnection();
133
139
  const [pending, setPending] = useState(false);
134
140
  const [result, setResult] = useState(null);
135
141
  const [error, setError] = useState(null);
@@ -1,5 +1,6 @@
1
1
  import { useCallback } from 'react';
2
2
  import { getTransport } from '../transport/singleton.js';
3
+ import { useTransportConnection } from './useTransportConnection.js';
3
4
  /**
4
5
  * Custom events for your app's analytics panel. Fire-and-forget: the host
5
6
  * forwards the event and the block gets no acknowledgement, so never wait on
@@ -47,6 +48,7 @@ import { getTransport } from '../transport/singleton.js';
47
48
  * track('generate_clicked', { mode: 'txt2img', batch: 4 });
48
49
  */
49
50
  export function useBlockAnalytics() {
51
+ useTransportConnection();
50
52
  const track = useCallback((eventName, properties) => {
51
53
  getTransport().sendMessage({ type: 'TRACK_EVENT', payload: { eventName, properties } });
52
54
  }, []);
@@ -1,5 +1,6 @@
1
1
  import { BREAKPOINT_KEYS, breakpoints } from '@civitai/theme';
2
2
  import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useTransportConnection } from './useTransportConnection.js';
3
4
  /** Ascending tier order. `base` first; the rest follow `BREAKPOINT_KEYS`. */
4
5
  const TIER_ORDER = ['base', ...BREAKPOINT_KEYS];
5
6
  /**
@@ -66,6 +67,7 @@ export function resolveBlockTier(width) {
66
67
  * );
67
68
  */
68
69
  export function useBlockBreakpoint(ref) {
70
+ useTransportConnection();
69
71
  // `null` means UNMEASURED — distinct from a measured `'base'`. See `measured`.
70
72
  const [tier, setTier] = useState(null);
71
73
  // 🔴 THE EFFECT MUST NOT DEPEND ON THE REF WRAPPER'S IDENTITY. Unlike
@@ -1,5 +1,6 @@
1
1
  import { useSyncExternalStore } from 'react';
2
2
  import { getTransport } from '../transport/singleton.js';
3
+ import { useTransportConnection } from './useTransportConnection.js';
3
4
  /**
4
5
  * Subscribe a hook to the singleton transport. All hooks build on this —
5
6
  * `useSyncExternalStore` gives concurrent-mode safety and minimal re-renders.
@@ -42,6 +43,7 @@ function useTransportSnapshot() {
42
43
  * return <div data-theme={theme}>{isSignedIn(viewer) ? 'Hi there' : 'Hi anon'}</div>;
43
44
  */
44
45
  export function useBlockContext() {
46
+ useTransportConnection();
45
47
  const snap = useTransportSnapshot();
46
48
  return {
47
49
  ready: snap.ready,
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef } from 'react';
2
2
  import { getTransport } from '../transport/singleton.js';
3
+ import { useTransportConnection } from './useTransportConnection.js';
3
4
  /**
4
5
  * Observes the referenced element's height and asks the host to resize on
5
6
  * every change. Attach to the block's root DOM element.
@@ -44,6 +45,7 @@ import { getTransport } from '../transport/singleton.js';
44
45
  * return <div ref={rootRef}>…</div>;
45
46
  */
46
47
  export function useBlockResize(ref) {
48
+ useTransportConnection();
47
49
  /** The element the live observer is watching. `null` = watching nothing. */
48
50
  const observedRef = useRef(null);
49
51
  const observerRef = useRef(null);
@@ -1,4 +1,5 @@
1
1
  import { useTransportSnapshot } from './useBlockContext.js';
2
+ import { useTransportConnection } from './useTransportConnection.js';
2
3
  /**
3
4
  * Shorthand for `useBlockContext().settings`. Returns the publisher- and
4
5
  * user-controlled settings the host forwarded at init. Read-only from the
@@ -19,6 +20,7 @@ import { useTransportSnapshot } from './useBlockContext.js';
19
20
  * const { publisherSettings, userSettings } = useBlockSettings();
20
21
  */
21
22
  export function useBlockSettings() {
23
+ useTransportConnection();
22
24
  return useTransportSnapshot().settings;
23
25
  }
24
26
  //# sourceMappingURL=useBlockSettings.js.map
@@ -1,4 +1,5 @@
1
1
  import { useTransportSnapshot } from './useBlockContext.js';
2
+ import { useTransportConnection } from './useTransportConnection.js';
2
3
  /**
3
4
  * The host's CURRENT site theme (`'light' | 'dark'`), kept live for the whole
4
5
  * life of the block ON THE IFRAME TRANSPORT.
@@ -36,6 +37,7 @@ import { useTransportSnapshot } from './useBlockContext.js';
36
37
  * return <div data-theme={theme}>…</div>;
37
38
  */
38
39
  export function useBlockTheme() {
40
+ useTransportConnection();
39
41
  return useTransportSnapshot().theme;
40
42
  }
41
43
  //# sourceMappingURL=useBlockTheme.js.map
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
2
2
  import { getTransport } from '../transport/singleton.js';
3
3
  import { sendTypedRequest } from '../transport/transport.js';
4
4
  import { useTransportSnapshot } from './useBlockContext.js';
5
+ import { useTransportConnection } from './useTransportConnection.js';
5
6
  /** Refresh fires `REFRESH_LEAD_MS` before the token's `expiresAt`. */
6
7
  const REFRESH_LEAD_MS = 2 * 60 * 1000;
7
8
  /**
@@ -37,6 +38,7 @@ const REFRESH_LEAD_MS = 2 * 60 * 1000;
37
38
  * }
38
39
  */
39
40
  export function useBlockToken() {
41
+ useTransportConnection();
40
42
  const snap = useTransportSnapshot();
41
43
  const token = snap.token;
42
44
  const instanceId = snap.blockInstanceId;
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
2
2
  import { getTransport } from '../transport/singleton.js';
3
3
  import { sendTypedRequest } from '../transport/transport.js';
4
4
  import { useRequestSequencer } from './useRequestSequencer.js';
5
+ import { useTransportConnection } from './useTransportConnection.js';
5
6
  /**
6
7
  * Read the signed-in viewer's ALL-pool Buzz balances through the host-mediated
7
8
  * `GET_BUZZ_ACCOUNTS` → `BUZZ_ACCOUNTS_RESULT` bridge. Token-bound: the host
@@ -19,6 +20,7 @@ import { useRequestSequencer } from './useRequestSequencer.js';
19
20
  * const { accounts, loading, error } = useBuzzAccounts();
20
21
  */
21
22
  export function useBuzzAccounts() {
23
+ useTransportConnection();
22
24
  const [accounts, setAccounts] = useState(null);
23
25
  const [loading, setLoading] = useState(true);
24
26
  const [error, setError] = useState(null);
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
2
2
  import { getTransport } from '../transport/singleton.js';
3
3
  import { sendTypedRequest } from '../transport/transport.js';
4
4
  import { useRequestSequencer } from './useRequestSequencer.js';
5
+ import { useTransportConnection } from './useTransportConnection.js';
5
6
  /**
6
7
  * Read the signed-in viewer's per-pool Buzz balance ({ blue, green, yellow })
7
8
  * through the host-mediated `GET_BUZZ_BALANCE` → `BUZZ_BALANCE_RESULT` bridge.
@@ -23,6 +24,7 @@ import { useRequestSequencer } from './useRequestSequencer.js';
23
24
  * return <span>Yellow: {balance?.yellow ?? 0}</span>;
24
25
  */
25
26
  export function useBuzzBalance() {
27
+ useTransportConnection();
26
28
  const [balance, setBalance] = useState(null);
27
29
  const [loading, setLoading] = useState(true);
28
30
  const [error, setError] = useState(null);
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
2
2
  import { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
3
3
  import { getTransport } from '../transport/singleton.js';
4
4
  import { sendTypedRequest } from '../transport/transport.js';
5
+ import { useTransportConnection } from './useTransportConnection.js';
5
6
  /**
6
7
  * Opens the Civitai Buzz purchase modal on the host. Resolves with the
7
8
  * outcome when the user closes the modal — `purchased: true` means the
@@ -22,6 +23,7 @@ import { sendTypedRequest } from '../transport/transport.js';
22
23
  * if (purchased) { /* retry the generation *\/ }
23
24
  */
24
25
  export function useBuzzPurchase() {
26
+ useTransportConnection();
25
27
  const openPurchaseModal = useCallback(async (suggestedAmount) => {
26
28
  const { purchased, newBalance } = await sendTypedRequest(getTransport(), { type: 'OPEN_BUZZ_PURCHASE', payload: { suggestedAmount } }, 'BUZZ_PURCHASE_RESULT', { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
27
29
  return { purchased, newBalance };