@civitai/blocks-react 0.76.1 → 0.78.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.
@@ -154,33 +154,81 @@ const trainingStepType = (body) => body.kind === 'training' ? 'training' : passT
154
154
  */
155
155
  export const MOCK_TRAINING_BOUNDS = {
156
156
  datasetMaxItems: 50,
157
- captionMaxChars: 1000,
157
+ captionMaxChars: 8192,
158
158
  maxBuzzPerRun: 5000,
159
159
  };
160
+ /** A consumer-blob key: the host's `BLOB_KEY_REGEX` (civitai/civitai#6461). */
161
+ const MOCK_BLOB_KEY = /^[A-Za-z0-9]{1,64}(\.[A-Za-z0-9]{1,8})?$/;
162
+ /** The path a blob url names its key under. */
163
+ const MOCK_BLOB_PATH_PREFIX = '/v2/consumer/blobs/';
164
+ /** The host's cap on an item's `air` string (`SOURCE_IMAGE_URL_MAX`). */
165
+ const MOCK_AIR_MAX_CHARS = 2048;
160
166
  /**
161
- * The host's `PREPARE_TRAINING_DATASET` item check, mirrored: the server's
162
- * `blockTrainingDatasetItemsSchema` — 1…50 items, each EXACTLY `{ imageId:
163
- * positive integer, caption: string ≤ 1000 }`. The real host runs this BEFORE any
164
- * server call and refuses with `invalid training dataset`, so the mock does too.
167
+ * The blob key an item's `air` names, or `null` — the host's
168
+ * `trainingBlobKeyFromAir`: the bare key, or an https url whose path is exactly
169
+ * `/v2/consumer/blobs/<key>` (its query dropped).
170
+ *
171
+ * ⚠️ NARROWER THAN THE HOST: the real check also requires the url to be on a
172
+ * Civitai-hosted origin; the mock accepts any https host so a harness can use
173
+ * placeholder urls. Name an item by its `air` (the bare key) and the two agree.
174
+ */
175
+ const mockBlobKeyFromAir = (air) => {
176
+ if (MOCK_BLOB_KEY.test(air))
177
+ return air;
178
+ let url;
179
+ try {
180
+ url = new URL(air);
181
+ }
182
+ catch {
183
+ return null;
184
+ }
185
+ if (url.protocol !== 'https:' || !url.pathname.startsWith(MOCK_BLOB_PATH_PREFIX))
186
+ return null;
187
+ const key = url.pathname.slice(MOCK_BLOB_PATH_PREFIX.length);
188
+ return MOCK_BLOB_KEY.test(key) ? key : null;
189
+ };
190
+ /** The host's key identity (`trainingBlobKeyIdentity`): keys are case-insensitive. */
191
+ const blobKeyIdentity = (key) => key.toLowerCase();
192
+ /**
193
+ * The server's `kind: 'training'` item schema (`blockTrainingDatasetItemsSchema`,
194
+ * civitai/civitai#6461), mirrored for the ESTIMATE: 1…50 items, each EXACTLY
195
+ * `{ air, caption }` with a caption up to 8,192 characters, every `air` a blob key
196
+ * or blob url, and no blob named twice. Returns the items with each `air`
197
+ * canonicalised to its bare key — what the server quotes and hashes — or the
198
+ * refusal message.
165
199
  */
