@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 +386 -13
- package/dist/hooks/useBatchEstimate.d.ts +190 -0
- package/dist/hooks/useBatchEstimate.js +195 -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/useUploadImageBytes.d.ts +2 -6
- package/dist/hooks/useUploadImageBytes.js +2 -6
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/internal/liveHost.d.ts +27 -2
- package/dist/internal/liveHost.js +133 -15
- package/dist/internal/mockHost.d.ts +81 -2
- package/dist/internal/mockHost.js +308 -78
- 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 +132 -28
- package/package.json +4 -4
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
|
|
1144
|
-
picked
|
|
1145
|
-
server-side at
|
|
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`
|
|
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
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
-
|
|
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', {
|
|
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
|