@civitai/blocks-react 0.68.0 → 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.
package/README.md CHANGED
@@ -495,6 +495,53 @@ if (quote.cost) showPrice(priceLine(quote.cost));
495
495
  reports `authorFee: 0`, and `authorFee: false` (`?authorFee=off`) leaves it
496
496
  out, as an older host does.
497
497
 
498
+ #### Why a training step could not be priced: `snapshot.errorCode`
499
+
500
+ A pass-through `step` body whose `$type` is a training type (`'training'` or
501
+ `'imageResourceTraining'`) needs a price quote; when the host cannot get one it
502
+ refuses with `{ workflowId: 'failed', status: 'failed', error, errorCode }` and
503
+ no `cost`. `estimate()` rejects with a `WorkflowEstimateError` and `submit()`
504
+ with a `WorkflowSubmitError` (before anything is reserved), and a batch
505
+ estimate returns it as that body's cell. `errorCode` is the stable value to
506
+ branch on; `error` stays the diagnostic read.
507
+
508
+ ```tsx
509
+ import { useBuzzWorkflow, WorkflowEstimateError } from '@civitai/blocks-react';
510
+ import type { WorkflowBody } from '@civitai/app-sdk/blocks';
511
+
512
+ const { estimate } = useBuzzWorkflow();
513
+ declare const body: WorkflowBody;
514
+
515
+ try {
516
+ await estimate(body);
517
+ } catch (err) {
518
+ if (!(err instanceof WorkflowEstimateError)) throw err;
519
+ switch (err.snapshot.errorCode) {
520
+ case 'training-quote-rejected': // the request itself was refused — change it
521
+ showError('These training settings were refused. Check them and try again.');
522
+ break;
523
+ case 'training-quote-unavailable': // no answer right now — retrying may work
524
+ case 'training-quote-unpriced': // answered without a price
525
+ showError('Training cannot be priced right now. Try again shortly.');
526
+ break;
527
+ default: // no code, or one a later host added
528
+ showError('Could not get a price.');
529
+ }
530
+ }
531
+ ```
532
+
533
+ | `errorCode` | Means |
534
+ |---|---|
535
+ | `'training-quote-rejected'` | the orchestrator refused the request itself; resending the same body will not help. `error` reads `Training could not be priced: <reason>` when the reason is safe to show |
536
+ | `'training-quote-unavailable'` | no usable answer (an outage, a timeout, a rate limit, insufficient funds, …); a retry may succeed |
537
+ | `'training-quote-unpriced'` | the orchestrator answered without a price |
538
+ | absent | any other reply, and every reply from a host that predates the field |
539
+
540
+ - **Treat an unknown value as possible.** The field is typed as an open string
541
+ (`KnownBlockWorkflowErrorCode | (string & {})`), the host may add codes, and
542
+ the inbound validator accepts any string. Keep a `default` branch.
543
+ - The `kind: 'training'` arm does not set it.
544
+
498
545
  > **Estimate must mirror submit** (gotcha #59): build the params for `estimate`
499
546
  > with the *exact* same logic as `submit` — same seed decision especially. The
500
547
  > orchestrator whatif prices a cache hit (identical workflow) at 0 and a fresh
@@ -529,10 +576,11 @@ resolves with one cell per body, in order, plus the run total. Returns
529
576
  - **`kind: 'training'` bodies are refused per cell.** Estimate a training run
530
577
  with `useBuzzWorkflow().estimate()`.
531
578
  - **Estimate only.** Nothing is submitted and no Buzz is held or spent. There is
532
- no batch submit, watch or cancel. Submit each cell with
579
+ no batch submit. Submit each cell with
533
580
  `useBuzzWorkflow().submit()`, which asks the viewer and checks the budget per
534
581
  cell. `aggregate.total` is a quote, not a lock: each cell is priced again when
535
- it is submitted.
582
+ it is submitted. To follow the submitted cells and cancel them together, see
583
+ [`useWorkflowBatch()`](#useworkflowbatch).
536
584
 
537
585
  `aggregate.total` sums the cells that priced. It covers the whole grid only when
538
586
  `aggregate.pricedCells === aggregate.cellCount`; otherwise show it as a partial
@@ -664,6 +712,121 @@ budget runs out (`ok: false`, `timedOut: true`). **`dev:live`** forwards the cal
664
712
  a civitai.com that predates the batch procedure it rejects with `'failed'`
665
713
  rather than `'unsupported'`.
666
714
 
715
+ ### `useWorkflowBatch()`
716
+
717
+ Watch a set of already-submitted workflows as one batch: per-cell status, a
718
+ summary, and cancel-all. Built for grid apps that submit each cell with
719
+ `useBuzzWorkflow().submit()` (each with its own confirmation, unchanged) and
720
+ then have to follow all of them. `useWorkflowBatch(workflowIds, options?)`
721
+ returns `{ cells, summary, cancelAll, rewatch }`.
722
+
723
+ - **The array of ids is the batch.** There is no server-side batch id. Append
724
+ each id as its `submit()` resolves, and persist the same array (plain
725
+ strings) to re-attach after a reload by passing it back. Skip a submit that
726
+ resolved with the `'failed'` placeholder id: nothing was created.
727
+ - **Every cell is one ordinary `watch()`**, so each workflow has exactly one
728
+ poll in flight. When a cell's loop restarts (the list changes, `rewatch()`,
729
+ a StrictMode remount) the new loop waits for the old loop's poll to return.
730
+ `options` (`intervalMs`, `waitSeconds`, `timeoutMs`, `maxRetries`) are passed
731
+ to every cell's `watch()`.
732
+ - **At most 30 ids** (`WORKFLOW_BATCH_MAX_CELLS`): the poll allowance (1,200 per
733
+ 60 s per block instance and viewer) divided by one watch's 40 polls a minute.
734
+ A longer list, a duplicate, an empty id or a `'failed'`/`'whatif'`
735
+ placeholder throws.
736
+ - **`summary`** counts `succeeded`, `failed`, `canceled`, `expired` and `running`
737
+ (not terminal, including cells with no snapshot yet) from each cell's latest
738
+ snapshot, plus `realisedCost`: the sum of `cost.total` on the terminal
739
+ snapshots that carry one. That is the generation cost the host reported, not a
740
+ ledger read; the author fee is charged separately and is not included.
741
+ `settled` is `running === 0`.
742
+ - **A host failure placeholder is not a workflow outcome.** When the host fails
743
+ a poll it answers `{ workflowId: 'failed', status: 'failed' }`. That says
744
+ nothing about the workflow, so the cell is not counted as failed: its `phase`
745
+ becomes `'stopped'` with `error` set, and `summary.stopped` counts it. A
746
+ stopped cell (also a watch that timed out or ran out of retries) is not
747
+ polled again until you call `rewatch()`.
748
+ - **`cancelAll()` cancels every cell not known to be finished** and resolves
749
+ (never rejects) with `{ canceled, alreadySettled, refused, notAttempted,
750
+ failed, complete }`, each a list of ids. Cancels charge the estimate allowance
751
+ (150 per 10 s per block instance; on a page app every viewer shares it), so at
752
+ most four are in flight. A refused cancel resolves `status: 'processing'`: it
753
+ was not sent and the workflow is still running. After the first refusal the
754
+ rest of the call is not sent (`notAttempted`). A cancel answered with the
755
+ host's failure placeholder is `failed`, not a failed workflow. Nothing is
756
+ retried automatically. When `complete` is `false`, wait a few seconds and call
757
+ `cancelAll()` again; it targets only cells that are still not finished.
758
+ - **Unmounting stops every poll.** A `cancelAll()` already running keeps sending.
759
+
760
+ ```tsx
761
+ import { useEffect, useState } from 'react';
762
+ import type { WorkflowBody } from '@civitai/app-sdk/blocks';
763
+ import { useBuzzWorkflow, useWorkflowBatch } from '@civitai/blocks-react';
764
+
765
+ const SAVED = 'grid-run-ids';
766
+
767
+ function GridRun({ bodies }: { bodies: WorkflowBody[] }) {
768
+ const { submit } = useBuzzWorkflow();
769
+ const [ids, setIds] = useState<string[]>(() => {
770
+ try {
771
+ return JSON.parse(localStorage.getItem(SAVED) ?? '[]') as string[];
772
+ } catch {
773
+ return [];
774
+ }
775
+ });
776
+ useEffect(() => {
777
+ try {
778
+ localStorage.setItem(SAVED, JSON.stringify(ids));
779
+ } catch {
780
+ // Storage can be unavailable; the run still works, it just won't re-attach.
781
+ }
782
+ }, [ids]);
783
+
784
+ const { cells, summary, cancelAll, rewatch } = useWorkflowBatch(ids);
785
+ const [cancelNote, setCancelNote] = useState<string | null>(null);
786
+
787
+ async function runCell(body: WorkflowBody, key: string) {
788
+ const snap = await submit(body, { idempotencyKey: key });
789
+ if (snap.workflowId !== 'failed') setIds((prev) => [...prev, snap.workflowId]);
790
+ }
791
+
792
+ async function onCancelAll() {
793
+ const result = await cancelAll();
794
+ setCancelNote(
795
+ result.complete
796
+ ? null
797
+ : `${result.refused.length + result.notAttempted.length + result.failed.length} cells are still running. Try again in a few seconds.`,
798
+ );
799
+ }
800
+
801
+ return (
802
+ <div>
803
+ <button onClick={() => bodies.forEach((b, i) => void runCell(b, `cell-${i}`))}>Run grid</button>
804
+ <p>
805
+ {summary.succeeded} done, {summary.failed} failed, {summary.canceled} cancelled,{' '}
806
+ {summary.running} running. Spent {summary.realisedCost} Buzz.
807
+ </p>
808
+ {!summary.settled && <button onClick={onCancelAll}>Cancel all</button>}
809
+ {summary.stopped > 0 && <button onClick={rewatch}>Refresh stalled cells</button>}
810
+ {cancelNote && <p>{cancelNote}</p>}
811
+ <ul>
812
+ {cells.map((cell) => (
813
+ <li key={cell.workflowId}>{cell.snapshot?.status ?? 'queued'}</li>
814
+ ))}
815
+ </ul>
816
+ </div>
817
+ );
818
+ }
819
+ ```
820
+
821
+ **Mock host:** `createMockHost` and `Harness` keep a cancelled workflow
822
+ cancelled on later polls, report a finished workflow's real status when it is
823
+ cancelled, and count down an id they did not submit (one restored after a
824
+ reload) like any other. Set `generation.cancelRefused` to `true` (or a list of
825
+ workflow ids) to answer cancels the way the host does when the allowance is used
826
+ up, and `generation.workflowOutcome` to a `(workflowId, body) => 'succeeded' |
827
+ 'failed' | 'expired'` function to give a grid a mix of outcomes. The mock does
828
+ not model the rate limits themselves.
829
+
667
830
  ### `useBuzzPurchase()`
668
831
 
669
832
  Open the Buzz purchase modal. It raises the viewer's WALLET, never a spend cap:
@@ -163,9 +163,10 @@ export interface UseBatchEstimate {
163
163
  * - **`kind: 'training'` bodies are refused per cell.** Estimate a training run
164
164
  * with `useBuzzWorkflow().estimate()`.
165
165
  *
166
- * 🔴 ESTIMATE ONLY. There is no batch submit, watch or cancel and no id for the
167
- * list. The total is a quote: nothing reserves it and each cell is priced again
168
- * at `submit()`.
166
+ * 🔴 ESTIMATE ONLY. There is no batch submit and no server-side id for the
167
+ * list. To watch and cancel the submitted cells together, use
168
+ * `useWorkflowBatch()`. The total is a quote: nothing reserves it and each cell
169
+ * is priced again at `submit()`.
169
170
  *
170
171
  * Like `estimate()`, this does NOT prompt for consent. It needs the
171
172
  * `ai:write:budgeted` scope; without it the call rejects with `code: 'failed'`.
@@ -105,9 +105,10 @@ function toBatchEstimateError(err) {
105
105
  * - **`kind: 'training'` bodies are refused per cell.** Estimate a training run
106
106
  * with `useBuzzWorkflow().estimate()`.
107
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()`.
108
+ * 🔴 ESTIMATE ONLY. There is no batch submit and no server-side id for the
109
+ * list. To watch and cancel the submitted cells together, use
110
+ * `useWorkflowBatch()`. The total is a quote: nothing reserves it and each cell
111
+ * is priced again at `submit()`.
111
112
  *
112
113
  * Like `estimate()`, this does NOT prompt for consent. It needs the
113
114
  * `ai:write:budgeted` scope; without it the call rejects with `code: 'failed'`.
@@ -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
@@ -0,0 +1,352 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useBuzzWorkflow } 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 const WORKFLOW_BATCH_MAX_CELLS = 30;
15
+ /**
16
+ * How many `cancel()` requests {@link UseWorkflowBatch.cancelAll} keeps in flight.
17
+ *
18
+ * Cancels charge the ESTIMATE allowance (150 per 10 s per block instance). On a
19
+ * page app every viewer of the app shares that allowance, along with the host's
20
+ * other reads for the app (viewer, balance, image and model lookups). Four at a
21
+ * time keeps a 16-cell cancel-all from landing as one burst on an allowance other
22
+ * people are using. Not exported: it is a pacing detail, not a contract.
23
+ */
24
+ const CANCEL_CONCURRENCY = 4;
25
+ /** Statuses after which a workflow will not change again. */
26
+ const TERMINAL = new Set([
27
+ 'succeeded',
28
+ 'failed',
29
+ 'canceled',
30
+ 'expired',
31
+ ]);
32
+ /**
33
+ * Placeholder ids a resolved `submit()` can carry when no workflow was created
34
+ * (see `useBuzzWorkflow().submit`). There is nothing behind them to watch.
35
+ */
36
+ const NON_WORKFLOW_IDS = new Set(['failed', 'whatif']);
37
+ const FRESH = { snapshot: null, phase: 'watching', error: null, cancel: 'idle' };
38
+ function validateIds(ids) {
39
+ if (ids.length > WORKFLOW_BATCH_MAX_CELLS) {
40
+ throw new RangeError(`useWorkflowBatch: ${ids.length} workflow ids, but at most ${WORKFLOW_BATCH_MAX_CELLS} can be watched at once`);
41
+ }
42
+ const seen = new Set();
43
+ for (const id of ids) {
44
+ if (typeof id !== 'string' || id.length === 0) {
45
+ throw new TypeError('useWorkflowBatch: every workflow id must be a non-empty string');
46
+ }
47
+ if (NON_WORKFLOW_IDS.has(id)) {
48
+ throw new TypeError(`useWorkflowBatch: '${id}' is not a workflow id. A submit that resolved with it created nothing to watch`);
49
+ }
50
+ if (seen.has(id)) {
51
+ throw new TypeError(`useWorkflowBatch: workflow id '${id}' appears more than once`);
52
+ }
53
+ seen.add(id);
54
+ }
55
+ }
56
+ function summarise(cells) {
57
+ const summary = {
58
+ total: cells.length,
59
+ running: 0,
60
+ succeeded: 0,
61
+ failed: 0,
62
+ canceled: 0,
63
+ expired: 0,
64
+ stopped: 0,
65
+ realisedCost: 0,
66
+ costedCells: 0,
67
+ settled: true,
68
+ };
69
+ for (const cell of cells) {
70
+ const status = cell.snapshot?.status;
71
+ if (status === undefined || !TERMINAL.has(status)) {
72
+ summary.running += 1;
73
+ if (cell.phase === 'stopped')
74
+ summary.stopped += 1;
75
+ continue;
76
+ }
77
+ summary[status] += 1;
78
+ const total = cell.snapshot?.cost?.total;
79
+ if (typeof total === 'number' && Number.isFinite(total)) {
80
+ summary.realisedCost += total;
81
+ summary.costedCells += 1;
82
+ }
83
+ }
84
+ summary.settled = summary.running === 0;
85
+ return summary;
86
+ }
87
+ /**
88
+ * Watch a set of already-submitted workflows as one batch, with per-cell status,
89
+ * a summary, and cancel-all. For grid apps that submit N workflows one by one
90
+ * with `useBuzzWorkflow().submit()` (each with its own confirmation) and then
91
+ * have to follow all N.
92
+ *
93
+ * 🔴 THE APP OWNS THE LIST, AND THE LIST IS THE BATCH. Pass the workflow ids as
94
+ * an array: append each id as its `submit()` resolves, and persist the same
95
+ * array (it is plain strings) to re-attach after a reload by passing it back.
96
+ * There is no server-side batch id. The host knows nothing about the grouping,
97
+ * so nothing here changes what the host allows: every cell is one ordinary
98
+ * `watch()` and every cancel is one ordinary `cancel()`.
99
+ *
100
+ * What it guarantees, and the host limits behind each:
101
+ *
102
+ * - **One poll in flight per workflow.** Each cell is one `watch()` loop. When a
103
+ * loop is restarted (the list changes, `rewatch()`, a React StrictMode
104
+ * remount), the new loop waits for the old loop's in-flight poll to return
105
+ * first, so two polls for one workflow never overlap.
106
+ * - **At most {@link WORKFLOW_BATCH_MAX_CELLS} workflows.** That is the poll
107
+ * allowance (1,200 per 60 s, per block instance and viewer) divided by one
108
+ * watch's 40 polls a minute. A longer list throws.
109
+ * - **Cancel-all is paced and stops on refusal.** Cancels charge the estimate
110
+ * allowance (150 per 10 s per block instance, shared by every viewer of a page
111
+ * app). At most four are in flight. When the host answers one with a
112
+ * non-terminal snapshot (its answer when that allowance is used up), the rest
113
+ * of the call is not sent and comes back as `notAttempted`. Nothing is retried
114
+ * automatically. Call `cancelAll()` again after a few seconds.
115
+ * - **A host failure placeholder is not a workflow outcome.** When the host
116
+ * fails a poll or a cancel it answers with `{ workflowId: 'failed', status:
117
+ * 'failed' }`. `watch()` and `cancel()` resolve with that, but it says nothing
118
+ * about the workflow, which may still be running. This hook never counts it as
119
+ * a failed cell: a poll placeholder stops the cell (phase `'stopped'`, with
120
+ * `error` set), and a cancel placeholder is a `'failed'` cancel.
121
+ * - **Unmounting stops every poll.** No state updates happen after unmount. A
122
+ * `cancelAll()` already running keeps sending, because the viewer asked for it.
123
+ *
124
+ * Throws (at render) when the list is longer than
125
+ * {@link WORKFLOW_BATCH_MAX_CELLS}, has a duplicate, has an empty id, or has the
126
+ * `'failed'` / `'whatif'` placeholder a refused `submit()` resolves with.
127
+ *
128
+ * Needs the same `ai:write:budgeted` scope as `submit()`. A viewer who submitted
129
+ * already has it.
130
+ *
131
+ * @example
132
+ * const { submit } = useBuzzWorkflow();
133
+ * const [ids, setIds] = useState<string[]>(() => loadSavedIds());
134
+ * const batch = useWorkflowBatch(ids);
135
+ *
136
+ * async function runCell(body: WorkflowBody, key: string) {
137
+ * const snap = await submit(body, { idempotencyKey: key });
138
+ * if (snap.workflowId !== 'failed') setIds((prev) => [...prev, snap.workflowId]);
139
+ * }
140
+ *
141
+ * useEffect(() => saveIds(ids), [ids]);
142
+ *
143
+ * // batch.summary.running, batch.summary.realisedCost, batch.cells[i].snapshot …
144
+ * const result = await batch.cancelAll();
145
+ * if (!result.complete) showRetryCancel(result);
146
+ */
147
+ export function useWorkflowBatch(workflowIds, options) {
148
+ validateIds(workflowIds);
149
+ // A private `useBuzzWorkflow()` instance, so the app's own instance's
150
+ // `result`/`status` are not overwritten by 16 cells' updates.
151
+ const { watch, cancel } = useBuzzWorkflow();
152
+ const [states, setStates] = useState(() => new Map());
153
+ // Mirror of `states` for async code, which must not read a stale closure.
154
+ const statesRef = useRef(states);
155
+ statesRef.current = states;
156
+ const idsKey = workflowIds.join('\u0000');
157
+ const idsRef = useRef(workflowIds);
158
+ idsRef.current = workflowIds;
159
+ const optionsRef = useRef(options);
160
+ optionsRef.current = options;
161
+ const mountedRef = useRef(false);
162
+ /** Per workflow: the running loop's controller and a promise that settles when it ends. */
163
+ const loopsRef = useRef(new Map());
164
+ const cancelAllRef = useRef(null);
165
+ const patch = useCallback((workflowId, change) => {
166
+ if (!mountedRef.current)
167
+ return;
168
+ setStates((prev) => {
169
+ const next = new Map(prev);
170
+ next.set(workflowId, { ...(prev.get(workflowId) ?? FRESH), ...change });
171
+ return next;
172
+ });
173
+ }, []);
174
+ const startLoop = useCallback((workflowId) => {
175
+ const loops = loopsRef.current;
176
+ const previous = loops.get(workflowId);
177
+ previous?.controller.abort();
178
+ const controller = new AbortController();
179
+ // 🔴 CHAIN ON THE PREVIOUS LOOP. Aborting a `watch()` does not withdraw a
180
+ // poll already in flight (the host can hold it up to 15 s); the loop only
181
+ // sees the abort when that poll returns. Starting the new loop at once would
182
+ // put two polls for one workflow in flight.
183
+ const done = (previous?.done ?? Promise.resolve()).then(async () => {
184
+ if (controller.signal.aborted)
185
+ return;
186
+ try {
187
+ const final = await watch(workflowId, {
188
+ ...optionsRef.current,
189
+ signal: controller.signal,
190
+ onUpdate: (snapshot) => {
191
+ if (controller.signal.aborted || snapshot.workflowId !== workflowId)
192
+ return;
193
+ patch(workflowId, { snapshot });
194
+ },
195
+ });
196
+ if (controller.signal.aborted)
197
+ return;
198
+ if (final.workflowId !== workflowId) {
199
+ patch(workflowId, {
200
+ phase: 'stopped',
201
+ error: new Error(`useWorkflowBatch: the host answered a poll for ${workflowId} with a failure placeholder, not the workflow's status`),
202
+ });
203
+ return;
204
+ }
205
+ patch(workflowId, {
206
+ snapshot: final,
207
+ phase: TERMINAL.has(final.status) ? 'settled' : 'stopped',
208
+ error: null,
209
+ });
210
+ }
211
+ catch (err) {
212
+ if (controller.signal.aborted)
213
+ return;
214
+ patch(workflowId, { phase: 'stopped', error: err instanceof Error ? err : new Error(String(err)) });
215
+ }
216
+ });
217
+ loops.set(workflowId, { controller, done });
218
+ }, [watch, patch]);
219
+ useEffect(() => {
220
+ mountedRef.current = true;
221
+ const loops = loopsRef.current;
222
+ return () => {
223
+ mountedRef.current = false;
224
+ for (const loop of loops.values())
225
+ loop.controller.abort();
226
+ };
227
+ }, []);
228
+ // Diff the list against the running loops: start new ids, stop removed ones.
229
+ useEffect(() => {
230
+ const loops = loopsRef.current;
231
+ const wanted = new Set(idsRef.current);
232
+ for (const [id, loop] of loops) {
233
+ // Abort, but KEEP the entry: its `done` is what a re-added id's new loop
234
+ // must wait on, because the aborted loop's poll may still be in flight.
235
+ if (!wanted.has(id))
236
+ loop.controller.abort();
237
+ }
238
+ for (const id of idsRef.current) {
239
+ const loop = loops.get(id);
240
+ if (loop && !loop.controller.signal.aborted)
241
+ continue;
242
+ const state = statesRef.current.get(id);
243
+ // A settled or stopped cell stays as it is until `rewatch()`.
244
+ if (state && state.phase !== 'watching')
245
+ continue;
246
+ startLoop(id);
247
+ }
248
+ setStates((prev) => {
249
+ let changed = false;
250
+ const next = new Map(prev);
251
+ for (const id of prev.keys()) {
252
+ if (!wanted.has(id)) {
253
+ next.delete(id);
254
+ changed = true;
255
+ }
256
+ }
257
+ return changed ? next : prev;
258
+ });
259
+ // `idsKey` stands for the list's contents; the list itself is read from the ref.
260
+ }, [idsKey, startLoop]);
261
+ const rewatch = useCallback(() => {
262
+ for (const id of idsRef.current) {
263
+ if (statesRef.current.get(id)?.phase !== 'stopped')
264
+ continue;
265
+ patch(id, { phase: 'watching', error: null });
266
+ startLoop(id);
267
+ }
268
+ }, [patch, startLoop]);
269
+ const cancelAll = useCallback(() => {
270
+ if (cancelAllRef.current)
271
+ return cancelAllRef.current;
272
+ const run = async () => {
273
+ const ids = [...idsRef.current];
274
+ const outcome = new Map();
275
+ const targets = [];
276
+ for (const id of ids) {
277
+ const status = statesRef.current.get(id)?.snapshot?.status;
278
+ if (status !== undefined && TERMINAL.has(status))
279
+ outcome.set(id, 'alreadySettled');
280
+ else
281
+ targets.push(id);
282
+ }
283
+ let refusedSeen = false;
284
+ let next = 0;
285
+ const worker = async () => {
286
+ for (let id = targets[next]; id !== undefined; id = targets[next]) {
287
+ next += 1;
288
+ if (refusedSeen) {
289
+ outcome.set(id, 'notAttempted');
290
+ patch(id, { cancel: 'refused' });
291
+ continue;
292
+ }
293
+ patch(id, { cancel: 'pending' });
294
+ let snapshot;
295
+ try {
296
+ snapshot = await cancel(id);
297
+ }
298
+ catch {
299
+ outcome.set(id, 'failed');
300
+ patch(id, { cancel: 'failed' });
301
+ continue;
302
+ }
303
+ if (snapshot.workflowId !== id) {
304
+ // The host's failure placeholder: the request failed, the workflow's
305
+ // state is unknown.
306
+ outcome.set(id, 'failed');
307
+ patch(id, { cancel: 'failed' });
308
+ }
309
+ else if (TERMINAL.has(snapshot.status)) {
310
+ outcome.set(id, snapshot.status === 'canceled' ? 'canceled' : 'alreadySettled');
311
+ loopsRef.current.get(id)?.controller.abort();
312
+ patch(id, { cancel: 'done', snapshot, phase: 'settled', error: null });
313
+ }
314
+ else {
315
+ refusedSeen = true;
316
+ outcome.set(id, 'refused');
317
+ patch(id, { cancel: 'refused' });
318
+ }
319
+ }
320
+ };
321
+ await Promise.all(Array.from({ length: Math.min(CANCEL_CONCURRENCY, targets.length) }, () => worker()));
322
+ const result = {
323
+ canceled: [],
324
+ alreadySettled: [],
325
+ refused: [],
326
+ notAttempted: [],
327
+ failed: [],
328
+ complete: true,
329
+ };
330
+ for (const id of ids) {
331
+ const bucket = outcome.get(id);
332
+ if (bucket)
333
+ result[bucket].push(id);
334
+ }
335
+ result.complete =
336
+ result.refused.length === 0 && result.notAttempted.length === 0 && result.failed.length === 0;
337
+ return result;
338
+ };
339
+ const promise = run().finally(() => {
340
+ cancelAllRef.current = null;
341
+ });
342
+ cancelAllRef.current = promise;
343
+ return promise;
344
+ }, [cancel, patch]);
345
+ const cells = useMemo(() => workflowIds.map((workflowId) => ({ workflowId, ...(states.get(workflowId) ?? FRESH) })),
346
+ // `idsKey` stands for the list's contents, so a new array with the same ids
347
+ // does not rebuild the cells.
348
+ [idsKey, states]);
349
+ const summary = useMemo(() => summarise(cells), [cells]);
350
+ return { cells, summary, cancelAll, rewatch };
351
+ }
352
+ //# sourceMappingURL=useWorkflowBatch.js.map
package/dist/index.d.ts CHANGED
@@ -29,6 +29,8 @@ export { DEFAULT_WATCH_WAIT_SECONDS, useBuzzWorkflow, WorkflowEstimateError, Wor
29
29
  export type { SubmitWorkflowOptions, UseBuzzWorkflow, WatchWorkflowOptions, WorkflowSubmitErrorCode, } from './hooks/useBuzzWorkflow.js';
30
30
  export { BATCH_ESTIMATE_MAX_CELLS, BatchEstimateError, useBatchEstimate, } from './hooks/useBatchEstimate.js';
31
31
  export type { BatchEstimateCell, BatchEstimateErrorCode, BatchEstimateResult, BlockEstimateBatchAggregate, EstimateBatchOptions, UseBatchEstimate, } from './hooks/useBatchEstimate.js';
32
+ export { WORKFLOW_BATCH_MAX_CELLS, useWorkflowBatch } from './hooks/useWorkflowBatch.js';
33
+ export type { UseWorkflowBatch, WorkflowBatchCancelResult, WorkflowBatchCancelState, WorkflowBatchCell, WorkflowBatchCellPhase, WorkflowBatchOptions, WorkflowBatchSummary, } from './hooks/useWorkflowBatch.js';
32
34
  export { useEntitlements } from './hooks/useEntitlements.js';
33
35
  export type { UseEntitlements, Entitlement } from './hooks/useEntitlements.js';
34
36
  export { useGoodPurchase, GoodPurchaseRefusal } from './hooks/useGoodPurchase.js';
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ export { useBlockToken } from './hooks/useBlockToken.js';
32
32
  export { useHostOrigin } from './hooks/useHostOrigin.js';
33
33
  export { DEFAULT_WATCH_WAIT_SECONDS, useBuzzWorkflow, WorkflowEstimateError, WorkflowSubmitError, } from './hooks/useBuzzWorkflow.js';
34
34
  export { BATCH_ESTIMATE_MAX_CELLS, BatchEstimateError, useBatchEstimate, } from './hooks/useBatchEstimate.js';
35
+ export { WORKFLOW_BATCH_MAX_CELLS, useWorkflowBatch } from './hooks/useWorkflowBatch.js';
35
36
  export { useEntitlements } from './hooks/useEntitlements.js';
36
37
  export { useGoodPurchase, GoodPurchaseRefusal } from './hooks/useGoodPurchase.js';
37
38
  export { useTip } from './hooks/useTip.js';
@@ -195,6 +195,29 @@ export interface MockGenerationScenario {
195
195
  * `batchEstimate: 'supported'`. Default: unset (no cell times out).
196
196
  */
197
197
  batchEstimateTimedOutCells?: number[];
198
+ /**
199
+ * Make `CANCEL_WORKFLOW` answer the way the real host answers a cancel it
200
+ * REFUSED because the estimate allowance is used up: `{ workflowId, status:
201
+ * 'processing' }`. The cancel is not applied, so later polls keep reporting
202
+ * the workflow as running. `true` refuses every cancel; an array refuses only
203
+ * those workflow ids. Use it to test a cancel button's "not cancelled, try
204
+ * again" path, including `useWorkflowBatch().cancelAll()`'s `refused` and
205
+ * `notAttempted` lists.
206
+ *
207
+ * Live-tunable via `setScenario({ generation: { cancelRefused } })`. Default:
208
+ * unset (every cancel applies).
209
+ */
210
+ cancelRefused?: boolean | readonly string[];
211
+ /**
212
+ * The terminal status each polled workflow ends with, so a grid can be tested
213
+ * with a mix of outcomes. Called once per workflow, on the poll that finishes
214
+ * it, with the workflow id and the body it was submitted with (`undefined`
215
+ * for an id this mock did not submit, such as one restored after a reload).
216
+ *
217
+ * `'failed'` and `'expired'` reply `{ workflowId, status, error }` with no
218
+ * `cost` and no images. Default: every workflow `'succeeded'`.
219
+ */
220
+ workflowOutcome?: (workflowId: string, body: WorkflowBody | undefined) => 'succeeded' | 'failed' | 'expired';
198
221
  /**
199
222
  * Force every SUBMIT to come back as a caught server EXCEPTION — the host's
200
223
  * real `failureSnapshot(err)` shape: `{ workflowId:'failed', status:'failed',
@@ -1084,6 +1084,9 @@ export function createMockHost(options = {}) {
1084
1084
  let tokenSerial = 0;
1085
1085
  let submitCount = 0;
1086
1086
  // body + cost remembered per workflow so the succeeded snapshot can echo them.
1087
+ // `body` is absent for an id this mock never submitted (a workflow id an app
1088
+ // restored after a reload). `canceled` is set by `CANCEL_WORKFLOW`, so later
1089
+ // polls keep answering `canceled` instead of resuming the countdown.
1087
1090
  const workflows = new Map();
1088
1091
  // Training: datasets this install prepared (id → admitted count) and the
1089
1092
  // quotes its estimates stored (id → what a run is checked against).
@@ -1703,11 +1706,35 @@ export function createMockHost(options = {}) {
1703
1706
  }
1704
1707
  case 'POLL_WORKFLOW': {
1705
1708
  const workflowId = typed.payload?.workflowId ?? '';
1706
- const wf = workflows.get(workflowId);
1707
- const polls = (wf?.polls ?? 0) + 1;
1708
- if (wf)
1709
- wf.polls = polls;
1710
- if (polls >= pollsUntilDone) {
1709
+ // An id this mock did not submit (e.g. restored from storage after a
1710
+ // reload) gets a record on its first poll, so it counts down to done
1711
+ // like any other instead of answering `processing` forever.
1712
+ let wf = workflows.get(workflowId);
1713
+ if (!wf) {
1714
+ wf = { polls: 0, cost: legacyCost };
1715
+ workflows.set(workflowId, wf);
1716
+ }
1717
+ if (wf.canceled) {
1718
+ dispatchToBlock({
1719
+ type: 'WORKFLOW_STATUS',
1720
+ payload: { requestId, snapshot: { workflowId, status: 'canceled' } },
1721
+ });
1722
+ return;
1723
+ }
1724
+ const polls = wf.polls + 1;
1725
+ wf.polls = polls;
1726
+ const outcome = polls >= pollsUntilDone ? (gen.workflowOutcome?.(workflowId, wf.body) ?? 'succeeded') : null;
1727
+ if (outcome === 'failed' || outcome === 'expired') {
1728
+ dispatchToBlock({
1729
+ type: 'WORKFLOW_STATUS',
1730
+ payload: {
1731
+ requestId,
1732
+ snapshot: { workflowId, status: outcome, error: `mock: workflow ${outcome}` },
1733
+ },
1734
+ });
1735
+ return;
1736
+ }
1737
+ if (outcome === 'succeeded') {
1711
1738
  // Apply synthetic latency on the terminal (succeeded) poll only.
1712
1739
  const delay = latencyFor();
1713
1740
  if (delay > 0) {
@@ -1733,7 +1760,35 @@ export function createMockHost(options = {}) {
1733
1760
  }
1734
1761
  case 'CANCEL_WORKFLOW': {
1735
1762
  const workflowId = typed.payload?.workflowId ?? '';
1736
- workflows.delete(workflowId);
1763
+ const refused = gen.cancelRefused;
1764
+ if (refused === true || (Array.isArray(refused) && refused.includes(workflowId))) {
1765
+ // The real host's answer when the estimate allowance is used up: a
1766
+ // NON-terminal snapshot, and the cancel is not sent.
1767
+ dispatchToBlock({
1768
+ type: 'WORKFLOW_CANCELED',
1769
+ payload: { requestId, snapshot: { workflowId, status: 'processing' } },
1770
+ });
1771
+ return;
1772
+ }
1773
+ const wf = workflows.get(workflowId);
1774
+ if (wf && !wf.canceled && wf.polls >= pollsUntilDone) {
1775
+ // Already finished: like the real host, report the real terminal
1776
+ // status rather than pretending the cancel took.
1777
+ const outcome = gen.workflowOutcome?.(workflowId, wf.body) ?? 'succeeded';
1778
+ dispatchToBlock({
1779
+ type: 'WORKFLOW_CANCELED',
1780
+ payload: {
1781
+ requestId,
1782
+ snapshot: outcome === 'succeeded'
1783
+ ? succeededSnapshot(workflowId)
1784
+ : { workflowId, status: outcome, error: `mock: workflow ${outcome}` },
1785
+ },
1786
+ });
1787
+ return;
1788
+ }
1789
+ // Keep the record, marked canceled, so later polls answer `canceled`
1790
+ // rather than counting down to `succeeded`.
1791
+ workflows.set(workflowId, { ...(wf ?? { polls: 0, cost: legacyCost }), canceled: true });
1737
1792
  dispatchToBlock({
1738
1793
  type: 'WORKFLOW_CANCELED',
1739
1794
  payload: { requestId, snapshot: { workflowId, status: 'canceled' } },
@@ -248,6 +248,12 @@ export function isValidWorkflowSnapshot(s) {
248
248
  }
249
249
  if (s.error !== undefined && typeof s.error !== 'string')
250
250
  return false;
251
+ // OPTIONAL: set only on the refusals that define a code (today the training
252
+ // price-quote refusal), absent everywhere else. Checked as a STRING, not
253
+ // against today's codes — the open-ended-union rule below: a host adding a
254
+ // code must not turn into a dropped reply in every shipped bundle.
255
+ if (s.errorCode !== undefined && typeof s.errorCode !== 'string')
256
+ return false;
251
257
  if (s.autoClaim !== undefined) {
252
258
  if (!isObject(s.autoClaim))
253
259
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/blocks-react",
3
- "version": "0.68.0",
3
+ "version": "0.69.0",
4
4
  "description": "React hooks and iframe transport for Civitai Apps. Pairs with @civitai/app-sdk/blocks.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -58,7 +58,7 @@
58
58
  "typescript": "^5.9.2",
59
59
  "vite": "^8.0.14",
60
60
  "vitest": "^4.1.11",
61
- "@civitai/app-sdk": "^0.64.0"
61
+ "@civitai/app-sdk": "^0.65.0"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public"