@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 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 one resource it
1144
- picked. DISCOVERY ONLY — the returned `versionId` is re-validated + re-priced
1145
- server-side at estimate/submit.
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` opens the picker once per LoRA slot, up to
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
- Fire-and-forget event tracking into the host's analytics pipeline.
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', { modelId });
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);