@civitai/blocks-react 0.76.1 → 0.77.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.
@@ -47,7 +47,7 @@
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
49
  * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 53, of which 15 REFUSE (enumerated under SCOPE below)
50
+ * switch case labels .... 52, of which 14 REFUSE (enumerated under SCOPE below)
51
51
  * --- END DERIVED ---
52
52
  *
53
53
  * PICKERS (Phase 1 of "make dev:live a faithful local host"): the live host
@@ -113,12 +113,9 @@
113
113
  * `entity-unavailable`.
114
114
  * • CREATE_POST_FROM_APP — needs the server-resolved preview plus host-chrome
115
115
  * confirm before a PUBLIC post is written. Replies with a refusal.
116
- * • PREPARE_TRAINING_DATASET — the server refuses every `kind: 'training'`
117
- * request from a dev token, so there is nothing to forward. Replies with a
118
- * refusal.
119
- * • RUN_TRAINING — the same dev-token refusal, plus the host-chrome consent
120
- * dialog a run is confirmed in, which this harness does not have. Replies
121
- * with a refusal.
116
+ * • RUN_TRAINING — the server refuses every `kind: 'training'` request from a
117
+ * dev token, and a run is confirmed in a host-chrome consent dialog this
118
+ * harness does not have. Replies with a refusal.
122
119
  * • RUN_CONFIRMED_STEP — the server refuses a confirmed step from a dev token
123
120
  * and the estimate never returns a `stepQuote` for one, and the price is
124
121
  * confirmed in host chrome this harness does not have. Replies with a
@@ -174,8 +171,7 @@ const DEFAULT_BACKEND_BASE_URL = 'https://civitai.com';
174
171
  /** The budgeted-spend scope the workflow procedures require. */
175
172
  const BUDGETED_SCOPE = 'ai:write:budgeted';
176
173
  /**
177
- * The reply both training bridges get in dev:live — see their handlers. One
178
- * string so the two cannot drift.
174
+ * The reply `RUN_TRAINING` gets in dev:live — see its handler.
179
175
  */
180
176
  export const TRAINING_UNSUPPORTED_ERROR = 'training is not supported in dev:live — use dev:mock';
181
177
  /** The once-per-session console line for a refused training bridge. */
@@ -1139,35 +1135,25 @@ export function createLiveHost(options) {
1139
1135
  });
1140
1136
  return;
1141
1137
  }
