@civitai/blocks-react 0.67.0 → 0.68.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,46 @@ 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
+
458
498
  > **Estimate must mirror submit** (gotcha #59): build the params for `estimate`
459
499
  > with the *exact* same logic as `submit` — same seed decision especially. The
460
500
  > orchestrator whatif prices a cache hit (identical workflow) at 0 and a fresh
@@ -467,6 +507,163 @@ if (priced) {
467
507
  > your installed version predates 0.5.0, do the client-side half and add the
468
508
  > `cancel(...)` call after upgrading.
469
509
 
510
+ ### `useBatchEstimate()`
511
+
512
+ Price a grid of generations in one request. `estimateBatch(bodies)` takes a list
513
+ of workflow bodies, each exactly what `useBuzzWorkflow().estimate()` takes, and
514
+ resolves with one cell per body, in order, plus the run total. Returns
515
+ `{ estimateBatch, pending, result, error }`.
516
+
517
+ - **One request against the estimate allowance** however many cells it carries.
518
+ On a page app that allowance is shared by every viewer of the app and by the
519
+ host's other reads for it, so a 16-cell grid priced cell by cell used 16 of it.
520
+ - **At most 16 bodies per call** (`BATCH_ESTIMATE_MAX_CELLS`). Price a larger grid
521
+ in several calls.
522
+ - **Cells are metered too**, against an allowance of their own that is shared
523
+ the same way (on a page app, by every viewer of the app), so a batch is not a
524
+ way around the estimate allowance. A call over either limit is refused whole:
525
+ it rejects with `BatchEstimateError` code `'failed'`. Retry in a few seconds.
526
+ - **Every rule of a single estimate applies to each cell.** A cell that one
527
+ `estimate()` would reject comes back as `ok: false` with the same
528
+ `WorkflowEstimateError`, and the other cells still price.
529
+ - **`kind: 'training'` bodies are refused per cell.** Estimate a training run
530
+ with `useBuzzWorkflow().estimate()`.
531
+ - **Estimate only.** Nothing is submitted and no Buzz is held or spent. There is
532
+ no batch submit, watch or cancel. Submit each cell with
533
+ `useBuzzWorkflow().submit()`, which asks the viewer and checks the budget per
534
+ cell. `aggregate.total` is a quote, not a lock: each cell is priced again when
535
+ it is submitted.
536
+
537
+ `aggregate.total` sums the cells that priced. It covers the whole grid only when
538
+ `aggregate.pricedCells === aggregate.cellCount`; otherwise show it as a partial
539
+ total.
540
+
541
+ **The host bounds the time one call takes.** If its budget runs out, the call
542
+ still resolves: the cells that priced are returned, and every other cell is
543
+ `ok: false` with `timedOut: true`. Show those cells as unavailable and let the
544
+ viewer retry later. Branch on `timedOut`, not on `snapshot.error`: the host's
545
+ wording is not a contract.
546
+
547
+ `estimateBatch` rejects with `BatchEstimateError` only when the whole call
548
+ failed. Branch on `err.code`:
549
+
550
+ | `code` | Meaning | What to do |
551
+ |---|---|---|
552
+ | `'unsupported'` | The host answered `unsupported on this host`. | Price each cell with `estimate()`. |
553
+ | `'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. |
554
+ | `'invalid-request'` | Empty list, or more than 16 bodies. Refused before sending. | Fix the list. |
555
+ | `'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. |
556
+
557
+ A 2 × 2 grid (two prompts × two checkpoints), falling back to per-cell estimates
558
+ only on a host that answers `'unsupported'`:
559
+
560
+ ```tsx
561
+ import { useState } from 'react';
562
+ import type { WorkflowBody } from '@civitai/app-sdk/blocks';
563
+ import {
564
+ BatchEstimateError,
565
+ useBatchEstimate,
566
+ useBuzzWorkflow,
567
+ WorkflowEstimateError,
568
+ } from '@civitai/blocks-react';
569
+
570
+ const PROMPTS = ['a lighthouse at dusk', 'a lighthouse in fog'];
571
+ const CHECKPOINTS = [
572
+ { modelId: 101, modelVersionId: 1001 },
573
+ { modelId: 202, modelVersionId: 2002 },
574
+ ];
575
+
576
+ /** One cell's quote: a price, or why it has none. */
577
+ type CellQuote = { total: number } | { unavailable: string };
578
+
579
+ export function GridQuote() {
580
+ const { estimateBatch } = useBatchEstimate();
581
+ const { estimate } = useBuzzWorkflow();
582
+ const [quotes, setQuotes] = useState<CellQuote[]>([]);
583
+ const [runTotal, setRunTotal] = useState<number | null>(null);
584
+
585
+ const bodies: WorkflowBody[] = PROMPTS.flatMap((prompt) =>
586
+ CHECKPOINTS.map((cp) => ({ kind: 'textToImage' as const, ...cp, params: { prompt } })),
587
+ );
588
+
589
+ // Your copy, chosen by `code` (and, for a batch cell, `timedOut`). Never
590
+ // render or match `err.message` or `snapshot.error`.
591
+ const why = (err: WorkflowEstimateError, timedOut = false) =>
592
+ timedOut
593
+ ? 'Price not ready, try again shortly'
594
+ : err.code === 'no-cost'
595
+ ? 'No price for this cell'
596
+ : 'This cell cannot run';
597
+
598
+ async function priceGrid() {
599
+ try {
600
+ const { cells, aggregate } = await estimateBatch(bodies);
601
+ setQuotes(
602
+ cells.map((c) => (c.ok ? { total: c.cost.total } : { unavailable: why(c.error, c.timedOut) })),
603
+ );
604
+ setRunTotal(aggregate.pricedCells === aggregate.cellCount ? aggregate.total : null);
605
+ return;
606
+ } catch (err) {
607
+ if (!(err instanceof BatchEstimateError)) throw err;
608
+ if (err.code !== 'unsupported') {
609
+ // 'timeout' is a slow host, not an old one: never fan out per cell here.
610
+ setQuotes([]);
611
+ setRunTotal(null);
612
+ return; // show "pricing unavailable, try again" in your UI
613
+ }
614
+ }
615
+ // This host has no batch estimate: one estimate() per cell, as before.
616
+ const fallback: CellQuote[] = [];
617
+ for (const body of bodies) {
618
+ try {
619
+ const snapshot = await estimate(body);
620
+ fallback.push({ total: snapshot.cost!.total });
621
+ } catch (err) {
622
+ if (!(err instanceof WorkflowEstimateError)) throw err;
623
+ fallback.push({ unavailable: why(err) });
624
+ }
625
+ }
626
+ setQuotes(fallback);
627
+ const priced = fallback.filter((q): q is { total: number } => 'total' in q);
628
+ setRunTotal(
629
+ priced.length === fallback.length ? priced.reduce((sum, q) => sum + q.total, 0) : null,
630
+ );
631
+ }
632
+
633
+ return (
634
+ <div>
635
+ <button onClick={() => void priceGrid()}>Price grid</button>
636
+ <ul>
637
+ {quotes.map((q, i) => (
638
+ <li key={i}>{'total' in q ? `${q.total} Buzz` : q.unavailable}</li>
639
+ ))}
640
+ </ul>
641
+ {runTotal !== null && <p>Run total: {runTotal} Buzz</p>}
642
+ </div>
643
+ );
644
+ }
645
+ ```
646
+
647
+ The fallback spends one request of the shared allowance per cell, which is why
648
+ the batch exists. Each submit stays per cell: call `submit(bodies[i])` for each
649
+ cell the viewer confirms.
650
+
651
+ A host that predates the batch message never replies, so the call ends in
652
+ `'timeout'` after the full wait. Only if your app must support such a host,
653
+ detect it with a deliberate probe: send a one-body batch once with a short
654
+ `{ timeoutMs }`, and treat only that probe's `'timeout'` as "no batch support".
655
+ Any other `'timeout'` is a slow host.
656
+
657
+ **Mock host:** `createMockHost` and `Harness` price each cell exactly as they
658
+ price one `estimate()` of that body (`costPerGen` and `failEstimate` apply per
659
+ cell). Set `generation.batchEstimate` to `'unsupported'` to test the fallback,
660
+ or to `'silent'` to test a call that never gets a reply (pass a short
661
+ `timeoutMs` in a test). Set `generation.batchEstimateTimedOutCells` to a list
662
+ of cell indices to answer those cells the way the host does when its time
663
+ budget runs out (`ok: false`, `timedOut: true`). **`dev:live`** forwards the call to civitai.com, so against
664
+ a civitai.com that predates the batch procedure it rejects with `'failed'`
665
+ rather than `'unsupported'`.
666
+
470
667
  ### `useBuzzPurchase()`
471
668
 
472
669
  Open the Buzz purchase modal. It raises the viewer's WALLET, never a spend cap:
@@ -1088,6 +1285,66 @@ await shared.unvote(key);
1088
1285
  await shared.withdraw(key); // remove my own entry
1089
1286
  ```
1090
1287
 
1288
+ #### Shared-storage write limits
1289
+
1290
+ Every write is budgeted per **(viewer, app)** on the server. One viewer
1291
+ exhausting their budget in your app costs them nothing in another app, and
1292
+ re-minting the block token does not reset it.
1293
+
1294
+ | Writes | Budget |
1295
+ |---|---|
1296
+ | `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. |
1297
+ | — 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. |
1298
+ | `vote` + `unvote` | 30 a minute, shared |
1299
+ | `withdraw` | 30 a minute |
1300
+ | `report` | 20 a day |
1301
+
1302
+ Only writes that **land** spend the `append`/`update` budget: a write that fails
1303
+ without landing gets its token back. The few failures the platform marks as its
1304
+ own fault are refunded without a rejection charge too.
1305
+
1306
+ > The `append`/`update` budget above goes live with the next civitai.com
1307
+ > release. Until then the live limit is a fixed window of **20 posts + edits a
1308
+ > day** per (viewer, app). The vote, withdraw and report limits, and the retry
1309
+ > metadata below, are already live.
1310
+
1311
+ A write refused for budget arrives with retry metadata, and the hook rejects with
1312
+ **`SharedStorageRateLimitError`**. It extends the plain `Error` every other
1313
+ refusal is thrown as, so existing `catch` code keeps working, and `.message` is
1314
+ still the server's text (`Too many submissions — retry in 312s`, or `votes`,
1315
+ `withdrawals`, `reports`). Branch on the class to render the wait instead of
1316
+ parsing the message:
1317
+
1318
+ ```tsx
1319
+ import { SharedStorageRateLimitError, useSharedStorage } from '@civitai/blocks-react';
1320
+
1321
+ const shared = useSharedStorage();
1322
+ try {
1323
+ await shared.append({ title: 'Add dark mode' });
1324
+ } catch (err) {
1325
+ if (err instanceof SharedStorageRateLimitError) {
1326
+ // err.retryAt: ISO-8601 instant a retry can succeed; err.retryAfterSeconds: whole seconds, ≥ 1.
1327
+ const at = new Date(err.retryAt).toLocaleTimeString();
1328
+ showNotice(`You're posting fast. Try again at ${at}.`);
1329
+ } else {
1330
+ throw err;
1331
+ }
1332
+ }
1333
+ ```
1334
+
1335
+ - **Don't retry before `retryAt`.** A refused retry gets nothing, and once the
1336
+ budget is empty every attempt is refused until it refills.
1337
+ - **Absent metadata is not "retry now".** A host that predates it, or a refusal
1338
+ that is not a rate limit, throws the plain `Error`. The hook also ignores a
1339
+ malformed `rateLimit` and throws the plain `Error`, rather than hand you
1340
+ something you would render.
1341
+ - On the wire the refusal reply is `{ requestId, error, rateLimit?: { retryAfterSeconds, retryAt } }`
1342
+ (type `SharedStorageRateLimit` in `@civitai/app-sdk/blocks`) on the six write
1343
+ replies.
1344
+ - `createMockHost({ shared: { rateLimitNext: 1, rateLimit: { retryAfterSeconds: 312 } } })`
1345
+ refuses the next write this way, so you can test the branch locally
1346
+ (`setScenario({ shared: { rateLimitNext } })` arms it mid-session).
1347
+
1091
1348
  ### `useCheckpointPicker()`
1092
1349
 
1093
1350
  Drive the platform Checkpoint picker + persist a viewer override.
@@ -1140,9 +1397,10 @@ if (isModelSlotContext(context) && context.checkpoint) {
1140
1397
  ### `useResourcePicker()`
1141
1398
 
1142
1399
  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.
1400
+ The viewer searches in host chrome; the block only ever sees what the viewer
1401
+ picked: one resource, or with `multiple` the few LoRAs they chose. DISCOVERY
1402
+ ONLY — a returned `versionId` is re-validated + re-priced server-side at
1403
+ estimate/submit.
1146
1404
 
1147
1405
  🔴 **OMIT `baseModelGroup` BY DEFAULT, and never pass a HARDCODED ecosystem.** It
1148
1406
  is an ecosystem-family FILTER, not a label: the host hides every resource outside
@@ -1159,10 +1417,11 @@ a picker returned earlier.
1159
1417
 
1160
1418
  For a complete multi-LoRA app, see
1161
1419
  [`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
