@civitai/blocks-react 0.67.1 → 0.69.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.
@@ -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
  }
@@ -1,4 +1,28 @@
1
1
  import type { SharedStorageValue } from '@civitai/app-sdk/blocks';
2
+ /**
3
+ * A shared-storage write the server REFUSED because a per-(viewer, app) budget
4
+ * is empty — thrown by `append`, `update`, `vote`, `unvote`, `withdraw` and
5
+ * `report` when the host's refusal carries retry metadata.
6
+ *
7
+ * It EXTENDS `Error`, the class every other shared-storage refusal is thrown
8
+ * as, so existing `catch` code keeps working unchanged: `.message` is still the
9
+ * host's refusal text (for example `Too many submissions — retry in 312s`).
10
+ * Branch on `instanceof SharedStorageRateLimitError` to render the wait from
11
+ * {@link SharedStorageRateLimitError.retryAt} instead of parsing the message.
12
+ *
13
+ * Not thrown when the refusal carries no metadata — a host that predates it, or
14
+ * a refusal that is not a rate limit. Those stay a plain `Error`.
15
+ */
16
+ export declare class SharedStorageRateLimitError extends Error {
17
+ /** Whole seconds until a retry can succeed, as of when the server refused. At least 1. */
18
+ readonly retryAfterSeconds: number;
19
+ /** ISO-8601 instant at which a retry can succeed. Prefer this for a countdown. */
20
+ readonly retryAt: string;
21
+ constructor(message: string, rateLimit: {
22
+ retryAfterSeconds: number;
23
+ retryAt: string;
24
+ });
25
+ }
2
26
  /**
3
27
  * The value a viewer contributes to the SHARED store: a required `title`, an
4
28
  * optional long-form `body`, and an optional opaque app-owned `data` blob.
@@ -2,6 +2,50 @@ import { useMemo } from 'react';
2
2
  import { getTransport } from '../transport/singleton.js';
3
3
  import { throwOnFailedReply, throwOnReplyError } from '../internal/replyError.js';
4
4
  import { sendTypedRequest } from '../transport/transport.js';
5
+ import { parseSharedStorageRateLimit } from '../transport/validate.js';
6
+ /**
7
+ * A shared-storage write the server REFUSED because a per-(viewer, app) budget
8
+ * is empty — thrown by `append`, `update`, `vote`, `unvote`, `withdraw` and
9
+ * `report` when the host's refusal carries retry metadata.
10
+ *
11
+ * It EXTENDS `Error`, the class every other shared-storage refusal is thrown
12
+ * as, so existing `catch` code keeps working unchanged: `.message` is still the
13
+ * host's refusal text (for example `Too many submissions — retry in 312s`).
14
+ * Branch on `instanceof SharedStorageRateLimitError` to render the wait from
15
+ * {@link SharedStorageRateLimitError.retryAt} instead of parsing the message.
16
+ *
17
+ * Not thrown when the refusal carries no metadata — a host that predates it, or
18
+ * a refusal that is not a rate limit. Those stay a plain `Error`.
19
+ */
20
+ export class SharedStorageRateLimitError extends Error {
21
+ /** Whole seconds until a retry can succeed, as of when the server refused. At least 1. */
22
+ retryAfterSeconds;
23
+ /** ISO-8601 instant at which a retry can succeed. Prefer this for a countdown. */
24
+ retryAt;
25
+ constructor(message, rateLimit) {
26
+ super(message);
27
+ this.name = 'SharedStorageRateLimitError';
28
+ this.retryAfterSeconds = rateLimit.retryAfterSeconds;
29
+ this.retryAt = rateLimit.retryAt;
30
+ }
31
+ }
32
+ /**
33
+ * Throw {@link SharedStorageRateLimitError} when a write reply is a refusal (a
34
+ * PRESENT `error`, the same test as `throwOnReplyError`) that carries valid
35
+ * `rateLimit`. Otherwise returns, and the caller's usual reply check throws the
36
+ * plain `Error` as before.
37
+ *
38
+ * Parses `rateLimit` rather than trusting it: the transport delivers it as the
39
+ * host sent it, so a malformed value is ignored here and the caller's plain
40
+ * `Error` is thrown instead.
41
+ */
42
+ function throwOnSharedRateLimit(result, fallback) {
43
+ if (result.error === undefined)
44
+ return;
45
+ const rateLimit = parseSharedStorageRateLimit(result.rateLimit);
46
+ if (rateLimit)
47
+ throw new SharedStorageRateLimitError(result.error || fallback, rateLimit);
48
+ }
5
49
  /**
6
50
  * App-scoped, append-only, community-votable SHARED datastore. Sibling of
7
51
  * {@link useAppStorage} (the per-viewer KV store) — same postMessage bridge,
@@ -15,6 +59,11 @@ import { sendTypedRequest } from '../transport/transport.js';
15
59
  * / `getCount(s)`) and a hard reject on mutations (`append`/`vote`/`unvote`/
16
60
  * `withdraw`).
17
61
  *
62
+ * Writes are budgeted per (viewer, app) server-side. A write refused for that
63
+ * reason rejects with {@link SharedStorageRateLimitError} (a subclass of the
64
+ * plain `Error` every other refusal is) carrying `retryAfterSeconds` and
65
+ * `retryAt`, whenever the host sends the metadata.
66
+ *
18
67
  * The hook is stable across renders — it returns the same object identity once
19
68
  * the transport singleton is created, so it's safe in `useEffect`/`useMemo`
20
69
  * dependency arrays.
@@ -71,6 +120,7 @@ export function useSharedStorage() {
71
120
  },
72
121
  async report(key, reason) {
73
122
  const result = await sendTypedRequest(transport, { type: 'SHARED_REPORT', payload: { key, reason } }, 'SHARED_REPORT_RESULT');
123
+ throwOnSharedRateLimit(result, 'shared report failed');
74
124
  throwOnFailedReply(result, 'shared report failed');
75
125
  },
76
126
  async getCount(key) {
@@ -85,25 +135,30 @@ export function useSharedStorage() {
85
135
  },
86
136
  async append(value) {
87
137
  const result = await sendTypedRequest(transport, { type: 'SHARED_APPEND', payload: { value } }, 'SHARED_APPEND_RESULT');
138
+ throwOnSharedRateLimit(result, 'shared append failed');
88
139
  throwOnReplyError(result, 'shared append failed');
89
140
  return { key: result.key };
90
141
  },
91
142
  async update(key, value) {
92
143
  const result = await sendTypedRequest(transport, { type: 'SHARED_UPDATE', payload: { key, value } }, 'SHARED_UPDATE_RESULT');
144
+ throwOnSharedRateLimit(result, 'shared update failed');
93
145
  throwOnFailedReply(result, 'shared update failed');
94
146
  },
95
147
  async vote(key) {
96
148
  const result = await sendTypedRequest(transport, { type: 'SHARED_VOTE', payload: { key } }, 'SHARED_VOTE_RESULT');
149
+ throwOnSharedRateLimit(result, 'shared vote failed');
97
150
  throwOnReplyError(result, 'shared vote failed');
98
151
  return result.count;
99
152
  },
100
153
  async unvote(key) {
101
154
  const result = await sendTypedRequest(transport, { type: 'SHARED_UNVOTE', payload: { key } }, 'SHARED_UNVOTE_RESULT');
155
+ throwOnSharedRateLimit(result, 'shared unvote failed');
102
156
  throwOnReplyError(result, 'shared unvote failed');
103
157
  return result.count;
104
158
  },
105
159
  async withdraw(key) {
106
160
  const result = await sendTypedRequest(transport, { type: 'SHARED_WITHDRAW', payload: { key } }, 'SHARED_WITHDRAW_RESULT');
161
+ throwOnSharedRateLimit(result, 'shared withdraw failed');
107
162
  throwOnFailedReply(result, 'shared withdraw failed');
108
163
  // `deleted` is optional on the wire type because an error reply omits
109
164
  // it. On the success path the validator requires a boolean, so this is
@@ -0,0 +1,196 @@
1
+ import type { BlockWorkflowSnapshot } from '@civitai/app-sdk/blocks';
2
+ import type { WatchWorkflowOptions } from './useBuzzWorkflow.js';
3
+ /**
4
+ * The most workflows one {@link useWorkflowBatch} may watch.
5
+ *
6
+ * Derived from the host's poll allowance, not picked: one watched workflow makes
7
+ * at most 60 / 1.5 = 40 polls a minute (one poll in flight, `intervalMs` 1,500
8
+ * between polls), and the viewer's poll allowance is 1,200 per 60 s per block
9
+ * instance, so 1,200 / 40 = 30 workflows is the most one viewer can watch before
10
+ * polls start coming back as placeholders. The developer docs' limits table
11
+ * gives the same figure. A list longer than this throws, because an app that
12
+ * needs more cells needs a different plan than "watch them all at once".
13
+ */
14
+ export declare const WORKFLOW_BATCH_MAX_CELLS = 30;
15
+ /**
16
+ * Where a cell's watch stands.
17
+ *
18
+ * - `'watching'` — a `watch()` loop is running for it.
19
+ * - `'settled'` — its latest snapshot is terminal. Nothing is polling it.
20
+ * - `'stopped'` — nothing is polling it and it is NOT known to be finished: the
21
+ * watch timed out (`timeoutMs`), gave up after `maxRetries` failed polls, or the
22
+ * host answered a poll with its failure placeholder instead of this workflow's
23
+ * status. The workflow may still be running. `error` says why when there was an
24
+ * error. Call {@link UseWorkflowBatch.rewatch} to start watching it again.
25
+ */
26
+ export type WorkflowBatchCellPhase = 'watching' | 'settled' | 'stopped';
27
+ /**
28
+ * Where a cell's most recent cancel request stands.
29
+ *
30
+ * - `'idle'` — no cancel was asked for.
31
+ * - `'pending'` — a cancel request is in flight.
32
+ * - `'done'` — the host answered with a terminal snapshot. Usually `canceled`,
33
+ * but a workflow that finished before the cancel landed reports its real
34
+ * status (`succeeded`, `failed`, `expired`).
35
+ * - `'refused'` — not cancelled. The host answered with a NON-terminal snapshot
36
+ * (its answer when the estimate allowance is used up: the cancel was not sent),
37
+ * or this cell was not attempted because an earlier cancel in the same
38
+ * `cancelAll()` was refused. The workflow is still running. Call `cancelAll()`
39
+ * again after a few seconds.
40
+ * - `'failed'` — the cancel request itself failed: it rejected, or the host
41
+ * answered with its failure placeholder (`workflowId: 'failed'`) rather than
42
+ * this workflow's snapshot. The workflow's state is unknown and it may still be
43
+ * running. Calling `cancelAll()` again retries it.
44
+ */
45
+ export type WorkflowBatchCancelState = 'idle' | 'pending' | 'done' | 'refused' | 'failed';
46
+ /** One watched workflow. */
47
+ export interface WorkflowBatchCell {
48
+ workflowId: string;
49
+ /**
50
+ * The latest snapshot the host returned FOR THIS WORKFLOW, or `null` before the
51
+ * first one. A host failure placeholder (whose `workflowId` is `'failed'`) is
52
+ * never stored here, because it is not this workflow's status.
53
+ */
54
+ snapshot: BlockWorkflowSnapshot | null;
55
+ phase: WorkflowBatchCellPhase;
56
+ /** Why the watch stopped, when it stopped on an error. `null` otherwise. */
57
+ error: Error | null;
58
+ cancel: WorkflowBatchCancelState;
59
+ }
60
+ /**
61
+ * Counts over every cell, from each cell's latest snapshot.
62
+ *
63
+ * `succeeded + failed + canceled + expired + running === total`.
64
+ */
65
+ export interface WorkflowBatchSummary {
66
+ total: number;
67
+ /** Cells whose latest snapshot is not terminal, including cells with no snapshot yet. */
68
+ running: number;
69
+ succeeded: number;
70
+ failed: number;
71
+ canceled: number;
72
+ expired: number;
73
+ /**
74
+ * The part of `running` that nothing is polling (phase `'stopped'`). A batch
75
+ * with `stopped > 0` will not settle on its own: call `rewatch()`.
76
+ */
77
+ stopped: number;
78
+ /**
79
+ * The sum of `cost.total` on the terminal snapshots that carry one: the
80
+ * generation cost the host reported for each finished workflow (a cancelled
81
+ * workflow reports its cost after the cancel). It is what the snapshots say,
82
+ * not a read of the viewer's Buzz ledger. The app author's fee is charged
83
+ * separately and is not included.
84
+ */
85
+ realisedCost: number;
86
+ /** How many terminal cells contributed to `realisedCost`. */
87
+ costedCells: number;
88
+ /** `true` when every cell is terminal (`running === 0`). `true` for an empty list. */
89
+ settled: boolean;
90
+ }
91
+ /**
92
+ * What one {@link UseWorkflowBatch.cancelAll} call did. Each array holds workflow
93
+ * ids, in list order.
94
+ */
95
+ export interface WorkflowBatchCancelResult {
96
+ /** The host answered `canceled`. */
97
+ canceled: string[];
98
+ /**
99
+ * The workflow had already finished: either its latest snapshot was already
100
+ * terminal (no request was sent), or the host answered the cancel with the
101
+ * workflow's real terminal status because it finished first.
102
+ */
103
+ alreadySettled: string[];
104
+ /** The host answered with a non-terminal snapshot: the cancel was not sent. */
105
+ refused: string[];
106
+ /**
107
+ * Not attempted, because an earlier cancel in this call was refused. The
108
+ * allowance is used up, so sending more would only be refused too.
109
+ */
110
+ notAttempted: string[];
111
+ /** The cancel request failed. The workflow's state is unknown. */
112
+ failed: string[];
113
+ /** `true` when `refused`, `notAttempted` and `failed` are all empty. */
114
+ complete: boolean;
115
+ }
116
+ /** Options for {@link useWorkflowBatch}. Passed to every cell's `watch()`. */
117
+ export type WorkflowBatchOptions = Pick<WatchWorkflowOptions, 'intervalMs' | 'waitSeconds' | 'timeoutMs' | 'maxRetries'>;
118
+ /** What {@link useWorkflowBatch} returns. */
119
+ export interface UseWorkflowBatch {
120
+ /** One cell per id passed in, in the same order. */
121
+ cells: readonly WorkflowBatchCell[];
122
+ summary: WorkflowBatchSummary;
123
+ /**
124
+ * Cancel every cell that is not known to be finished. Resolves (never rejects)
125
+ * with what happened to each one; see {@link WorkflowBatchCancelResult}.
126
+ *
127
+ * Calling it while a previous call is still running returns that call's
128
+ * promise. Calling it again after a call that was not `complete` retries the
129
+ * cells that are still not finished.
130
+ */
131
+ cancelAll: () => Promise<WorkflowBatchCancelResult>;
132
+ /** Start watching every `'stopped'` cell again. */
133
+ rewatch: () => void;
134
+ }
135
+ /**
136
+ * Watch a set of already-submitted workflows as one batch, with per-cell status,
137
+ * a summary, and cancel-all. For grid apps that submit N workflows one by one
138
+ * with `useBuzzWorkflow().submit()` (each with its own confirmation) and then
139
+ * have to follow all N.
140
+ *
141
+ * 🔴 THE APP OWNS THE LIST, AND THE LIST IS THE BATCH. Pass the workflow ids as
142
+ * an array: append each id as its `submit()` resolves, and persist the same
143
+ * array (it is plain strings) to re-attach after a reload by passing it back.
144
+ * There is no server-side batch id. The host knows nothing about the grouping,
145
+ * so nothing here changes what the host allows: every cell is one ordinary
146
+ * `watch()` and every cancel is one ordinary `cancel()`.
147
+ *
148
+ * What it guarantees, and the host limits behind each:
149
+ *
150
+ * - **One poll in flight per workflow.** Each cell is one `watch()` loop. When a
151
+ * loop is restarted (the list changes, `rewatch()`, a React StrictMode
152
+ * remount), the new loop waits for the old loop's in-flight poll to return
153
+ * first, so two polls for one workflow never overlap.
154
+ * - **At most {@link WORKFLOW_BATCH_MAX_CELLS} workflows.** That is the poll
155
+ * allowance (1,200 per 60 s, per block instance and viewer) divided by one
156
+ * watch's 40 polls a minute. A longer list throws.
157
+ * - **Cancel-all is paced and stops on refusal.** Cancels charge the estimate
158
+ * allowance (150 per 10 s per block instance, shared by every viewer of a page
159
+ * app). At most four are in flight. When the host answers one with a
160
+ * non-terminal snapshot (its answer when that allowance is used up), the rest
161
+ * of the call is not sent and comes back as `notAttempted`. Nothing is retried
162
+ * automatically. Call `cancelAll()` again after a few seconds.
163
+ * - **A host failure placeholder is not a workflow outcome.** When the host
164
+ * fails a poll or a cancel it answers with `{ workflowId: 'failed', status:
165
+ * 'failed' }`. `watch()` and `cancel()` resolve with that, but it says nothing
166
+ * about the workflow, which may still be running. This hook never counts it as
167
+ * a failed cell: a poll placeholder stops the cell (phase `'stopped'`, with
168
+ * `error` set), and a cancel placeholder is a `'failed'` cancel.
169
+ * - **Unmounting stops every poll.** No state updates happen after unmount. A
170
+ * `cancelAll()` already running keeps sending, because the viewer asked for it.
171
+ *
172
+ * Throws (at render) when the list is longer than
173
+ * {@link WORKFLOW_BATCH_MAX_CELLS}, has a duplicate, has an empty id, or has the
174
+ * `'failed'` / `'whatif'` placeholder a refused `submit()` resolves with.
175
+ *
176
+ * Needs the same `ai:write:budgeted` scope as `submit()`. A viewer who submitted
177
+ * already has it.
178
+ *
179
+ * @example
180
+ * const { submit } = useBuzzWorkflow();
181
+ * const [ids, setIds] = useState<string[]>(() => loadSavedIds());
182
+ * const batch = useWorkflowBatch(ids);
183
+ *
184
+ * async function runCell(body: WorkflowBody, key: string) {
185
+ * const snap = await submit(body, { idempotencyKey: key });
186
+ * if (snap.workflowId !== 'failed') setIds((prev) => [...prev, snap.workflowId]);
187
+ * }
188
+ *
189
+ * useEffect(() => saveIds(ids), [ids]);
190
+ *
191
+ * // batch.summary.running, batch.summary.realisedCost, batch.cells[i].snapshot …
192
+ * const result = await batch.cancelAll();
193
+ * if (!result.complete) showRetryCancel(result);
194
+ */
195
+ export declare function useWorkflowBatch(workflowIds: readonly string[], options?: WorkflowBatchOptions): UseWorkflowBatch;
196
+ //# sourceMappingURL=useWorkflowBatch.d.ts.map