@civitai/blocks-react 0.67.1 → 0.69.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 +520 -8
- package/dist/hooks/useBatchEstimate.d.ts +191 -0
- package/dist/hooks/useBatchEstimate.js +196 -0
- package/dist/hooks/useBlockAnalytics.d.ts +46 -4
- package/dist/hooks/useBlockAnalytics.js +41 -4
- package/dist/hooks/useBuzzWorkflow.d.ts +4 -1
- package/dist/hooks/useBuzzWorkflow.js +4 -1
- package/dist/hooks/useResourcePicker.d.ts +98 -37
- package/dist/hooks/useResourcePicker.js +77 -7
- package/dist/hooks/useSharedStorage.d.ts +24 -0
- package/dist/hooks/useSharedStorage.js +55 -0
- package/dist/hooks/useWorkflowBatch.d.ts +196 -0
- package/dist/hooks/useWorkflowBatch.js +352 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -2
- package/dist/internal/liveHost.d.ts +27 -2
- package/dist/internal/liveHost.js +133 -15
- package/dist/internal/mockHost.d.ts +104 -2
- package/dist/internal/mockHost.js +369 -84
- package/dist/internal/pickerOverlay.d.ts +32 -0
- package/dist/internal/pickerOverlay.js +99 -2
- package/dist/internal/trackEventCheck.d.ts +34 -0
- package/dist/internal/trackEventCheck.js +194 -0
- package/dist/transport/requestTimeouts.js +3 -0
- package/dist/transport/validate.d.ts +49 -6
- package/dist/transport/validate.js +138 -28
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -455,6 +455,93 @@ if (priced) {
|
|
|
455
455
|
prices a cache hit at 0). `estimate` resolves it; only a non-numeric
|
|
456
456
|
`cost.total` rejects.
|
|
457
457
|
|
|
458
|
+
#### Showing the app fee: `cost.authorFee`
|
|
459
|
+
|
|
460
|
+
`cost.total` is what the viewer pays and **already includes the app author's
|
|
461
|
+
per-generation fee**. `cost.authorFee` is that fee on its own, in whole Buzz, so
|
|
462
|
+
`total - authorFee` is the generation's price. It is display only.
|
|
463
|
+
|
|
464
|
+
```tsx
|
|
465
|
+
import type { BlockWorkflowSnapshot, WorkflowBody } from '@civitai/app-sdk/blocks';
|
|
466
|
+
|
|
467
|
+
const { estimate } = useBuzzWorkflow();
|
|
468
|
+
declare const body: WorkflowBody;
|
|
469
|
+
|
|
470
|
+
function priceLine(cost: NonNullable<BlockWorkflowSnapshot['cost']>): string {
|
|
471
|
+
// Absent = not itemised. Show the total alone; do NOT treat it as a 0 fee.
|
|
472
|
+
if (cost.authorFee === undefined) return `${cost.total} Buzz`;
|
|
473
|
+
// 0 = a fee was looked up and none applies to this request.
|
|
474
|
+
if (cost.authorFee === 0) return `${cost.total} Buzz (no app fee)`;
|
|
475
|
+
const generation = cost.total - cost.authorFee;
|
|
476
|
+
return `${cost.total} Buzz (generation ${generation} + app fee ${cost.authorFee})`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const quote = await estimate(body); // rejects without a usable price — see above
|
|
480
|
+
if (quote.cost) showPrice(priceLine(quote.cost));
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
| `cost.authorFee` | Means | Where |
|
|
484
|
+
|---|---|---|
|
|
485
|
+
| a number `> 0` | that much of `total` is the app fee | `estimate()` for a `textToImage` or registered-`step` body, and the refusal `submit()` resolves for those kinds when a budget, spend cap or rate limit refuses the quoted price |
|
|
486
|
+
| `0` | a fee was looked up and none applies to this request | the same replies |
|
|
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
|
+
|
|
489
|
+
- **Against an older host the field is simply absent.** Nothing else changes:
|
|
490
|
+
`total` has the same value with or without it, so an app that never reads
|
|
491
|
+
`authorFee` is unaffected.
|
|
492
|
+
- **Test all three locally** with the mock host's `generation.authorFee`
|
|
493
|
+
(`createMockHost({ generation: { costPerGen: 40, authorFee: 5 } })`, or
|
|
494
|
+
`?authorFee=5` in the dev harness): a number itemises it, the default `0`
|
|
495
|
+
reports `authorFee: 0`, and `authorFee: false` (`?authorFee=off`) leaves it
|
|
496
|
+
out, as an older host does.
|
|
497
|
+
|
|
498
|
+
#### Why a training step could not be priced: `snapshot.errorCode`
|
|
499
|
+
|
|
500
|
+
A pass-through `step` body whose `$type` is a training type (`'training'` or
|
|
501
|
+
`'imageResourceTraining'`) needs a price quote; when the host cannot get one it
|
|
502
|
+
refuses with `{ workflowId: 'failed', status: 'failed', error, errorCode }` and
|
|
503
|
+
no `cost`. `estimate()` rejects with a `WorkflowEstimateError` and `submit()`
|
|
504
|
+
with a `WorkflowSubmitError` (before anything is reserved), and a batch
|
|
505
|
+
estimate returns it as that body's cell. `errorCode` is the stable value to
|
|
506
|
+
branch on; `error` stays the diagnostic read.
|
|
507
|
+
|
|
508
|
+
```tsx
|
|
509
|
+
import { useBuzzWorkflow, WorkflowEstimateError } from '@civitai/blocks-react';
|
|
510
|
+
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
|
|
511
|
+
|
|
512
|
+
const { estimate } = useBuzzWorkflow();
|
|
513
|
+
declare const body: WorkflowBody;
|
|
514
|
+
|
|
515
|
+
try {
|
|
516
|
+
await estimate(body);
|
|
517
|
+
} catch (err) {
|
|
518
|
+
if (!(err instanceof WorkflowEstimateError)) throw err;
|
|
519
|
+
switch (err.snapshot.errorCode) {
|
|
520
|
+
case 'training-quote-rejected': // the request itself was refused — change it
|
|
521
|
+
showError('These training settings were refused. Check them and try again.');
|
|
522
|
+
break;
|
|
523
|
+
case 'training-quote-unavailable': // no answer right now — retrying may work
|
|
524
|
+
case 'training-quote-unpriced': // answered without a price
|
|
525
|
+
showError('Training cannot be priced right now. Try again shortly.');
|
|
526
|
+
break;
|
|
527
|
+
default: // no code, or one a later host added
|
|
528
|
+
showError('Could not get a price.');
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
| `errorCode` | Means |
|
|
534
|
+
|---|---|
|
|
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
|
+
| `'training-quote-unavailable'` | no usable answer (an outage, a timeout, a rate limit, insufficient funds, …); a retry may succeed |
|
|
537
|
+
| `'training-quote-unpriced'` | the orchestrator answered without a price |
|
|
538
|
+
| absent | any other reply, and every reply from a host that predates the field |
|
|
539
|
+
|
|
540
|
+
- **Treat an unknown value as possible.** The field is typed as an open string
|
|
541
|
+
(`KnownBlockWorkflowErrorCode | (string & {})`), the host may add codes, and
|
|
542
|
+
the inbound validator accepts any string. Keep a `default` branch.
|
|
543
|
+
- The `kind: 'training'` arm does not set it.
|
|
544
|
+
|
|
458
545
|
> **Estimate must mirror submit** (gotcha #59): build the params for `estimate`
|
|
459
546
|
> with the *exact* same logic as `submit` — same seed decision especially. The
|
|
460
547
|
> orchestrator whatif prices a cache hit (identical workflow) at 0 and a fresh
|
|
@@ -467,6 +554,279 @@ if (priced) {
|
|
|
467
554
|
> your installed version predates 0.5.0, do the client-side half and add the
|
|
468
555
|
> `cancel(...)` call after upgrading.
|
|
469
556
|
|
|
557
|
+
### `useBatchEstimate()`
|
|
558
|
+
|
|
559
|
+
Price a grid of generations in one request. `estimateBatch(bodies)` takes a list
|
|
560
|
+
of workflow bodies, each exactly what `useBuzzWorkflow().estimate()` takes, and
|
|
561
|
+
resolves with one cell per body, in order, plus the run total. Returns
|
|
562
|
+
`{ estimateBatch, pending, result, error }`.
|
|
563
|
+
|
|
564
|
+
- **One request against the estimate allowance** however many cells it carries.
|
|
565
|
+
On a page app that allowance is shared by every viewer of the app and by the
|
|
566
|
+
host's other reads for it, so a 16-cell grid priced cell by cell used 16 of it.
|
|
567
|
+
- **At most 16 bodies per call** (`BATCH_ESTIMATE_MAX_CELLS`). Price a larger grid
|
|
568
|
+
in several calls.
|
|
569
|
+
- **Cells are metered too**, against an allowance of their own that is shared
|
|
570
|
+
the same way (on a page app, by every viewer of the app), so a batch is not a
|
|
571
|
+
way around the estimate allowance. A call over either limit is refused whole:
|
|
572
|
+
it rejects with `BatchEstimateError` code `'failed'`. Retry in a few seconds.
|
|
573
|
+
- **Every rule of a single estimate applies to each cell.** A cell that one
|
|
574
|
+
`estimate()` would reject comes back as `ok: false` with the same
|
|
575
|
+
`WorkflowEstimateError`, and the other cells still price.
|
|
576
|
+
- **`kind: 'training'` bodies are refused per cell.** Estimate a training run
|
|
577
|
+
with `useBuzzWorkflow().estimate()`.
|
|
578
|
+
- **Estimate only.** Nothing is submitted and no Buzz is held or spent. There is
|
|
579
|
+
no batch submit. Submit each cell with
|
|
580
|
+
`useBuzzWorkflow().submit()`, which asks the viewer and checks the budget per
|
|
581
|
+
cell. `aggregate.total` is a quote, not a lock: each cell is priced again when
|
|
582
|
+
it is submitted. To follow the submitted cells and cancel them together, see
|
|
583
|
+
[`useWorkflowBatch()`](#useworkflowbatch).
|
|
584
|
+
|
|
585
|
+
`aggregate.total` sums the cells that priced. It covers the whole grid only when
|
|
586
|
+
`aggregate.pricedCells === aggregate.cellCount`; otherwise show it as a partial
|
|
587
|
+
total.
|
|
588
|
+
|
|
589
|
+
**The host bounds the time one call takes.** If its budget runs out, the call
|
|
590
|
+
still resolves: the cells that priced are returned, and every other cell is
|
|
591
|
+
`ok: false` with `timedOut: true`. Show those cells as unavailable and let the
|
|
592
|
+
viewer retry later. Branch on `timedOut`, not on `snapshot.error`: the host's
|
|
593
|
+
wording is not a contract.
|
|
594
|
+
|
|
595
|
+
`estimateBatch` rejects with `BatchEstimateError` only when the whole call
|
|
596
|
+
failed. Branch on `err.code`:
|
|
597
|
+
|
|
598
|
+
| `code` | Meaning | What to do |
|
|
599
|
+
|---|---|---|
|
|
600
|
+
| `'unsupported'` | The host answered `unsupported on this host`. | Price each cell with `estimate()`. |
|
|
601
|
+
| `'timeout'` | No reply in time. A host that supports the batch replies within its own time budget, well inside the default wait, so this usually means a slow host, not an old one. | Show the estimate as unavailable and retry later. **Do not** fall back to one `estimate()` per cell: that sends up to 16 more estimates at a host that is already slow. |
|
|
602
|
+
| `'invalid-request'` | Empty list, or more than 16 bodies. Refused before sending. | Fix the list. |
|
|
603
|
+
| `'failed'` | Any other whole-call refusal: the scope, a rate limit, review preview. The reason is on `err.hostError`; log it, don't render it. | Show your own copy and retry later. |
|
|
604
|
+
|
|
605
|
+
A 2 × 2 grid (two prompts × two checkpoints), falling back to per-cell estimates
|
|
606
|
+
only on a host that answers `'unsupported'`:
|
|
607
|
+
|
|
608
|
+
```tsx
|
|
609
|
+
import { useState } from 'react';
|
|
610
|
+
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
|
|
611
|
+
import {
|
|
612
|
+
BatchEstimateError,
|
|
613
|
+
useBatchEstimate,
|
|
614
|
+
useBuzzWorkflow,
|
|
615
|
+
WorkflowEstimateError,
|
|
616
|
+
} from '@civitai/blocks-react';
|
|
617
|
+
|
|
618
|
+
const PROMPTS = ['a lighthouse at dusk', 'a lighthouse in fog'];
|
|
619
|
+
const CHECKPOINTS = [
|
|
620
|
+
{ modelId: 101, modelVersionId: 1001 },
|
|
621
|
+
{ modelId: 202, modelVersionId: 2002 },
|
|
622
|
+
];
|
|
623
|
+
|
|
624
|
+
/** One cell's quote: a price, or why it has none. */
|
|
625
|
+
type CellQuote = { total: number } | { unavailable: string };
|
|
626
|
+
|
|
627
|
+
export function GridQuote() {
|
|
628
|
+
const { estimateBatch } = useBatchEstimate();
|
|
629
|
+
const { estimate } = useBuzzWorkflow();
|
|
630
|
+
const [quotes, setQuotes] = useState<CellQuote[]>([]);
|
|
631
|
+
const [runTotal, setRunTotal] = useState<number | null>(null);
|
|
632
|
+
|
|
633
|
+
const bodies: WorkflowBody[] = PROMPTS.flatMap((prompt) =>
|
|
634
|
+
CHECKPOINTS.map((cp) => ({ kind: 'textToImage' as const, ...cp, params: { prompt } })),
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
// Your copy, chosen by `code` (and, for a batch cell, `timedOut`). Never
|
|
638
|
+
// render or match `err.message` or `snapshot.error`.
|
|
639
|
+
const why = (err: WorkflowEstimateError, timedOut = false) =>
|
|
640
|
+
timedOut
|
|
641
|
+
? 'Price not ready, try again shortly'
|
|
642
|
+
: err.code === 'no-cost'
|
|
643
|
+
? 'No price for this cell'
|
|
644
|
+
: 'This cell cannot run';
|
|
645
|
+
|
|
646
|
+
async function priceGrid() {
|
|
647
|
+
try {
|
|
648
|
+
const { cells, aggregate } = await estimateBatch(bodies);
|
|
649
|
+
setQuotes(
|
|
650
|
+
cells.map((c) => (c.ok ? { total: c.cost.total } : { unavailable: why(c.error, c.timedOut) })),
|
|
651
|
+
);
|
|
652
|
+
setRunTotal(aggregate.pricedCells === aggregate.cellCount ? aggregate.total : null);
|
|
653
|
+
return;
|
|
654
|
+
} catch (err) {
|
|
655
|
+
if (!(err instanceof BatchEstimateError)) throw err;
|
|
656
|
+
if (err.code !== 'unsupported') {
|
|
657
|
+
// 'timeout' is a slow host, not an old one: never fan out per cell here.
|
|
658
|
+
setQuotes([]);
|
|
659
|
+
setRunTotal(null);
|
|
660
|
+
return; // show "pricing unavailable, try again" in your UI
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// This host has no batch estimate: one estimate() per cell, as before.
|
|
664
|
+
const fallback: CellQuote[] = [];
|
|
665
|
+
for (const body of bodies) {
|
|
666
|
+
try {
|
|
667
|
+
const snapshot = await estimate(body);
|
|
668
|
+
fallback.push({ total: snapshot.cost!.total });
|
|
669
|
+
} catch (err) {
|
|
670
|
+
if (!(err instanceof WorkflowEstimateError)) throw err;
|
|
671
|
+
fallback.push({ unavailable: why(err) });
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
setQuotes(fallback);
|
|
675
|
+
const priced = fallback.filter((q): q is { total: number } => 'total' in q);
|
|
676
|
+
setRunTotal(
|
|
677
|
+
priced.length === fallback.length ? priced.reduce((sum, q) => sum + q.total, 0) : null,
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return (
|
|
682
|
+
<div>
|
|
683
|
+
<button onClick={() => void priceGrid()}>Price grid</button>
|
|
684
|
+
<ul>
|
|
685
|
+
{quotes.map((q, i) => (
|
|
686
|
+
<li key={i}>{'total' in q ? `${q.total} Buzz` : q.unavailable}</li>
|
|
687
|
+
))}
|
|
688
|
+
</ul>
|
|
689
|
+
{runTotal !== null && <p>Run total: {runTotal} Buzz</p>}
|
|
690
|
+
</div>
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
The fallback spends one request of the shared allowance per cell, which is why
|
|
696
|
+
the batch exists. Each submit stays per cell: call `submit(bodies[i])` for each
|
|
697
|
+
cell the viewer confirms.
|
|
698
|
+
|
|
699
|
+
A host that predates the batch message never replies, so the call ends in
|
|
700
|
+
`'timeout'` after the full wait. Only if your app must support such a host,
|
|
701
|
+
detect it with a deliberate probe: send a one-body batch once with a short
|
|
702
|
+
`{ timeoutMs }`, and treat only that probe's `'timeout'` as "no batch support".
|
|
703
|
+
Any other `'timeout'` is a slow host.
|
|
704
|
+
|
|
705
|
+
**Mock host:** `createMockHost` and `Harness` price each cell exactly as they
|
|
706
|
+
price one `estimate()` of that body (`costPerGen` and `failEstimate` apply per
|
|
707
|
+
cell). Set `generation.batchEstimate` to `'unsupported'` to test the fallback,
|
|
708
|
+
or to `'silent'` to test a call that never gets a reply (pass a short
|
|
709
|
+
`timeoutMs` in a test). Set `generation.batchEstimateTimedOutCells` to a list
|
|
710
|
+
of cell indices to answer those cells the way the host does when its time
|
|
711
|
+
budget runs out (`ok: false`, `timedOut: true`). **`dev:live`** forwards the call to civitai.com, so against
|
|
712
|
+
a civitai.com that predates the batch procedure it rejects with `'failed'`
|
|
713
|
+
rather than `'unsupported'`.
|
|
714
|
+
|
|
715
|
+
### `useWorkflowBatch()`
|
|
716
|
+
|
|
717
|
+
Watch a set of already-submitted workflows as one batch: per-cell status, a
|
|
718
|
+
summary, and cancel-all. Built for grid apps that submit each cell with
|
|
719
|
+
`useBuzzWorkflow().submit()` (each with its own confirmation, unchanged) and
|
|
720
|
+
then have to follow all of them. `useWorkflowBatch(workflowIds, options?)`
|
|
721
|
+
returns `{ cells, summary, cancelAll, rewatch }`.
|
|
722
|
+
|
|
723
|
+
- **The array of ids is the batch.** There is no server-side batch id. Append
|
|
724
|
+
each id as its `submit()` resolves, and persist the same array (plain
|
|
725
|
+
strings) to re-attach after a reload by passing it back. Skip a submit that
|
|
726
|
+
resolved with the `'failed'` placeholder id: nothing was created.
|
|
727
|
+
- **Every cell is one ordinary `watch()`**, so each workflow has exactly one
|
|
728
|
+
poll in flight. When a cell's loop restarts (the list changes, `rewatch()`,
|
|
729
|
+
a StrictMode remount) the new loop waits for the old loop's poll to return.
|
|
730
|
+
`options` (`intervalMs`, `waitSeconds`, `timeoutMs`, `maxRetries`) are passed
|
|
731
|
+
to every cell's `watch()`.
|
|
732
|
+
- **At most 30 ids** (`WORKFLOW_BATCH_MAX_CELLS`): the poll allowance (1,200 per
|
|
733
|
+
60 s per block instance and viewer) divided by one watch's 40 polls a minute.
|
|
734
|
+
A longer list, a duplicate, an empty id or a `'failed'`/`'whatif'`
|
|
735
|
+
placeholder throws.
|
|
736
|
+
- **`summary`** counts `succeeded`, `failed`, `canceled`, `expired` and `running`
|
|
737
|
+
(not terminal, including cells with no snapshot yet) from each cell's latest
|
|
738
|
+
snapshot, plus `realisedCost`: the sum of `cost.total` on the terminal
|
|
739
|
+
snapshots that carry one. That is the generation cost the host reported, not a
|
|
740
|
+
ledger read; the author fee is charged separately and is not included.
|
|
741
|
+
`settled` is `running === 0`.
|
|
742
|
+
- **A host failure placeholder is not a workflow outcome.** When the host fails
|
|
743
|
+
a poll it answers `{ workflowId: 'failed', status: 'failed' }`. That says
|
|
744
|
+
nothing about the workflow, so the cell is not counted as failed: its `phase`
|
|
745
|
+
becomes `'stopped'` with `error` set, and `summary.stopped` counts it. A
|
|
746
|
+
stopped cell (also a watch that timed out or ran out of retries) is not
|
|
747
|
+
polled again until you call `rewatch()`.
|
|
748
|
+
- **`cancelAll()` cancels every cell not known to be finished** and resolves
|
|
749
|
+
(never rejects) with `{ canceled, alreadySettled, refused, notAttempted,
|
|
750
|
+
failed, complete }`, each a list of ids. Cancels charge the estimate allowance
|
|
751
|
+
(150 per 10 s per block instance; on a page app every viewer shares it), so at
|
|
752
|
+
most four are in flight. A refused cancel resolves `status: 'processing'`: it
|
|
753
|
+
was not sent and the workflow is still running. After the first refusal the
|
|
754
|
+
rest of the call is not sent (`notAttempted`). A cancel answered with the
|
|
755
|
+
host's failure placeholder is `failed`, not a failed workflow. Nothing is
|
|
756
|
+
retried automatically. When `complete` is `false`, wait a few seconds and call
|
|
757
|
+
`cancelAll()` again; it targets only cells that are still not finished.
|
|
758
|
+
- **Unmounting stops every poll.** A `cancelAll()` already running keeps sending.
|
|
759
|
+
|
|
760
|
+
```tsx
|
|
761
|
+
import { useEffect, useState } from 'react';
|
|
762
|
+
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
|
|
763
|
+
import { useBuzzWorkflow, useWorkflowBatch } from '@civitai/blocks-react';
|
|
764
|
+
|
|
765
|
+
const SAVED = 'grid-run-ids';
|
|
766
|
+
|
|
767
|
+
function GridRun({ bodies }: { bodies: WorkflowBody[] }) {
|
|
768
|
+
const { submit } = useBuzzWorkflow();
|
|
769
|
+
const [ids, setIds] = useState<string[]>(() => {
|
|
770
|
+
try {
|
|
771
|
+
return JSON.parse(localStorage.getItem(SAVED) ?? '[]') as string[];
|
|
772
|
+
} catch {
|
|
773
|
+
return [];
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
useEffect(() => {
|
|
777
|
+
try {
|
|
778
|
+
localStorage.setItem(SAVED, JSON.stringify(ids));
|
|
779
|
+
} catch {
|
|
780
|
+
// Storage can be unavailable; the run still works, it just won't re-attach.
|
|
781
|
+
}
|
|
782
|
+
}, [ids]);
|
|
783
|
+
|
|
784
|
+
const { cells, summary, cancelAll, rewatch } = useWorkflowBatch(ids);
|
|
785
|
+
const [cancelNote, setCancelNote] = useState<string | null>(null);
|
|
786
|
+
|
|
787
|
+
async function runCell(body: WorkflowBody, key: string) {
|
|
788
|
+
const snap = await submit(body, { idempotencyKey: key });
|
|
789
|
+
if (snap.workflowId !== 'failed') setIds((prev) => [...prev, snap.workflowId]);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function onCancelAll() {
|
|
793
|
+
const result = await cancelAll();
|
|
794
|
+
setCancelNote(
|
|
795
|
+
result.complete
|
|
796
|
+
? null
|
|
797
|
+
: `${result.refused.length + result.notAttempted.length + result.failed.length} cells are still running. Try again in a few seconds.`,
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return (
|
|
802
|
+
<div>
|
|
803
|
+
<button onClick={() => bodies.forEach((b, i) => void runCell(b, `cell-${i}`))}>Run grid</button>
|
|
804
|
+
<p>
|
|
805
|
+
{summary.succeeded} done, {summary.failed} failed, {summary.canceled} cancelled,{' '}
|
|
806
|
+
{summary.running} running. Spent {summary.realisedCost} Buzz.
|
|
807
|
+
</p>
|
|
808
|
+
{!summary.settled && <button onClick={onCancelAll}>Cancel all</button>}
|
|
809
|
+
{summary.stopped > 0 && <button onClick={rewatch}>Refresh stalled cells</button>}
|
|
810
|
+
{cancelNote && <p>{cancelNote}</p>}
|
|
811
|
+
<ul>
|
|
812
|
+
{cells.map((cell) => (
|
|
813
|
+
<li key={cell.workflowId}>{cell.snapshot?.status ?? 'queued'}</li>
|
|
814
|
+
))}
|
|
815
|
+
</ul>
|
|
816
|
+
</div>
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
**Mock host:** `createMockHost` and `Harness` keep a cancelled workflow
|
|
822
|
+
cancelled on later polls, report a finished workflow's real status when it is
|
|
823
|
+
cancelled, and count down an id they did not submit (one restored after a
|
|
824
|
+
reload) like any other. Set `generation.cancelRefused` to `true` (or a list of
|
|
825
|
+
workflow ids) to answer cancels the way the host does when the allowance is used
|
|
826
|
+
up, and `generation.workflowOutcome` to a `(workflowId, body) => 'succeeded' |
|
|
827
|
+
'failed' | 'expired'` function to give a grid a mix of outcomes. The mock does
|
|
828
|
+
not model the rate limits themselves.
|
|
829
|
+
|
|
470
830
|
### `useBuzzPurchase()`
|
|
471
831
|
|
|
472
832
|
Open the Buzz purchase modal. It raises the viewer's WALLET, never a spend cap:
|
|
@@ -1088,6 +1448,66 @@ await shared.unvote(key);
|
|
|
1088
1448
|
await shared.withdraw(key); // remove my own entry
|
|
1089
1449
|
```
|
|
1090
1450
|
|
|
1451
|
+
#### Shared-storage write limits
|
|
1452
|
+
|
|
1453
|
+
Every write is budgeted per **(viewer, app)** on the server. One viewer
|
|
1454
|
+
exhausting their budget in your app costs them nothing in another app, and
|
|
1455
|
+
re-minting the block token does not reset it.
|
|
1456
|
+
|
|
1457
|
+
| Writes | Budget |
|
|
1458
|
+
|---|---|
|
|
1459
|
+
| `append` + `update` (one shared budget) | a **burst of 10**, refilling **1 every 6 minutes**, and a **daily bucket of 50**, refilling continuously (one every ~29 minutes). A write needs a token from both. |
|
|
1460
|
+
| — writes the server **refused** (moderation block, the 50-entry cap, quota, not found, …) | also charged to a **rejection bucket of 10**, refilling **1 every 30 minutes**. No `append`/`update` is accepted while it is empty. Concurrent refused writes can drive it into debt, down to −10, which has to refill first. |
|
|
1461
|
+
| `vote` + `unvote` | 30 a minute, shared |
|
|
1462
|
+
| `withdraw` | 30 a minute |
|
|
1463
|
+
| `report` | 20 a day |
|
|
1464
|
+
|
|
1465
|
+
Only writes that **land** spend the `append`/`update` budget: a write that fails
|
|
1466
|
+
without landing gets its token back. The few failures the platform marks as its
|
|
1467
|
+
own fault are refunded without a rejection charge too.
|
|
1468
|
+
|
|
1469
|
+
> The `append`/`update` budget above goes live with the next civitai.com
|
|
1470
|
+
> release. Until then the live limit is a fixed window of **20 posts + edits a
|
|
1471
|
+
> day** per (viewer, app). The vote, withdraw and report limits, and the retry
|
|
1472
|
+
> metadata below, are already live.
|
|
1473
|
+
|
|
1474
|
+
A write refused for budget arrives with retry metadata, and the hook rejects with
|
|
1475
|
+
**`SharedStorageRateLimitError`**. It extends the plain `Error` every other
|
|
1476
|
+
refusal is thrown as, so existing `catch` code keeps working, and `.message` is
|
|
1477
|
+
still the server's text (`Too many submissions — retry in 312s`, or `votes`,
|
|
1478
|
+
`withdrawals`, `reports`). Branch on the class to render the wait instead of
|
|
1479
|
+
parsing the message:
|
|
1480
|
+
|
|
1481
|
+
```tsx
|
|
1482
|
+
import { SharedStorageRateLimitError, useSharedStorage } from '@civitai/blocks-react';
|
|
1483
|
+
|
|
1484
|
+
const shared = useSharedStorage();
|
|
1485
|
+
try {
|
|
1486
|
+
await shared.append({ title: 'Add dark mode' });
|
|
1487
|
+
} catch (err) {
|
|
1488
|
+
if (err instanceof SharedStorageRateLimitError) {
|
|
1489
|
+
// err.retryAt: ISO-8601 instant a retry can succeed; err.retryAfterSeconds: whole seconds, ≥ 1.
|
|
1490
|
+
const at = new Date(err.retryAt).toLocaleTimeString();
|
|
1491
|
+
showNotice(`You're posting fast. Try again at ${at}.`);
|
|
1492
|
+
} else {
|
|
1493
|
+
throw err;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
```
|
|
1497
|
+
|
|
1498
|
+
- **Don't retry before `retryAt`.** A refused retry gets nothing, and once the
|
|
1499
|
+
budget is empty every attempt is refused until it refills.
|
|
1500
|
+
- **Absent metadata is not "retry now".** A host that predates it, or a refusal
|
|
1501
|
+
that is not a rate limit, throws the plain `Error`. The hook also ignores a
|
|
1502
|
+
malformed `rateLimit` and throws the plain `Error`, rather than hand you
|
|
1503
|
+
something you would render.
|
|
1504
|
+
- On the wire the refusal reply is `{ requestId, error, rateLimit?: { retryAfterSeconds, retryAt } }`
|
|
1505
|
+
(type `SharedStorageRateLimit` in `@civitai/app-sdk/blocks`) on the six write
|
|
1506
|
+
replies.
|
|
1507
|
+
- `createMockHost({ shared: { rateLimitNext: 1, rateLimit: { retryAfterSeconds: 312 } } })`
|
|
1508
|
+
refuses the next write this way, so you can test the branch locally
|
|
1509
|
+
(`setScenario({ shared: { rateLimitNext } })` arms it mid-session).
|
|
1510
|
+
|
|
1091
1511
|
### `useCheckpointPicker()`
|
|
1092
1512
|
|
|
1093
1513
|
Drive the platform Checkpoint picker + persist a viewer override.
|
|
@@ -1140,9 +1560,10 @@ if (isModelSlotContext(context) && context.checkpoint) {
|
|
|
1140
1560
|
### `useResourcePicker()`
|
|
1141
1561
|
|
|
1142
1562
|
Drive the platform resource picker for page blocks — `'Checkpoint' | 'LORA'`.
|
|
1143
|
-
The viewer searches in host chrome; the block only ever sees the
|
|
1144
|
-
picked
|
|
1145
|
-
server-side at
|
|
1563
|
+
The viewer searches in host chrome; the block only ever sees what the viewer
|
|
1564
|
+
picked: one resource, or with `multiple` the few LoRAs they chose. DISCOVERY
|
|
1565
|
+
ONLY — a returned `versionId` is re-validated + re-priced server-side at
|
|
1566
|
+
estimate/submit.
|
|
1146
1567
|
|
|
1147
1568
|
🔴 **OMIT `baseModelGroup` BY DEFAULT, and never pass a HARDCODED ecosystem.** It
|
|
1148
1569
|
is an ecosystem-family FILTER, not a label: the host hides every resource outside
|
|
@@ -1159,10 +1580,11 @@ a picker returned earlier.
|
|
|
1159
1580
|
|
|
1160
1581
|
For a complete multi-LoRA app, see
|
|
1161
1582
|
[`starters/examples/generate-studio`](https://github.com/civitai/civitai-app-starters/tree/main/starters/examples/generate-studio):
|
|
1162
|
-
`src/components/ModelSection.tsx`
|
|
1583
|
+
`src/components/ModelSection.tsx` fills one LoRA slot per picker session, up to
|
|
1163
1584
|
`MAX_LORAS`, with a `baseModelGroup` derived from the selected checkpoint, and
|
|
1164
1585
|
`keepCompatibleLoras` (`src/studio/setup.ts`) re-filters the stack when the
|
|
1165
|
-
checkpoint changes, dropping LoRAs made for another family.
|
|
1586
|
+
checkpoint changes, dropping LoRAs made for another family. To fill several
|
|
1587
|
+
slots in ONE session, use `multiple` (below).
|
|
1166
1588
|
|
|
1167
1589
|
```tsx
|
|
1168
1590
|
const { open } = useResourcePicker();
|
|
@@ -1188,6 +1610,45 @@ if (checkpoint) {
|
|
|
1188
1610
|
}
|
|
1189
1611
|
```
|
|
1190
1612
|
|
|
1613
|
+
**Several LoRAs in one session — `multiple: { max }`.** The viewer stages up to
|
|
1614
|
+
`max` LoRAs in the picker and confirms once; `open` resolves with a **list**, in
|
|
1615
|
+
the order the viewer picked them, instead of a single resource. Dismissing
|
|
1616
|
+
resolves with `[]`.
|
|
1617
|
+
|
|
1618
|
+
```tsx
|
|
1619
|
+
const { open } = useResourcePicker();
|
|
1620
|
+
|
|
1621
|
+
const loras = await open({ resourceType: 'LORA', multiple: { max: 3 } });
|
|
1622
|
+
// [] when dismissed; otherwise up to 3 BlockResourceInfo, in pick order
|
|
1623
|
+
const additionalResources = loras.map((lora) => ({
|
|
1624
|
+
modelVersionId: lora.versionId,
|
|
1625
|
+
strength: lora.strength ?? 1,
|
|
1626
|
+
}));
|
|
1627
|
+
```
|
|
1628
|
+
|
|
1629
|
+
- **LoRA only.** `multiple` with `resourceType: 'Checkpoint'` is a type error and
|
|
1630
|
+
rejects at runtime — it is never quietly treated as a single pick.
|
|
1631
|
+
- **`max` is a whole number of at least 1, capped at 5** (the cap on
|
|
1632
|
+
`additionalResources`, exported as `RESOURCE_PICKER_MULTIPLE_MAX`). A larger
|
|
1633
|
+
value is clamped to 5; `0`, a negative or a fraction rejects.
|
|
1634
|
+
- **`baseModelGroup` works the same** — pass the family derived from the
|
|
1635
|
+
checkpoint the block holds and every LoRA offered matches it.
|
|
1636
|
+
- **Each entry is the same `BlockResourceInfo`** a single pick returns, and every
|
|
1637
|
+
id is still re-validated at estimate/submit.
|
|
1638
|
+
- **Without `multiple`, nothing changes**: `open` resolves with one resource or
|
|
1639
|
+
`null`, exactly as before.
|
|
1640
|
+
- **On a host that predates multi-select** the viewer gets the single-pick
|
|
1641
|
+
picker, and `open` still resolves with a list: of the one LoRA they picked, or
|
|
1642
|
+
`[]`. The result does not say which kind of host answered, so a one-item
|
|
1643
|
+
list is not a signal to open the picker again: on a current host it means the
|
|
1644
|
+
viewer chose one. Let the viewer add more with their own action instead.
|
|
1645
|
+
|
|
1646
|
+
With the mock host — `createMockHost` or `Harness` from
|
|
1647
|
+
`@civitai/blocks-react/testing` — a `multiple` request resolves with two curated
|
|
1648
|
+
LoRAs by default. Pass `cannedMultiPicks: [...]` to choose the list (it is cut to
|
|
1649
|
+
the request's `max`), or `cannedMultiPicks: null` for a dismissed picker. The mock
|
|
1650
|
+
answers a raw `multiple` + Checkpoint request with the host's error.
|
|
1651
|
+
|
|
1191
1652
|
### `useImageUpload()`
|
|
1192
1653
|
|
|
1193
1654
|
Host-mediated image upload — the host opens its native upload modal and the
|
|
@@ -1523,13 +1984,60 @@ produces from the call a block actually makes.
|
|
|
1523
1984
|
|
|
1524
1985
|
### `useBlockAnalytics()`
|
|
1525
1986
|
|
|
1526
|
-
|
|
1987
|
+
Custom events for your app's analytics panel. Fire-and-forget: no reply, no
|
|
1988
|
+
acknowledgement.
|
|
1527
1989
|
|
|
1528
1990
|
```tsx
|
|
1529
1991
|
const { track } = useBlockAnalytics();
|
|
1530
|
-
track('generate_clicked', {
|
|
1992
|
+
track('generate_clicked', { mode: 'txt2img', batch: 4 });
|
|
1531
1993
|
```
|
|
1532
1994
|
|
|
1995
|
+
🔴 **Only what your manifest DECLARES is recorded.** Declare every event, and
|
|
1996
|
+
every property it carries, under `analytics.events` in `block.manifest.json`.
|
|
1997
|
+
Events are accepted against the APPROVED manifest, so a reviewer sees what your
|
|
1998
|
+
app tracks:
|
|
1999
|
+
|
|
2000
|
+
```json
|
|
2001
|
+
"analytics": {
|
|
2002
|
+
"events": {
|
|
2003
|
+
"generate_clicked": {
|
|
2004
|
+
"description": "User pressed Generate",
|
|
2005
|
+
"properties": {
|
|
2006
|
+
"mode": { "type": "enum", "values": ["txt2img", "img2img"] },
|
|
2007
|
+
"batch": { "type": "number" },
|
|
2008
|
+
"advanced": { "type": "boolean" }
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
```
|
|
2014
|
+
|
|
2015
|
+
| You send | Production |
|
|
2016
|
+
|---|---|
|
|
2017
|
+
| A declared event, declared properties of the declared type | Recorded |
|
|
2018
|
+
| An event the manifest does not declare | Counted as a drop; its name and properties are not stored |
|
|
2019
|
+
| A property the event does not declare | Dropped; the rest of the event is recorded |
|
|
2020
|
+
| A value of the wrong type, or a string that is not one of the enum's values | Dropped; the rest of the event is recorded |
|
|
2021
|
+
|
|
2022
|
+
- **Types are `enum`, `number` and `boolean`.** There is no free-text string, so
|
|
2023
|
+
a prompt, an email or a username cannot be recorded. A `number` is not
|
|
2024
|
+
range-limited: do not use one to carry an identifier.
|
|
2025
|
+
- **Names** are lowercase snake_case (a letter, then up to 63 of `a-z`, `0-9`,
|
|
2026
|
+
`_`). At most 50 events per app and 10 properties per event.
|
|
2027
|
+
- **Limits.** Each mount of your app may send 10 events a second and 500 in
|
|
2028
|
+
total, in bodies of at most 32 KiB. An event over a limit is dropped in the
|
|
2029
|
+
browser and counted nowhere, so the panel's numbers are floors.
|
|
2030
|
+
- **Where.** Events are recorded only from the production site.
|
|
2031
|
+
- **What owners see.** Aggregates in the app's analytics panel: per declared
|
|
2032
|
+
event, counts, unique signed-in viewers and property breakdowns. Signed-out
|
|
2033
|
+
viewers are counted as events, never identified. The team's own activity is
|
|
2034
|
+
excluded by default. Individual events are never shown.
|
|
2035
|
+
|
|
2036
|
+
**Check it locally.** Pass your manifest to `createLiveHost` (see
|
|
2037
|
+
[`/live`](#in-a-devlive-harness)); in `dev:live` the console then warns once per
|
|
2038
|
+
undeclared event, undeclared property, or value of the wrong type. Every
|
|
2039
|
+
`starters/examples/*` harness does this.
|
|
2040
|
+
|
|
1533
2041
|
### `useRequestSignIn()`
|
|
1534
2042
|
|
|
1535
2043
|
Ask the host to open its sign-in flow for an ANONYMOUS viewer (fire-and-forget).
|
|
@@ -2267,7 +2775,7 @@ set, failing on growth and on shrinkage.
|
|
|
2267
2775
|
| Export | What it is |
|
|
2268
2776
|
|---|---|
|
|
2269
2777
|
| `createLiveHost` | 🔴 **Real backend, real Buzz.** Installs a host that proxies the block's `postMessage` traffic to civitai.com using a dev block token. Returns a handle; call `.install()` and keep the teardown, exactly like `createMockHost`. |
|
|
2270
|
-
| `LiveHostOptions` *(type)* | Options for the above. `blockToken` is required; everything else (`backendBaseUrl`, `viewer`, `theme`, `context`, `onOutbound`, …) has a default. |
|
|
2778
|
+
| `LiveHostOptions` *(type)* | Options for the above. `blockToken` is required; everything else (`backendBaseUrl`, `viewer`, `theme`, `context`, `onOutbound`, `manifest`, …) has a default. Pass `manifest` to have `TRACK_EVENT` checked against `analytics.events`. |
|
|
2271
2779
|
|
|
2272
2780
|
### In a `dev:live` harness
|
|
2273
2781
|
|
|
@@ -2279,6 +2787,10 @@ import { createLiveHost, type LiveHostOptions } from '@civitai/blocks-react/live
|
|
|
2279
2787
|
const options: LiveHostOptions = {
|
|
2280
2788
|
blockToken: devBlockToken,
|
|
2281
2789
|
theme: 'dark',
|
|
2790
|
+
// Your block.manifest.json, as imported. Each `track()` is checked against its
|
|
2791
|
+
// `analytics.events`, and the console warns once per undeclared event,
|
|
2792
|
+
// undeclared property, or value of the wrong type — what production drops.
|
|
2793
|
+
manifest,
|
|
2282
2794
|
};
|
|
2283
2795
|
|
|
2284
2796
|
const host = createLiveHost(options);
|