@civitai/blocks-react 0.67.1 → 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 +357 -8
- 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/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 +2 -2
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
import { WORKFLOW_REQUEST_TIMEOUT_MS } from '../transport/requestTimeouts.js';
|
|
3
|
+
import { getTransport } from '../transport/singleton.js';
|
|
4
|
+
import { RequestTimeoutError, sendTypedRequest } from '../transport/transport.js';
|
|
5
|
+
import { WorkflowEstimateError } from './useBuzzWorkflow.js';
|
|
6
|
+
import { useRequestSequencer } from './useRequestSequencer.js';
|
|
7
|
+
/**
|
|
8
|
+
* The most bodies one {@link UseBatchEstimate.estimateBatch} call may carry.
|
|
9
|
+
*
|
|
10
|
+
* The host's own cap (`BLOCK_ESTIMATE_BATCH_MAX_CELLS` in civitai/civitai). The
|
|
11
|
+
* hook refuses a longer list before sending it, with the code the host would
|
|
12
|
+
* have answered with a round trip later. A larger grid is priced in several
|
|
13
|
+
* calls; each call is still one request against the estimate allowance.
|
|
14
|
+
*/
|
|
15
|
+
export const BATCH_ESTIMATE_MAX_CELLS = 16;
|
|
16
|
+
/**
|
|
17
|
+
* The host's generic reply to a request it registers no handler for. The copy is
|
|
18
|
+
* the host's (`BRIDGE_NACK_NO_HANDLER` in civitai/civitai), matched here ONCE so
|
|
19
|
+
* no app has to match it: branch on {@link BatchEstimateError.code} instead.
|
|
20
|
+
*/
|
|
21
|
+
const HOST_UNSUPPORTED_REPLY = 'unsupported on this host';
|
|
22
|
+
/**
|
|
23
|
+
* The host's `snapshot.error` for a batch cell its time budget ran out on before
|
|
24
|
+
* the cell priced. The copy is the host's (`BLOCK_ESTIMATE_BATCH_TIMEOUT_ERROR`
|
|
25
|
+
* in civitai/civitai `src/server/routers/blocks.router.ts`). This is the ONE
|
|
26
|
+
* place the SDK spells it: the hook maps it to {@link BatchEstimateCell}'s
|
|
27
|
+
* `timedOut`, and the mock host answers with it. Not exported from the package:
|
|
28
|
+
* apps branch on `timedOut`, never on this string.
|
|
29
|
+
*/
|
|
30
|
+
export const BATCH_ESTIMATE_CELL_TIMEOUT_ERROR = 'estimate timed out';
|
|
31
|
+
/**
|
|
32
|
+
* Thrown by {@link UseBatchEstimate.estimateBatch} when the WHOLE call failed.
|
|
33
|
+
*
|
|
34
|
+
* One cell failing is NOT this: a cell that could not be priced comes back as an
|
|
35
|
+
* `ok: false` entry in {@link BatchEstimateResult.cells} and the call resolves.
|
|
36
|
+
*
|
|
37
|
+
* Same three-audience split as `WorkflowEstimateError`:
|
|
38
|
+
* - {@link BatchEstimateError.code} is what you branch on.
|
|
39
|
+
* - {@link BatchEstimateError.hostError} is the host's or server's own text. It
|
|
40
|
+
* is server-authored and unsanitised: log it, never render it.
|
|
41
|
+
* - `message` is a generic developer-facing summary whose wording is not a
|
|
42
|
+
* contract.
|
|
43
|
+
*/
|
|
44
|
+
export class BatchEstimateError extends Error {
|
|
45
|
+
code;
|
|
46
|
+
/** The host's or server's reason, verbatim, when it gave one. */
|
|
47
|
+
hostError;
|
|
48
|
+
constructor(code, hostError) {
|
|
49
|
+
super(`batch estimate failed (${code})${hostError === undefined ? '' : ' — reason on .hostError'}`);
|
|
50
|
+
this.name = 'BatchEstimateError';
|
|
51
|
+
this.code = code;
|
|
52
|
+
if (hostError !== undefined)
|
|
53
|
+
this.hostError = hostError;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function toCell(snapshot) {
|
|
57
|
+
// The rule `useBuzzWorkflow().estimate()` applies to one reply, per cell.
|
|
58
|
+
if (snapshot.status === 'failed') {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: new WorkflowEstimateError(snapshot, 'failed'),
|
|
62
|
+
// The one place the host's per-cell timeout string is matched.
|
|
63
|
+
timedOut: snapshot.error === BATCH_ESTIMATE_CELL_TIMEOUT_ERROR,
|
|
64
|
+
snapshot,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const cost = snapshot.cost;
|
|
68
|
+
if (!cost || typeof cost.total !== 'number') {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: new WorkflowEstimateError(snapshot, 'no-cost'),
|
|
72
|
+
timedOut: false,
|
|
73
|
+
snapshot,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, cost, snapshot };
|
|
77
|
+
}
|
|
78
|
+
function toBatchEstimateError(err) {
|
|
79
|
+
if (err instanceof BatchEstimateError)
|
|
80
|
+
return err;
|
|
81
|
+
// Structural, not a message match — see `RequestTimeoutError`.
|
|
82
|
+
if (err instanceof RequestTimeoutError)
|
|
83
|
+
return new BatchEstimateError('timeout', err.message);
|
|
84
|
+
return new BatchEstimateError('failed', err instanceof Error ? err.message : String(err));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Price a grid of generations in ONE request: a list of workflow bodies in, one
|
|
88
|
+
* result per body (a cost, or that cell's error) plus a run total out. Backed by
|
|
89
|
+
* the `ESTIMATE_WORKFLOW_BATCH` → `ESTIMATE_BATCH_RESULT` bridge.
|
|
90
|
+
*
|
|
91
|
+
* Use it where an app would otherwise call `useBuzzWorkflow().estimate()` once
|
|
92
|
+
* per cell. Every rule a single estimate enforces still applies to each cell;
|
|
93
|
+
* what changes is the accounting and the round trips:
|
|
94
|
+
*
|
|
95
|
+
* - **One request against the estimate allowance**, whatever the number of
|
|
96
|
+
* cells. On a page app that allowance is shared by every viewer of the app and
|
|
97
|
+
* by the host's other reads for it, so a 16-cell grid used to take 16 of it per
|
|
98
|
+
* pricing pass.
|
|
99
|
+
* - **Cells are metered separately.** The host also counts the cells of every
|
|
100
|
+
* batch against an allowance of their own, shared like the estimate allowance
|
|
101
|
+
* (on a page app, by every viewer of the app), so a batch is not a way around
|
|
102
|
+
* it. A call over either is refused whole (`code: 'failed'`, the host's
|
|
103
|
+
* rate-limit text on `hostError`); retry in a few seconds.
|
|
104
|
+
* - **At most {@link BATCH_ESTIMATE_MAX_CELLS} bodies per call.**
|
|
105
|
+
* - **`kind: 'training'` bodies are refused per cell.** Estimate a training run
|
|
106
|
+
* with `useBuzzWorkflow().estimate()`.
|
|
107
|
+
*
|
|
108
|
+
* 🔴 ESTIMATE ONLY. There is no batch submit, watch or cancel and no id for the
|
|
109
|
+
* list. The total is a quote: nothing reserves it and each cell is priced again
|
|
110
|
+
* at `submit()`.
|
|
111
|
+
*
|
|
112
|
+
* Like `estimate()`, this does NOT prompt for consent. It needs the
|
|
113
|
+
* `ai:write:budgeted` scope; without it the call rejects with `code: 'failed'`.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* const { estimateBatch } = useBatchEstimate();
|
|
117
|
+
* try {
|
|
118
|
+
* const { cells, aggregate } = await estimateBatch(bodies);
|
|
119
|
+
* // cells[i] answers bodies[i]; a cell with `!c.ok && c.timedOut` was not
|
|
120
|
+
* // priced in time — show it as unavailable and retry later
|
|
121
|
+
* } catch (err) {
|
|
122
|
+
* if (!(err instanceof BatchEstimateError)) throw err;
|
|
123
|
+
* if (err.code === 'unsupported') {
|
|
124
|
+
* // this host has no batch estimate — price each cell with estimate()
|
|
125
|
+
* } else {
|
|
126
|
+
* // 'timeout' (a slow host, not an old one) or 'failed': show the estimate
|
|
127
|
+
* // as unavailable and retry later — never one estimate() per cell
|
|
128
|
+
* }
|
|
129
|
+
* }
|
|
130
|
+
*/
|
|
131
|
+
export function useBatchEstimate() {
|
|
132
|
+
const [pending, setPending] = useState(false);
|
|
133
|
+
const [result, setResult] = useState(null);
|
|
134
|
+
const [error, setError] = useState(null);
|
|
135
|
+
// Latest call wins: an app re-prices on a form edit, so an older reply landing
|
|
136
|
+
// after a newer one must not put the older total back on display.
|
|
137
|
+
const sequencer = useRequestSequencer();
|
|
138
|
+
const estimateBatch = useCallback(async (bodies, options) => {
|
|
139
|
+
const call = sequencer.begin();
|
|
140
|
+
if (sequencer.isCurrent(call)) {
|
|
141
|
+
setPending(true);
|
|
142
|
+
setError(null);
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
if (!Array.isArray(bodies) || bodies.length === 0) {
|
|
146
|
+
throw new BatchEstimateError('invalid-request', 'invalid estimate batch');
|
|
147
|
+
}
|
|
148
|
+
if (bodies.length > BATCH_ESTIMATE_MAX_CELLS) {
|
|
149
|
+
throw new BatchEstimateError('invalid-request', 'estimate batch too large');
|
|
150
|
+
}
|
|
151
|
+
const reply = await sendTypedRequest(getTransport(), { type: 'ESTIMATE_WORKFLOW_BATCH', payload: { bodies } }, 'ESTIMATE_BATCH_RESULT',
|
|
152
|
+
// Server-bound like the single estimate, and slower: up to 16 cost
|
|
153
|
+
// quotes behind one reply.
|
|
154
|
+
{ timeoutMs: options?.timeoutMs ?? WORKFLOW_REQUEST_TIMEOUT_MS });
|
|
155
|
+
if (reply.error !== undefined) {
|
|
156
|
+
if (reply.error === HOST_UNSUPPORTED_REPLY) {
|
|
157
|
+
throw new BatchEstimateError('unsupported', reply.error);
|
|
158
|
+
}
|
|
159
|
+
if (reply.error === 'invalid estimate batch' ||
|
|
160
|
+
reply.error === 'estimate batch too large') {
|
|
161
|
+
throw new BatchEstimateError('invalid-request', reply.error);
|
|
162
|
+
}
|
|
163
|
+
throw new BatchEstimateError('failed', reply.error);
|
|
164
|
+
}
|
|
165
|
+
const { snapshots, aggregate } = reply;
|
|
166
|
+
if (!snapshots || !aggregate || snapshots.length !== bodies.length) {
|
|
167
|
+
// A reply that does not answer every body cannot be lined up with the
|
|
168
|
+
// grid, so it is refused rather than shown shifted by a cell.
|
|
169
|
+
throw new BatchEstimateError('failed', 'the host returned a malformed batch estimate');
|
|
170
|
+
}
|
|
171
|
+
const next = { cells: snapshots.map(toCell), aggregate };
|
|
172
|
+
if (sequencer.isCurrent(call))
|
|
173
|
+
setResult(next);
|
|
174
|
+
return next;
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
const wrapped = toBatchEstimateError(err);
|
|
178
|
+
if (sequencer.isCurrent(call)) {
|
|
179
|
+
setError(wrapped);
|
|
180
|
+
// Fail closed: a confirm gate reading `result` must not keep quoting the
|
|
181
|
+
// previous list's total after this one failed.
|
|
182
|
+
setResult(null);
|
|
183
|
+
}
|
|
184
|
+
// Thrown even when superseded or unmounted — the caller's `await` is not
|
|
185
|
+
// the component.
|
|
186
|
+
throw wrapped;
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
if (sequencer.isCurrent(call))
|
|
190
|
+
setPending(false);
|
|
191
|
+
}
|
|
192
|
+
}, [sequencer]);
|
|
193
|
+
return { estimateBatch, pending, result, error };
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=useBatchEstimate.js.map
|
|
@@ -1,15 +1,57 @@
|
|
|
1
1
|
/** What {@link useBlockAnalytics} returns. */
|
|
2
2
|
export interface UseBlockAnalytics {
|
|
3
|
+
/**
|
|
4
|
+
* Send one custom event. Fire-and-forget: returns nothing, and nothing tells
|
|
5
|
+
* you whether it was recorded. See {@link useBlockAnalytics}
|
|
6
|
+
* for what is kept.
|
|
7
|
+
*/
|
|
3
8
|
track: (eventName: string, properties?: Record<string, unknown>) => void;
|
|
4
9
|
}
|
|
5
10
|
/**
|
|
6
|
-
* Fire-and-forget
|
|
7
|
-
*
|
|
8
|
-
*
|
|
11
|
+
* Custom events for your app's analytics panel. Fire-and-forget: the host
|
|
12
|
+
* forwards the event and the block gets no acknowledgement, so never wait on
|
|
13
|
+
* one or branch on it.
|
|
14
|
+
*
|
|
15
|
+
* 🔴 **Only what your manifest DECLARES is recorded.** Declare every event, and
|
|
16
|
+
* every property it carries, under `analytics.events` in `block.manifest.json`.
|
|
17
|
+
* The declaration is reviewed with the rest of the manifest, and events are
|
|
18
|
+
* accepted against the APPROVED version:
|
|
19
|
+
*
|
|
20
|
+
* - **An undeclared event** is counted as dropped. Its name and properties are
|
|
21
|
+
* not stored; the owner sees only a count of drops.
|
|
22
|
+
* - **A declared event keeps only its declared properties**, each only when the
|
|
23
|
+
* value has the declared type. An undeclared property, or a value of the
|
|
24
|
+
* wrong type, is dropped and the rest of the event is kept.
|
|
25
|
+
* - **Property types are `enum`, `number` and `boolean`.** There is no free-text
|
|
26
|
+
* string: a string is kept only when it is one of the enum's declared values.
|
|
27
|
+
* So a prompt, an email or a username cannot be recorded. A `number` is not
|
|
28
|
+
* range-limited, so do not use one to carry an identifier.
|
|
29
|
+
* - Names are lowercase snake_case: a letter, then up to 63 of `a-z`, `0-9`, `_`.
|
|
30
|
+
* At most 50 events per app and 10 properties per event.
|
|
31
|
+
*
|
|
32
|
+
* **Limits.** Each mount of your app may send at most 10 events per second and
|
|
33
|
+
* 500 in total; the host batches events into bodies of at most 32 KiB. An event
|
|
34
|
+
* over a limit is dropped in the browser and is not counted anywhere, so the
|
|
35
|
+
* panel's numbers are floors. Events are recorded only from the production
|
|
36
|
+
* site.
|
|
37
|
+
*
|
|
38
|
+
* **What owners see.** Aggregates in the app's analytics panel: per declared
|
|
39
|
+
* event, the count, unique signed-in viewers, and the declared properties'
|
|
40
|
+
* breakdowns. Signed-out viewers are counted as events but never identified,
|
|
41
|
+
* and the team's own activity is excluded by default. Individual events are
|
|
42
|
+
* never shown.
|
|
43
|
+
*
|
|
44
|
+
* **Check locally.** In `dev:live`, pass your manifest to `createLiveHost`
|
|
45
|
+
* (`createLiveHost({ blockToken, manifest })`) and the console warns once per
|
|
46
|
+
* undeclared event, undeclared property, or value of the wrong type.
|
|
9
47
|
*
|
|
10
48
|
* @example
|
|
49
|
+
* // block.manifest.json
|
|
50
|
+
* // "analytics": { "events": { "generate_clicked": { "properties": {
|
|
51
|
+
* // "mode": { "type": "enum", "values": ["txt2img", "img2img"] },
|
|
52
|
+
* // "batch": { "type": "number" } } } } }
|
|
11
53
|
* const { track } = useBlockAnalytics();
|
|
12
|
-
* track('generate_clicked', {
|
|
54
|
+
* track('generate_clicked', { mode: 'txt2img', batch: 4 });
|
|
13
55
|
*/
|
|
14
56
|
export declare function useBlockAnalytics(): UseBlockAnalytics;
|
|
15
57
|
//# sourceMappingURL=useBlockAnalytics.d.ts.map
|
|
@@ -1,13 +1,50 @@
|
|
|
1
1
|
import { useCallback } from 'react';
|
|
2
2
|
import { getTransport } from '../transport/singleton.js';
|
|
3
3
|
/**
|
|
4
|
-
* Fire-and-forget
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Custom events for your app's analytics panel. Fire-and-forget: the host
|
|
5
|
+
* forwards the event and the block gets no acknowledgement, so never wait on
|
|
6
|
+
* one or branch on it.
|
|
7
|
+
*
|
|
8
|
+
* 🔴 **Only what your manifest DECLARES is recorded.** Declare every event, and
|
|
9
|
+
* every property it carries, under `analytics.events` in `block.manifest.json`.
|
|
10
|
+
* The declaration is reviewed with the rest of the manifest, and events are
|
|
11
|
+
* accepted against the APPROVED version:
|
|
12
|
+
*
|
|
13
|
+
* - **An undeclared event** is counted as dropped. Its name and properties are
|
|
14
|
+
* not stored; the owner sees only a count of drops.
|
|
15
|
+
* - **A declared event keeps only its declared properties**, each only when the
|
|
16
|
+
* value has the declared type. An undeclared property, or a value of the
|
|
17
|
+
* wrong type, is dropped and the rest of the event is kept.
|
|
18
|
+
* - **Property types are `enum`, `number` and `boolean`.** There is no free-text
|
|
19
|
+
* string: a string is kept only when it is one of the enum's declared values.
|
|
20
|
+
* So a prompt, an email or a username cannot be recorded. A `number` is not
|
|
21
|
+
* range-limited, so do not use one to carry an identifier.
|
|
22
|
+
* - Names are lowercase snake_case: a letter, then up to 63 of `a-z`, `0-9`, `_`.
|
|
23
|
+
* At most 50 events per app and 10 properties per event.
|
|
24
|
+
*
|
|
25
|
+
* **Limits.** Each mount of your app may send at most 10 events per second and
|
|
26
|
+
* 500 in total; the host batches events into bodies of at most 32 KiB. An event
|
|
27
|
+
* over a limit is dropped in the browser and is not counted anywhere, so the
|
|
28
|
+
* panel's numbers are floors. Events are recorded only from the production
|
|
29
|
+
* site.
|
|
30
|
+
*
|
|
31
|
+
* **What owners see.** Aggregates in the app's analytics panel: per declared
|
|
32
|
+
* event, the count, unique signed-in viewers, and the declared properties'
|
|
33
|
+
* breakdowns. Signed-out viewers are counted as events but never identified,
|
|
34
|
+
* and the team's own activity is excluded by default. Individual events are
|
|
35
|
+
* never shown.
|
|
36
|
+
*
|
|
37
|
+
* **Check locally.** In `dev:live`, pass your manifest to `createLiveHost`
|
|
38
|
+
* (`createLiveHost({ blockToken, manifest })`) and the console warns once per
|
|
39
|
+
* undeclared event, undeclared property, or value of the wrong type.
|
|
7
40
|
*
|
|
8
41
|
* @example
|
|
42
|
+
* // block.manifest.json
|
|
43
|
+
* // "analytics": { "events": { "generate_clicked": { "properties": {
|
|
44
|
+
* // "mode": { "type": "enum", "values": ["txt2img", "img2img"] },
|
|
45
|
+
* // "batch": { "type": "number" } } } } }
|
|
9
46
|
* const { track } = useBlockAnalytics();
|
|
10
|
-
* track('generate_clicked', {
|
|
47
|
+
* track('generate_clicked', { mode: 'txt2img', batch: 4 });
|
|
11
48
|
*/
|
|
12
49
|
export function useBlockAnalytics() {
|
|
13
50
|
const track = useCallback((eventName, properties) => {
|
|
@@ -207,7 +207,10 @@ export declare class WorkflowEstimateError extends Error {
|
|
|
207
207
|
* rather than on prose. It is the ONLY stable branch target here:
|
|
208
208
|
* `snapshot.error` is server-authored and can change without notice, and
|
|
209
209
|
* {@link WorkflowEstimateError.message} is a generic developer-facing summary
|
|
210
|
-
* whose exact wording is not a contract either.
|
|
210
|
+
* whose exact wording is not a contract either. The one carve-out is a batch
|
|
211
|
+
* cell the host's time budget ran out on: it keeps `code: 'failed'`, and its
|
|
212
|
+
* stable signal is `timedOut: true` on the `useBatchEstimate()` cell, never a
|
|
213
|
+
* match on `snapshot.error`.
|
|
211
214
|
*
|
|
212
215
|
* 🔴 THIS IS WHAT A VIEWER-FACING MESSAGE MUST BE DERIVED FROM. Branch on
|
|
213
216
|
* `code` and return a string your app owns and localises; never render
|
|
@@ -191,7 +191,10 @@ export class WorkflowEstimateError extends Error {
|
|
|
191
191
|
* rather than on prose. It is the ONLY stable branch target here:
|
|
192
192
|
* `snapshot.error` is server-authored and can change without notice, and
|
|
193
193
|
* {@link WorkflowEstimateError.message} is a generic developer-facing summary
|
|
194
|
-
* whose exact wording is not a contract either.
|
|
194
|
+
* whose exact wording is not a contract either. The one carve-out is a batch
|
|
195
|
+
* cell the host's time budget ran out on: it keeps `code: 'failed'`, and its
|
|
196
|
+
* stable signal is `timedOut: true` on the `useBatchEstimate()` cell, never a
|
|
197
|
+
* match on `snapshot.error`.
|
|
195
198
|
*
|
|
196
199
|
* 🔴 THIS IS WHAT A VIEWER-FACING MESSAGE MUST BE DERIVED FROM. Branch on
|
|
197
200
|
* `code` and return a string your app owns and localises; never render
|
|
@@ -1,43 +1,87 @@
|
|
|
1
1
|
import type { BlockResourceInfo, BlockResourcePickerType } from '@civitai/app-sdk/blocks';
|
|
2
|
+
/**
|
|
3
|
+
* The most resources ONE multi-select `open({ multiple })` can return — the
|
|
4
|
+
* same cap the generation body puts on `additionalResources`, so a full batch
|
|
5
|
+
* is always one a submit accepts. A larger `multiple.max` is CLAMPED to this.
|
|
6
|
+
*/
|
|
7
|
+
export declare const RESOURCE_PICKER_MULTIPLE_MAX = 5;
|
|
8
|
+
/** The options every {@link UseResourcePicker} `open` call takes. */
|
|
9
|
+
export interface ResourcePickerOpenOptions {
|
|
10
|
+
/** Which resource type to pick. v1: `'Checkpoint' | 'LORA'` only — the
|
|
11
|
+
* host rejects any other type (the modal never opens). */
|
|
12
|
+
resourceType: BlockResourcePickerType;
|
|
13
|
+
/**
|
|
14
|
+
* 🔴 OMIT THIS BY DEFAULT. It is an optional base-model family FILTER, not a
|
|
15
|
+
* label: whatever you pass, the host HIDES every resource outside that
|
|
16
|
+
* ecosystem, so the viewer's own perfectly valid LoRAs simply do not appear
|
|
17
|
+
* and the picker looks empty or broken. Omitting it is an unconstrained pick
|
|
18
|
+
* of the type — every resource the viewer can reach.
|
|
19
|
+
*
|
|
20
|
+
* Pass it ONLY when the block already holds a chosen checkpoint the pick has
|
|
21
|
+
* to match, and then DERIVE it from that checkpoint. Never a hardcoded
|
|
22
|
+
* ecosystem string: a literal pins every viewer of the app to whichever
|
|
23
|
+
* family the author happened to be testing with. Nor a literal reached
|
|
24
|
+
* through a fallback — `checkpoint?.baseModel ?? 'SDXL'` re-pins every viewer
|
|
25
|
+
* who has not picked yet, which is the same defect wearing a default.
|
|
26
|
+
*
|
|
27
|
+
* 🔴 WHERE THE FAMILY COMES FROM ON THIS HOOK'S ONLY SURFACE. This hook is
|
|
28
|
+
* PAGE-ONLY (`OPEN_RESOURCE_PICKER` is a page affordance; the model slot has
|
|
29
|
+
* the narrower `useCheckpointPicker` instead), and a page slot carries no
|
|
30
|
+
* checkpoint: `checkpoint` exists on `ModelSlotContext` alone, and
|
|
31
|
+
* `BlockContext` is a union with no index signature, so
|
|
32
|
+
* `useBlockContext().context.checkpoint` does not even typecheck. The source
|
|
33
|
+
* is {@link BlockResourceInfo}`.baseModel` — the `baseModel` of a Checkpoint
|
|
34
|
+
* THIS picker returned earlier:
|
|
35
|
+
*
|
|
36
|
+
* const checkpoint = await open({ resourceType: 'Checkpoint' });
|
|
37
|
+
* if (checkpoint) await open({ resourceType: 'LORA', baseModelGroup: checkpoint.baseModel });
|
|
38
|
+
*
|
|
39
|
+
* Accepts an ecosystem key (e.g. 'Flux1', 'SDXL') OR a baseModel name (e.g.
|
|
40
|
+
* 'Flux.1 D'); the host collapses either to the ecosystem family. `''` is not
|
|
41
|
+
* a way to widen: the page host drops a zero-length value, so it is only ever
|
|
42
|
+
* a confusing spelling of omitting the key.
|
|
43
|
+
*/
|
|
44
|
+
baseModelGroup?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Multi-select: let the viewer pick up to `max` resources in ONE picker session.
|
|
48
|
+
*
|
|
49
|
+
* - **LoRA only.** `open({ resourceType: 'Checkpoint', multiple })` is a type
|
|
50
|
+
* error, and at runtime it REJECTS — it is never quietly treated as a single
|
|
51
|
+
* pick. A generation takes one checkpoint and a list of LoRAs.
|
|
52
|
+
* - **`max` is a whole number ≥ 1.** Anything above
|
|
53
|
+
* {@link RESOURCE_PICKER_MULTIPLE_MAX} (5) is CLAMPED to it; anything that is
|
|
54
|
+
* not a whole number ≥ 1 rejects.
|
|
55
|
+
* - `baseModelGroup` works exactly as it does for a single pick.
|
|
56
|
+
*/
|
|
57
|
+
export interface ResourcePickerMultiple {
|
|
58
|
+
max: number;
|
|
59
|
+
}
|
|
2
60
|
/** What {@link useResourcePicker} returns. */
|
|
3
61
|
export interface UseResourcePicker {
|
|
4
|
-
open:
|
|
5
|
-
/** Which resource type to pick. v1: `'Checkpoint' | 'LORA'` only — the
|
|
6
|
-
* host rejects any other type (the modal never opens). */
|
|
7
|
-
resourceType: BlockResourcePickerType;
|
|
62
|
+
open: {
|
|
8
63
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* who has not picked yet, which is the same defect wearing a default.
|
|
21
|
-
*
|
|
22
|
-
* 🔴 WHERE THE FAMILY COMES FROM ON THIS HOOK'S ONLY SURFACE. This hook is
|
|
23
|
-
* PAGE-ONLY (`OPEN_RESOURCE_PICKER` is a page affordance; the model slot has
|
|
24
|
-
* the narrower `useCheckpointPicker` instead), and a page slot carries no
|
|
25
|
-
* checkpoint: `checkpoint` exists on `ModelSlotContext` alone, and
|
|
26
|
-
* `BlockContext` is a union with no index signature, so
|
|
27
|
-
* `useBlockContext().context.checkpoint` does not even typecheck. The source
|
|
28
|
-
* is {@link BlockResourceInfo}`.baseModel` — the `baseModel` of a Checkpoint
|
|
29
|
-
* THIS picker returned earlier:
|
|
30
|
-
*
|
|
31
|
-
* const checkpoint = await open({ resourceType: 'Checkpoint' });
|
|
32
|
-
* if (checkpoint) await open({ resourceType: 'LORA', baseModelGroup: checkpoint.baseModel });
|
|
64
|
+
* Single pick. Resolves with the chosen resource, or `null` when the viewer
|
|
65
|
+
* dismissed the picker.
|
|
66
|
+
*/
|
|
67
|
+
(opts: ResourcePickerOpenOptions & {
|
|
68
|
+
multiple?: undefined;
|
|
69
|
+
}): Promise<BlockResourceInfo | null>;
|
|
70
|
+
/**
|
|
71
|
+
* Multi-select (see {@link ResourcePickerMultiple}). Resolves with the
|
|
72
|
+
* picked resources IN THE ORDER THE VIEWER PICKED THEM — never more than
|
|
73
|
+
* `max` — or `[]` when the viewer dismissed. Each entry is the same
|
|
74
|
+
* {@link BlockResourceInfo} a single pick returns.
|
|
33
75
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* a confusing spelling of omitting the key.
|
|
76
|
+
* Against a host that predates multi-select the viewer gets the single-pick
|
|
77
|
+
* picker, and this still resolves with a list: of the one resource they
|
|
78
|
+
* picked, or `[]`.
|
|
38
79
|
*/
|
|
39
|
-
|
|
40
|
-
|
|
80
|
+
(opts: ResourcePickerOpenOptions & {
|
|
81
|
+
resourceType: 'LORA';
|
|
82
|
+
multiple: ResourcePickerMultiple;
|
|
83
|
+
}): Promise<BlockResourceInfo[]>;
|
|
84
|
+
};
|
|
41
85
|
}
|
|
42
86
|
/**
|
|
43
87
|
* Drives the platform-side resource picker for PAGE App Blocks (Design 1 —
|
|
@@ -51,6 +95,13 @@ export interface UseResourcePicker {
|
|
|
51
95
|
* pick. Resolves with the chosen {@link BlockResourceInfo}, or `null` when the
|
|
52
96
|
* user dismissed without picking.
|
|
53
97
|
*
|
|
98
|
+
* MULTI-SELECT: pass `multiple: { max }` with `resourceType: 'LORA'` and the
|
|
99
|
+
* viewer picks up to `max` LoRAs (at most {@link RESOURCE_PICKER_MULTIPLE_MAX})
|
|
100
|
+
* in ONE picker session. `open` then resolves with a LIST — the picks in the
|
|
101
|
+
* order the viewer made them, `[]` on dismiss — instead of a single resource.
|
|
102
|
+
* See {@link ResourcePickerMultiple}. Without `multiple`, nothing about a
|
|
103
|
+
* single pick changes.
|
|
104
|
+
*
|
|
54
105
|
* DISCOVERY ONLY: the returned `versionId` is a hint, never an entitlement.
|
|
55
106
|
* Feed it into `body.modelVersionId` (Checkpoint) or
|
|
56
107
|
* `body.additionalResources` (LoRA) and submit — the host re-validates every id
|
|
@@ -61,14 +112,14 @@ export interface UseResourcePicker {
|
|
|
61
112
|
* Host-mediated, same trust model as `useCheckpointPicker` / `useBuzzWorkflow`:
|
|
62
113
|
* the block never touches the picker UI directly.
|
|
63
114
|
*
|
|
64
|
-
* 🔴 THE ORDER OF THE
|
|
65
|
-
*
|
|
115
|
+
* 🔴 THE ORDER OF THE `@example` BLOCKS BELOW IS LOAD-BEARING — DO NOT MOVE
|
|
116
|
+
* THE LAST ONE. `<civitai-developer-docs>/scripts/gen-appblocks-hooks.mjs` assigns
|
|
66
117
|
* `jsdocExample` inside its tag loop, so on a hook with several `@example` tags
|
|
67
118
|
* the LAST one wins and becomes the single published example whenever the README
|
|
68
119
|
* fence is unavailable (renamed heading, dropped fence). The unconstrained call
|
|
69
120
|
* is what every agent should copy, so it is last. The constrained variant is
|
|
70
121
|
* first, where a human reading top-down still meets the exception before the
|
|
71
|
-
* default it excepts.
|
|
122
|
+
* default it excepts; the multi-select variant sits between them.
|
|
72
123
|
*
|
|
73
124
|
* @example
|
|
74
125
|
* // CONSTRAINED — only when the pick has to match a checkpoint the block
|
|
@@ -83,6 +134,16 @@ export interface UseResourcePicker {
|
|
|
83
134
|
* }
|
|
84
135
|
*
|
|
85
136
|
* @example
|
|
137
|
+
* // MULTI-SELECT — several LoRAs in one picker session, in pick order.
|
|
138
|
+
* const { open } = useResourcePicker();
|
|
139
|
+
* const loras = await open({ resourceType: 'LORA', multiple: { max: 3 } });
|
|
140
|
+
* // [] when dismissed; otherwise up to 3 picks, in the order the viewer chose
|
|
141
|
+
* const additionalResources = loras.map((lora) => ({
|
|
142
|
+
* modelVersionId: lora.versionId,
|
|
143
|
+
* strength: lora.strength ?? 1,
|
|
144
|
+
* }));
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
86
147
|
* // THE DEFAULT — no ecosystem filter at all. The viewer sees every LoRA they
|
|
87
148
|
* // can reach, which is almost always what you want.
|
|
88
149
|
* const { open } = useResourcePicker();
|
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import { useCallback } from 'react';
|
|
2
|
+
import { throwOnReplyError } from '../internal/replyError.js';
|
|
2
3
|
import { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
|
|
3
4
|
import { getTransport } from '../transport/singleton.js';
|
|
4
5
|
import { sendTypedRequest } from '../transport/transport.js';
|
|
6
|
+
/**
|
|
7
|
+
* The most resources ONE multi-select `open({ multiple })` can return — the
|
|
8
|
+
* same cap the generation body puts on `additionalResources`, so a full batch
|
|
9
|
+
* is always one a submit accepts. A larger `multiple.max` is CLAMPED to this.
|
|
10
|
+
*/
|
|
11
|
+
export const RESOURCE_PICKER_MULTIPLE_MAX = 5;
|
|
12
|
+
/**
|
|
13
|
+
* The LoRA family, lower-cased. The host's picker accepts `LoCon` and `DoRA`
|
|
14
|
+
* beside `LORA` and matches case-insensitively, so the runtime check here does
|
|
15
|
+
* too — a caller reaching those through a cast is not refused by the SDK for
|
|
16
|
+
* something the host allows.
|
|
17
|
+
*/
|
|
18
|
+
const MULTIPLE_RESOURCE_TYPES = new Set(['lora', 'locon', 'dora']);
|
|
5
19
|
/**
|
|
6
20
|
* Drives the platform-side resource picker for PAGE App Blocks (Design 1 —
|
|
7
21
|
* host-chrome). Generalizes {@link useCheckpointPicker} from Checkpoint-only to
|
|
@@ -14,6 +28,13 @@ import { sendTypedRequest } from '../transport/transport.js';
|
|
|
14
28
|
* pick. Resolves with the chosen {@link BlockResourceInfo}, or `null` when the
|
|
15
29
|
* user dismissed without picking.
|
|
16
30
|
*
|
|
31
|
+
* MULTI-SELECT: pass `multiple: { max }` with `resourceType: 'LORA'` and the
|
|
32
|
+
* viewer picks up to `max` LoRAs (at most {@link RESOURCE_PICKER_MULTIPLE_MAX})
|
|
33
|
+
* in ONE picker session. `open` then resolves with a LIST — the picks in the
|
|
34
|
+
* order the viewer made them, `[]` on dismiss — instead of a single resource.
|
|
35
|
+
* See {@link ResourcePickerMultiple}. Without `multiple`, nothing about a
|
|
36
|
+
* single pick changes.
|
|
37
|
+
*
|
|
17
38
|
* DISCOVERY ONLY: the returned `versionId` is a hint, never an entitlement.
|
|
18
39
|
* Feed it into `body.modelVersionId` (Checkpoint) or
|
|
19
40
|
* `body.additionalResources` (LoRA) and submit — the host re-validates every id
|
|
@@ -24,14 +45,14 @@ import { sendTypedRequest } from '../transport/transport.js';
|
|
|
24
45
|
* Host-mediated, same trust model as `useCheckpointPicker` / `useBuzzWorkflow`:
|
|
25
46
|
* the block never touches the picker UI directly.
|
|
26
47
|
*
|
|
27
|
-
* 🔴 THE ORDER OF THE
|
|
28
|
-
*
|
|
48
|
+
* 🔴 THE ORDER OF THE `@example` BLOCKS BELOW IS LOAD-BEARING — DO NOT MOVE
|
|
49
|
+
* THE LAST ONE. `<civitai-developer-docs>/scripts/gen-appblocks-hooks.mjs` assigns
|
|
29
50
|
* `jsdocExample` inside its tag loop, so on a hook with several `@example` tags
|
|
30
51
|
* the LAST one wins and becomes the single published example whenever the README
|
|
31
52
|
* fence is unavailable (renamed heading, dropped fence). The unconstrained call
|
|
32
53
|
* is what every agent should copy, so it is last. The constrained variant is
|
|
33
54
|
* first, where a human reading top-down still meets the exception before the
|
|
34
|
-
* default it excepts.
|
|
55
|
+
* default it excepts; the multi-select variant sits between them.
|
|
35
56
|
*
|
|
36
57
|
* @example
|
|
37
58
|
* // CONSTRAINED — only when the pick has to match a checkpoint the block
|
|
@@ -46,6 +67,16 @@ import { sendTypedRequest } from '../transport/transport.js';
|
|
|
46
67
|
* }
|
|
47
68
|
*
|
|
48
69
|
* @example
|
|
70
|
+
* // MULTI-SELECT — several LoRAs in one picker session, in pick order.
|
|
71
|
+
* const { open } = useResourcePicker();
|
|
72
|
+
* const loras = await open({ resourceType: 'LORA', multiple: { max: 3 } });
|
|
73
|
+
* // [] when dismissed; otherwise up to 3 picks, in the order the viewer chose
|
|
74
|
+
* const additionalResources = loras.map((lora) => ({
|
|
75
|
+
* modelVersionId: lora.versionId,
|
|
76
|
+
* strength: lora.strength ?? 1,
|
|
77
|
+
* }));
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
49
80
|
* // THE DEFAULT — no ecosystem filter at all. The viewer sees every LoRA they
|
|
50
81
|
* // can reach, which is almost always what you want.
|
|
51
82
|
* const { open } = useResourcePicker();
|
|
@@ -56,16 +87,55 @@ import { sendTypedRequest } from '../transport/transport.js';
|
|
|
56
87
|
*/
|
|
57
88
|
export function useResourcePicker() {
|
|
58
89
|
const open = useCallback(async (opts) => {
|
|
59
|
-
|
|
90
|
+
if (opts.multiple == null) {
|
|
91
|
+
const { selected } = await sendTypedRequest(getTransport(), {
|
|
92
|
+
type: 'OPEN_RESOURCE_PICKER',
|
|
93
|
+
payload: {
|
|
94
|
+
resourceType: opts.resourceType,
|
|
95
|
+
...(opts.baseModelGroup != null ? { baseModelGroup: opts.baseModelGroup } : {}),
|
|
96
|
+
},
|
|
97
|
+
}, 'RESOURCE_PICKER_RESULT', { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
|
|
98
|
+
// Normalize the "dismissed" case to an explicit null so callers can
|
|
99
|
+
// `if (!picked) return;` without an `undefined` ambiguity.
|
|
100
|
+
return selected ?? null;
|
|
101
|
+
}
|
|
102
|
+
// ── Multi-select ──────────────────────────────────────────────────────
|
|
103
|
+
// Refused HERE, before anything is sent, for two reasons: the caller gets
|
|
104
|
+
// an error naming the mistake instead of a picker that never opens, and a
|
|
105
|
+
// host that predates multi-select never sees a request it would answer
|
|
106
|
+
// with the wrong thing (it ignores `multiple` and would happily open a
|
|
107
|
+
// single CHECKPOINT pick).
|
|
108
|
+
if (!MULTIPLE_RESOURCE_TYPES.has(String(opts.resourceType).trim().toLowerCase())) {
|
|
109
|
+
throw new Error(`useResourcePicker: \`multiple\` is only supported for LoRA picks, not ` +
|
|
110
|
+
`resourceType "${String(opts.resourceType)}". Call open() without \`multiple\` for a single pick.`);
|
|
111
|
+
}
|
|
112
|
+
const requestedMax = opts.multiple.max;
|
|
113
|
+
if (typeof requestedMax !== 'number' || !Number.isInteger(requestedMax) || requestedMax < 1) {
|
|
114
|
+
throw new Error('useResourcePicker: `multiple.max` must be a whole number of at least 1 ' +
|
|
115
|
+
`(values above ${RESOURCE_PICKER_MULTIPLE_MAX} are clamped to ${RESOURCE_PICKER_MULTIPLE_MAX}).`);
|
|
116
|
+
}
|
|
117
|
+
const max = Math.min(requestedMax, RESOURCE_PICKER_MULTIPLE_MAX);
|
|
118
|
+
const result = await sendTypedRequest(getTransport(), {
|
|
60
119
|
type: 'OPEN_RESOURCE_PICKER',
|
|
61
120
|
payload: {
|
|
62
121
|
resourceType: opts.resourceType,
|
|
63
122
|
...(opts.baseModelGroup != null ? { baseModelGroup: opts.baseModelGroup } : {}),
|
|
123
|
+
multiple: { max },
|
|
64
124
|
},
|
|
65
125
|
}, 'RESOURCE_PICKER_RESULT', { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
126
|
+
// The host answers a multi request it will not open with `{ error }`.
|
|
127
|
+
throwOnReplyError(result, 'The host refused the multi-select resource picker request.');
|
|
128
|
+
// A host that knows multi-select answers `selectedResources` (`[]` on
|
|
129
|
+
// dismiss). One that predates it ignored `multiple`, opened a single pick
|
|
130
|
+
// and answered `selected` (or nothing, on dismiss) — still a list here, of
|
|
131
|
+
// one or none, so the caller has ONE result shape to handle either way.
|
|
132
|
+
const picked = Array.isArray(result.selectedResources)
|
|
133
|
+
? result.selectedResources
|
|
134
|
+
: result.selected
|
|
135
|
+
? [result.selected]
|
|
136
|
+
: [];
|
|
137
|
+
// Belt: never hand back more than was asked for.
|
|
138
|
+
return picked.slice(0, max);
|
|
69
139
|
}, []);
|
|
70
140
|
return { open };
|
|
71
141
|
}
|