1420
+ `src/components/ModelSection.tsx` fills one LoRA slot per picker session, up to
1163
1421
  `MAX_LORAS`, with a `baseModelGroup` derived from the selected checkpoint, and
1164
1422
  `keepCompatibleLoras` (`src/studio/setup.ts`) re-filters the stack when the
1165
- checkpoint changes, dropping LoRAs made for another family.
1423
+ checkpoint changes, dropping LoRAs made for another family. To fill several
1424
+ slots in ONE session, use `multiple` (below).
1166
1425
 
1167
1426
  ```tsx
1168
1427
  const { open } = useResourcePicker();
@@ -1188,6 +1447,45 @@ if (checkpoint) {
1188
1447
  }
1189
1448
  ```
1190
1449
 
1450
+ **Several LoRAs in one session — `multiple: { max }`.** The viewer stages up to
1451
+ `max` LoRAs in the picker and confirms once; `open` resolves with a **list**, in
1452
+ the order the viewer picked them, instead of a single resource. Dismissing
1453
+ resolves with `[]`.
1454
+
1455
+ ```tsx
1456
+ const { open } = useResourcePicker();
1457
+
1458
+ const loras = await open({ resourceType: 'LORA', multiple: { max: 3 } });
1459
+ // [] when dismissed; otherwise up to 3 BlockResourceInfo, in pick order
1460
+ const additionalResources = loras.map((lora) => ({
1461
+ modelVersionId: lora.versionId,
1462
+ strength: lora.strength ?? 1,
1463
+ }));
1464
+ ```
1465
+
1466
+ - **LoRA only.** `multiple` with `resourceType: 'Checkpoint'` is a type error and
1467
+ rejects at runtime — it is never quietly treated as a single pick.
1468
+ - **`max` is a whole number of at least 1, capped at 5** (the cap on
1469
+ `additionalResources`, exported as `RESOURCE_PICKER_MULTIPLE_MAX`). A larger
1470
+ value is clamped to 5; `0`, a negative or a fraction rejects.
1471
+ - **`baseModelGroup` works the same** — pass the family derived from the
1472
+ checkpoint the block holds and every LoRA offered matches it.
1473
+ - **Each entry is the same `BlockResourceInfo`** a single pick returns, and every
1474
+ id is still re-validated at estimate/submit.
1475
+ - **Without `multiple`, nothing changes**: `open` resolves with one resource or
1476
+ `null`, exactly as before.
1477
+ - **On a host that predates multi-select** the viewer gets the single-pick
1478
+ picker, and `open` still resolves with a list: of the one LoRA they picked, or
1479
+ `[]`. The result does not say which kind of host answered, so a one-item
1480
+ list is not a signal to open the picker again: on a current host it means the
1481
+ viewer chose one. Let the viewer add more with their own action instead.
1482
+
1483
+ With the mock host — `createMockHost` or `Harness` from
1484
+ `@civitai/blocks-react/testing` — a `multiple` request resolves with two curated
1485
+ LoRAs by default. Pass `cannedMultiPicks: [...]` to choose the list (it is cut to
1486
+ the request's `max`), or `cannedMultiPicks: null` for a dismissed picker. The mock
1487
+ answers a raw `multiple` + Checkpoint request with the host's error.
1488
+
1191
1489
  ### `useImageUpload()`
1192
1490
 
1193
1491
  Host-mediated image upload — the host opens its native upload modal and the
@@ -1249,11 +1547,35 @@ neither an image nor an error. If no reply arrives within 10 minutes it
1249
1547
  rejects with a `RequestTimeoutError`; the host may still have stored the
1250
1548
  image, so retrying after a timeout can create a duplicate.
1251
1549
 
1252
- 🔴 **A host that predates this variant ignores `bytes` and opens its upload
1253
- picker instead**, so the call settles on whatever the viewer picks. The hook
1254
- needs civitai/civitai#5639 merged and deployed to civitai.com. That PR is still
1255
- changing, so no intermediate head of it is enough. Do not ship a block that
1256
- relies on `useUploadImageBytes()` before then.
1550
+ The hook ships in `@civitai/blocks-react` 0.67.0, and the `bytes` payload type
1551
+ it sends is in `@civitai/app-sdk` 0.62.0.
1552
+
1553
+ What the upload does and does not give you on civitai.com:
1554
+
1555
+ - 🔴 **Generation metadata embedded in the file is not read.** The posted
1556
+ image has no generation details (prompt, resources, sampler settings),
1557
+ whatever the PNG or JPEG carries. In the example below the healed pixels are
1558
+ posted, and the metadata the app repaired is not shown on the post.
1559
+ - 🔴 **Each uploaded image goes into exactly one post.** Once a post holds it,
1560
+ `createPost()` refuses that `imageId` with `an image is not available to post`.
1561
+ To post the same picture again, upload it again.
1562
+ - **An uploaded image never appears in `useGatedImages()`.** That read returns
1563
+ only what the app published with `usePublishGenerationOutputs()`. Keep the
1564
+ `imageId` and `url` that `upload` resolves with if you need to show it.
1565
+ - **A post holds at most 20 images and names at most 10 sources.** Past 20
1566
+ images the server refuses with `a post may contain at most 20 images`.
1567
+ - **Posting from apps is still being rolled out.** A viewer who does not have it
1568
+ yet is refused with `posting from apps is not enabled`, and the refusal
1569
+ arrives from `upload` as well as from `createPost()`. Handle it as an
1570
+ ordinary error branch, not as a bug in your app.
1571
+ - **Creating the post needs an account with a verified email or a linked
1572
+ sign-in provider.** Other account checks apply too: onboarding must be
1573
+ finished, the account must not be brand new, and it must not be muted or
1574
+ banned. `upload` refuses a banned account but checks none of the rest, so a
1575
+ viewer can upload and then be refused at `createPost()`.
1576
+ - **Server-side limits also apply**, to the upload and to the post, beyond the
1577
+ host's per-page window above. A `busy` reply or a rate-limit error such as
1578
+ `Rate limit exceeded, please retry shortly.` means wait and retry.
1257
1579
 
1258
1580
  ```tsx
1259
1581
  import {
@@ -1499,13 +1821,60 @@ produces from the call a block actually makes.
1499
1821
 
1500
1822
  ### `useBlockAnalytics()`
1501
1823
 
1502
- Fire-and-forget event tracking into the host's analytics pipeline.
1824
+ Custom events for your app's analytics panel. Fire-and-forget: no reply, no
1825
+ acknowledgement.
1503
1826
 
1504
1827
  ```tsx
1505
1828
  const { track } = useBlockAnalytics();
1506
- track('generate_clicked', { modelId });
1829
+ track('generate_clicked', { mode: 'txt2img', batch: 4 });
1830
+ ```
1831
+
1832
+ 🔴 **Only what your manifest DECLARES is recorded.** Declare every event, and
1833
+ every property it carries, under `analytics.events` in `block.manifest.json`.
1834
+ Events are accepted against the APPROVED manifest, so a reviewer sees what your
1835
+ app tracks:
1836
+
1837
+ ```json
1838
+ "analytics": {
1839
+ "events": {
1840
+ "generate_clicked": {
1841
+ "description": "User pressed Generate",
1842
+ "properties": {
1843
+ "mode": { "type": "enum", "values": ["txt2img", "img2img"] },
1844
+ "batch": { "type": "number" },
1845
+ "advanced": { "type": "boolean" }
1846
+ }
1847
+ }
1848
+ }
1849
+ }
1507
1850
  ```
1508
1851
 
1852
+ | You send | Production |
1853
+ |---|---|
1854
+ | A declared event, declared properties of the declared type | Recorded |
1855
+ | An event the manifest does not declare | Counted as a drop; its name and properties are not stored |
1856
+ | A property the event does not declare | Dropped; the rest of the event is recorded |
1857
+ | 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 |
1858
+
1859
+ - **Types are `enum`, `number` and `boolean`.** There is no free-text string, so
1860
+ a prompt, an email or a username cannot be recorded. A `number` is not
1861
+ range-limited: do not use one to carry an identifier.
1862
+ - **Names** are lowercase snake_case (a letter, then up to 63 of `a-z`, `0-9`,
1863
+ `_`). At most 50 events per app and 10 properties per event.
1864
+ - **Limits.** Each mount of your app may send 10 events a second and 500 in
1865
+ total, in bodies of at most 32 KiB. An event over a limit is dropped in the
1866
+ browser and counted nowhere, so the panel's numbers are floors.
1867
+ - **Where.** Events are recorded only from the production site.
1868
+ - **What owners see.** Aggregates in the app's analytics panel: per declared
1869
+ event, counts, unique signed-in viewers and property breakdowns. Signed-out
1870
+ viewers are counted as events, never identified. The team's own activity is
1871
+ excluded by default. Individual events are never shown.
1872
+
1873
+ **Check it locally.** Pass your manifest to `createLiveHost` (see
1874
+ [`/live`](#in-a-devlive-harness)); in `dev:live` the console then warns once per
1875
+ undeclared event, undeclared property, or value of the wrong type. Every
1876
+ `starters/examples/*` harness does this.
1877
+
1509
1878
  ### `useRequestSignIn()`
1510
1879
 
1511
1880
  Ask the host to open its sign-in flow for an ANONYMOUS viewer (fire-and-forget).
@@ -2243,7 +2612,7 @@ set, failing on growth and on shrinkage.
2243
2612
  | Export | What it is |
2244
2613
  |---|---|
2245
2614
  | `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`. |
2246
- | `LiveHostOptions` *(type)* | Options for the above. `blockToken` is required; everything else (`backendBaseUrl`, `viewer`, `theme`, `context`, `onOutbound`, …) has a default. |
2615
+ | `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`. |
2247
2616
 
2248
2617
  ### In a `dev:live` harness
2249
2618
 
@@ -2255,6 +2624,10 @@ import { createLiveHost, type LiveHostOptions } from '@civitai/blocks-react/live
2255
2624
  const options: LiveHostOptions = {
2256
2625
  blockToken: devBlockToken,
2257
2626
  theme: 'dark',
2627
+ // Your block.manifest.json, as imported. Each `track()` is checked against its
2628
+ // `analytics.events`, and the console warns once per undeclared event,
2629
+ // undeclared property, or value of the wrong type — what production drops.
2630
+ manifest,
2258
2631
  };
2259
2632
 
2260
2633
  const host = createLiveHost(options);
@@ -0,0 +1,190 @@
1
+ import type { BlockEstimateBatchAggregate, BlockWorkflowSnapshot, WorkflowBody } from '@civitai/app-sdk/blocks';
2
+ import { WorkflowEstimateError } from './useBuzzWorkflow.js';
3
+ export type { BlockEstimateBatchAggregate };
4
+ /**
5
+ * The most bodies one {@link UseBatchEstimate.estimateBatch} call may carry.
6
+ *
7
+ * The host's own cap (`BLOCK_ESTIMATE_BATCH_MAX_CELLS` in civitai/civitai). The
8
+ * hook refuses a longer list before sending it, with the code the host would
9
+ * have answered with a round trip later. A larger grid is priced in several
10
+ * calls; each call is still one request against the estimate allowance.
11
+ */
12
+ export declare const BATCH_ESTIMATE_MAX_CELLS = 16;
13
+ /**
14
+ * The host's `snapshot.error` for a batch cell its time budget ran out on before
15
+ * the cell priced. The copy is the host's (`BLOCK_ESTIMATE_BATCH_TIMEOUT_ERROR`
16
+ * in civitai/civitai `src/server/routers/blocks.router.ts`). This is the ONE
17
+ * place the SDK spells it: the hook maps it to {@link BatchEstimateCell}'s
18
+ * `timedOut`, and the mock host answers with it. Not exported from the package:
19
+ * apps branch on `timedOut`, never on this string.
20
+ */
21
+ export declare const BATCH_ESTIMATE_CELL_TIMEOUT_ERROR = "estimate timed out";
22
+ /**
23
+ * Why a WHOLE batch estimate was refused. The stable branch target on
24
+ * {@link BatchEstimateError}.
25
+ *
26
+ * - `'unsupported'` — the host answered `unsupported on this host`: it knows the
27
+ * message and has no handler for it on this surface. Nothing was priced. **Fall
28
+ * back to one `useBuzzWorkflow().estimate()` per cell.**
29
+ * - `'timeout'` — no reply arrived in time. A host that supports the message
30
+ * bounds its own work and replies well inside the default wait, marking any
31
+ * cell it could not price in time as an `ok: false` cell with `timedOut:
32
+ * true`, so this code usually means the host or its upstream is SLOW, not
33
+ * that it is old. 🔴 Do NOT fall back to per-cell estimates here:
34
+ * that sends up to 16 more estimates at an upstream that is already struggling.
35
+ * Show the estimate as unavailable and let the viewer retry later. A host that
36
+ * predates the message also ends here (it never replies); an app that must
37
+ * tell the two apart can probe once with a one-body batch and a short explicit
38
+ * `timeoutMs`, and treat only THAT probe's `'timeout'` as "no batch support".
39
+ * - `'invalid-request'` — the list is empty, is not an array, or holds more than
40
+ * {@link BATCH_ESTIMATE_MAX_CELLS} bodies. Refused by the hook before sending,
41
+ * or by the host. A per-cell fallback is pointless for an empty list and is the
42
+ * wrong fix for an over-long one: split it.
43
+ * - `'failed'` — the host or the server refused the call for another reason (the
44
+ * scope, the rate limit, review preview, no credential) or returned something
45
+ * that is not a batch result. The reason is on
46
+ * {@link BatchEstimateError.hostError}.
47
+ */
48
+ export type BatchEstimateErrorCode = 'unsupported' | 'timeout' | 'invalid-request' | 'failed';
49
+ /**
50
+ * Thrown by {@link UseBatchEstimate.estimateBatch} when the WHOLE call failed.
51
+ *
52
+ * One cell failing is NOT this: a cell that could not be priced comes back as an
53
+ * `ok: false` entry in {@link BatchEstimateResult.cells} and the call resolves.
54
+ *
55
+ * Same three-audience split as `WorkflowEstimateError`:
56
+ * - {@link BatchEstimateError.code} is what you branch on.
57
+ * - {@link BatchEstimateError.hostError} is the host's or server's own text. It
58
+ * is server-authored and unsanitised: log it, never render it.
59
+ * - `message` is a generic developer-facing summary whose wording is not a
60
+ * contract.
61
+ */
62
+ export declare class BatchEstimateError extends Error {
63
+ readonly code: BatchEstimateErrorCode;
64
+ /** The host's or server's reason, verbatim, when it gave one. */
65
+ readonly hostError?: string;
66
+ constructor(code: BatchEstimateErrorCode, hostError?: string);
67
+ }
68
+ /**
69
+ * One cell of a batch estimate — the answer to the body at the same index.
70
+ *
71
+ * - `ok: true` — the cell priced. `cost` is the cost object a single
72
+ * `estimate()` returns for that body, passed through untouched (`cost.total`
73
+ * is the price shown before `submit()`).
74
+ * - `ok: false` — the cell did not price. `error` is the SAME
75
+ * `WorkflowEstimateError` a single `estimate()` of that body would have
76
+ * rejected with (`code: 'failed' | 'no-cost'`, the reason on
77
+ * `error.snapshot.error`), so one function can turn either into viewer copy.
78
+ * It is a value here, not a throw: the other cells are still usable.
79
+ * - `timedOut` (on `ok: false` only) — `true` when the host's time budget for
80
+ * the call ran out before this cell priced (its `error.code` is `'failed'`).
81
+ * Show it as unavailable and retry later rather than re-estimating it at once.
82
+ * Branch on `timedOut`, never on `snapshot.error`: the host's wording is not a
83
+ * contract, and the hook maps it here so no app has to match it.
84
+ *
85
+ * `snapshot` is the host's snapshot for the cell, verbatim, on both arms.
86
+ */
87
+ export type BatchEstimateCell = {
88
+ ok: true;
89
+ cost: NonNullable<BlockWorkflowSnapshot['cost']>;
90
+ snapshot: BlockWorkflowSnapshot;
91
+ } | {
92
+ ok: false;
93
+ error: WorkflowEstimateError;
94
+ /** `true` when the host's time budget for the call ran out before this cell priced. */
95
+ timedOut: boolean;
96
+ snapshot: BlockWorkflowSnapshot;
97
+ };
98
+ /** What {@link UseBatchEstimate.estimateBatch} resolves with. */
99
+ export interface BatchEstimateResult {
100
+ /** One entry per body, in the order the bodies were given. */
101
+ cells: BatchEstimateCell[];
102
+ /**
103
+ * The host's run total over the cells that priced. It covers the whole list
104
+ * only when `aggregate.pricedCells === aggregate.cellCount`.
105
+ */
106
+ aggregate: BlockEstimateBatchAggregate;
107
+ }
108
+ /** Options for one {@link UseBatchEstimate.estimateBatch} call. */
109
+ export interface EstimateBatchOptions {
110
+ /**
111
+ * How long to wait for the host's reply, in milliseconds. Default
112
+ * `WORKFLOW_REQUEST_TIMEOUT_MS` (120,000), the single estimate's bound.
113
+ *
114
+ * A host that supports the batch replies within its own time budget, well
115
+ * inside this default, so leave it unset for ordinary pricing. Pass a short
116
+ * value only for a deliberate old-host probe (a one-body batch, sent once):
117
+ * a host that predates the message never replies, and a short wait finds that
118
+ * out sooner. A short wait on a full grid gives up on slow but healthy batches.
119
+ */
120
+ timeoutMs?: number;
121
+ }
122
+ /** What {@link useBatchEstimate} returns. */
123
+ export interface UseBatchEstimate {
124
+ /**
125
+ * Price several workflow bodies in one request. Resolves with one cell per
126
+ * body, in order, plus a run total; rejects with {@link BatchEstimateError}
127
+ * only when the whole call failed.
128
+ *
129
+ * 🔴 ESTIMATE ONLY. Nothing is submitted and no Buzz is held or spent. Submit
130
+ * each cell with `useBuzzWorkflow().submit()`, which asks the viewer and
131
+ * checks the budget per cell.
132
+ */
133
+ estimateBatch: (bodies: WorkflowBody[], options?: EstimateBatchOptions) => Promise<BatchEstimateResult>;
134
+ /** `true` while the most recent call is in flight. */
135
+ pending: boolean;
136
+ /**
137
+ * The most recent call's result, or `null` before the first one and after a
138
+ * call that failed — a failed call never leaves an earlier total on display.
139
+ */
140
+ result: BatchEstimateResult | null;
141
+ /** The most recent call's error, or `null`. */
142
+ error: BatchEstimateError | null;
143
+ }
144
+ /**
145
+ * Price a grid of generations in ONE request: a list of workflow bodies in, one
146
+ * result per body (a cost, or that cell's error) plus a run total out. Backed by
147
+ * the `ESTIMATE_WORKFLOW_BATCH` → `ESTIMATE_BATCH_RESULT` bridge.
148
+ *
149
+ * Use it where an app would otherwise call `useBuzzWorkflow().estimate()` once
150
+ * per cell. Every rule a single estimate enforces still applies to each cell;
151
+ * what changes is the accounting and the round trips:
152
+ *
153
+ * - **One request against the estimate allowance**, whatever the number of
154
+ * cells. On a page app that allowance is shared by every viewer of the app and
155
+ * by the host's other reads for it, so a 16-cell grid used to take 16 of it per
156
+ * pricing pass.
157
+ * - **Cells are metered separately.** The host also counts the cells of every
158
+ * batch against an allowance of their own, shared like the estimate allowance
159
+ * (on a page app, by every viewer of the app), so a batch is not a way around
160
+ * it. A call over either is refused whole (`code: 'failed'`, the host's
161
+ * rate-limit text on `hostError`); retry in a few seconds.
162
+ * - **At most {@link BATCH_ESTIMATE_MAX_CELLS} bodies per call.**
163
+ * - **`kind: 'training'` bodies are refused per cell.** Estimate a training run
164
+ * with `useBuzzWorkflow().estimate()`.
165
+ *
166
+ * 🔴 ESTIMATE ONLY. There is no batch submit, watch or cancel and no id for the
167
+ * list. The total is a quote: nothing reserves it and each cell is priced again
168
+ * at `submit()`.
169
+ *
170
+ * Like `estimate()`, this does NOT prompt for consent. It needs the
171
+ * `ai:write:budgeted` scope; without it the call rejects with `code: 'failed'`.
172
+ *
173
+ * @example
174
+ * const { estimateBatch } = useBatchEstimate();
175
+ * try {
176
+ * const { cells, aggregate } = await estimateBatch(bodies);
177
+ * // cells[i] answers bodies[i]; a cell with `!c.ok && c.timedOut` was not
178
+ * // priced in time — show it as unavailable and retry later
179
+ * } catch (err) {
180
+ * if (!(err instanceof BatchEstimateError)) throw err;
181
+ * if (err.code === 'unsupported') {
182
+ * // this host has no batch estimate — price each cell with estimate()
183
+ * } else {
184
+ * // 'timeout' (a slow host, not an old one) or 'failed': show the estimate
185
+ * // as unavailable and retry later — never one estimate() per cell
186
+ * }
187
+ * }
188
+ */
189
+ export declare function useBatchEstimate(): UseBatchEstimate;
190
+ //# sourceMappingURL=useBatchEstimate.d.ts.map