@civitai/blocks-react 0.60.0 → 0.61.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 +242 -6
- package/dist/hooks/consentRetryOptions.d.ts +40 -0
- package/dist/hooks/consentRetryOptions.js +2 -0
- package/dist/hooks/useBuzzWorkflow.d.ts +23 -1
- package/dist/hooks/useBuzzWorkflow.js +125 -44
- package/dist/hooks/useCheckpointPicker.d.ts +43 -10
- package/dist/hooks/useCheckpointPicker.js +37 -6
- package/dist/hooks/useCivitaiNavigate.d.ts +57 -6
- package/dist/hooks/useCivitaiNavigate.js +50 -7
- package/dist/hooks/useCivitaiRoute.d.ts +63 -0
- package/dist/hooks/useCivitaiRoute.js +71 -0
- package/dist/hooks/useCreatePostFromApp.d.ts +2 -1
- package/dist/hooks/useCreatePostFromApp.js +87 -34
- package/dist/hooks/useGoodPurchase.d.ts +2 -1
- package/dist/hooks/useGoodPurchase.js +64 -19
- package/dist/hooks/useRequestConsent.js +10 -12
- package/dist/hooks/useResourcePicker.d.ts +56 -7
- package/dist/hooks/useResourcePicker.js +27 -3
- package/dist/hooks/useTip.d.ts +2 -1
- package/dist/hooks/useTip.js +99 -20
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/internal/liveHost.js +238 -7
- package/dist/internal/mockHost.js +67 -9
- package/dist/internal/withConsentRetry.d.ts +239 -0
- package/dist/internal/withConsentRetry.js +457 -0
- package/dist/transport/iframeTransport.d.ts +33 -0
- package/dist/transport/iframeTransport.js +56 -0
- package/dist/transport/validate.d.ts +25 -0
- package/dist/transport/validate.js +31 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -123,7 +123,9 @@ const { ready, context, viewer, theme, settings, blockId, blockInstanceId, appId
|
|
|
123
123
|
```
|
|
124
124
|
|
|
125
125
|
- `context` — `BlockContext` (`{ slotId, … }`); narrow to `ModelSlotContext` for
|
|
126
|
-
model-page slots.
|
|
126
|
+
model-page slots. LIVE on a page slot: `context.subPath` starts at the
|
|
127
|
+
`BLOCK_INIT` value and then tracks the host's `ROUTE_CHANGED` push on every
|
|
128
|
+
navigation — see [`useCivitaiRoute()`](#usecivitairoute).
|
|
127
129
|
- `viewer` — `ViewerInfo | null` (`null` = anonymous). **Gate sign-in with
|
|
128
130
|
`isSignedIn(viewer)`** (from `@civitai/app-sdk/blocks`), never on
|
|
129
131
|
`viewer.id`/`viewer.username` (both `@deprecated`). Don't open-code the gate:
|
|
@@ -961,10 +963,31 @@ await shared.withdraw(key); // remove my own entry
|
|
|
961
963
|
|
|
962
964
|
Drive the platform Checkpoint picker + persist a viewer override.
|
|
963
965
|
|
|
966
|
+
🔴 **`baseModelGroup` is a FILTER — derive it, never hardcode it.** The host hides
|
|
967
|
+
every checkpoint outside the family you pass, so a literal ecosystem pins every
|
|
968
|
+
viewer to whichever family the author happened to test with. Read it from the
|
|
969
|
+
checkpoint the block already holds. The parameter is currently **required** by
|
|
970
|
+
this hook's type, and `''` is **not** an escape hatch — it does not even mean the
|
|
971
|
+
same thing on both hosts. On a **model slot** the host normalises whatever string
|
|
972
|
+
you send, so `''` resolves to the real ecosystem key `Other` and NARROWS to that
|
|
973
|
+
one family. On a **page** the host drops a zero-length value, so `''` behaves
|
|
974
|
+
exactly like omitting it. Neither is what you meant on at least one surface:
|
|
975
|
+
pass a family derived from a real checkpoint, and never `''`.
|
|
976
|
+
|
|
964
977
|
```tsx
|
|
978
|
+
import { isModelSlotContext } from '@civitai/app-sdk/blocks';
|
|
979
|
+
|
|
980
|
+
const { context } = useBlockContext();
|
|
965
981
|
const { open, persist } = useCheckpointPicker();
|
|
966
|
-
|
|
967
|
-
|
|
982
|
+
|
|
983
|
+
// Derive the family from the checkpoint the block already holds — never a literal.
|
|
984
|
+
if (isModelSlotContext(context) && context.checkpoint) {
|
|
985
|
+
const { selected } = await open({
|
|
986
|
+
baseModelGroup: context.checkpoint.baseModel,
|
|
987
|
+
currentVersionId: context.checkpoint.versionId,
|
|
988
|
+
});
|
|
989
|
+
if (selected) await persist(selected.versionId); // null clears the override
|
|
990
|
+
}
|
|
968
991
|
```
|
|
969
992
|
|
|
970
993
|
### `useResourcePicker()`
|
|
@@ -974,15 +997,39 @@ The viewer searches in host chrome; the block only ever sees the one resource it
|
|
|
974
997
|
picked. DISCOVERY ONLY — the returned `versionId` is re-validated + re-priced
|
|
975
998
|
server-side at estimate/submit.
|
|
976
999
|
|
|
1000
|
+
🔴 **Pass NO `baseModelGroup` by default.** It is an optional FILTER, and the host
|
|
1001
|
+
hides every resource outside the family you pass — so a hardcoded ecosystem makes
|
|
1002
|
+
the viewer's own valid LoRAs invisible and the picker look empty or broken. Omit
|
|
1003
|
+
it and the viewer sees everything of that type.
|
|
1004
|
+
|
|
977
1005
|
```tsx
|
|
978
1006
|
const { open } = useResourcePicker();
|
|
979
|
-
const picked = await open({ resourceType: 'LORA'
|
|
1007
|
+
const picked = await open({ resourceType: 'LORA' }); // unconstrained — the default
|
|
980
1008
|
if (picked) {
|
|
981
1009
|
const versionId = picked.versionId; // feed into body.additionalResources
|
|
982
1010
|
const weight = picked.strength; // recommended default weight (may be undefined)
|
|
983
1011
|
}
|
|
984
1012
|
```
|
|
985
1013
|
|
|
1014
|
+
Constrain it **only** when the block already holds a chosen checkpoint the pick has
|
|
1015
|
+
to match — and then derive the family from that checkpoint, never from a literal.
|
|
1016
|
+
🔴 **This hook is PAGE-ONLY, and a page slot has no `context.checkpoint`** — that
|
|
1017
|
+
field lives on `ModelSlotContext` alone, so the family comes from
|
|
1018
|
+
`BlockResourceInfo.baseModel`, the `baseModel` of a Checkpoint this same picker
|
|
1019
|
+
returned earlier:
|
|
1020
|
+
|
|
1021
|
+
```tsx
|
|
1022
|
+
const { open } = useResourcePicker();
|
|
1023
|
+
|
|
1024
|
+
const checkpoint = await open({ resourceType: 'Checkpoint' });
|
|
1025
|
+
if (checkpoint) {
|
|
1026
|
+
const matching = await open({
|
|
1027
|
+
resourceType: 'LORA',
|
|
1028
|
+
baseModelGroup: checkpoint.baseModel, // from the pick above — BlockResourceInfo.baseModel
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
```
|
|
1032
|
+
|
|
986
1033
|
### `useImageUpload()`
|
|
987
1034
|
|
|
988
1035
|
Host-mediated image upload — the host opens its native upload modal and the
|
|
@@ -1018,13 +1065,113 @@ const first = resources[0]; // .versionId / .strength / .trainedWord
|
|
|
1018
1065
|
|
|
1019
1066
|
### `useCivitaiNavigate()`
|
|
1020
1067
|
|
|
1021
|
-
Request a navigation
|
|
1068
|
+
Request a navigation from the host. The hook sends a `NAVIGATE` message and
|
|
1069
|
+
returns — fire-and-forget, so the block never learns what the host did,
|
|
1070
|
+
including when the host **refuses** the request.
|
|
1071
|
+
|
|
1072
|
+
`scope` selects the **space** `path` is resolved in, and it **defaults to
|
|
1073
|
+
`'app'`**:
|
|
1074
|
+
|
|
1075
|
+
| `scope` | `path` resolves | The viewer |
|
|
1076
|
+
|---|---|---|
|
|
1077
|
+
| `'app'` *(default)* | under **this app's own route**, as a sub-path of it | stays in your app; the page stays mounted |
|
|
1078
|
+
| `'site'` | at the **civitai.com root** | leaves your app for a site page |
|
|
1079
|
+
|
|
1080
|
+
> 🔴 **A leading slash carries no meaning.** The host normalises it away in
|
|
1081
|
+
> **both** scopes, so `'/settings'` and `'settings'` are one request within
|
|
1082
|
+
> whichever scope you chose. That means `navigate('/models/12345')` asks for
|
|
1083
|
+
> **your app's** `/models/12345` — *not* civitai's model page. To reach the
|
|
1084
|
+
> civitai.com page, say so: `navigate('models/12345', { scope: 'site' })`.
|
|
1085
|
+
>
|
|
1086
|
+
> Both spellings were app-scoped before `scope` existed, so no call you have
|
|
1087
|
+
> already written changed meaning — that is the point of the default.
|
|
1088
|
+
|
|
1089
|
+
`'site'` is granted **per-surface**: the public run page and the dev tunnel allow
|
|
1090
|
+
it, and a private run or a moderator's review preview refuse it. A refusal is
|
|
1091
|
+
silent, so do not build a flow that needs to know it happened.
|
|
1092
|
+
|
|
1093
|
+
`target` is a REQUEST, not a guarantee. How the host acts on `'current'` vs
|
|
1094
|
+
`'new_tab'` is host-side behaviour and the host is the authority on it; this
|
|
1095
|
+
package sends the message and makes no promise about the outcome.
|
|
1096
|
+
|
|
1097
|
+
> 🔴 **Nothing in your manifest enables `'new_tab'`.** In particular, do **not**
|
|
1098
|
+
> declare `allow-popups-to-escape-sandbox`: the host intersects a manifest's
|
|
1099
|
+
> `iframe.sandbox` with a fixed allowlist that does not contain that token, so it
|
|
1100
|
+
> is dropped for every block at every trust tier and declaring it has no effect.
|
|
1101
|
+
> Earlier versions of this page said `'new_tab'` required it — that was wrong.
|
|
1022
1102
|
|
|
1023
1103
|
```tsx
|
|
1024
1104
|
const { navigate } = useCivitaiNavigate();
|
|
1025
|
-
|
|
1105
|
+
|
|
1106
|
+
// Your app's own pages — the default.
|
|
1107
|
+
navigate('settings'); // this app's /settings
|
|
1108
|
+
navigate('/settings'); // identical; the slash means nothing
|
|
1109
|
+
|
|
1110
|
+
// A civitai.com page — needs an explicit scope.
|
|
1111
|
+
navigate('models/12345', { scope: 'site' });
|
|
1112
|
+
navigate('models/12345', { scope: 'site', target: 'new_tab' });
|
|
1113
|
+
|
|
1114
|
+
// The pre-`scope` two-argument shape still works, and is still app-scoped.
|
|
1115
|
+
navigate('detail/7', 'new_tab');
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
An app-scoped `navigate()` is **half** of a round trip. The host owns the
|
|
1119
|
+
history, so the way your block learns where it ended up is
|
|
1120
|
+
[`useCivitaiRoute()`](#usecivitairoute) — read that next if you are routing.
|
|
1121
|
+
|
|
1122
|
+
### `useCivitaiRoute()`
|
|
1123
|
+
|
|
1124
|
+
The sub-path below your app's root that is **currently showing**. This is the
|
|
1125
|
+
other half of an app-scoped [`useCivitaiNavigate()`](#usecivitainavigate): you
|
|
1126
|
+
ask the host to move, the host pushes it shallowly so your frame stays mounted,
|
|
1127
|
+
and this is how you find out where you went.
|
|
1128
|
+
|
|
1129
|
+
```tsx
|
|
1130
|
+
function Router() {
|
|
1131
|
+
const subPath = useCivitaiRoute(); // '' on your app's index
|
|
1132
|
+
const [view, id] = subPath.split('/');
|
|
1133
|
+
return view === 'compare' ? <Compare id={id} /> : <Index />;
|
|
1134
|
+
}
|
|
1026
1135
|
```
|
|
1027
1136
|
|
|
1137
|
+
It also reports the moves you **did not** ask for: the viewer's own
|
|
1138
|
+
back/forward, and a deep link the host resolved after init.
|
|
1139
|
+
|
|
1140
|
+
Two things set the value — `BLOCK_INIT`'s `context.subPath` at mount, and the
|
|
1141
|
+
host's `ROUTE_CHANGED` push on every later change. **The first value is never a
|
|
1142
|
+
message**, which is why this is a value hook rather than an `onRouteChanged`
|
|
1143
|
+
callback: a callback alone cannot see where the block started, and a change that
|
|
1144
|
+
lands before its subscription effect runs is lost. It is the same value as
|
|
1145
|
+
`useBlockContext().context.subPath` on a page slot — reach for this when the
|
|
1146
|
+
route is all you need, and because its return type is a plain `string` instead of
|
|
1147
|
+
a field on a union you have to narrow.
|
|
1148
|
+
|
|
1149
|
+
> 🔴 **No leading slash.** The host sends the segment below your app root, so
|
|
1150
|
+
> `subPath === 'compare/42'` is the comparison that works and
|
|
1151
|
+
> `subPath === '/compare/42'` is the one that silently never matches.
|
|
1152
|
+
|
|
1153
|
+
> 🔴 **`''` is a real route — your app's index — and it is also the pre-init
|
|
1154
|
+
> value.** The two are indistinguishable from this hook alone, exactly as
|
|
1155
|
+
> `'light'` is both a real theme and [`useBlockTheme()`](#useblocktheme)'s
|
|
1156
|
+
> pre-init value. Gate on `useBlockContext().ready` if your first paint must tell
|
|
1157
|
+
> them apart.
|
|
1158
|
+
|
|
1159
|
+
> 🔴 **Read it on every render.** A block that copies the value into state once
|
|
1160
|
+
> at mount, or routes imperatively in a mount-only effect, stays on the route it
|
|
1161
|
+
> started with — the URL moves and nothing renders, which is the exact symptom
|
|
1162
|
+
> this message exists to end.
|
|
1163
|
+
|
|
1164
|
+
**Page slot only.** A model-page slot has no route of its own, so this returns
|
|
1165
|
+
`''` there and never moves. Against a host that predates `ROUTE_CHANGED` the
|
|
1166
|
+
value simply stays at the init sub-path (the old behaviour); nothing awaits the
|
|
1167
|
+
message, so there is no hang either way.
|
|
1168
|
+
|
|
1169
|
+
Exercise it locally with `pnpm dev:live`, where `navigate()` drives the real
|
|
1170
|
+
message end-to-end. `createMockHost` has **no** route control, deliberately: it
|
|
1171
|
+
does not handle `NAVIGATE` at all and has no URL to move, so a synthetic setter
|
|
1172
|
+
there would be a second, weaker way to produce a message the live host already
|
|
1173
|
+
produces from the call a block actually makes.
|
|
1174
|
+
|
|
1028
1175
|
### `useBlockAnalytics()`
|
|
1029
1176
|
|
|
1030
1177
|
Fire-and-forget event tracking into the host's analytics pipeline.
|
|
@@ -1045,8 +1192,97 @@ const { requestSignIn } = useRequestSignIn();
|
|
|
1045
1192
|
requestSignIn();
|
|
1046
1193
|
```
|
|
1047
1194
|
|
|
1195
|
+
### Automatic consent prompt-and-retry (on by default)
|
|
1196
|
+
|
|
1197
|
+
**You usually do not need to write any of this.** Since `1.0.0` the
|
|
1198
|
+
consent-gated calls below handle a missing scope themselves: the call fails, the
|
|
1199
|
+
SDK opens the host's consent dialog naming the scope the call needs, waits for
|
|
1200
|
+
the grant, and then **retries the original call once** so it resolves as if it
|
|
1201
|
+
had just worked.
|
|
1202
|
+
|
|
1203
|
+
| Hook | Call | Scope it prompts for |
|
|
1204
|
+
|---|---|---|
|
|
1205
|
+
| `useBuzzWorkflow()` | `submit()` | `ai:write:budgeted` |
|
|
1206
|
+
| `useCreatePostFromApp()` | `createPost()` | `posts:write:self` |
|
|
1207
|
+
| `useGoodPurchase()` | `purchase()` | `goods:purchase:self` |
|
|
1208
|
+
| `useTip()` | `tip()` | `social:tip:self` |
|
|
1209
|
+
|
|
1210
|
+
🔴 **`estimate()` is deliberately NOT in that table.** It is a price READ, and
|
|
1211
|
+
blocks call it from an effect keyed on the generation form — on mount, and again
|
|
1212
|
+
on every parameter change. Prompting there would open a consent dialog with no
|
|
1213
|
+
user gesture behind it, once per edit. A failed `estimate()` rejects exactly as
|
|
1214
|
+
it always has; show no price and let `submit()` do the asking.
|
|
1215
|
+
|
|
1216
|
+
```tsx
|
|
1217
|
+
// This is the whole thing. No try/catch around a consent prompt, no watching
|
|
1218
|
+
// useBlockToken().scopes, no manual retry.
|
|
1219
|
+
const { submit } = useBuzzWorkflow();
|
|
1220
|
+
const snapshot = await submit(body);
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
🔴 **The retry re-sends the FIRST attempt's `idempotencyKey`.** That is what
|
|
1224
|
+
makes it safe on the money paths: `submit()`, `purchase()` and `tip()` mint the
|
|
1225
|
+
key once, before the first attempt, so however many attempts one call makes the
|
|
1226
|
+
server sees **one** logical operation and charges once. A retry with a fresh key
|
|
1227
|
+
would be a second reservation against the viewer's Buzz.
|
|
1228
|
+
|
|
1229
|
+
It **never** retries when:
|
|
1230
|
+
|
|
1231
|
+
- the token already holds every scope the call needs (so the failure was not
|
|
1232
|
+
about consent — a rate limit, a 5xx, a bad body all behave exactly as before);
|
|
1233
|
+
- the call failed **before `BLOCK_INIT`** landed. There is no real token yet, so
|
|
1234
|
+
"the token is missing this scope" is not a fact about consent;
|
|
1235
|
+
- the host has pushed `CONSENT_UNAVAILABLE` **naming a scope this call needs** —
|
|
1236
|
+
that scope can never be granted here, so a retry is a guaranteed second
|
|
1237
|
+
failure. A refusal that names *other* scopes leaves this call alone, and one
|
|
1238
|
+
that names **none** (the payload's `scopes` is documented as advisory and may
|
|
1239
|
+
be empty) is treated as covering everything, which is the safe reading;
|
|
1240
|
+
- the viewer dismissed a host confirm (`declined`), or there is no session to
|
|
1241
|
+
grant anything to (`signInRequired` — route that into `useRequestSignIn()`);
|
|
1242
|
+
- the component **unmounted** — including *during* the 60 s wait, so a grant that
|
|
1243
|
+
arrives after your component is gone does not spend anything;
|
|
1244
|
+
- the request timed out on a bridge with **no idempotency key** (`createPost()`,
|
|
1245
|
+
collection follow) — the retry would be a genuine second write, i.e. a second
|
|
1246
|
+
public post. A timeout on a call that HAS a key — `submit()`, `purchase()`,
|
|
1247
|
+
`tip()` — *is* retried, because the retry re-sends that key and the server
|
|
1248
|
+
collapses the two into one operation;
|
|
1249
|
+
- **a second time.** One retry, never a loop. A second consent failure surfaces
|
|
1250
|
+
to you unchanged.
|
|
1251
|
+
|
|
1252
|
+
If the viewer never answers the dialog, the **original** error is re-thrown after
|
|
1253
|
+
60 s and your `catch` sees exactly what it would have seen before.
|
|
1254
|
+
|
|
1255
|
+
⚠️ **One failure is deliberately NOT excluded:** a host refusal about the
|
|
1256
|
+
*payload* rather than the token — `createPost()`'s `'no images to post'` — still
|
|
1257
|
+
prompts, because nothing distinguishes it structurally from a genuine consent
|
|
1258
|
+
failure without string-matching server copy. The cost is one needless dialog; the
|
|
1259
|
+
retry re-opens the host's own confirm and cannot publish anything new.
|
|
1260
|
+
|
|
1261
|
+
**Concurrent callers share one dialog.** `N` calls that are in flight together and
|
|
1262
|
+
need the same scope post **one** `REQUEST_CONSENT` and wait on it together — a
|
|
1263
|
+
feed of tip buttons does not open a dialog per button. Each call still retries its
|
|
1264
|
+
**own** request with its **own** idempotency key, so *N* tips remain *N* transfers.
|
|
1265
|
+
⚠️ This is de-duplication of *concurrent* waits only: calls made one after another
|
|
1266
|
+
each get their own prompt, which is the shape that keeps `estimate()` out of the
|
|
1267
|
+
table above.
|
|
1268
|
+
|
|
1269
|
+
Opt out per call — the same single option on every hook above:
|
|
1270
|
+
|
|
1271
|
+
```tsx
|
|
1272
|
+
await submit(body, { autoRequestConsent: false }); // pre-1.0 behaviour
|
|
1273
|
+
```
|
|
1274
|
+
|
|
1275
|
+
⚠️ **Not covered:** a scope your **manifest** never declared can never be granted
|
|
1276
|
+
either, and a block cannot see its own manifest at runtime — so that case is
|
|
1277
|
+
caught one round-trip late, by the host's `CONSENT_UNAVAILABLE`, rather than
|
|
1278
|
+
before the first attempt. Declare the scopes your app uses.
|
|
1279
|
+
|
|
1048
1280
|
### `useRequestConsent()`
|
|
1049
1281
|
|
|
1282
|
+
The manual version of the above — still exported, still the right tool when you
|
|
1283
|
+
want to prompt *before* a call (e.g. on an onboarding screen) rather than after
|
|
1284
|
+
one fails.
|
|
1285
|
+
|
|
1050
1286
|
Lazy consent: ask the host to open its consent UI when a LOGGED-IN viewer takes
|
|
1051
1287
|
an action whose consent-gated scope the block token is missing (e.g. Generate
|
|
1052
1288
|
needs `ai:write:budgeted` but the viewer hasn't granted it). Fire-and-forget —
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, PUBLIC options for the SDK's automatic consent prompt-and-retry.
|
|
3
|
+
*
|
|
4
|
+
* Declared here rather than beside the mechanics in
|
|
5
|
+
* `internal/withConsentRetry.ts` for one structural reason: `src/index.ts` may
|
|
6
|
+
* not reach a module under `internal/` (#378, enforced by
|
|
7
|
+
* `tests/guards/blocks-react-entry-directory-names.test.mjs`, which follows
|
|
8
|
+
* re-export edges transitively). A caller has to be able to SPELL this type in
|
|
9
|
+
* its own signatures, so the type is public and the mechanics stay private.
|
|
10
|
+
*
|
|
11
|
+
* Every consent-gated call reaches this field through the ONE declaration here
|
|
12
|
+
* — `SubmitWorkflowOptions`, `GoodPurchaseOptions` and `TipOptions` EXTEND it
|
|
13
|
+
* (they have money fields of their own); `createPost()` takes it directly. A
|
|
14
|
+
* predicate — or an option name — open-coded at N sites is typically wrong at
|
|
15
|
+
* N-1 of them.
|
|
16
|
+
*
|
|
17
|
+
* 🔴 ONE FIELD, DELIBERATELY. A second, `consentTimeoutMs`, was cut in #500
|
|
18
|
+
* round 1: it had no consumer outside this package and its only demonstrated
|
|
19
|
+
* use was shortening the 60s wait inside a TEST. A test seam does not belong on
|
|
20
|
+
* five public signatures — the test now uses fake timers instead, and the wait
|
|
21
|
+
* bound is the non-public `CONSENT_GRANT_WAIT_MS`. Adding a knob here commits
|
|
22
|
+
* the package to it forever; do not add one without a caller that needs it.
|
|
23
|
+
*/
|
|
24
|
+
export interface ConsentRetryOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Whether a consent-gated failure should automatically open the host's
|
|
27
|
+
* consent dialog and, on grant, retry the call ONCE.
|
|
28
|
+
*
|
|
29
|
+
* **Defaults to `true`** — the good behaviour is the default one. Set `false`
|
|
30
|
+
* to get the pre-1.0 behaviour: the original error is re-thrown unchanged
|
|
31
|
+
* and nothing is prompted.
|
|
32
|
+
*
|
|
33
|
+
* 🔴 THE RETRY REUSES THE FIRST ATTEMPT'S IDEMPOTENCY KEY on every money path
|
|
34
|
+
* that has one (`submit`, `purchase`, `tip`). That is what makes it safe: a
|
|
35
|
+
* retry with a FRESH key is a SECOND reservation against the viewer's Buzz.
|
|
36
|
+
* See `internal/withConsentRetry.ts`.
|
|
37
|
+
*/
|
|
38
|
+
autoRequestConsent?: boolean;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=consentRetryOptions.d.ts.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BlockWorkflowSnapshot, WorkflowBody, WorkflowStatus } from '@civitai/app-sdk/blocks';
|
|
2
|
+
import type { ConsentRetryOptions } from './consentRetryOptions.js';
|
|
2
3
|
/**
|
|
3
4
|
* Default orchestrator-side hold per {@link UseBuzzWorkflow.watch} poll,
|
|
4
5
|
* in SECONDS.
|
|
@@ -393,7 +394,7 @@ export declare class WorkflowSubmitError extends Error {
|
|
|
393
394
|
constructor(snapshot: BlockWorkflowSnapshot, code: WorkflowSubmitErrorCode);
|
|
394
395
|
}
|
|
395
396
|
/** Optional per-submit controls. */
|
|
396
|
-
export interface SubmitWorkflowOptions {
|
|
397
|
+
export interface SubmitWorkflowOptions extends ConsentRetryOptions {
|
|
397
398
|
/**
|
|
398
399
|
* A STABLE idempotency key for this logical submit. Reuse the SAME value when
|
|
399
400
|
* RETRYING a submit whose response was lost (timeout / network drop) so the
|
|
@@ -405,6 +406,11 @@ export interface SubmitWorkflowOptions {
|
|
|
405
406
|
* SAFE. That code means a workflow probably exists and its spend may already
|
|
406
407
|
* be committed server-side; retrying WITHOUT reusing the key mints a fresh one
|
|
407
408
|
* and therefore a SECOND reservation. See {@link WorkflowSubmitError.code}.
|
|
409
|
+
*
|
|
410
|
+
* The SDK's own automatic consent retry obeys this: whichever value ends up
|
|
411
|
+
* here — yours, or the one `submit()` mints — is the value BOTH of its
|
|
412
|
+
* attempts carry. So an error you receive may already be a second attempt's;
|
|
413
|
+
* if you then retry a third time by hand, reuse this key for that too.
|
|
408
414
|
*/
|
|
409
415
|
idempotencyKey?: string;
|
|
410
416
|
}
|
|
@@ -430,6 +436,13 @@ export interface UseBuzzWorkflow {
|
|
|
430
436
|
* `result` is updated to the returned snapshot BEFORE any rejection, so a
|
|
431
437
|
* failed estimate can never leave a previous, differently-configured
|
|
432
438
|
* estimate's price sitting in `result` for a Confirm gate to read.
|
|
439
|
+
*
|
|
440
|
+
* 🔴 NO AUTOMATIC CONSENT PROMPT HERE, unlike {@link UseBuzzWorkflow.submit}.
|
|
441
|
+
* Blocks call `estimate()` from an effect keyed on the generation form, so it
|
|
442
|
+
* fires on mount and on every parameter change — prompting there would open a
|
|
443
|
+
* consent dialog with no user gesture behind it, once per edit. A missing
|
|
444
|
+
* scope surfaces as an ordinary rejection; show no price and let `submit()`
|
|
445
|
+
* do the asking.
|
|
433
446
|
*/
|
|
434
447
|
estimate: (body: WorkflowBody) => Promise<BlockWorkflowSnapshot>;
|
|
435
448
|
/**
|
|
@@ -468,6 +481,15 @@ export interface UseBuzzWorkflow {
|
|
|
468
481
|
*
|
|
469
482
|
* `result` is updated to the returned snapshot BEFORE any rejection, so a
|
|
470
483
|
* failed submit can never leave a previous submit's workflow in `result`.
|
|
484
|
+
*
|
|
485
|
+
* 🔴 CONSENT IS HANDLED FOR YOU. When the token lacks `ai:write:budgeted`,
|
|
486
|
+
* this opens the host's consent dialog, waits for the grant, and re-sends the
|
|
487
|
+
* submit ONCE — with the SAME {@link SubmitWorkflowOptions.idempotencyKey}, so
|
|
488
|
+
* the two attempts are one reservation, not two. Nothing else changes: a
|
|
489
|
+
* failure while the token DOES hold the scope is untouched, a
|
|
490
|
+
* `CONSENT_UNAVAILABLE` environment is never retried, and a second consent
|
|
491
|
+
* failure reaches you unchanged. Opt out with
|
|
492
|
+
* {@link ConsentRetryOptions.autoRequestConsent}`: false`.
|
|
471
493
|
*/
|
|
472
494
|
submit: (body: WorkflowBody, options?: SubmitWorkflowOptions) => Promise<BlockWorkflowSnapshot>;
|
|
473
495
|
/**
|
|
@@ -1,6 +1,21 @@
|
|
|
1
|
-
import { useCallback, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { BLOCK_SCOPES } from '@civitai/app-sdk/blocks';
|
|
3
|
+
import { withConsentRetry } from '../internal/withConsentRetry.js';
|
|
2
4
|
import { getTransport } from '../transport/singleton.js';
|
|
3
5
|
import { generateIdempotencyKey, sendTypedRequest } from '../transport/transport.js';
|
|
6
|
+
/**
|
|
7
|
+
* The consent-gated scope {@link UseBuzzWorkflow.submit} requires.
|
|
8
|
+
*
|
|
9
|
+
* 🔴 `submit()` ONLY. `estimate()` needs the same scope but is deliberately not
|
|
10
|
+
* routed through the automatic consent retry — see the comment on `estimate`
|
|
11
|
+
* below; it is an on-mount read with no gesture behind it.
|
|
12
|
+
*
|
|
13
|
+
* Named from {@link BLOCK_SCOPES}, never a string literal: this array is sent to
|
|
14
|
+
* the host as the `REQUEST_CONSENT` hint, and the host ignores a hint with no
|
|
15
|
+
* RECOGNISED non-empty name — so a typo here would not error, it would make the
|
|
16
|
+
* automatic prompt silently do nothing.
|
|
17
|
+
*/
|
|
18
|
+
const WORKFLOW_SCOPES = [BLOCK_SCOPES.AI_WRITE_BUDGETED];
|
|
4
19
|
/**
|
|
5
20
|
* Snapshot statuses that mean "no further polling is needed."
|
|
6
21
|
* Used by both `submit` (a host can return an instant-fail / cached result)
|
|
@@ -556,6 +571,50 @@ export function useBuzzWorkflow() {
|
|
|
556
571
|
const [status, setStatus] = useState('idle');
|
|
557
572
|
const [result, setResult] = useState(null);
|
|
558
573
|
const [error, setError] = useState(null);
|
|
574
|
+
/**
|
|
575
|
+
* Whether this hook's component is still mounted.
|
|
576
|
+
*
|
|
577
|
+
* 🔴 ITS ONLY JOB IS THE CONSENT RETRY, and that is a MONEY gate rather than a
|
|
578
|
+
* setState-after-unmount tidy-up. `submit()` can sit in `withConsentRetry`'s
|
|
579
|
+
* 60s grant wait long after the component is gone, and nothing else can see
|
|
580
|
+
* that: this hook's calls go through the postMessage bridge, so there is no
|
|
581
|
+
* `AbortController` to fire and rule 3's `AbortError` path never triggers. A
|
|
582
|
+
* grant arriving after unmount would then RESERVE BUZZ for a generation nobody
|
|
583
|
+
* is left to watch. Read immediately before the retry, never cached.
|
|
584
|
+
*/
|
|
585
|
+
const mountedRef = useRef(true);
|
|
586
|
+
useEffect(() => {
|
|
587
|
+
mountedRef.current = true;
|
|
588
|
+
return () => {
|
|
589
|
+
mountedRef.current = false;
|
|
590
|
+
};
|
|
591
|
+
}, []);
|
|
592
|
+
/**
|
|
593
|
+
* 🔴 `estimate()` IS DELIBERATELY NOT ROUTED THROUGH `withConsentRetry`, and
|
|
594
|
+
* that exclusion is load-bearing rather than an oversight.
|
|
595
|
+
*
|
|
596
|
+
* The automatic prompt is for calls a PERSON just made. `estimate()` is not
|
|
597
|
+
* one: `starters/examples/buzz-workflow/src/App.tsx` calls it from a
|
|
598
|
+
* `useEffect` keyed on the form inputs, so it fires on mount and again on
|
|
599
|
+
* every parameter edit. Routing it would open a consent dialog with no gesture
|
|
600
|
+
* behind it and hold each call pending for the full 60s grant wait. That is the
|
|
601
|
+
* same reason the Buzz READS are excluded; `estimate()` just happens to live on
|
|
602
|
+
* a hook whose OTHER call moves money.
|
|
603
|
+
*
|
|
604
|
+
* ⚠️ The in-flight DE-DUPLICATION `withConsentRetry` gained in #500 round 2
|
|
605
|
+
* does not change this verdict, and reading it as a reason to route
|
|
606
|
+
* `estimate()` would be a mistake. It collapses CONCURRENT waits on the same
|
|
607
|
+
* scope set into one dialog; a form edited over several seconds produces
|
|
608
|
+
* SEQUENTIAL calls, each after the previous wait settled, so the N-dialogs
|
|
609
|
+
* problem survives for exactly this shape. The no-gesture objection is
|
|
610
|
+
* independent of it either way.
|
|
611
|
+
*
|
|
612
|
+
* A failed estimate keeps the behaviour that starter's own `catch` is written
|
|
613
|
+
* against: reject with the server's reason, and show no price, because a
|
|
614
|
+
* missing quote is not viewer-actionable copy. `submit()` IS routed, and that
|
|
615
|
+
* is where both the gesture and the charge are. Pinned by
|
|
616
|
+
* `test/withConsentRetry.test.tsx`.
|
|
617
|
+
*/
|
|
559
618
|
const estimate = useCallback(async (body) => {
|
|
560
619
|
setError(null);
|
|
561
620
|
setStatus('estimating');
|
|
@@ -606,62 +665,84 @@ export function useBuzzWorkflow() {
|
|
|
606
665
|
throw err;
|
|
607
666
|
}
|
|
608
667
|
}, []);
|
|
668
|
+
/**
|
|
669
|
+
* ONE submit round-trip AND its result contract.
|
|
670
|
+
*
|
|
671
|
+
* 🔴 `idempotencyKey` IS A PARAMETER, NOT MINTED HERE, AND THAT IS THE MONEY
|
|
672
|
+
* SAFETY PROPERTY OF THIS WHOLE FILE. `submit` mints it ONCE, above the
|
|
673
|
+
* consent retry, and passes the same value into both invocations of this
|
|
674
|
+
* function. Minting it here instead would give the automatic retry a FRESH
|
|
675
|
+
* key — a SECOND Buzz reservation for one logical submit, which is exactly
|
|
676
|
+
* what {@link SubmitWorkflowOptions.idempotencyKey}'s docs forbid.
|
|
677
|
+
*/
|
|
678
|
+
const submitOnce = useCallback(async (body, idempotencyKey) => {
|
|
679
|
+
const { snapshot } = await sendTypedRequest(getTransport(), { type: 'SUBMIT_WORKFLOW', payload: { body, idempotencyKey } }, 'WORKFLOW_SUBMITTED', { timeoutMs: WORKFLOW_REQUEST_TIMEOUT_MS });
|
|
680
|
+
// 🔴 PUBLISH THE SNAPSHOT BEFORE THE REJECTION BELOW, for the same reason
|
|
681
|
+
// `estimate` does: a block may render from `result` rather than from the
|
|
682
|
+
// returned value, and jumping over this line would leave the PREVIOUS
|
|
683
|
+
// submit's snapshot in place — a live control pointing at a workflow THIS
|
|
684
|
+
// submit did not queue.
|
|
685
|
+
setResult(snapshot);
|
|
686
|
+
// 🔴 AN ERRORED SUBMIT MUST REJECT (civitai/civitai-app-starters#251, the
|
|
687
|
+
// `submit` half of civitai/civitai#4159). Two producers report
|
|
688
|
+
// `status:'failed'` and `status` separates neither:
|
|
689
|
+
//
|
|
690
|
+
// - a budget / spend-cap REJECTION is an OUTCOME the block recovers from
|
|
691
|
+
// (open a top-up flow). The server quotes the price it refused to
|
|
692
|
+
// charge, so `cost.total` is present. It RESOLVES — turning this arm
|
|
693
|
+
// into a throw is the one change that would break the recovery path.
|
|
694
|
+
// - a failure-shaped reply with NO `cost` is not a usable outcome. It
|
|
695
|
+
// REJECTS — and the `code` says which kind, because they differ on
|
|
696
|
+
// whether money moved (see WorkflowSubmitError.code).
|
|
697
|
+
//
|
|
698
|
+
// BOTH clauses are load-bearing. Dropping `status === 'failed'` would
|
|
699
|
+
// reject every ordinary in-flight reply (`{status:'pending'}` is cost-less
|
|
700
|
+
// too); dropping the cost test would reject the budget rejection. And the
|
|
701
|
+
// test is `typeof … !== 'number'`, never `!snapshot.cost?.total`: `0` is a
|
|
702
|
+
// real price and falsy.
|
|
703
|
+
//
|
|
704
|
+
// 🔴 `status === 'failed'`, NOT `TERMINAL_STATUSES.has(status)`. Widening it
|
|
705
|
+
// would reject cost-less `succeeded`/`canceled`/`expired` replies, which are
|
|
706
|
+
// legitimate outcomes the server really does emit without a price
|
|
707
|
+
// (`snapshotFromWorkflow` omits `cost` on any non-numeric total). That
|
|
708
|
+
// WIDENING is invisible to a mutation sweep that only deletes clauses, so
|
|
709
|
+
// all three statuses are pinned by their own fixtures in the test file.
|
|
710
|
+
if (snapshot.status === 'failed' && typeof snapshot.cost?.total !== 'number') {
|
|
711
|
+
// 🔴 WHICH ARM: the host stamps its own synthesised failures with the
|
|
712
|
+
// `'failed'` sentinel, while a server-built reply carries `workflow.id`
|
|
713
|
+
// (or the `'whatif'` sentinel).
|
|
714
|
+
// Anything unrecognised falls to `'workflow-failed'`, the arm that assumes
|
|
715
|
+
// money MAY be committed — an unknown id must never buy the reassuring
|
|
716
|
+
// "nothing was charged" reading.
|
|
717
|
+
throw new WorkflowSubmitError(snapshot, snapshot.workflowId === HOST_SYNTHESISED_WORKFLOW_ID ? 'exception' : 'workflow-failed');
|
|
718
|
+
}
|
|
719
|
+
setStatus(TERMINAL_STATUSES.has(snapshot.status) ? 'done' : 'polling');
|
|
720
|
+
return snapshot;
|
|
721
|
+
}, []);
|
|
609
722
|
const submit = useCallback(async (body, options) => {
|
|
610
723
|
setError(null);
|
|
611
724
|
setStatus('submitting');
|
|
612
725
|
// Idempotency: reuse a caller-supplied stable key across a retry (→ one Buzz
|
|
613
726
|
// charge), or mint a fresh one per call (each call is a new logical submit).
|
|
727
|
+
//
|
|
728
|
+
// 🔴 MINTED HERE, OUTSIDE THE CLOSURE `withConsentRetry` RE-INVOKES. Both
|
|
729
|
+
// attempts therefore carry the SAME key and the host+orchestrator collapse
|
|
730
|
+
// them to ONE reservation. Move this line inside `submitOnce` and an
|
|
731
|
+
// automatic retry double-reserves a real person's Buzz — the single
|
|
732
|
+
// regression this feature exists to not have.
|
|
614
733
|
const idempotencyKey = options?.idempotencyKey ?? generateIdempotencyKey();
|
|
615
734
|
try {
|
|
616
|
-
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
|
|
620
|
-
// submit's snapshot in place — a live control pointing at a workflow THIS
|
|
621
|
-
// submit did not queue.
|
|
622
|
-
setResult(snapshot);
|
|
623
|
-
// 🔴 AN ERRORED SUBMIT MUST REJECT (civitai/civitai-app-starters#251, the
|
|
624
|
-
// `submit` half of civitai/civitai#4159). Two producers report
|
|
625
|
-
// `status:'failed'` and `status` separates neither:
|
|
626
|
-
//
|
|
627
|
-
// - a budget / spend-cap REJECTION is an OUTCOME the block recovers from
|
|
628
|
-
// (open a top-up flow). The server quotes the price it refused to
|
|
629
|
-
// charge, so `cost.total` is present. It RESOLVES — turning this arm
|
|
630
|
-
// into a throw is the one change that would break the recovery path.
|
|
631
|
-
// - a failure-shaped reply with NO `cost` is not a usable outcome. It
|
|
632
|
-
// REJECTS — and the `code` says which kind, because they differ on
|
|
633
|
-
// whether money moved (see WorkflowSubmitError.code).
|
|
634
|
-
//
|
|
635
|
-
// BOTH clauses are load-bearing. Dropping `status === 'failed'` would
|
|
636
|
-
// reject every ordinary in-flight reply (`{status:'pending'}` is cost-less
|
|
637
|
-
// too); dropping the cost test would reject the budget rejection. And the
|
|
638
|
-
// test is `typeof … !== 'number'`, never `!snapshot.cost?.total`: `0` is a
|
|
639
|
-
// real price and falsy.
|
|
640
|
-
//
|
|
641
|
-
// 🔴 `status === 'failed'`, NOT `TERMINAL_STATUSES.has(status)`. Widening it
|
|
642
|
-
// would reject cost-less `succeeded`/`canceled`/`expired` replies, which are
|
|
643
|
-
// legitimate outcomes the server really does emit without a price
|
|
644
|
-
// (`snapshotFromWorkflow` omits `cost` on any non-numeric total). That
|
|
645
|
-
// WIDENING is invisible to a mutation sweep that only deletes clauses, so
|
|
646
|
-
// all three statuses are pinned by their own fixtures in the test file.
|
|
647
|
-
if (snapshot.status === 'failed' && typeof snapshot.cost?.total !== 'number') {
|
|
648
|
-
// 🔴 WHICH ARM: the host stamps its own synthesised failures with the
|
|
649
|
-
// `'failed'` sentinel, while a server-built reply carries `workflow.id`
|
|
650
|
-
// (or the `'whatif'` sentinel).
|
|
651
|
-
// Anything unrecognised falls to `'workflow-failed'`, the arm that assumes
|
|
652
|
-
// money MAY be committed — an unknown id must never buy the reassuring
|
|
653
|
-
// "nothing was charged" reading.
|
|
654
|
-
throw new WorkflowSubmitError(snapshot, snapshot.workflowId === HOST_SYNTHESISED_WORKFLOW_ID ? 'exception' : 'workflow-failed');
|
|
655
|
-
}
|
|
656
|
-
setStatus(TERMINAL_STATUSES.has(snapshot.status) ? 'done' : 'polling');
|
|
657
|
-
return snapshot;
|
|
735
|
+
return await withConsentRetry(getTransport(), WORKFLOW_SCOPES, () => submitOnce(body, idempotencyKey), options,
|
|
736
|
+
// Rule 3 in the time axis — see `mountedRef` above for why this hook
|
|
737
|
+
// needs it even though it has no `AbortController`.
|
|
738
|
+
() => mountedRef.current);
|
|
658
739
|
}
|
|
659
740
|
catch (err) {
|
|
660
741
|
setError(err);
|
|
661
742
|
setStatus('error');
|
|
662
743
|
throw err;
|
|
663
744
|
}
|
|
664
|
-
}, []);
|
|
745
|
+
}, [submitOnce]);
|
|
665
746
|
/**
|
|
666
747
|
* ONE poll round-trip, with an optional long-poll hint. The single place that
|
|
667
748
|
* builds a `POLL_WORKFLOW` message, so `poll` and `watch` cannot drift on the
|