@civitai/blocks-react 0.67.0 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 analytics tracking. The host forwards events to its
7
- * analytics pipeline (ClickHouse in production); the block doesn't see
8
- * acknowledgements and shouldn't block on them.
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', { modelId });
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 analytics tracking. The host forwards events to its
5
- * analytics pipeline (ClickHouse in production); the block doesn't see
6
- * acknowledgements and shouldn't block on them.
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', { modelId });
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: (opts: {
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
- * 🔴 OMIT THIS BY DEFAULT. It is an optional base-model family FILTER, not a
10
- * label: whatever you pass, the host HIDES every resource outside that
11
- * ecosystem, so the viewer's own perfectly valid LoRAs simply do not appear
12
- * and the picker looks empty or broken. Omitting it is an unconstrained pick
13
- * of the type — every resource the viewer can reach.
14
- *
15
- * Pass it ONLY when the block already holds a chosen checkpoint the pick has
16
- * to match, and then DERIVE it from that checkpoint. Never a hardcoded
17
- * ecosystem string: a literal pins every viewer of the app to whichever
18
- * family the author happened to be testing with. Nor a literal reached
19
- * through a fallback — `checkpoint?.baseModel ?? 'SDXL'` re-pins every viewer
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
- * Accepts an ecosystem key (e.g. 'Flux1', 'SDXL') OR a baseModel name (e.g.
35
- * 'Flux.1 D'); the host collapses either to the ecosystem family. `''` is not
36
- * a way to widen: the page host drops a zero-length value, so it is only ever
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
- baseModelGroup?: string;
40
- }) => Promise<BlockResourceInfo | null>;
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 TWO `@example` BLOCKS BELOW IS LOAD-BEARING — DO NOT SWAP
65
- * THEM. `<civitai-developer-docs>/scripts/gen-appblocks-hooks.mjs` assigns
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 TWO `@example` BLOCKS BELOW IS LOAD-BEARING — DO NOT SWAP
28
- * THEM. `<civitai-developer-docs>/scripts/gen-appblocks-hooks.mjs` assigns
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
- const { selected } = await sendTypedRequest(getTransport(), {
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
- // Normalize the "dismissed" case to an explicit null so callers can
67
- // `if (!picked) return;` without an `undefined` ambiguity.
68
- return selected ?? null;
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
  }