@civitai/blocks-react 0.69.1 → 0.70.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 +67 -1
- package/dist/internal/mockHost.d.ts +18 -2
- package/dist/internal/mockHost.js +90 -29
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -704,7 +704,10 @@ Any other `'timeout'` is a slow host.
|
|
|
704
704
|
|
|
705
705
|
**Mock host:** `createMockHost` and `Harness` price each cell exactly as they
|
|
706
706
|
price one `estimate()` of that body (`costPerGen` and `failEstimate` apply per
|
|
707
|
-
cell).
|
|
707
|
+
cell). Like the host, they refuse the **whole call** while the token lacks
|
|
708
|
+
`ai:write:budgeted` — `'failed'`, with `hostError` `block lacks
|
|
709
|
+
ai:write:budgeted scope` — so pass `consentGranted: true`, or drive
|
|
710
|
+
`requestConsent()` and estimate again once the scope lands. Set `generation.batchEstimate` to `'unsupported'` to test the fallback,
|
|
708
711
|
or to `'silent'` to test a call that never gets a reply (pass a short
|
|
709
712
|
`timeoutMs` in a test). Set `generation.batchEstimateTimedOutCells` to a list
|
|
710
713
|
of cell indices to answer those cells the way the host does when its time
|
|
@@ -2071,6 +2074,46 @@ on every parameter change. Prompting there would open a consent dialog with no
|
|
|
2071
2074
|
user gesture behind it, once per edit. A failed `estimate()` rejects exactly as
|
|
2072
2075
|
it always has; show no price and let `submit()` do the asking.
|
|
2073
2076
|
|
|
2077
|
+
The same goes for `useBatchEstimate()`. Until the viewer grants
|
|
2078
|
+
`ai:write:budgeted` the host refuses **every** estimate, single and batch, with
|
|
2079
|
+
`block lacks ai:write:budgeted scope` (`WorkflowEstimateError` / `BatchEstimateError`,
|
|
2080
|
+
code `'failed'`). To show a price before the first Generate, ask on a user
|
|
2081
|
+
gesture and estimate again once the grant lands:
|
|
2082
|
+
|
|
2083
|
+
```tsx
|
|
2084
|
+
import { useEffect, useState } from 'react';
|
|
2085
|
+
import { useBlockToken, useBuzzWorkflow, useRequestConsent } from '@civitai/blocks-react';
|
|
2086
|
+
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
|
|
2087
|
+
|
|
2088
|
+
function Price({ body }: { body: WorkflowBody }) {
|
|
2089
|
+
const token = useBlockToken();
|
|
2090
|
+
const { requestConsent } = useRequestConsent();
|
|
2091
|
+
const { estimate } = useBuzzWorkflow();
|
|
2092
|
+
const [price, setPrice] = useState<number | null>(null);
|
|
2093
|
+
const canPrice = token.scopes.includes('ai:write:budgeted');
|
|
2094
|
+
|
|
2095
|
+
// Keyed on `canPrice`, so the quote is fetched again when the grant lands.
|
|
2096
|
+
useEffect(() => {
|
|
2097
|
+
if (!canPrice) return;
|
|
2098
|
+
estimate(body).then((s) => setPrice(s.cost?.total ?? null), () => setPrice(null));
|
|
2099
|
+
}, [canPrice, body, estimate]);
|
|
2100
|
+
|
|
2101
|
+
if (!canPrice) {
|
|
2102
|
+
return (
|
|
2103
|
+
<button onClick={() => requestConsent({ scopes: ['ai:write:budgeted'] })}>
|
|
2104
|
+
Allow generations
|
|
2105
|
+
</button>
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
return <span>{price === null ? '…' : `${price} Buzz`}</span>;
|
|
2109
|
+
}
|
|
2110
|
+
```
|
|
2111
|
+
|
|
2112
|
+
The mock host enforces this too (since `@civitai/blocks-react` 0.70.0, #608): with
|
|
2113
|
+
the default `consentGranted: false` its estimate, batch estimate and submit
|
|
2114
|
+
refuse exactly as the host does, `REQUEST_CONSENT` grants and pushes the
|
|
2115
|
+
refreshed token, and `consentGrantable: false` keeps the refusal in place.
|
|
2116
|
+
|
|
2074
2117
|
```tsx
|
|
2075
2118
|
// This is the whole thing. No try/catch around a consent prompt, no watching
|
|
2076
2119
|
// useBlockToken().scopes, no manual retry.
|
|
@@ -2653,6 +2696,29 @@ host.setScenario({ failMode: 'none' }); // live-tune mid-test
|
|
|
2653
2696
|
uninstall();
|
|
2654
2697
|
```
|
|
2655
2698
|
|
|
2699
|
+
🔴 **The mock enforces the spend scope, as the host does** (since `0.70.0`,
|
|
2700
|
+
[#608](https://github.com/civitai/civitai-app-starters/issues/608)).
|
|
2701
|
+
`consentGranted` defaults to `false` — production's first token does not carry
|
|
2702
|
+
`ai:write:budgeted` — and while it is `false`, an estimate, a batch estimate and
|
|
2703
|
+
a submit are refused with `block lacks ai:write:budgeted scope`, each in the
|
|
2704
|
+
shape the host sends:
|
|
2705
|
+
|
|
2706
|
+
| Call | What your code sees before consent |
|
|
2707
|
+
|---|---|
|
|
2708
|
+
| `estimate()` | rejects `WorkflowEstimateError`, code `'failed'`, reason on `err.snapshot.error` |
|
|
2709
|
+
| `estimateBatch()` | rejects `BatchEstimateError`, code `'failed'`, reason on `err.hostError` |
|
|
2710
|
+
| `submit()` | prompts for consent, the mock grants it and pushes a refreshed token, and the call is retried once with the **same** idempotency key — it resolves, and is charged once |
|
|
2711
|
+
|
|
2712
|
+
`consentGrantable: false` (or `?consent=ungrantable`) keeps the refusal: the
|
|
2713
|
+
prompt is answered with `CONSENT_UNAVAILABLE` and `submit()` rejects with code
|
|
2714
|
+
`'exception'`.
|
|
2715
|
+
|
|
2716
|
+
**Migrating a test:** one that estimates or submits and is not *about* consent
|
|
2717
|
+
passes `consentGranted: true` (or uses `?consent=granted` in a harness). One that
|
|
2718
|
+
is about consent drives it: estimate, see the refusal, call
|
|
2719
|
+
`requestConsent({ scopes: ['ai:write:budgeted'] })`, wait for
|
|
2720
|
+
`useBlockToken().scopes` to include it, estimate again.
|
|
2721
|
+
|
|
2656
2722
|
### Testing overlay elements
|
|
2657
2723
|
|
|
2658
2724
|
`<civitai-menu>`, and anything else that opens a panel, live in an environment
|
|
@@ -327,8 +327,14 @@ export declare const MOCK_TRAINING_BOUNDS: {
|
|
|
327
327
|
readonly captionMaxChars: 1000;
|
|
328
328
|
readonly maxBuzzPerRun: 5000;
|
|
329
329
|
};
|
|
330
|
-
/**
|
|
331
|
-
|
|
330
|
+
/**
|
|
331
|
+
* The real server's refusal for ANY money call from a token without the spend
|
|
332
|
+
* scope: civitai's `estimateWorkflow`, `estimateWorkflowBatch`, `submitWorkflow`
|
|
333
|
+
* and the training procedures all throw `FORBIDDEN` with exactly this message
|
|
334
|
+
* BEFORE any pricing, rate-limit or budget work. ONE constant for every mock
|
|
335
|
+
* path that refuses on `consentGranted === false`, so the copies cannot drift.
|
|
336
|
+
*/
|
|
337
|
+
export declare const MOCK_SPEND_SCOPE_ERROR = "block lacks ai:write:budgeted scope";
|
|
332
338
|
/** The real server's refusal for an unknown, expired or spent quote on `RUN_TRAINING`. */
|
|
333
339
|
export declare const MOCK_TRAINING_QUOTE_GONE_ERROR = "training quote not found, expired or already used \u2014 estimate again";
|
|
334
340
|
/** The real server's refusal for a training estimate naming a dataset it does not hold. */
|
|
@@ -535,6 +541,16 @@ export interface MockHostOptions {
|
|
|
535
541
|
* real mint WITHHOLDS it until the viewer consents, so this defaults to
|
|
536
542
|
* `false` — the first token carries NO budgeted scope, and `REQUEST_CONSENT`
|
|
537
543
|
* grants it + pushes a `TOKEN_REFRESH` (the lazy-consent round-trip).
|
|
544
|
+
*
|
|
545
|
+
* 🔴 WHILE IT IS `false` THE MOCK ENFORCES THE SCOPE, as the real server does:
|
|
546
|
+
* `ESTIMATE_WORKFLOW`, `ESTIMATE_WORKFLOW_BATCH` and `SUBMIT_WORKFLOW` (and the
|
|
547
|
+
* training paths) refuse the whole call with `block lacks ai:write:budgeted
|
|
548
|
+
* scope`, in the shape the page host sends for each. `submit()` recovers on
|
|
549
|
+
* its own (its consent prompt-and-retry grants and re-sends with the same
|
|
550
|
+
* idempotency key); `estimate()` and the batch estimate do NOT retry, so a
|
|
551
|
+
* block must call `requestConsent({ scopes: ['ai:write:budgeted'] })` and
|
|
552
|
+
* estimate again once the grant lands. A test that is not ABOUT consent sets
|
|
553
|
+
* this to `true`.
|
|
538
554
|
*/
|
|
539
555
|
consentGranted?: boolean;
|
|
540
556
|
/**
|
|
@@ -173,8 +173,14 @@ function isValidTrainingDatasetItems(items) {
|
|
|
173
173
|
caption.length <= MOCK_TRAINING_BOUNDS.captionMaxChars);
|
|
174
174
|
});
|
|
175
175
|
}
|
|
176
|
-
/**
|
|
177
|
-
|
|
176
|
+
/**
|
|
177
|
+
* The real server's refusal for ANY money call from a token without the spend
|
|
178
|
+
* scope: civitai's `estimateWorkflow`, `estimateWorkflowBatch`, `submitWorkflow`
|
|
179
|
+
* and the training procedures all throw `FORBIDDEN` with exactly this message
|
|
180
|
+
* BEFORE any pricing, rate-limit or budget work. ONE constant for every mock
|
|
181
|
+
* path that refuses on `consentGranted === false`, so the copies cannot drift.
|
|
182
|
+
*/
|
|
183
|
+
export const MOCK_SPEND_SCOPE_ERROR = 'block lacks ai:write:budgeted scope';
|
|
178
184
|
/** The real server's refusal for an unknown, expired or spent quote on `RUN_TRAINING`. */
|
|
179
185
|
export const MOCK_TRAINING_QUOTE_GONE_ERROR = 'training quote not found, expired or already used — estimate again';
|
|
180
186
|
/** The real server's refusal for a training estimate naming a dataset it does not hold. */
|
|
@@ -1262,20 +1268,39 @@ export function createMockHost(options = {}) {
|
|
|
1262
1268
|
// two to diagnose from the block side.
|
|
1263
1269
|
return { workflowId: 'wf_estimate', status: 'pending' };
|
|
1264
1270
|
}
|
|
1271
|
+
// 🔴 THE SPEND-SCOPE GATE — EVERY body kind, not just training.
|
|
1272
|
+
// civitai's `estimateWorkflow` throws `FORBIDDEN` with exactly this
|
|
1273
|
+
// message as its FIRST act after authenticating the token, before any
|
|
1274
|
+
// pricing, and the page host catches the throw into
|
|
1275
|
+
// `failureSnapshot(err)` — the `'failed'` sentinel id, no `cost`. That
|
|
1276
|
+
// is the signal a block answers with `requestConsent(...)` and a retry.
|
|
1277
|
+
// Until #608 only the training arm checked it, so the refusal → consent
|
|
1278
|
+
// → retry loop was unreachable for every other estimate in `pnpm dev`.
|
|
1279
|
+
//
|
|
1280
|
+
// Sits AFTER the `failEstimate` knobs on purpose: an explicitly forced
|
|
1281
|
+
// failure is what the author asked to see, and `estimate()` is not
|
|
1282
|
+
// consent-retried, so a scope refusal ahead of it would hide the knob
|
|
1283
|
+
// behind a message the author never asked for.
|
|
1284
|
+
//
|
|
1285
|
+
// The batch path refuses the WHOLE call before it reaches this function
|
|
1286
|
+
// (see ESTIMATE_WORKFLOW_BATCH), so this branch is the single-estimate
|
|
1287
|
+
// one in practice.
|
|
1288
|
+
if (!consentGranted) {
|
|
1289
|
+
return { workflowId: 'failed', status: 'failed', error: MOCK_SPEND_SCOPE_ERROR };
|
|
1290
|
+
}
|
|
1265
1291
|
// TRAINING ESTIMATE — KIND-FAITHFUL, the one exception to the
|
|
1266
1292
|
// kind-agnostic rule above, because the real arm is not a priced
|
|
1267
|
-
// pass-through: it
|
|
1268
|
-
//
|
|
1269
|
-
//
|
|
1270
|
-
// server's message in the host's `failureSnapshot`
|
|
1293
|
+
// pass-through: it names a dataset the server must hold, refuses a
|
|
1294
|
+
// price above the per-run ceiling, and STORES a quote the run is later
|
|
1295
|
+
// checked against. (The spend scope is checked above, for every kind.)
|
|
1296
|
+
// Each refusal is the server's message in the host's `failureSnapshot`
|
|
1297
|
+
// shape.
|
|
1271
1298
|
if (body.kind === 'training') {
|
|
1272
1299
|
const refuse = (error) => ({
|
|
1273
1300
|
workflowId: 'failed',
|
|
1274
1301
|
status: 'failed',
|
|
1275
1302
|
error,
|
|
1276
1303
|
});
|
|
1277
|
-
if (!consentGranted)
|
|
1278
|
-
return refuse(MOCK_TRAINING_SCOPE_ERROR);
|
|
1279
1304
|
const imageCount = trainingDatasets.get(body.datasetId);
|
|
1280
1305
|
if (imageCount === undefined)
|
|
1281
1306
|
return refuse(MOCK_TRAINING_DATASET_GONE_ERROR);
|
|
@@ -1458,6 +1483,21 @@ export function createMockHost(options = {}) {
|
|
|
1458
1483
|
});
|
|
1459
1484
|
return;
|
|
1460
1485
|
}
|
|
1486
|
+
// 🔴 THE SPEND-SCOPE GATE, WHOLE-CALL. civitai's
|
|
1487
|
+
// `estimateWorkflowBatch` throws `FORBIDDEN` before pricing a single
|
|
1488
|
+
// cell, and the host (`estimateBatchGate.ts`) catches the throw into
|
|
1489
|
+
// `{ requestId, error: err.message }` — NOT a per-cell failure, and
|
|
1490
|
+
// no `snapshots`/`aggregate`. The SDK surfaces it as
|
|
1491
|
+
// `BatchEstimateError` code `'failed'` with this text on `hostError`.
|
|
1492
|
+
// After the request-shape checks above, because those are the host's
|
|
1493
|
+
// own gate and run before the server is consulted.
|
|
1494
|
+
if (!consentGranted) {
|
|
1495
|
+
dispatchToBlock({
|
|
1496
|
+
type: 'ESTIMATE_BATCH_RESULT',
|
|
1497
|
+
payload: { requestId, error: MOCK_SPEND_SCOPE_ERROR },
|
|
1498
|
+
});
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1461
1501
|
const snapshots = bodies.map((cell, index) => {
|
|
1462
1502
|
// The host's snapshot for a cell its call budget ran out on.
|
|
1463
1503
|
if (gen.batchEstimateTimedOutCells?.includes(index)) {
|
|
@@ -1510,13 +1550,14 @@ export function createMockHost(options = {}) {
|
|
|
1510
1550
|
// the largest-wallet stamp. No recipe-registry validation.
|
|
1511
1551
|
submitCount += 1;
|
|
1512
1552
|
const body = typed.payload?.body ?? {};
|
|
1513
|
-
//
|
|
1514
|
-
//
|
|
1515
|
-
//
|
|
1516
|
-
//
|
|
1517
|
-
// (
|
|
1518
|
-
//
|
|
1519
|
-
|
|
1553
|
+
// CAUGHT-SERVER-EXCEPTION simulation (`generation.failSubmitException`).
|
|
1554
|
+
// Reproduces the host's `failureSnapshot(err)` byte-for-byte: the
|
|
1555
|
+
// 'failed' sentinel id and NO `cost`, which is what separates an
|
|
1556
|
+
// errored submit from a priced budget rejection
|
|
1557
|
+
// (civitai/civitai-app-starters#251). Checked FIRST — a forced
|
|
1558
|
+
// host-side throw pre-empts every server-side decision below,
|
|
1559
|
+
// including the spend-scope gate.
|
|
1560
|
+
if (gen.failSubmitException === true) {
|
|
1520
1561
|
dispatchToBlock({
|
|
1521
1562
|
type: 'WORKFLOW_SUBMITTED',
|
|
1522
1563
|
payload: {
|
|
@@ -1524,22 +1565,39 @@ export function createMockHost(options = {}) {
|
|
|
1524
1565
|
snapshot: {
|
|
1525
1566
|
workflowId: 'failed',
|
|
1526
1567
|
status: 'failed',
|
|
1527
|
-
error:
|
|
1568
|
+
error: gen.failSubmitExceptionMessage ?? 'mock: submit failed',
|
|
1528
1569
|
},
|
|
1529
1570
|
},
|
|
1530
1571
|
});
|
|
1531
1572
|
return;
|
|
1532
1573
|
}
|
|
1533
|
-
|
|
1534
|
-
//
|
|
1535
|
-
|
|
1536
|
-
//
|
|
1537
|
-
//
|
|
1538
|
-
// 'failed' sentinel
|
|
1539
|
-
//
|
|
1540
|
-
//
|
|
1541
|
-
//
|
|
1542
|
-
|
|
1574
|
+
// 🔴 THE SPEND-SCOPE GATE. civitai's `submitWorkflow` throws
|
|
1575
|
+
// `FORBIDDEN` with this message as its first act after
|
|
1576
|
+
// authenticating the token — before the budget, the training
|
|
1577
|
+
// confirmation, the currency resolution and any debit — and the page
|
|
1578
|
+
// host's SUBMIT_WORKFLOW handler catches the rejected mutation into
|
|
1579
|
+
// `failureSnapshot(err)`: the `'failed'` sentinel, NO `cost`. So
|
|
1580
|
+
// `submit()` rejects as `'exception'`, and `withConsentRetry` (the
|
|
1581
|
+
// token lacks `ai:write:budgeted`) prompts, this mock grants and
|
|
1582
|
+
// pushes TOKEN_REFRESH, and the SAME-KEY retry lands below. Nothing
|
|
1583
|
+
// is debited and no workflow is recorded on this arm.
|
|
1584
|
+
if (!consentGranted) {
|
|
1585
|
+
dispatchToBlock({
|
|
1586
|
+
type: 'WORKFLOW_SUBMITTED',
|
|
1587
|
+
payload: {
|
|
1588
|
+
requestId,
|
|
1589
|
+
snapshot: { workflowId: 'failed', status: 'failed', error: MOCK_SPEND_SCOPE_ERROR },
|
|
1590
|
+
},
|
|
1591
|
+
});
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
// A `kind: 'training'` body is never started by SUBMIT_WORKFLOW: the
|
|
1595
|
+
// server charges a training run only against a quote a signed-in
|
|
1596
|
+
// session confirmed, which only RUN_TRAINING's dialog records. The
|
|
1597
|
+
// server's own refusal, in the host's `failureSnapshot` shape.
|
|
1598
|
+
// (`useBuzzWorkflow().submit()` refuses before sending; this covers a
|
|
1599
|
+
// raw transport send.)
|
|
1600
|
+
if (body.kind === 'training') {
|
|
1543
1601
|
dispatchToBlock({
|
|
1544
1602
|
type: 'WORKFLOW_SUBMITTED',
|
|
1545
1603
|
payload: {
|
|
@@ -1547,12 +1605,15 @@ export function createMockHost(options = {}) {
|
|
|
1547
1605
|
snapshot: {
|
|
1548
1606
|
workflowId: 'failed',
|
|
1549
1607
|
status: 'failed',
|
|
1550
|
-
error:
|
|
1608
|
+
error: 'this training run has not been confirmed by the viewer in this session',
|
|
1551
1609
|
},
|
|
1552
1610
|
},
|
|
1553
1611
|
});
|
|
1554
1612
|
return;
|
|
1555
1613
|
}
|
|
1614
|
+
const cost = costFor(body);
|
|
1615
|
+
// What the viewer pays: the generation plus the app fee.
|
|
1616
|
+
const charged = cost + (authorFeeFor(body) ?? 0);
|
|
1556
1617
|
// Disallowed-account path (content-rating clamp): the real backend
|
|
1557
1618
|
// rejects a picked pool outside the app's maturity policy at the
|
|
1558
1619
|
// currency-resolution boundary — BEFORE any Buzz spend — so this is
|
|
@@ -2111,7 +2172,7 @@ export function createMockHost(options = {}) {
|
|
|
2111
2172
|
return reply({ error: 'invalid training dataset' });
|
|
2112
2173
|
}
|
|
2113
2174
|
if (!consentGranted)
|
|
2114
|
-
return reply({ error:
|
|
2175
|
+
return reply({ error: MOCK_SPEND_SCOPE_ERROR });
|
|
2115
2176
|
if (trainingDatasetError !== undefined)
|
|
2116
2177
|
return reply({ error: trainingDatasetError });
|
|
2117
2178
|
const named = new Set(items.map((it) => it.imageId));
|
|
@@ -2154,7 +2215,7 @@ export function createMockHost(options = {}) {
|
|
|
2154
2215
|
return reply({ error: 'invalid training request' });
|
|
2155
2216
|
}
|
|
2156
2217
|
if (!consentGranted)
|
|
2157
|
-
return reply({ error:
|
|
2218
|
+
return reply({ error: MOCK_SPEND_SCOPE_ERROR });
|
|
2158
2219
|
const quote = trainingQuotes.get(b.quoteId);
|
|
2159
2220
|
if (!quote || quote.spent || Date.now() >= quote.expiresAtMs) {
|
|
2160
2221
|
return reply({ error: MOCK_TRAINING_QUOTE_GONE_ERROR });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@civitai/blocks-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.0",
|
|
4
4
|
"description": "React hooks and iframe transport for Civitai Apps. Pairs with @civitai/app-sdk/blocks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"node": ">=20"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@civitai/
|
|
41
|
-
"@civitai/
|
|
40
|
+
"@civitai/theme": "^0.5.2",
|
|
41
|
+
"@civitai/components": "^0.9.3"
|
|
42
42
|
},
|
|
43
43
|
"comment-peerDependencies": "This floor is DERIVED, not chosen — the lowest published app-sdk that exports every symbol this package imports. Do not edit it without reading ./PEER_FLOOR.md (in-repo, unpublished) and tests/guards/blocks-react-peer-floor.test.mjs.",
|
|
44
44
|
"peerDependencies": {
|