166
- function isValidTrainingDatasetItems(items) {
200
+ function resolveTrainingItems(items) {
167
201
  if (!Array.isArray(items))
168
- return false;
169
- if (items.length < 1 || items.length > MOCK_TRAINING_BOUNDS.datasetMaxItems)
170
- return false;
171
- return items.every((it) => {
202
+ return { refusal: MOCK_TRAINING_ITEMS_ERROR };
203
+ if (items.length < 1 || items.length > MOCK_TRAINING_BOUNDS.datasetMaxItems) {
204
+ return { refusal: MOCK_TRAINING_ITEMS_ERROR };
205
+ }
206
+ const shaped = items.every((it) => {
172
207
  if (typeof it !== 'object' || it === null || Array.isArray(it))
173
208
  return false;
174
209
  const keys = Object.keys(it);
175
- if (keys.length !== 2 || !keys.includes('imageId') || !keys.includes('caption'))
210
+ if (keys.length !== 2 || !keys.includes('air') || !keys.includes('caption'))
176
211
  return false;
177
- const { imageId, caption } = it;
178
- return (typeof imageId === 'number' &&
179
- Number.isInteger(imageId) &&
180
- imageId > 0 &&
212
+ const { air, caption } = it;
213
+ return (typeof air === 'string' &&
214
+ air.length <= MOCK_AIR_MAX_CHARS &&
181
215
  typeof caption === 'string' &&
182
216
  caption.length <= MOCK_TRAINING_BOUNDS.captionMaxChars);
183
217
  });
218
+ if (!shaped)
219
+ return { refusal: MOCK_TRAINING_ITEMS_ERROR };
220
+ const out = [];
221
+ const seen = new Set();
222
+ for (const it of items) {
223
+ const key = mockBlobKeyFromAir(it.air);
224
+ if (key === null)
225
+ return { refusal: MOCK_TRAINING_AIR_ERROR };
226
+ if (seen.has(blobKeyIdentity(key)))
227
+ return { refusal: MOCK_TRAINING_DUPLICATE_ERROR };
228
+ seen.add(blobKeyIdentity(key));
229
+ out.push({ air: key, caption: it.caption });
230
+ }
231
+ return { items: out };
184
232
  }
185
233
  /**
186
234
  * The real server's refusal for ANY money call from a token without the spend
@@ -192,16 +240,19 @@ function isValidTrainingDatasetItems(items) {
192
240
  export const MOCK_SPEND_SCOPE_ERROR = 'block lacks ai:write:budgeted scope';
193
241
  /** The real server's refusal for an unknown, expired or spent quote on `RUN_TRAINING`. */
194
242
  export const MOCK_TRAINING_QUOTE_GONE_ERROR = 'training quote not found, expired or already used — estimate again';
195
- /** The real server's refusal for a training estimate naming a dataset it does not hold. */
196
- export const MOCK_TRAINING_DATASET_GONE_ERROR = 'training dataset not found or expired';
197
243
  /**
198
- * The real server's two refusals for a dataset with NOTHING admitted
199
- * (`prepareBlockTrainingDataset`): every image failed eligibility, or every
200
- * eligible image failed its import. The server THROWS rather than returning
201
- * `count: 0`, so a successful reply always admits at least one image.
244
+ * The estimate's refusals for `items` outside the server's schema. The first two
245
+ * are the server's own custom messages (`blockTrainingDatasetItemsSchema`); the
246
+ * third stands for every other zod issue (count, caption length, missing or extra
247
+ * keys), whose real text is zod's formatting and not a contract.
202
248
  */
203
- export const MOCK_TRAINING_NONE_ELIGIBLE_ERROR = 'none of the requested images can be used for training';
204
- export const MOCK_TRAINING_NONE_IMPORTED_ERROR = 'none of the requested images could be prepared for training';
249
+ export const MOCK_TRAINING_AIR_ERROR = 'air must be a consumer blob key or its Civitai-hosted blob url';
250
+ export const MOCK_TRAINING_DUPLICATE_ERROR = 'a training dataset names each image once';
251
+ export const MOCK_TRAINING_ITEMS_ERROR = 'training items must be 1 to 50 { air, caption } entries with captions of at most 8192 characters';
252
+ /** The host code for a quote naming an image the host did not upload for the block. */
253
+ const MOCK_TRAINING_NOT_UPLOADED_HERE = 'training data not uploaded here';
254
+ /** How many images a default `trainingData` upload returns. */
255
+ const DEFAULT_TRAINING_DATA_UPLOAD_COUNT = 3;
205
256
  /**
206
257
  * The forced `RUN_TRAINING` errors that settle BEFORE the server claims (and so
207
258
  * consumes) the quote: the host's own gate and dialog codes. Derived from the
@@ -496,6 +547,8 @@ const DEFAULT_IMAGE_BYTES_UPLOAD = {
496
547
  contentRating: 'pg',
497
548
  url: 'https://image.civitai.com/mock/original=true/dev-bytes-upload.png',
498
549
  };
550
+ /** Where the default `trainingData` upload's placeholder blob urls point. */
551
+ const DEFAULT_TRAINING_DATA_URL_BASE = 'https://blobs.mock.invalid/v2/consumer/blobs/';
499
552
  const DEFAULT_GENERATION_SOURCE_UPLOAD = {
500
553
  url: 'https://image.civitai.com/mock/original=true/dev-generation-source.jpeg',
501
554
  width: 1024,
@@ -699,6 +752,38 @@ const DEFAULT_WILDCARD_PACK = {
699
752
  truncatedLists: [],
700
753
  maturity: { browsingLevel: SFW_LEVELS, sfwOnly: true },
701
754
  };
755
+ /**
756
+ * Default file reported on `GET_RESOURCE_FILE`: one plain `.json` workflow in the
757
+ * ComfyUI UI format (a `nodes` array), so a block renders a graph out of the box.
758
+ */
759
+ const DEFAULT_RESOURCE_FILE = {
760
+ meta: {
761
+ modelId: 618700,
762
+ modelVersionId: 691700,
763
+ fileId: 591700,
764
+ fileName: 'sample-workflow.json',
765
+ modelName: 'Sample Workflow',
766
+ versionName: 'v1.0',
767
+ creatorUsername: 'creator',
768
+ },
769
+ entries: [
770
+ {
771
+ path: 'sample-workflow.json',
772
+ format: 'ui',
773
+ graph: {
774
+ last_node_id: 2,
775
+ last_link_id: 1,
776
+ nodes: [
777
+ { id: 1, type: 'CheckpointLoaderSimple', inputs: [], outputs: [{ name: 'MODEL', links: [1] }] },
778
+ { id: 2, type: 'KSampler', inputs: [{ name: 'model', link: 1 }], outputs: [] },
779
+ ],
780
+ links: [[1, 1, 0, 2, 0, 'MODEL']],
781
+ version: 0.4,
782
+ },
783
+ },
784
+ ],
785
+ truncated: false,
786
+ };
702
787
  /**
703
788
  * Default app generator SUBQUEUE page reported on `QUERY_APP_WORKFLOWS`. A small
704
789
  * mixed-status list (a done gen with two images, one still processing) so a block
@@ -1083,6 +1168,9 @@ export function createMockHost(options = {}) {
1083
1168
  // Wildcard-pack bridge data + forced discriminated-error knob.
1084
1169
  let wildcardPack = options.wildcardPack ?? DEFAULT_WILDCARD_PACK;
1085
1170
  let wildcardPackError = options.wildcardPackError;
1171
+ // Resource-file bridge data + forced closed-set error knob.
1172
+ let resourceFile = options.resourceFile ?? DEFAULT_RESOURCE_FILE;
1173
+ let resourceFileError = options.resourceFileError;
1086
1174
  // Collection-follow bridge.
1087
1175
  //
1088
1176
  // 🔴 THERE IS DELIBERATELY NO FOLLOW-STATE MAP HERE, and an earlier version's
@@ -1108,10 +1196,11 @@ export function createMockHost(options = {}) {
1108
1196
  // App Blocks → Post bridge: the canned post + a forced-refusal knob.
1109
1197
  let createPostResult = options.createPostResult ?? DEFAULT_CREATE_POST_RESULT;
1110
1198
  let createPostError = options.createPostError;
1111
- // App Blocks training bridges: canned dataset rejections, quote total, and
1112
- // forced-refusal knobs. The datasets/quotes themselves live per install.
1113
- let trainingDatasetRejected = options.trainingDatasetRejected ?? [];
1114
- let trainingDatasetError = options.trainingDatasetError;
1199
+ // App Blocks training: the canned upload, the "host forgot its uploads" knob,
1200
+ // the quote total, and forced-refusal knobs. The uploaded keys and the quotes
1201
+ // themselves live per install.
1202
+ let cannedTrainingDataUpload = options.cannedTrainingDataUpload;
1203
+ let trainingDataNotUploadedHere = options.trainingDataNotUploadedHere === true;
1115
1204
  let trainingQuoteTotal = options.trainingQuoteTotal ?? DEFAULT_TRAINING_QUOTE_TOTAL;
1116
1205
  let runTrainingError = options.runTrainingError;
1117
1206
  // SAVE_IMAGE: a forced-refusal knob (see `saveImageError`).
@@ -1368,10 +1457,29 @@ export function createMockHost(options = {}) {
1368
1457
  // restored after a reload). `canceled` is set by `CANCEL_WORKFLOW`, so later
1369
1458
  // polls keep answering `canceled` instead of resuming the countdown.
1370
1459
  const workflows = new Map();
1371
- // Training: datasets this install prepared (id → admitted count) and the
1372
- // quotes its estimates stored (id → what a run is checked against).
1373
- const trainingDatasets = new Map();
1460
+ // Training: the blob keys this install's uploads handed out (by identity —
1461
+ // the host's per-block-instance memory), and the quotes its estimates stored
1462
+ // (id → what a run is checked against).
1463
+ const trainingUploads = new Set();
1464
+ let trainingUploadSerial = 0;
1374
1465
  const trainingQuotes = new Map();
1466
+ /**
1467
+ * What a training quote is bound to: the body without `quoteId`, its items
1468
+ * canonicalised (the server quotes the bare key). `null` when the body is not
1469
+ * plain JSON or its items do not resolve — then no run can match it.
1470
+ */
1471
+ const trainingFingerprint = (raw) => {
1472
+ const resolved = resolveTrainingItems(raw.items);
1473
+ if ('refusal' in resolved)
1474
+ return null;
1475
+ const { quoteId: _quoteId, ...rest } = raw;
1476
+ try {
1477
+ return canonicalStepJson({ ...rest, items: resolved.items }, 'body');
1478
+ }
1479
+ catch {
1480
+ return null;
1481
+ }
1482
+ };
1375
1483
  // Confirmed steps: the quotes this install's estimates stored (id → what a
1376
1484
  // run is checked against). Same serial as the training handles, so every
1377
1485
  // handle this install mints is distinct.
@@ -1667,9 +1775,10 @@ export function createMockHost(options = {}) {
1667
1775
  }
1668
1776
  // TRAINING ESTIMATE — KIND-FAITHFUL, the one exception to the
1669
1777
  // kind-agnostic rule above, because the real arm is not a priced
1670
- // pass-through: it names a dataset the server must hold, refuses a
1671
- // price above the per-run ceiling, and STORES a quote the run is later
1672
- // checked against. (The spend scope is checked above, for every kind.)
1778
+ // pass-through: it checks the dataset items against the server's
1779
+ // schema, refuses a price above the per-run ceiling, and STORES a quote
1780
+ // the run is later checked against. (Uploads are NOT checked here —
1781
+ // the server's estimate does not; the host checks them at the run.) (The spend scope is checked above, for every kind.)
1673
1782
  // Each refusal is the server's message in the host's `failureSnapshot`
1674
1783
  // shape.
1675
1784
  if (body.kind === 'training') {
@@ -1678,9 +1787,10 @@ export function createMockHost(options = {}) {
1678
1787
  status: 'failed',
1679
1788
  error,
1680
1789
  });
1681
- const imageCount = trainingDatasets.get(body.datasetId);
1682
- if (imageCount === undefined)
1683
- return refuse(MOCK_TRAINING_DATASET_GONE_ERROR);
1790
+ const resolved = resolveTrainingItems(body.items);
1791
+ if ('refusal' in resolved)
1792
+ return refuse(resolved.refusal);
1793
+ const imageCount = resolved.items.length;
1684
1794
  const total = trainingQuoteTotal;
1685
1795
  if (total > MOCK_TRAINING_BOUNDS.maxBuzzPerRun) {
1686
1796
  return refuse(`this training run costs ${total} Buzz, above the per-run limit of ${MOCK_TRAINING_BOUNDS.maxBuzzPerRun} for apps`);
@@ -1688,7 +1798,8 @@ export function createMockHost(options = {}) {
1688
1798
  const quoteId = trainingHandle('tq_');
1689
1799
  const expiresAtMs = Date.now() + TRAINING_QUOTE_TTL_MS;
1690
1800
  trainingQuotes.set(quoteId, {
1691
- datasetId: body.datasetId,
1801
+ fingerprint: trainingFingerprint(body),
1802
+ itemKeys: resolved.items.map((it) => it.air),
1692
1803
  total,
1693
1804
  expiresAtMs,
1694
1805
  spent: false,
@@ -2540,6 +2651,25 @@ export function createMockHost(options = {}) {
2540
2651
  });
2541
2652
  return;
2542
2653
  }
2654
+ case 'GET_RESOURCE_FILE': {
2655
+ // Token-INDEPENDENT read, like GET_WILDCARD_PACK. A forced error
2656
+ // replies with the closed-set code (never free text), as the real
2657
+ // host does. The canned file is returned for any version / file id.
2658
+ if (!isRoutableRequestId(requestId))
2659
+ return;
2660
+ if (resourceFileError !== undefined) {
2661
+ dispatchToBlock({
2662
+ type: 'RESOURCE_FILE_RESULT',
2663
+ payload: { requestId, error: resourceFileError },
2664
+ });
2665
+ return;
2666
+ }
2667
+ dispatchToBlock({
2668
+ type: 'RESOURCE_FILE_RESULT',
2669
+ payload: { requestId, file: resourceFile },
2670
+ });
2671
+ return;
2672
+ }
2543
2673
  case 'QUERY_APP_WORKFLOWS': {
2544
2674
  // App generator SUBQUEUE read. Drop a request with no requestId
2545
2675
  // (unroutable). A forced error replies with the FREE-TEXT error
@@ -2712,46 +2842,6 @@ export function createMockHost(options = {}) {
2712
2842
  });
2713
2843
  return;
2714
2844
  }
2715
- case 'PREPARE_TRAINING_DATASET': {
2716
- // The host's gate order (`prepareTrainingDatasetGate.ts`): no
2717
- // requestId → drop; signed-out → `sign in to train`; items outside
2718
- // the server's schema → `invalid training dataset`, before any call.
2719
- // Then the server: the spend scope, then the dataset.
2720
- if (!isRoutableRequestId(requestId))
2721
- return;
2722
- const reply = (payload) => dispatchToBlock({ type: 'TRAINING_DATASET_RESULT', payload: { requestId, ...payload } });
2723
- if (viewer === null)
2724
- return reply({ error: 'sign in to train' });
2725
- const items = typed.payload?.items;
2726
- if (!isValidTrainingDatasetItems(items)) {
2727
- return reply({ error: 'invalid training dataset' });
2728
- }
2729
- if (!consentGranted)
2730
- return reply({ error: MOCK_SPEND_SCOPE_ERROR });
2731
- if (trainingDatasetError !== undefined)
2732
- return reply({ error: trainingDatasetError });
2733
- const named = new Set(items.map((it) => it.imageId));
2734
- const rejected = trainingDatasetRejected
2735
- .filter((r) => named.has(r.imageId))
2736
- .map(({ imageId, reason }) => ({ imageId, reason }));
2737
- const rejectedIds = new Set(rejected.map((r) => r.imageId));
2738
- const count = [...named].filter((id) => !rejectedIds.has(id)).length;
2739
- if (count === 0) {
2740
- // The server THROWS here, eligibility before import: an `import-*`
2741
- // rejection means that image PASSED eligibility, so the server
2742
- // reached the import and refuses there.
2743
- const anyImported = rejected.some((r) => r.reason.startsWith('import-'));
2744
- return reply({
2745
- error: anyImported
2746
- ? MOCK_TRAINING_NONE_IMPORTED_ERROR
2747
- : MOCK_TRAINING_NONE_ELIGIBLE_ERROR,
2748
- });
2749
- }
2750
- const datasetId = trainingHandle('tds_');
2751
- trainingDatasets.set(datasetId, count);
2752
- const result = { datasetId, count, rejected };
2753
- return reply({ result });
2754
- }
2755
2845
  case 'RUN_TRAINING': {
2756
2846
  // The host's gate (`runTrainingGate.ts`): no requestId → drop;
2757
2847
  // signed-out → `sign in to train`; not a `kind:'training'` body with
@@ -2775,6 +2865,13 @@ export function createMockHost(options = {}) {
2775
2865
  if (!quote || quote.spent || Date.now() >= quote.expiresAtMs) {
2776
2866
  return reply({ error: MOCK_TRAINING_QUOTE_GONE_ERROR });
2777
2867
  }
2868
+ // The host reads the quote back and refuses, with no dialog, one
2869
+ // naming any blob it did not upload for this block — before the
2870
+ // server claims it, so the quote survives.
2871
+ if (trainingDataNotUploadedHere ||
2872
+ quote.itemKeys.some((key) => !trainingUploads.has(blobKeyIdentity(key)))) {
2873
+ return reply({ error: MOCK_TRAINING_NOT_UPLOADED_HERE });
2874
+ }
2778
2875
  // Which outcomes consume the quote, modelled on the real flow: the
2779
2876
  // host's gate and consent dialog come first (quote kept); the
2780
2877
  // server's submit then claims the quote with a GETDEL
@@ -2788,7 +2885,8 @@ export function createMockHost(options = {}) {
2788
2885
  return reply({ error: runTrainingError });
2789
2886
  }
2790
2887
  quote.spent = true;
2791
- if (b.datasetId !== quote.datasetId) {
2888
+ const runFingerprint = trainingFingerprint(b);
2889
+ if (runFingerprint === null || runFingerprint !== quote.fingerprint) {
2792
2890
  return reply({
2793
2891
  error: 'the training body differs from the one that was quoted — estimate again',
2794
2892
  });
@@ -3120,6 +3218,36 @@ export function createMockHost(options = {}) {
3120
3218
  postableImageIds.add(uploadImageBytesResult.imageId);
3121
3219
  return reply({ selected: { ...uploadImageBytesResult } });
3122
3220
  }
3221
+ // TRAINING DATA (civitai/civitai#6461): several private images in ONE
3222
+ // reply, `{ images: [{ url, air }] }`. Every key handed out is
3223
+ // remembered for RUN_TRAINING's not-uploaded-here check.
3224
+ if (typed.payload?.purpose === 'trainingData') {
3225
+ let images;
3226
+ if (cannedTrainingDataUpload === undefined) {
3227
+ images = Array.from({ length: DEFAULT_TRAINING_DATA_UPLOAD_COUNT }, () => {
3228
+ trainingUploadSerial += 1;
3229
+ const air = `mockblob${trainingUploadSerial.toString(16).padStart(8, '0')}.png`;
3230
+ return { url: `${DEFAULT_TRAINING_DATA_URL_BASE}${air}`, air };
3231
+ });
3232
+ }
3233
+ else {
3234
+ images = cannedTrainingDataUpload;
3235
+ }
3236
+ if (!images || images.length === 0) {
3237
+ dispatchToBlock({ type: 'IMAGE_UPLOAD_RESULT', payload: { requestId } });
3238
+ return;
3239
+ }
3240
+ for (const img of images) {
3241
+ const key = mockBlobKeyFromAir(img.air);
3242
+ if (key !== null)
3243
+ trainingUploads.add(blobKeyIdentity(key));
3244
+ }
3245
+ dispatchToBlock({
3246
+ type: 'IMAGE_UPLOAD_RESULT',
3247
+ payload: { requestId, selected: { images: images.map(({ url, air }) => ({ url, air })) } },
3248
+ });
3249
+ return;
3250
+ }
3123
3251
  const isGenerationSource = typed.payload?.purpose === 'generationSource';
3124
3252
  // NON-BLOCKING display path (asyncScan:true): early-resolve with a
3125
3253
  // PENDING handle, then stream the canned scan verdict on a later tick.
@@ -3847,6 +3975,11 @@ export function createMockHost(options = {}) {
3847
3975
  wildcardPack = patch.wildcardPack;
3848
3976
  if (patch.wildcardPackError !== undefined)
3849
3977
  wildcardPackError = patch.wildcardPackError;
3978
+ if (patch.resourceFile !== undefined)
3979
+ resourceFile = patch.resourceFile;
3980
+ // Key PRESENCE, not value: `{ resourceFileError: undefined }` clears it.
3981
+ if ('resourceFileError' in patch)
3982
+ resourceFileError = patch.resourceFileError;
3850
3983
  if (patch.collectionFollowError !== undefined)
3851
3984
  collectionFollowError = patch.collectionFollowError;
3852
3985
  if (patch.reactionStates !== undefined || patch.reactionError !== undefined) {
@@ -3856,13 +3989,15 @@ export function createMockHost(options = {}) {
3856
3989
  createPostResult = patch.createPostResult;
3857
3990
  if (patch.createPostError !== undefined)
3858
3991
  createPostError = patch.createPostError;
3859
- if (patch.trainingDatasetRejected !== undefined) {
3860
- trainingDatasetRejected = patch.trainingDatasetRejected;
3861
- }
3862
- // The training error knobs clear on an explicit `undefined` (key PRESENT in
3992
+ // `null` is meaningful (a cancel) and `undefined` restores the default, so
3993
+ // check for the KEY's presence.
3994
+ if ('cannedTrainingDataUpload' in patch)
3995
+ cannedTrainingDataUpload = patch.cannedTrainingDataUpload;
3996
+ // The training refusal knobs clear on an explicit `undefined` (key PRESENT in
3863
3997
  // the patch), so a harness can turn a refusal off without re-installing.
3864
- if ('trainingDatasetError' in patch)
3865
- trainingDatasetError = patch.trainingDatasetError;
3998
+ if ('trainingDataNotUploadedHere' in patch) {
3999
+ trainingDataNotUploadedHere = patch.trainingDataNotUploadedHere === true;
4000
+ }
3866
4001
  if (patch.trainingQuoteTotal !== undefined)
3867
4002
  trainingQuoteTotal = patch.trainingQuoteTotal;
3868
4003
  if ('runTrainingError' in patch)
@@ -28,6 +28,23 @@ export declare const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
28
28
  * no deep hook paths, so the rename touches no reachable API.
29
29
  */
30
30
  export declare const HUMAN_INTERACTION_TIMEOUT_MS: number;
31
+ /**
32
+ * The bound for `OPEN_IMAGE_UPLOAD { purpose: 'trainingData' }` (civitai/civitai#6461),
33
+ * which is longer than {@link HUMAN_INTERACTION_TIMEOUT_MS} on purpose.
34
+ *
35
+ * The host replies ONCE, when its modal closes — after the viewer picked up to 50
36
+ * images AND the host uploaded every original. That second half is network work
37
+ * the viewer does not control, so ten minutes from `open()` can expire while the
38
+ * host is still uploading. The block would then reject, drop the late reply, and
39
+ * never learn the keys the host now remembers for `RUN_TRAINING`. The host always
40
+ * answers on close (images, or a bare cancel), so the bound only has to end a wait
41
+ * on a host that will never answer; an hour does that without cutting off a slow
42
+ * upload.
43
+ *
44
+ * Still in the `'human'` bucket of {@link REQUEST_TIMEOUT_CLASS}: it is at least
45
+ * the human bound, which is what that bucket guarantees.
46
+ */
47
+ export declare const TRAINING_DATA_UPLOAD_TIMEOUT_MS: number;
31
48
  /**
32
49
  * The bound for a `'protocol'` request whose SERVER WORK is itself slow — the
33
50
  * host forwards it to the orchestrator and replies only once that answers. No
@@ -36,17 +53,25 @@ export declare const HUMAN_INTERACTION_TIMEOUT_MS: number;
36
53
  *
37
54
  * Callers: the workflow trio in `hooks/useBuzzWorkflow.ts` (a submit is a whatif
38
55
  * preflight plus the real submit plus a prompt audit), and
39
- * `PREPARE_TRAINING_DATASET` in `hooks/usePrepareTrainingDataset.ts`, whose
40
- * server moderates the captions and imports up to 50 images into the
41
- * orchestrator, the import alone under a 60s budget
42
- * (`BLOCK_TRAINING_IMPORT_BUDGET_MS` in civitai/civitai's
43
- * `block-training-dataset.service.ts`) — so the 30s default would reject a
44
- * healthy prepare mid-import.
56
+ * `ESTIMATE_WORKFLOW_BATCH` in `hooks/useBatchEstimate.ts`.
45
57
  *
46
- * Was a module-local constant in `useBuzzWorkflow.ts`; moved here so the second
58
+ * Was a module-local constant in `useBuzzWorkflow.ts`; moved here so every
47
59
  * caller shares the number instead of re-typing it.
48
60
  */
49
61
  export declare const WORKFLOW_REQUEST_TIMEOUT_MS = 120000;
62
+ /**
63
+ * The bound for `GET_RESOURCE_FILE` (`hooks/useResourceFile.ts`), a `'protocol'`
64
+ * request whose HOST work is slow: a session-authed resolve, a fetch of the
65
+ * signed URL (up to 32 MB), then an unzip and parse of up to 16 MB in the
66
+ * viewer's tab. The host bounds all three with ONE 110 s deadline
67
+ * (civitai/civitai#6818 `RESOURCE_FILE_DEADLINE_MS`), after which it replies
68
+ * `parse-failed`. At {@link DEFAULT_REQUEST_TIMEOUT_MS} a large file on a slow
69
+ * connection would time out on the block while the host was still working, and
70
+ * the host's valid reply would be dropped as unroutable. 120 s leaves the host's
71
+ * 110 s deadline room to answer first. Same number as
72
+ * {@link WORKFLOW_REQUEST_TIMEOUT_MS}.
73
+ */
74
+ export declare const RESOURCE_FILE_REQUEST_TIMEOUT_MS = 120000;
50
75
  /** Which bound a block→parent message is answered under. */
51
76
  export type RequestTimeoutClass =
52
77
  /** Reply waits on a PERSON — must pass {@link HUMAN_INTERACTION_TIMEOUT_MS}. */
@@ -27,6 +27,23 @@ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
27
27
  * no deep hook paths, so the rename touches no reachable API.
28
28
  */
29
29
  export const HUMAN_INTERACTION_TIMEOUT_MS = 10 * 60_000;
30
+ /**
31
+ * The bound for `OPEN_IMAGE_UPLOAD { purpose: 'trainingData' }` (civitai/civitai#6461),
32
+ * which is longer than {@link HUMAN_INTERACTION_TIMEOUT_MS} on purpose.
33
+ *
34
+ * The host replies ONCE, when its modal closes — after the viewer picked up to 50
35
+ * images AND the host uploaded every original. That second half is network work
36
+ * the viewer does not control, so ten minutes from `open()` can expire while the
37
+ * host is still uploading. The block would then reject, drop the late reply, and
38
+ * never learn the keys the host now remembers for `RUN_TRAINING`. The host always
39
+ * answers on close (images, or a bare cancel), so the bound only has to end a wait
40
+ * on a host that will never answer; an hour does that without cutting off a slow
41
+ * upload.
42
+ *
43
+ * Still in the `'human'` bucket of {@link REQUEST_TIMEOUT_CLASS}: it is at least
44
+ * the human bound, which is what that bucket guarantees.
45
+ */
46
+ export const TRAINING_DATA_UPLOAD_TIMEOUT_MS = 60 * 60_000;
30
47
  /**
31
48
  * The bound for a `'protocol'` request whose SERVER WORK is itself slow — the
32
49
  * host forwards it to the orchestrator and replies only once that answers. No
@@ -35,17 +52,25 @@ export const HUMAN_INTERACTION_TIMEOUT_MS = 10 * 60_000;
35
52
  *
36
53
  * Callers: the workflow trio in `hooks/useBuzzWorkflow.ts` (a submit is a whatif
37
54
  * preflight plus the real submit plus a prompt audit), and
38
- * `PREPARE_TRAINING_DATASET` in `hooks/usePrepareTrainingDataset.ts`, whose
39
- * server moderates the captions and imports up to 50 images into the
40
- * orchestrator, the import alone under a 60s budget
41
- * (`BLOCK_TRAINING_IMPORT_BUDGET_MS` in civitai/civitai's
42
- * `block-training-dataset.service.ts`) — so the 30s default would reject a
43
- * healthy prepare mid-import.
55
+ * `ESTIMATE_WORKFLOW_BATCH` in `hooks/useBatchEstimate.ts`.
44
56
  *
45
- * Was a module-local constant in `useBuzzWorkflow.ts`; moved here so the second
57
+ * Was a module-local constant in `useBuzzWorkflow.ts`; moved here so every
46
58
  * caller shares the number instead of re-typing it.
47
59
  */
48
60
  export const WORKFLOW_REQUEST_TIMEOUT_MS = 120_000;
61
+ /**
62
+ * The bound for `GET_RESOURCE_FILE` (`hooks/useResourceFile.ts`), a `'protocol'`
63
+ * request whose HOST work is slow: a session-authed resolve, a fetch of the
64
+ * signed URL (up to 32 MB), then an unzip and parse of up to 16 MB in the
65
+ * viewer's tab. The host bounds all three with ONE 110 s deadline
66
+ * (civitai/civitai#6818 `RESOURCE_FILE_DEADLINE_MS`), after which it replies
67
+ * `parse-failed`. At {@link DEFAULT_REQUEST_TIMEOUT_MS} a large file on a slow
68
+ * connection would time out on the block while the host was still working, and
69
+ * the host's valid reply would be dropped as unroutable. 120 s leaves the host's
70
+ * 110 s deadline room to answer first. Same number as
71
+ * {@link WORKFLOW_REQUEST_TIMEOUT_MS}.
72
+ */
73
+ export const RESOURCE_FILE_REQUEST_TIMEOUT_MS = 120_000;
49
74
  /**
50
75
  * 🔴 THE TOTALITY GATE — every block→parent message type, bucketed.
51
76
  *
@@ -130,11 +155,6 @@ const REQUEST_TIMEOUT_CLASS = {
130
155
  APP_STORAGE_SET: 'protocol',
131
156
  CANCEL_APP_WORKFLOW: 'protocol',
132
157
  CANCEL_WORKFLOW: 'protocol',
133
- // No dialog — preparing a dataset charges nothing and the host replies when
134
- // the server does. Server-bound, so `'protocol'`; but sent under
135
- // `WORKFLOW_REQUEST_TIMEOUT_MS`, not the 30s default, because the server's
136
- // image import alone may take 60s. Same reasoning as the workflow trio below.
137
- PREPARE_TRAINING_DATASET: 'protocol',
138
158
  // The workflow trio is server-bound, not person-bound. It carries its OWN
139
159
  // longer bound (`WORKFLOW_REQUEST_TIMEOUT_MS` in `hooks/useBuzzWorkflow.ts`)
140
160
  // for orchestrator latency — a different reason for a different number.
@@ -151,6 +171,10 @@ const REQUEST_TIMEOUT_CLASS = {
151
171
  GET_IMAGES_BY_IDS: 'protocol',
152
172
  GET_VIEWER: 'protocol',
153
173
  GET_WILDCARD_PACK: 'protocol',
174
+ // No person in the loop, so `'protocol'` — but sent under its own longer
175
+ // `RESOURCE_FILE_REQUEST_TIMEOUT_MS`: the host's resolve + fetch + unzip run
176
+ // under a 110 s deadline, which the 30 s default would cut short.
177
+ GET_RESOURCE_FILE: 'protocol',
154
178
  QUERY_APP_WORKFLOWS: 'protocol',
155
179
  REQUEST_TOKEN: 'protocol',
156
180
  // The host fetches the blob and triggers the browser's own download; it replies
@@ -245,7 +245,8 @@ export declare function isValidViewerResult(p: unknown): p is {
245
245
  * value, `url` non-empty).
246
246
  * • `'generationSource'` → the source `{ url, width, height }` (positive finite
247
247
  * dimensions, non-empty url; no imageId/nsfwLevel).
248
- * The guard accepts EITHER shape (the result carries no purpose discriminator).
248
+ * • `'trainingData'` → `{ images: [{ url, air }] }` (non-empty strings).
249
+ * The guard accepts ANY of the shapes (the result carries no purpose discriminator).
249
250
  * Kept in lockstep with the host's `IMAGE_UPLOAD_RESULT` (civitai/civitai
250
251
  * `PageBlockHost.tsx` / `BlockImageUploadModal.tsx` /
251
252
  * `BlockGenerationSourceUploadModal.tsx`).
@@ -265,6 +266,11 @@ export declare function isValidImageUploadResult(p: unknown): p is {
265
266
  status: 'pending';
266
267
  imageId: number;
267
268
  url: string;
269
+ } | {
270
+ images: Array<{
271
+ url: string;
272
+ air: string;
273
+ }>;
268
274
  };
269
275
  error?: string;
270
276
  };
@@ -341,6 +347,17 @@ export declare function isValidDailyCompensationResult(p: unknown): boolean;
341
347
  * reply, mirroring the host's enum contract.
342
348
  */
343
349
  export declare function isValidWildcardPackResult(p: unknown): boolean;
350
+ /**
351
+ * Reply to a block-initiated `GET_RESOURCE_FILE`. Same contract shape as
352
+ * {@link isValidWildcardPackResult}: EITHER a `file` OR an `error` that is one of
353
+ * {@link RESOURCE_FILE_ERROR_CODES}; a free-text or unknown `error`, or a reply
354
+ * with neither, is malformed and dropped. The `file` is checked field by field —
355
+ * `meta` ids positive integers, names strings, `creatorUsername` a string or
356
+ * `null`, `truncated` a boolean, and EVERY entry an object with a string `path`,
357
+ * a known `format` and a `graph` key. `graph` itself is the host's parsed JSON
358
+ * and may be any JSON value.
359
+ */
360
+ export declare function isValidResourceFileResult(p: unknown): boolean;
344
361
  /**
345
362
  * Reply to a block-initiated `QUERY_APP_WORKFLOWS`. A well-formed reply carries
346
363
  * EITHER a `result` ({ workflows: AppWorkflow[], cursor: string | null }) OR a
@@ -442,22 +459,6 @@ export declare function isValidEntityReactionsResult(p: unknown): boolean;
442
459
  * so a drop at least times out visibly.
443
460
  */
444
461
  export declare function isValidCreatePostResult(p: unknown): boolean;
445
- /**
446
- * Reply to `PREPARE_TRAINING_DATASET`. A well-formed reply carries EITHER a
447
- * `result` (`{ datasetId: non-empty string, count: integer ≥ 0, rejected: [{
448
- * imageId: number, reason: string }] }`) OR an `error`; one with neither is
449
- * malformed and dropped.
450
- *
451
- * 🔴 `error` IS SHAPE-CHECKED ONLY — the same decision as
452
- * {@link isValidCreatePostResult}: the host's closed codes share the field with
453
- * any server message it forwards, and a membership check would drop those
454
- * replies before correlation, leaving the request to its timer.
455
- *
456
- * `rejected[].reason` is checked as a STRING, not against today's six members —
457
- * the host adding a reason must not make every prepare reply that carries it a
458
- * dropped one (the forward-compatibility rule in {@link isValidWorkflowSnapshot}).
459
- */
460
- export declare function isValidTrainingDatasetResult(p: unknown): boolean;
461
462
  /**
462
463
  * Reply to `RUN_TRAINING`. A well-formed reply carries EITHER a `snapshot` (a
463
464
  * valid {@link isValidWorkflowSnapshot} — the block polls its `workflowId`) OR an