@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.
- package/README.md +520 -8
- package/dist/hooks/useBatchEstimate.d.ts +191 -0
- package/dist/hooks/useBatchEstimate.js +196 -0
- package/dist/hooks/useBlockAnalytics.d.ts +46 -4
- package/dist/hooks/useBlockAnalytics.js +41 -4
- package/dist/hooks/useBuzzWorkflow.d.ts +4 -1
- package/dist/hooks/useBuzzWorkflow.js +4 -1
- package/dist/hooks/useResourcePicker.d.ts +98 -37
- package/dist/hooks/useResourcePicker.js +77 -7
- package/dist/hooks/useSharedStorage.d.ts +24 -0
- package/dist/hooks/useSharedStorage.js +55 -0
- package/dist/hooks/useWorkflowBatch.d.ts +196 -0
- package/dist/hooks/useWorkflowBatch.js +352 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -2
- package/dist/internal/liveHost.d.ts +27 -2
- package/dist/internal/liveHost.js +133 -15
- package/dist/internal/mockHost.d.ts +104 -2
- package/dist/internal/mockHost.js +369 -84
- package/dist/internal/pickerOverlay.d.ts +32 -0
- package/dist/internal/pickerOverlay.js +99 -2
- package/dist/internal/trackEventCheck.d.ts +34 -0
- package/dist/internal/trackEventCheck.js +194 -0
- package/dist/transport/requestTimeouts.js +3 -0
- package/dist/transport/validate.d.ts +49 -6
- package/dist/transport/validate.js +138 -28
- package/package.json +2 -2
|
@@ -1,43 +1,87 @@
|
|
|
1
1
|
import type { BlockResourceInfo, BlockResourcePickerType } from '@civitai/app-sdk/blocks';
|
|
2
|
+
/**
|
|
3
|
+
* The most resources ONE multi-select `open({ multiple })` can return — the
|
|
4
|
+
* same cap the generation body puts on `additionalResources`, so a full batch
|
|
5
|
+
* is always one a submit accepts. A larger `multiple.max` is CLAMPED to this.
|
|
6
|
+
*/
|
|
7
|
+
export declare const RESOURCE_PICKER_MULTIPLE_MAX = 5;
|
|
8
|
+
/** The options every {@link UseResourcePicker} `open` call takes. */
|
|
9
|
+
export interface ResourcePickerOpenOptions {
|
|
10
|
+
/** Which resource type to pick. v1: `'Checkpoint' | 'LORA'` only — the
|
|
11
|
+
* host rejects any other type (the modal never opens). */
|
|
12
|
+
resourceType: BlockResourcePickerType;
|
|
13
|
+
/**
|
|
14
|
+
* 🔴 OMIT THIS BY DEFAULT. It is an optional base-model family FILTER, not a
|
|
15
|
+
* label: whatever you pass, the host HIDES every resource outside that
|
|
16
|
+
* ecosystem, so the viewer's own perfectly valid LoRAs simply do not appear
|
|
17
|
+
* and the picker looks empty or broken. Omitting it is an unconstrained pick
|
|
18
|
+
* of the type — every resource the viewer can reach.
|
|
19
|
+
*
|
|
20
|
+
* Pass it ONLY when the block already holds a chosen checkpoint the pick has
|
|
21
|
+
* to match, and then DERIVE it from that checkpoint. Never a hardcoded
|
|
22
|
+
* ecosystem string: a literal pins every viewer of the app to whichever
|
|
23
|
+
* family the author happened to be testing with. Nor a literal reached
|
|
24
|
+
* through a fallback — `checkpoint?.baseModel ?? 'SDXL'` re-pins every viewer
|
|
25
|
+
* who has not picked yet, which is the same defect wearing a default.
|
|
26
|
+
*
|
|
27
|
+
* 🔴 WHERE THE FAMILY COMES FROM ON THIS HOOK'S ONLY SURFACE. This hook is
|
|
28
|
+
* PAGE-ONLY (`OPEN_RESOURCE_PICKER` is a page affordance; the model slot has
|
|
29
|
+
* the narrower `useCheckpointPicker` instead), and a page slot carries no
|
|
30
|
+
* checkpoint: `checkpoint` exists on `ModelSlotContext` alone, and
|
|
31
|
+
* `BlockContext` is a union with no index signature, so
|
|
32
|
+
* `useBlockContext().context.checkpoint` does not even typecheck. The source
|
|
33
|
+
* is {@link BlockResourceInfo}`.baseModel` — the `baseModel` of a Checkpoint
|
|
34
|
+
* THIS picker returned earlier:
|
|
35
|
+
*
|
|
36
|
+
* const checkpoint = await open({ resourceType: 'Checkpoint' });
|
|
37
|
+
* if (checkpoint) await open({ resourceType: 'LORA', baseModelGroup: checkpoint.baseModel });
|
|
38
|
+
*
|
|
39
|
+
* Accepts an ecosystem key (e.g. 'Flux1', 'SDXL') OR a baseModel name (e.g.
|
|
40
|
+
* 'Flux.1 D'); the host collapses either to the ecosystem family. `''` is not
|
|
41
|
+
* a way to widen: the page host drops a zero-length value, so it is only ever
|
|
42
|
+
* a confusing spelling of omitting the key.
|
|
43
|
+
*/
|
|
44
|
+
baseModelGroup?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Multi-select: let the viewer pick up to `max` resources in ONE picker session.
|
|
48
|
+
*
|
|
49
|
+
* - **LoRA only.** `open({ resourceType: 'Checkpoint', multiple })` is a type
|
|
50
|
+
* error, and at runtime it REJECTS — it is never quietly treated as a single
|
|
51
|
+
* pick. A generation takes one checkpoint and a list of LoRAs.
|
|
52
|
+
* - **`max` is a whole number ≥ 1.** Anything above
|
|
53
|
+
* {@link RESOURCE_PICKER_MULTIPLE_MAX} (5) is CLAMPED to it; anything that is
|
|
54
|
+
* not a whole number ≥ 1 rejects.
|
|
55
|
+
* - `baseModelGroup` works exactly as it does for a single pick.
|
|
56
|
+
*/
|
|
57
|
+
export interface ResourcePickerMultiple {
|
|
58
|
+
max: number;
|
|
59
|
+
}
|
|
2
60
|
/** What {@link useResourcePicker} returns. */
|
|
3
61
|
export interface UseResourcePicker {
|
|
4
|
-
open:
|
|
5
|
-
/** Which resource type to pick. v1: `'Checkpoint' | 'LORA'` only — the
|
|
6
|
-
* host rejects any other type (the modal never opens). */
|
|
7
|
-
resourceType: BlockResourcePickerType;
|
|
62
|
+
open: {
|
|
8
63
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* who has not picked yet, which is the same defect wearing a default.
|
|
21
|
-
*
|
|
22
|
-
* 🔴 WHERE THE FAMILY COMES FROM ON THIS HOOK'S ONLY SURFACE. This hook is
|
|
23
|
-
* PAGE-ONLY (`OPEN_RESOURCE_PICKER` is a page affordance; the model slot has
|
|
24
|
-
* the narrower `useCheckpointPicker` instead), and a page slot carries no
|
|
25
|
-
* checkpoint: `checkpoint` exists on `ModelSlotContext` alone, and
|
|
26
|
-
* `BlockContext` is a union with no index signature, so
|
|
27
|
-
* `useBlockContext().context.checkpoint` does not even typecheck. The source
|
|
28
|
-
* is {@link BlockResourceInfo}`.baseModel` — the `baseModel` of a Checkpoint
|
|
29
|
-
* THIS picker returned earlier:
|
|
30
|
-
*
|
|
31
|
-
* const checkpoint = await open({ resourceType: 'Checkpoint' });
|
|
32
|
-
* if (checkpoint) await open({ resourceType: 'LORA', baseModelGroup: checkpoint.baseModel });
|
|
64
|
+
* Single pick. Resolves with the chosen resource, or `null` when the viewer
|
|
65
|
+
* dismissed the picker.
|
|
66
|
+
*/
|
|
67
|
+
(opts: ResourcePickerOpenOptions & {
|
|
68
|
+
multiple?: undefined;
|
|
69
|
+
}): Promise<BlockResourceInfo | null>;
|
|
70
|
+
/**
|
|
71
|
+
* Multi-select (see {@link ResourcePickerMultiple}). Resolves with the
|
|
72
|
+
* picked resources IN THE ORDER THE VIEWER PICKED THEM — never more than
|
|
73
|
+
* `max` — or `[]` when the viewer dismissed. Each entry is the same
|
|
74
|
+
* {@link BlockResourceInfo} a single pick returns.
|
|
33
75
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* a confusing spelling of omitting the key.
|
|
76
|
+
* Against a host that predates multi-select the viewer gets the single-pick
|
|
77
|
+
* picker, and this still resolves with a list: of the one resource they
|
|
78
|
+
* picked, or `[]`.
|
|
38
79
|
*/
|
|
39
|
-
|
|
40
|
-
|
|
80
|
+
(opts: ResourcePickerOpenOptions & {
|
|
81
|
+
resourceType: 'LORA';
|
|
82
|
+
multiple: ResourcePickerMultiple;
|
|
83
|
+
}): Promise<BlockResourceInfo[]>;
|
|
84
|
+
};
|
|
41
85
|
}
|
|
42
86
|
/**
|
|
43
87
|
* Drives the platform-side resource picker for PAGE App Blocks (Design 1 —
|
|
@@ -51,6 +95,13 @@ export interface UseResourcePicker {
|
|
|
51
95
|
* pick. Resolves with the chosen {@link BlockResourceInfo}, or `null` when the
|
|
52
96
|
* user dismissed without picking.
|
|
53
97
|
*
|
|
98
|
+
* MULTI-SELECT: pass `multiple: { max }` with `resourceType: 'LORA'` and the
|
|
99
|
+
* viewer picks up to `max` LoRAs (at most {@link RESOURCE_PICKER_MULTIPLE_MAX})
|
|
100
|
+
* in ONE picker session. `open` then resolves with a LIST — the picks in the
|
|
101
|
+
* order the viewer made them, `[]` on dismiss — instead of a single resource.
|
|
102
|
+
* See {@link ResourcePickerMultiple}. Without `multiple`, nothing about a
|
|
103
|
+
* single pick changes.
|
|
104
|
+
*
|
|
54
105
|
* DISCOVERY ONLY: the returned `versionId` is a hint, never an entitlement.
|
|
55
106
|
* Feed it into `body.modelVersionId` (Checkpoint) or
|
|
56
107
|
* `body.additionalResources` (LoRA) and submit — the host re-validates every id
|
|
@@ -61,14 +112,14 @@ export interface UseResourcePicker {
|
|
|
61
112
|
* Host-mediated, same trust model as `useCheckpointPicker` / `useBuzzWorkflow`:
|
|
62
113
|
* the block never touches the picker UI directly.
|
|
63
114
|
*
|
|
64
|
-
* 🔴 THE ORDER OF THE
|
|
65
|
-
*
|
|
115
|
+
* 🔴 THE ORDER OF THE `@example` BLOCKS BELOW IS LOAD-BEARING — DO NOT MOVE
|
|
116
|
+
* THE LAST ONE. `<civitai-developer-docs>/scripts/gen-appblocks-hooks.mjs` assigns
|
|
66
117
|
* `jsdocExample` inside its tag loop, so on a hook with several `@example` tags
|
|
67
118
|
* the LAST one wins and becomes the single published example whenever the README
|
|
68
119
|
* fence is unavailable (renamed heading, dropped fence). The unconstrained call
|
|
69
120
|
* is what every agent should copy, so it is last. The constrained variant is
|
|
70
121
|
* first, where a human reading top-down still meets the exception before the
|
|
71
|
-
* default it excepts.
|
|
122
|
+
* default it excepts; the multi-select variant sits between them.
|
|
72
123
|
*
|
|
73
124
|
* @example
|
|
74
125
|
* // CONSTRAINED — only when the pick has to match a checkpoint the block
|
|
@@ -83,6 +134,16 @@ export interface UseResourcePicker {
|
|
|
83
134
|
* }
|
|
84
135
|
*
|
|
85
136
|
* @example
|
|
137
|
+
* // MULTI-SELECT — several LoRAs in one picker session, in pick order.
|
|
138
|
+
* const { open } = useResourcePicker();
|
|
139
|
+
* const loras = await open({ resourceType: 'LORA', multiple: { max: 3 } });
|
|
140
|
+
* // [] when dismissed; otherwise up to 3 picks, in the order the viewer chose
|
|
141
|
+
* const additionalResources = loras.map((lora) => ({
|
|
142
|
+
* modelVersionId: lora.versionId,
|
|
143
|
+
* strength: lora.strength ?? 1,
|
|
144
|
+
* }));
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
86
147
|
* // THE DEFAULT — no ecosystem filter at all. The viewer sees every LoRA they
|
|
87
148
|
* // can reach, which is almost always what you want.
|
|
88
149
|
* const { open } = useResourcePicker();
|
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import { useCallback } from 'react';
|
|
2
|
+
import { throwOnReplyError } from '../internal/replyError.js';
|
|
2
3
|
import { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
|
|
3
4
|
import { getTransport } from '../transport/singleton.js';
|
|
4
5
|
import { sendTypedRequest } from '../transport/transport.js';
|
|
6
|
+
/**
|
|
7
|
+
* The most resources ONE multi-select `open({ multiple })` can return — the
|
|
8
|
+
* same cap the generation body puts on `additionalResources`, so a full batch
|
|
9
|
+
* is always one a submit accepts. A larger `multiple.max` is CLAMPED to this.
|
|
10
|
+
*/
|
|
11
|
+
export const RESOURCE_PICKER_MULTIPLE_MAX = 5;
|
|
12
|
+
/**
|
|
13
|
+
* The LoRA family, lower-cased. The host's picker accepts `LoCon` and `DoRA`
|
|
14
|
+
* beside `LORA` and matches case-insensitively, so the runtime check here does
|
|
15
|
+
* too — a caller reaching those through a cast is not refused by the SDK for
|
|
16
|
+
* something the host allows.
|
|
17
|
+
*/
|
|
18
|
+
const MULTIPLE_RESOURCE_TYPES = new Set(['lora', 'locon', 'dora']);
|
|
5
19
|
/**
|
|
6
20
|
* Drives the platform-side resource picker for PAGE App Blocks (Design 1 —
|
|
7
21
|
* host-chrome). Generalizes {@link useCheckpointPicker} from Checkpoint-only to
|
|
@@ -14,6 +28,13 @@ import { sendTypedRequest } from '../transport/transport.js';
|
|
|
14
28
|
* pick. Resolves with the chosen {@link BlockResourceInfo}, or `null` when the
|
|
15
29
|
* user dismissed without picking.
|
|
16
30
|
*
|
|
31
|
+
* MULTI-SELECT: pass `multiple: { max }` with `resourceType: 'LORA'` and the
|
|
32
|
+
* viewer picks up to `max` LoRAs (at most {@link RESOURCE_PICKER_MULTIPLE_MAX})
|
|
33
|
+
* in ONE picker session. `open` then resolves with a LIST — the picks in the
|
|
34
|
+
* order the viewer made them, `[]` on dismiss — instead of a single resource.
|
|
35
|
+
* See {@link ResourcePickerMultiple}. Without `multiple`, nothing about a
|
|
36
|
+
* single pick changes.
|
|
37
|
+
*
|
|
17
38
|
* DISCOVERY ONLY: the returned `versionId` is a hint, never an entitlement.
|
|
18
39
|
* Feed it into `body.modelVersionId` (Checkpoint) or
|
|
19
40
|
* `body.additionalResources` (LoRA) and submit — the host re-validates every id
|
|
@@ -24,14 +45,14 @@ import { sendTypedRequest } from '../transport/transport.js';
|
|
|
24
45
|
* Host-mediated, same trust model as `useCheckpointPicker` / `useBuzzWorkflow`:
|
|
25
46
|
* the block never touches the picker UI directly.
|
|
26
47
|
*
|
|
27
|
-
* 🔴 THE ORDER OF THE
|
|
28
|
-
*
|
|
48
|
+
* 🔴 THE ORDER OF THE `@example` BLOCKS BELOW IS LOAD-BEARING — DO NOT MOVE
|
|
49
|
+
* THE LAST ONE. `<civitai-developer-docs>/scripts/gen-appblocks-hooks.mjs` assigns
|
|
29
50
|
* `jsdocExample` inside its tag loop, so on a hook with several `@example` tags
|
|
30
51
|
* the LAST one wins and becomes the single published example whenever the README
|
|
31
52
|
* fence is unavailable (renamed heading, dropped fence). The unconstrained call
|
|
32
53
|
* is what every agent should copy, so it is last. The constrained variant is
|
|
33
54
|
* first, where a human reading top-down still meets the exception before the
|
|
34
|
-
* default it excepts.
|
|
55
|
+
* default it excepts; the multi-select variant sits between them.
|
|
35
56
|
*
|
|
36
57
|
* @example
|
|
37
58
|
* // CONSTRAINED — only when the pick has to match a checkpoint the block
|
|
@@ -46,6 +67,16 @@ import { sendTypedRequest } from '../transport/transport.js';
|
|
|
46
67
|
* }
|
|
47
68
|
*
|
|
48
69
|
* @example
|
|
70
|
+
* // MULTI-SELECT — several LoRAs in one picker session, in pick order.
|
|
71
|
+
* const { open } = useResourcePicker();
|
|
72
|
+
* const loras = await open({ resourceType: 'LORA', multiple: { max: 3 } });
|
|
73
|
+
* // [] when dismissed; otherwise up to 3 picks, in the order the viewer chose
|
|
74
|
+
* const additionalResources = loras.map((lora) => ({
|
|
75
|
+
* modelVersionId: lora.versionId,
|
|
76
|
+
* strength: lora.strength ?? 1,
|
|
77
|
+
* }));
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
49
80
|
* // THE DEFAULT — no ecosystem filter at all. The viewer sees every LoRA they
|
|
50
81
|
* // can reach, which is almost always what you want.
|
|
51
82
|
* const { open } = useResourcePicker();
|
|
@@ -56,16 +87,55 @@ import { sendTypedRequest } from '../transport/transport.js';
|
|
|
56
87
|
*/
|
|
57
88
|
export function useResourcePicker() {
|
|
58
89
|
const open = useCallback(async (opts) => {
|
|
59
|
-
|
|
90
|
+
if (opts.multiple == null) {
|
|
91
|
+
const { selected } = await sendTypedRequest(getTransport(), {
|
|
92
|
+
type: 'OPEN_RESOURCE_PICKER',
|
|
93
|
+
payload: {
|
|
94
|
+
resourceType: opts.resourceType,
|
|
95
|
+
...(opts.baseModelGroup != null ? { baseModelGroup: opts.baseModelGroup } : {}),
|
|
96
|
+
},
|
|
97
|
+
}, 'RESOURCE_PICKER_RESULT', { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
|
|
98
|
+
// Normalize the "dismissed" case to an explicit null so callers can
|
|
99
|
+
// `if (!picked) return;` without an `undefined` ambiguity.
|
|
100
|
+
return selected ?? null;
|
|
101
|
+
}
|
|
102
|
+
// ── Multi-select ──────────────────────────────────────────────────────
|
|
103
|
+
// Refused HERE, before anything is sent, for two reasons: the caller gets
|
|
104
|
+
// an error naming the mistake instead of a picker that never opens, and a
|
|
105
|
+
// host that predates multi-select never sees a request it would answer
|
|
106
|
+
// with the wrong thing (it ignores `multiple` and would happily open a
|
|
107
|
+
// single CHECKPOINT pick).
|
|
108
|
+
if (!MULTIPLE_RESOURCE_TYPES.has(String(opts.resourceType).trim().toLowerCase())) {
|
|
109
|
+
throw new Error(`useResourcePicker: \`multiple\` is only supported for LoRA picks, not ` +
|
|
110
|
+
`resourceType "${String(opts.resourceType)}". Call open() without \`multiple\` for a single pick.`);
|
|
111
|
+
}
|
|
112
|
+
const requestedMax = opts.multiple.max;
|
|
113
|
+
if (typeof requestedMax !== 'number' || !Number.isInteger(requestedMax) || requestedMax < 1) {
|
|
114
|
+
throw new Error('useResourcePicker: `multiple.max` must be a whole number of at least 1 ' +
|
|
115
|
+
`(values above ${RESOURCE_PICKER_MULTIPLE_MAX} are clamped to ${RESOURCE_PICKER_MULTIPLE_MAX}).`);
|
|
116
|
+
}
|
|
117
|
+
const max = Math.min(requestedMax, RESOURCE_PICKER_MULTIPLE_MAX);
|
|
118
|
+
const result = await sendTypedRequest(getTransport(), {
|
|
60
119
|
type: 'OPEN_RESOURCE_PICKER',
|
|
61
120
|
payload: {
|
|
62
121
|
resourceType: opts.resourceType,
|
|
63
122
|
...(opts.baseModelGroup != null ? { baseModelGroup: opts.baseModelGroup } : {}),
|
|
123
|
+
multiple: { max },
|
|
64
124
|
},
|
|
65
125
|
}, 'RESOURCE_PICKER_RESULT', { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
126
|
+
// The host answers a multi request it will not open with `{ error }`.
|
|
127
|
+
throwOnReplyError(result, 'The host refused the multi-select resource picker request.');
|
|
128
|
+
// A host that knows multi-select answers `selectedResources` (`[]` on
|
|
129
|
+
// dismiss). One that predates it ignored `multiple`, opened a single pick
|
|
130
|
+
// and answered `selected` (or nothing, on dismiss) — still a list here, of
|
|
131
|
+
// one or none, so the caller has ONE result shape to handle either way.
|
|
132
|
+
const picked = Array.isArray(result.selectedResources)
|
|
133
|
+
? result.selectedResources
|
|
134
|
+
: result.selected
|
|
135
|
+
? [result.selected]
|
|
136
|
+
: [];
|
|
137
|
+
// Belt: never hand back more than was asked for.
|
|
138
|
+
return picked.slice(0, max);
|
|
69
139
|
}, []);
|
|
70
140
|
return { open };
|
|
71
141
|
}
|
|
@@ -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
|