1142
- case 'PREPARE_TRAINING_DATASET': {
1138
+ case 'RUN_TRAINING': {
1143
1139
  // UNSUPPORTED in live, and here the SERVER decides it, not this
1144
1140
  // harness: every `kind: 'training'` procedure refuses a dev token
1145
1141
  // ("training is not available from a development or review session"
1146
1142
  // — `assertTrainingRequestAllowed` in civitai/civitai), because there
1147
1143
  // is no host page whose chrome can show the viewer a price. A
1148
1144
  // training ESTIMATE is still forwarded (ESTIMATE_WORKFLOW above) and
1149
- // comes back carrying that same server refusal.
1145
+ // comes back carrying that same server refusal. A run is also
1146
+ // CHARGED and confirmed in host chrome this harness does not have, so
1147
+ // faking either half would let dev prove out a flow production does
1148
+ // not have. The upload half (`OPEN_IMAGE_UPLOAD { purpose:
1149
+ // 'trainingData' }`) is answered as a dismissal (`null`), like every
1150
+ // other upload mode — see OPEN_IMAGE_UPLOAD below.
1150
1151
  //
1151
1152
  // FREE TEXT on the message's own reply channel; no host code is
1152
1153
  // honest about "this harness cannot do it". Use dev:mock — its
1153
- // `trainingDatasetError` / `trainingDatasetRejected` knobs cover the
1154
- // refusal set.
1155
- if (!isRoutableRequestId(requestId))
1156
- return;
1157
- logOnce('training-dataset', trainingUnsupportedLog('PREPARE_TRAINING_DATASET'));
1158
- dispatchToBlock({
1159
- type: 'TRAINING_DATASET_RESULT',
1160
- payload: { requestId, error: TRAINING_UNSUPPORTED_ERROR },
1161
- });
1162
- return;
1163
- }
1164
- case 'RUN_TRAINING': {
1165
- // UNSUPPORTED in live for the same server-side reason as
1166
- // PREPARE_TRAINING_DATASET directly above — and doubly so: a run is
1167
- // CHARGED and confirmed in host chrome this harness does not have, so
1168
- // faking either half would let dev prove out a flow production does
1169
- // not have. Use dev:mock — its `runTrainingError` knob covers
1170
- // `declined`, `submission-unconfirmed` and the rest.
1154
+ // `cannedTrainingDataUpload`, `trainingDataNotUploadedHere` and
1155
+ // `runTrainingError` knobs cover the upload, the not-uploaded-here
1156
+ // refusal, `declined`, `submission-unconfirmed` and the rest.
1171
1157
  if (!isRoutableRequestId(requestId))
1172
1158
  return;
1173
1159
  logOnce('training-run', trainingUnsupportedLog('RUN_TRAINING'));
@@ -1391,6 +1377,12 @@ export function createLiveHost(options) {
1391
1377
  'Test the bytes upload against the real site, or use ' +
1392
1378
  'createMockHost({ uploadImageBytesResult }) in dev:mock.');
1393
1379
  }
1380
+ else if (typed.payload?.purpose === 'trainingData') {
1381
+ logOnce('open-image-upload-training-data', "OPEN_IMAGE_UPLOAD { purpose: 'trainingData' } received but live dev mode " +
1382
+ 'cannot serve the training-data upload modal — resolving the upload as cancelled (null). ' +
1383
+ 'Test the training flow against the real site, or use ' +
1384
+ 'createMockHost({ cannedTrainingDataUpload }) in dev:mock.');
1385
+ }
1394
1386
  else {
1395
1387
  logOnce('open-image-upload', 'OPEN_IMAGE_UPLOAD received but live dev mode cannot serve the native upload ' +
1396
1388
  'modal — resolving the upload as dismissed (null). Test the image-upload flow ' +
@@ -37,7 +37,7 @@
37
37
  * called). Only the postMessage bridge round-trips are exercised. Never import
38
38
  * this from production code.
39
39
  */
40
- import { type BlockContext, type BlockResourceInfo, type BlockResourcePickerType, type BlockUploadedImageInfo, type BlockGenerationSourceImageInfo, type BuzzAccountType, type BlockBuzzTransaction, type BlockBuzzAccount, type BlockDailyCompensationResource, type BlockViewer, type BlockWildcardPack, type BlockWildcardPackErrorCode, type AppWorkflow, type BlockModel3dOutput, type BlockPublishedModel, type BlockRunConfirmedStepHostError, type BlockGatedImage, type BlockPickedImageInfo, type BlockCollectionFollowErrorCode, type BlockEntityReactionErrorCode, type BlockCreatePostHostError, type BlockCreatePostResult, type BlockPrepareTrainingDatasetHostError, type BlockRunTrainingHostError, type BlockTrainingRejectionReason, type ColorDomain, type SharedStorageValue, type Theme, type ViewerInfo, type WorkflowBody } from '@civitai/app-sdk/blocks';
40
+ import { type BlockContext, type BlockResourceInfo, type BlockResourcePickerType, type BlockUploadedImageInfo, type BlockGenerationSourceImageInfo, type BuzzAccountType, type BlockBuzzTransaction, type BlockBuzzAccount, type BlockDailyCompensationResource, type BlockViewer, type BlockWildcardPack, type BlockWildcardPackErrorCode, type AppWorkflow, type BlockModel3dOutput, type BlockPublishedModel, type BlockRunConfirmedStepHostError, type BlockGatedImage, type BlockPickedImageInfo, type BlockCollectionFollowErrorCode, type BlockEntityReactionErrorCode, type BlockCreatePostHostError, type BlockCreatePostResult, type BlockRunTrainingHostError, type BlockTrainingDataImage, type ColorDomain, type SharedStorageValue, type Theme, type ViewerInfo, type WorkflowBody } from '@civitai/app-sdk/blocks';
41
41
  import { type MockMediaUploads } from './mediaUploadBytes.js';
42
42
  import { type MockWorkflowQuoteFailure } from './mockWorkflowGraph.js';
43
43
  import { type MockReactionSeed } from './mockHostReactions.js';
@@ -412,7 +412,7 @@ export interface MockGenerationScenario {
412
412
  */
413
413
  export declare const MOCK_TRAINING_BOUNDS: {
414
414
  readonly datasetMaxItems: 50;
415
- readonly captionMaxChars: 1000;
415
+ readonly captionMaxChars: 8192;
416
416
  readonly maxBuzzPerRun: 5000;
417
417
  };
418
418
  /**
@@ -425,16 +425,15 @@ export declare const MOCK_TRAINING_BOUNDS: {
425
425
  export declare const MOCK_SPEND_SCOPE_ERROR = "block lacks ai:write:budgeted scope";
426
426
  /** The real server's refusal for an unknown, expired or spent quote on `RUN_TRAINING`. */
427
427
  export declare const MOCK_TRAINING_QUOTE_GONE_ERROR = "training quote not found, expired or already used \u2014 estimate again";
428
- /** The real server's refusal for a training estimate naming a dataset it does not hold. */
429
- export declare const MOCK_TRAINING_DATASET_GONE_ERROR = "training dataset not found or expired";
430
428
  /**
431
- * The real server's two refusals for a dataset with NOTHING admitted
432
- * (`prepareBlockTrainingDataset`): every image failed eligibility, or every
433
- * eligible image failed its import. The server THROWS rather than returning
434
- * `count: 0`, so a successful reply always admits at least one image.
429
+ * The estimate's refusals for `items` outside the server's schema. The first two
430
+ * are the server's own custom messages (`blockTrainingDatasetItemsSchema`); the
431
+ * third stands for every other zod issue (count, caption length, missing or extra
432
+ * keys), whose real text is zod's formatting and not a contract.
435
433
  */
436
- export declare const MOCK_TRAINING_NONE_ELIGIBLE_ERROR = "none of the requested images can be used for training";
437
- export declare const MOCK_TRAINING_NONE_IMPORTED_ERROR = "none of the requested images could be prepared for training";
434
+ export declare const MOCK_TRAINING_AIR_ERROR = "air must be a consumer blob key or its Civitai-hosted blob url";
435
+ export declare const MOCK_TRAINING_DUPLICATE_ERROR = "a training dataset names each image once";
436
+ export declare const MOCK_TRAINING_ITEMS_ERROR = "training items must be 1 to 50 { air, caption } entries with captions of at most 8192 characters";
438
437
  /**
439
438
  * The per-job ceiling of a confirmed step — a COPY of `@civitai/app-sdk/blocks`'s
440
439
  * `BLOCK_CONFIRMED_STEP_MAX_BUZZ_PER_JOB`, not value-imported for the reason
@@ -1014,41 +1013,34 @@ export interface MockHostOptions {
1014
1013
  */
1015
1014
  createPostError?: BlockCreatePostHostError | string;
1016
1015
  /**
1017
- * Images `PREPARE_TRAINING_DATASET` leaves out, with their reasons — what
1018
- * `usePrepareTrainingDataset()` reports on `rejected`. Only entries whose
1019
- * `imageId` the request actually named are reported, and `count` is the rest,
1020
- * exactly as the server derives it. Absent → every image is admitted.
1016
+ * The images an `OPEN_IMAGE_UPLOAD { purpose: 'trainingData' }` returns — what
1017
+ * `useImageUpload({ purpose: 'trainingData' }).open()` resolves with. Absent →
1018
+ * {@link DEFAULT_TRAINING_DATA_UPLOAD_COUNT} images with FRESH keys on every
1019
+ * upload, so two uploads never collide. `null` (or `[]`) → the viewer cancelled.
1021
1020
  * Live-tunable via {@link MockHost.setScenario}.
1022
1021
  *
1023
- * When this rejects EVERY named image the reply is an `error`, as on the real
1024
- * server — {@link MOCK_TRAINING_NONE_ELIGIBLE_ERROR}, or
1025
- * {@link MOCK_TRAINING_NONE_IMPORTED_ERROR} when every image that passed
1026
- * eligibility was rejected with an `import-*` reason. A success never has
1027
- * `count: 0`.
1028
- *
1029
- * KIND-FAITHFUL, like the rest of the training path: the mock holds the
1030
- * datasets it prepared, an estimate naming any other `datasetId` fails as the
1031
- * server's does, a quote is run at most once, and every training call needs
1032
- * `ai:write:budgeted` on the token ({@link MockHostOptions.consentGranted}) and
1033
- * a signed-in viewer. What it cannot model: image ownership and moderation (it
1034
- * has no images), the flag, and the page-only / dev-token refusals.
1035
- *
1036
- * ⚠️ The quote↔body check is NARROWER than the server's: the mock compares only
1037
- * `datasetId`, while the server hashes the whole body (minus `quoteId`) and
1038
- * refuses any change. A body edited between estimate and run passes here and
1039
- * fails in production.
1040
- */
1041
- trainingDatasetRejected?: Array<{
1042
- imageId: number;
1043
- reason: BlockTrainingRejectionReason;
1044
- }>;
1045
- /**
1046
- * Force `PREPARE_TRAINING_DATASET` to reply with an `error` — a
1047
- * {@link BlockPrepareTrainingDatasetHostError} for a host refusal, or any other
1048
- * string for the free-text server-message variant. Absent → it prepares.
1049
- * Live-tunable via {@link MockHost.setScenario}.
1022
+ * The mock REMEMBERS every key it hands out, per install, as the real host
1023
+ * does per block instance: `RUN_TRAINING` refuses a quote naming any other key
1024
+ * with `training data not uploaded here`, before any other check, and keeps the
1025
+ * quote. Keys compare case-insensitively; an item may name its blob by the
1026
+ * `url` too.
1027
+ *
1028
+ * KIND-FAITHFUL, like the rest of the training path: the estimate refuses items
1029
+ * outside the server's schema, a quote is run at most once and is checked
1030
+ * against the whole body it was quoted for (minus `quoteId`), and every
1031
+ * training call needs `ai:write:budgeted` on the token
1032
+ * ({@link MockHostOptions.consentGranted}) and a signed-in viewer. What it
1033
+ * cannot model: blob storage, the Civitai-hosted-url rule on `air`, the flag,
1034
+ * and the page-only / dev-token refusals.
1035
+ */
1036
+ cannedTrainingDataUpload?: BlockTrainingDataImage[] | null;
1037
+ /**
1038
+ * Make `RUN_TRAINING` refuse EVERY quote with `training data not uploaded
1039
+ * here`, as the real host does after a page reload forgot the uploads. Settles
1040
+ * before the server claims the quote, so the quote survives. Live-tunable via
1041
+ * {@link MockHost.setScenario}; `undefined` clears it.
1050
1042
  */
1051
- trainingDatasetError?: BlockPrepareTrainingDatasetHostError | string;
1043
+ trainingDataNotUploadedHere?: boolean;
1052
1044
  /**
1053
1045
  * The Buzz total a `kind: 'training'` estimate quotes. Default `500`. Above
1054
1046
  * `BLOCK_TRAINING_MAX_BUZZ_PER_RUN` (5,000) the estimate fails exactly as the
@@ -1064,7 +1056,8 @@ export interface MockHostOptions {
1064
1056
  *
1065
1057
  * Whether the QUOTE survives follows the real flow: the host's own gate and
1066
1058
  * dialog codes (`declined`, `review-mode`, `block is not ready`, `sign in to
1067
- * train`, `invalid training request`, `no block token`) settle before the
1059
+ * train`, `invalid training request`, `no block token`, `training data not
1060
+ * uploaded here`) settle before the
1068
1061
  * server claims the quote, so it stays runnable; `submission-unconfirmed` and
1069
1062
  * any server message settle after the claim, so it is used up — re-running it
1070
1063
  * gets the quote-gone error. Live-tunable via {@link MockHost.setScenario};
@@ -1431,7 +1424,7 @@ export interface MockHostOptions {
1431
1424
  * {@link MockHost.setTheme}, which pushes a real `THEME_CHANGE` — the mock of
1432
1425
  * the viewer toggling dark mode with the block already mounted.)
1433
1426
  */
1434
- export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedMultiPicks' | 'cannedImagePicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'reactionStates' | 'reactionError' | 'createPostResult' | 'createPostError' | 'trainingDatasetRejected' | 'trainingDatasetError' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'runConfirmedStepError' | 'runConfirmedStepCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes' | 'saveImageError' | 'uploadImageBytesResult' | 'uploadImageBytesError' | 'cannedMediaUploads' | 'mediaUploadError'>;
1427
+ export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedMultiPicks' | 'cannedImagePicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'reactionStates' | 'reactionError' | 'createPostResult' | 'createPostError' | 'cannedTrainingDataUpload' | 'trainingDataNotUploadedHere' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'runConfirmedStepError' | 'runConfirmedStepCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes' | 'saveImageError' | 'uploadImageBytesResult' | 'uploadImageBytesError' | 'cannedMediaUploads' | 'mediaUploadError'>;
1435
1428
  /** Runtime Buzz-balance handle exposed on {@link MockHost.buzz}. */
1436
1429
  export interface MockBuzzHandle {
1437
1430
  /** Current simulated balance, or `undefined` when balance isn't simulated. */
@@ -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.
165
174
  */
166
- function isValidTrainingDatasetItems(items) {
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.
199
+ */
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,
@@ -1108,10 +1161,11 @@ export function createMockHost(options = {}) {
1108
1161
  // App Blocks → Post bridge: the canned post + a forced-refusal knob.
1109
1162
  let createPostResult = options.createPostResult ?? DEFAULT_CREATE_POST_RESULT;
1110
1163
  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;
1164
+ // App Blocks training: the canned upload, the "host forgot its uploads" knob,
1165
+ // the quote total, and forced-refusal knobs. The uploaded keys and the quotes
1166
+ // themselves live per install.
1167
+ let cannedTrainingDataUpload = options.cannedTrainingDataUpload;
1168
+ let trainingDataNotUploadedHere = options.trainingDataNotUploadedHere === true;
1115
1169
  let trainingQuoteTotal = options.trainingQuoteTotal ?? DEFAULT_TRAINING_QUOTE_TOTAL;
1116
1170
  let runTrainingError = options.runTrainingError;
1117
1171
  // SAVE_IMAGE: a forced-refusal knob (see `saveImageError`).
@@ -1368,10 +1422,29 @@ export function createMockHost(options = {}) {
1368
1422
  // restored after a reload). `canceled` is set by `CANCEL_WORKFLOW`, so later
1369
1423
  // polls keep answering `canceled` instead of resuming the countdown.
1370
1424
  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();
1425
+ // Training: the blob keys this install's uploads handed out (by identity —
1426
+ // the host's per-block-instance memory), and the quotes its estimates stored
1427
+ // (id → what a run is checked against).
1428
+ const trainingUploads = new Set();
1429
+ let trainingUploadSerial = 0;
1374
1430
  const trainingQuotes = new Map();
1431
+ /**
1432
+ * What a training quote is bound to: the body without `quoteId`, its items
1433
+ * canonicalised (the server quotes the bare key). `null` when the body is not
1434
+ * plain JSON or its items do not resolve — then no run can match it.
1435
+ */
1436
+ const trainingFingerprint = (raw) => {
1437
+ const resolved = resolveTrainingItems(raw.items);
1438
+ if ('refusal' in resolved)
1439
+ return null;
1440
+ const { quoteId: _quoteId, ...rest } = raw;
1441
+ try {
1442
+ return canonicalStepJson({ ...rest, items: resolved.items }, 'body');
1443
+ }
1444
+ catch {
1445
+ return null;
1446
+ }
1447
+ };
1375
1448
  // Confirmed steps: the quotes this install's estimates stored (id → what a
1376
1449
  // run is checked against). Same serial as the training handles, so every
1377
1450
  // handle this install mints is distinct.
@@ -1667,9 +1740,10 @@ export function createMockHost(options = {}) {
1667
1740
  }
1668
1741
  // TRAINING ESTIMATE — KIND-FAITHFUL, the one exception to the
1669
1742
  // 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.)
1743
+ // pass-through: it checks the dataset items against the server's
1744
+ // schema, refuses a price above the per-run ceiling, and STORES a quote
1745
+ // the run is later checked against. (Uploads are NOT checked here —
1746
+ // the server's estimate does not; the host checks them at the run.) (The spend scope is checked above, for every kind.)
1673
1747
  // Each refusal is the server's message in the host's `failureSnapshot`
1674
1748
  // shape.
1675
1749
  if (body.kind === 'training') {
@@ -1678,9 +1752,10 @@ export function createMockHost(options = {}) {
1678
1752
  status: 'failed',
1679
1753
  error,
1680
1754
  });
1681
- const imageCount = trainingDatasets.get(body.datasetId);
1682
- if (imageCount === undefined)
1683
- return refuse(MOCK_TRAINING_DATASET_GONE_ERROR);
1755
+ const resolved = resolveTrainingItems(body.items);
1756
+ if ('refusal' in resolved)
1757
+ return refuse(resolved.refusal);
1758
+ const imageCount = resolved.items.length;
1684
1759
  const total = trainingQuoteTotal;
1685
1760
  if (total > MOCK_TRAINING_BOUNDS.maxBuzzPerRun) {
1686
1761
  return refuse(`this training run costs ${total} Buzz, above the per-run limit of ${MOCK_TRAINING_BOUNDS.maxBuzzPerRun} for apps`);
@@ -1688,7 +1763,8 @@ export function createMockHost(options = {}) {
1688
1763
  const quoteId = trainingHandle('tq_');
1689
1764
  const expiresAtMs = Date.now() + TRAINING_QUOTE_TTL_MS;
1690
1765
  trainingQuotes.set(quoteId, {
1691
- datasetId: body.datasetId,
1766
+ fingerprint: trainingFingerprint(body),
1767
+ itemKeys: resolved.items.map((it) => it.air),
1692
1768
  total,
1693
1769
  expiresAtMs,
1694
1770
  spent: false,
@@ -2712,46 +2788,6 @@ export function createMockHost(options = {}) {
2712
2788
  });
2713
2789
  return;
2714
2790
  }
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
2791
  case 'RUN_TRAINING': {
2756
2792
  // The host's gate (`runTrainingGate.ts`): no requestId → drop;
2757
2793
  // signed-out → `sign in to train`; not a `kind:'training'` body with
@@ -2775,6 +2811,13 @@ export function createMockHost(options = {}) {
2775
2811
  if (!quote || quote.spent || Date.now() >= quote.expiresAtMs) {
2776
2812
  return reply({ error: MOCK_TRAINING_QUOTE_GONE_ERROR });
2777
2813
  }
2814
+ // The host reads the quote back and refuses, with no dialog, one
2815
+ // naming any blob it did not upload for this block — before the
2816
+ // server claims it, so the quote survives.
2817
+ if (trainingDataNotUploadedHere ||
2818
+ quote.itemKeys.some((key) => !trainingUploads.has(blobKeyIdentity(key)))) {
2819
+ return reply({ error: MOCK_TRAINING_NOT_UPLOADED_HERE });
2820
+ }
2778
2821
  // Which outcomes consume the quote, modelled on the real flow: the
2779
2822
  // host's gate and consent dialog come first (quote kept); the
2780
2823
  // server's submit then claims the quote with a GETDEL
@@ -2788,7 +2831,8 @@ export function createMockHost(options = {}) {
2788
2831
  return reply({ error: runTrainingError });
2789
2832
  }
2790
2833
  quote.spent = true;
2791
- if (b.datasetId !== quote.datasetId) {
2834
+ const runFingerprint = trainingFingerprint(b);
2835
+ if (runFingerprint === null || runFingerprint !== quote.fingerprint) {
2792
2836
  return reply({
2793
2837
  error: 'the training body differs from the one that was quoted — estimate again',
2794
2838
  });
@@ -3120,6 +3164,36 @@ export function createMockHost(options = {}) {
3120
3164
  postableImageIds.add(uploadImageBytesResult.imageId);
3121
3165
  return reply({ selected: { ...uploadImageBytesResult } });
3122
3166
  }
3167
+ // TRAINING DATA (civitai/civitai#6461): several private images in ONE
3168
+ // reply, `{ images: [{ url, air }] }`. Every key handed out is
3169
+ // remembered for RUN_TRAINING's not-uploaded-here check.
3170
+ if (typed.payload?.purpose === 'trainingData') {
3171
+ let images;
3172
+ if (cannedTrainingDataUpload === undefined) {
3173
+ images = Array.from({ length: DEFAULT_TRAINING_DATA_UPLOAD_COUNT }, () => {
3174
+ trainingUploadSerial += 1;
3175
+ const air = `mockblob${trainingUploadSerial.toString(16).padStart(8, '0')}.png`;
3176
+ return { url: `${DEFAULT_TRAINING_DATA_URL_BASE}${air}`, air };
3177
+ });
3178
+ }
3179
+ else {
3180
+ images = cannedTrainingDataUpload;
3181
+ }
3182
+ if (!images || images.length === 0) {
3183
+ dispatchToBlock({ type: 'IMAGE_UPLOAD_RESULT', payload: { requestId } });
3184
+ return;
3185
+ }
3186
+ for (const img of images) {
3187
+ const key = mockBlobKeyFromAir(img.air);
3188
+ if (key !== null)
3189
+ trainingUploads.add(blobKeyIdentity(key));
3190
+ }
3191
+ dispatchToBlock({
3192
+ type: 'IMAGE_UPLOAD_RESULT',
3193
+ payload: { requestId, selected: { images: images.map(({ url, air }) => ({ url, air })) } },
3194
+ });
3195
+ return;
3196
+ }
3123
3197
  const isGenerationSource = typed.payload?.purpose === 'generationSource';
3124
3198
  // NON-BLOCKING display path (asyncScan:true): early-resolve with a
3125
3199
  // PENDING handle, then stream the canned scan verdict on a later tick.
@@ -3856,13 +3930,15 @@ export function createMockHost(options = {}) {
3856
3930
  createPostResult = patch.createPostResult;
3857
3931
  if (patch.createPostError !== undefined)
3858
3932
  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
3933
+ // `null` is meaningful (a cancel) and `undefined` restores the default, so
3934
+ // check for the KEY's presence.
3935
+ if ('cannedTrainingDataUpload' in patch)
3936
+ cannedTrainingDataUpload = patch.cannedTrainingDataUpload;
3937
+ // The training refusal knobs clear on an explicit `undefined` (key PRESENT in
3863
3938
  // the patch), so a harness can turn a refusal off without re-installing.
3864
- if ('trainingDatasetError' in patch)
3865
- trainingDatasetError = patch.trainingDatasetError;
3939
+ if ('trainingDataNotUploadedHere' in patch) {
3940
+ trainingDataNotUploadedHere = patch.trainingDataNotUploadedHere === true;
3941
+ }
3866
3942
  if (patch.trainingQuoteTotal !== undefined)
3867
3943
  trainingQuoteTotal = patch.trainingQuoteTotal;
3868
3944
  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,14 +53,9 @@ 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;