@civitai/blocks-react 0.67.0 → 0.68.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.
@@ -43,6 +43,7 @@ import { requiredStorageScope, storageResultType, storageScopeDeniedMessage, sto
43
43
  import { governsIdempotencyKey, idempotencyKeyDeniedMessage, idempotencyKeyRefusal, } from './mockHostIdempotency.js';
44
44
  import { SAVE_IMAGE_INVALID_REQUEST_ERROR, prepareSaveBytes, saveImageRequestKind, } from './saveBytes.js';
45
45
  import { UPLOAD_BYTES_INVALID_ERROR, admitUploadBytes, resolveUploadBytesRequest, } from './uploadBytes.js';
46
+ import { BATCH_ESTIMATE_CELL_TIMEOUT_ERROR } from '../hooks/useBatchEstimate.js';
46
47
  import { RUN_TRAINING_ERROR_CODES } from '../hooks/useRunTraining.js';
47
48
  import { hostContextWithTheme } from '../transport/transport.js';
48
49
  import { isRoutableRequestId } from '../transport/requestId.js';
@@ -195,6 +196,15 @@ export const MOCK_TRAINING_NONE_IMPORTED_ERROR = 'none of the requested images c
195
196
  * server made AFTER its claim, so it consumes the quote.
196
197
  */
197
198
  const PRE_CLAIM_RUN_TRAINING_ERRORS = new Set(RUN_TRAINING_ERROR_CODES.filter((c) => c !== 'submission-unconfirmed'));
199
+ /**
200
+ * The host's cell cap for one `ESTIMATE_WORKFLOW_BATCH` (civitai/civitai
201
+ * `BLOCK_ESTIMATE_BATCH_MAX_CELLS`). The hook's `BATCH_ESTIMATE_MAX_CELLS` is the
202
+ * same number; the mock keeps its own copy so it refuses exactly as the host does
203
+ * when a block bypasses the hook.
204
+ */
205
+ const MOCK_ESTIMATE_BATCH_MAX_CELLS = 16;
206
+ /** The server's per-cell refusal of a `kind: 'training'` body inside a batch. */
207
+ const MOCK_TRAINING_IN_BATCH_ERROR = 'a training estimate cannot be part of a batch — estimate it on its own';
198
208
  /** Default quote total for a `kind: 'training'` estimate when {@link MockHostOptions.trainingQuoteTotal} is unset. */
199
209
  const DEFAULT_TRAINING_QUOTE_TOTAL = 500;
200
210
  /** The real quote lifetime (`BLOCK_TRAINING_QUOTE_TTL_SECONDS`, 15 min). */
@@ -229,6 +239,24 @@ const DEFAULT_LORA_PICK = {
229
239
  trainedWords: ['sinfully stylish'],
230
240
  clipSkip: null,
231
241
  };
242
+ // The second curated LoRA a MULTI-select request returns after
243
+ // DEFAULT_LORA_PICK. Every field differs from the first so a block that mixes
244
+ // two picks up (or renders one twice) shows it.
245
+ const DEFAULT_SECOND_LORA_PICK = {
246
+ versionId: 777003,
247
+ modelId: 555003,
248
+ modelName: 'Ink Wash Lines',
249
+ versionName: 'v1.1',
250
+ baseModel: 'SDXL 1.0',
251
+ modelType: 'LORA',
252
+ strength: 0.8,
253
+ minStrength: -1,
254
+ maxStrength: 2,
255
+ trainedWords: ['ink wash'],
256
+ clipSkip: null,
257
+ };
258
+ /** The multi-select cap — the real host's, and `additionalResources`'. */
259
+ const MOCK_RESOURCE_PICKER_MULTI_MAX = 5;
232
260
  /**
233
261
  * The canned image the mock host "returns" from `OPEN_IMAGE_UPLOAD`. Mirrors the
234
262
  * host's moderated {@link BlockUploadedImageInfo} projection (imageId/nsfwLevel/
@@ -601,7 +629,7 @@ function jsonByteSize(value) {
601
629
  /**
602
630
  * Reads the URL query toggles the gen-matrix dev harness uses, so a starter's
603
631
  * dev harness keeps working with `?viewer/?consent/?fail/?theme/?pick/?pickCkpt`.
604
- * Layer-1 additions: `?balance/?latency/?costPerGen/?failNext/?failRate/?seed`
632
+ * Layer-1 additions: `?balance/?latency/?costPerGen/?authorFee/?failNext/?failRate/?seed`
605
633
  * map onto the new scenario groups so a dev can flip out-of-Buzz /
606
634
  * failures / latency without editing code. `?capRefusal=1` (or `=<text>`) sets
607
635
  * {@link MockGenerationScenario.submitCapRefusal}.
@@ -693,6 +721,15 @@ export function readMockHostUrlOptions(win = globalThis
693
721
  if (costPerGen !== null && Number.isFinite(Number(costPerGen))) {
694
722
  generation.costPerGen = Number(costPerGen);
695
723
  }
724
+ // `?authorFee=<n>` itemises an app fee on the quote; `?authorFee=off`
725
+ // simulates a host that predates the field.
726
+ const authorFee = params.get('authorFee');
727
+ if (authorFee === 'off') {
728
+ generation.authorFee = false;
729
+ }
730
+ else if (authorFee !== null && authorFee !== '' && Number.isFinite(Number(authorFee))) {
731
+ generation.authorFee = Number(authorFee);
732
+ }
696
733
  const failNext = params.get('failNext');
697
734
  if (failNext !== null && Number.isInteger(Number(failNext))) {
698
735
  generation.failNext = Number(failNext);
@@ -799,6 +836,9 @@ export function createMockHost(options = {}) {
799
836
  // Legacy `cost` feeds costPerGen unless the scenario set its own.
800
837
  let legacyCost = options.cost ?? 8;
801
838
  let cannedPicks = options.cannedPicks ?? { Checkpoint: DEFAULT_CHECKPOINT_PICK, LORA: DEFAULT_LORA_PICK };
839
+ // Canned MULTI-select picks. `undefined` = derive from the single LoRA pick
840
+ // (see the option's docs); `null` = dismissed.
841
+ let cannedMultiPicks = options.cannedMultiPicks;
802
842
  // Canned image-upload result. `null` = dismissed; undefined = default image.
803
843
  let cannedImageUpload = options.cannedImageUpload === undefined ? DEFAULT_IMAGE_UPLOAD : options.cannedImageUpload;
804
844
  // Canned generationSource-upload result (purpose:'generationSource').
@@ -899,6 +939,25 @@ export function createMockHost(options = {}) {
899
939
  const sharedStore = new Map();
900
940
  let sharedSeq = 0;
901
941
  let sharedFailNext = sharedScenario.failNext ?? 0;
942
+ let sharedRateLimitNext = sharedScenario.rateLimitNext ?? 0;
943
+ let sharedRateLimitMeta = sharedScenario.rateLimit;
944
+ /**
945
+ * When a {@link MockSharedScenario.rateLimitNext} refusal is pending, consume
946
+ * one and return the reply's error fields; otherwise `null`. The message
947
+ * matches the platform's wording per write family
948
+ * (`apps-shared.router.ts`: submissions / votes / withdrawals / reports).
949
+ */
950
+ const takeSharedRateLimit = (noun) => {
951
+ if (sharedRateLimitNext <= 0)
952
+ return null;
953
+ sharedRateLimitNext -= 1;
954
+ const retryAfterSeconds = sharedRateLimitMeta?.retryAfterSeconds ?? 60;
955
+ const retryAt = sharedRateLimitMeta?.retryAt ?? new Date(Date.now() + retryAfterSeconds * 1000).toISOString();
956
+ return {
957
+ error: `Too many ${noun} — retry in ${retryAfterSeconds}s`,
958
+ rateLimit: { retryAfterSeconds, retryAt },
959
+ };
960
+ };
902
961
  const sharedNow = new Date().toISOString();
903
962
  // Seed newest-LAST so the last-listed seed has the highest seq (newest-first).
904
963
  for (const s of sharedScenario.seed ?? []) {
@@ -929,6 +988,24 @@ export function createMockHost(options = {}) {
929
988
  const spec = gen.costPerGen ?? legacyCost;
930
989
  return typeof spec === 'function' ? spec(body) : (spec ?? legacyCost);
931
990
  };
991
+ // `null` = this quote is not itemised (a fee-less kind, or `authorFee: false`).
992
+ const authorFeeFor = (body) => {
993
+ if (gen.authorFee === false)
994
+ return null;
995
+ // A REGISTERED step is keyed on the VALUE of `step`, as the host keys it —
996
+ // never `'step' in body`: `{ kind: 'step', step: undefined, ... }` is a legal
997
+ // pass-through body, and a pass-through quote prices no fee.
998
+ const quotesFee = body.kind === 'textToImage' || (body.kind === 'step' && typeof body.step === 'string');
999
+ if (!quotesFee)
1000
+ return null;
1001
+ const spec = gen.authorFee ?? 0;
1002
+ return typeof spec === 'function' ? spec(body) : spec;
1003
+ };
1004
+ /** The `cost` of a QUOTE: the fee is inside `total` and itemised beside it. */
1005
+ const quotedCost = (body) => {
1006
+ const fee = authorFeeFor(body);
1007
+ return fee === null ? { total: costFor(body) } : { total: costFor(body) + fee, authorFee: fee };
1008
+ };
932
1009
  const latencyFor = () => {
933
1010
  const l = gen.latencyMs;
934
1011
  if (l === undefined)
@@ -1156,6 +1233,79 @@ export function createMockHost(options = {}) {
1156
1233
  return;
1157
1234
  }
1158
1235
  }
1236
+ /**
1237
+ * The snapshot ONE estimate of `body` replies with. Shared by
1238
+ * ESTIMATE_WORKFLOW and ESTIMATE_WORKFLOW_BATCH so a batch cell is priced
1239
+ * exactly as a single estimate of the same body.
1240
+ */
1241
+ const estimateSnapshotFor = (body) => {
1242
+ // UNUSABLE-ESTIMATE simulation (`generation.failEstimate`). Reproduces
1243
+ // the two real producers byte-for-byte so a block's `catch` around
1244
+ // `estimate()` is exercisable locally — see the knob's docs for why
1245
+ // that was previously impossible (civitai/civitai#4159).
1246
+ if (gen.failEstimate === 'failed') {
1247
+ // Mirrors the host's `failureSnapshot(err)` exactly: the 'failed'
1248
+ // sentinel id (a real one would be empty and get dropped by the
1249
+ // inbound validator), no `cost`.
1250
+ return {
1251
+ workflowId: 'failed',
1252
+ status: 'failed',
1253
+ error: gen.failEstimateMessage ?? 'mock: estimate failed',
1254
+ };
1255
+ }
1256
+ if (gen.failEstimate === 'no-cost') {
1257
+ // A SUCCESSFUL snapshot that simply has no price — no `error` to
1258
+ // explain it, which is what makes this producer the harder of the
1259
+ // two to diagnose from the block side.
1260
+ return { workflowId: 'wf_estimate', status: 'pending' };
1261
+ }
1262
+ // TRAINING ESTIMATE — KIND-FAITHFUL, the one exception to the
1263
+ // kind-agnostic rule above, because the real arm is not a priced
1264
+ // pass-through: it needs the spend scope, names a dataset the server
1265
+ // must hold, refuses a price above the per-run ceiling, and STORES a
1266
+ // quote the run is later checked against. Each refusal is the
1267
+ // server's message in the host's `failureSnapshot` shape.
1268
+ if (body.kind === 'training') {
1269
+ const refuse = (error) => ({
1270
+ workflowId: 'failed',
1271
+ status: 'failed',
1272
+ error,
1273
+ });
1274
+ if (!consentGranted)
1275
+ return refuse(MOCK_TRAINING_SCOPE_ERROR);
1276
+ const imageCount = trainingDatasets.get(body.datasetId);
1277
+ if (imageCount === undefined)
1278
+ return refuse(MOCK_TRAINING_DATASET_GONE_ERROR);
1279
+ const total = trainingQuoteTotal;
1280
+ if (total > MOCK_TRAINING_BOUNDS.maxBuzzPerRun) {
1281
+ return refuse(`this training run costs ${total} Buzz, above the per-run limit of ${MOCK_TRAINING_BOUNDS.maxBuzzPerRun} for apps`);
1282
+ }
1283
+ const quoteId = trainingHandle('tq_');
1284
+ const expiresAtMs = Date.now() + TRAINING_QUOTE_TTL_MS;
1285
+ trainingQuotes.set(quoteId, {
1286
+ datasetId: body.datasetId,
1287
+ total,
1288
+ expiresAtMs,
1289
+ spent: false,
1290
+ });
1291
+ return {
1292
+ workflowId: 'wf_estimate',
1293
+ status: 'pending',
1294
+ cost: { total },
1295
+ trainingQuote: {
1296
+ quoteId,
1297
+ total,
1298
+ imageCount,
1299
+ expiresAt: new Date(expiresAtMs).toISOString(),
1300
+ },
1301
+ };
1302
+ }
1303
+ return {
1304
+ workflowId: 'wf_estimate',
1305
+ status: 'pending',
1306
+ cost: quotedCost(body),
1307
+ };
1308
+ };
1159
1309
  switch (typed.type) {
1160
1310
  case 'REQUEST_TOKEN':
1161
1311
  dispatchToBlock({
@@ -1263,94 +1413,86 @@ export function createMockHost(options = {}) {
1263
1413
  // a registry (that's server-only); the mock accepts any id, fail-open. The
1264
1414
  // sentinel `workflowId` is non-empty so the snapshot survives the
1265
1415
  // SDK inbound validator (which drops empty-workflowId snapshots).
1416
+ // The pricing itself is `estimateSnapshotFor`, shared with the batch.
1266
1417
  const body = typed.payload?.body ?? {};
1267
- // UNUSABLE-ESTIMATE simulation (`generation.failEstimate`). Reproduces
1268
- // the two real producers byte-for-byte so a block's `catch` around
1269
- // `estimate()` is exercisable locally — see the knob's docs for why
1270
- // that was previously impossible (civitai/civitai#4159).
1271
- if (gen.failEstimate === 'failed') {
1418
+ dispatchToBlock({
1419
+ type: 'ESTIMATE_RESULT',
1420
+ payload: { requestId, snapshot: estimateSnapshotFor(body) },
1421
+ });
1422
+ return;
1423
+ }
1424
+ case 'ESTIMATE_WORKFLOW_BATCH': {
1425
+ // The batch twin of ESTIMATE_WORKFLOW, in the host's order: the
1426
+ // request shape, then the list, then each cell through
1427
+ // `estimateSnapshotFor` — the function ESTIMATE_WORKFLOW prices with,
1428
+ // so a cell is priced exactly as one estimate of that body would be.
1429
+ // Nothing is submitted and no Buzz moves.
1430
+ if (!isRoutableRequestId(requestId))
1431
+ return;
1432
+ const mode = gen.batchEstimate ?? 'supported';
1433
+ // A host that predates the message sends nothing at all.
1434
+ if (mode === 'silent')
1435
+ return;
1436
+ if (mode === 'unsupported') {
1272
1437
  dispatchToBlock({
1273
- type: 'ESTIMATE_RESULT',
1274
- payload: {
1275
- requestId,
1276
- // Mirrors the host's `failureSnapshot(err)` exactly: the
1277
- // 'failed' sentinel id (a real one would be empty and get
1278
- // dropped by the inbound validator), no `cost`.
1279
- snapshot: {
1280
- workflowId: 'failed',
1281
- status: 'failed',
1282
- error: gen.failEstimateMessage ?? 'mock: estimate failed',
1283
- },
1284
- },
1438
+ type: 'ESTIMATE_BATCH_RESULT',
1439
+ payload: { requestId, error: 'unsupported on this host' },
1285
1440
  });
1286
1441
  return;
1287
1442
  }
1288
- if (gen.failEstimate === 'no-cost') {
1443
+ const bodies = typed.payload?.bodies;
1444
+ if (!Array.isArray(bodies) || bodies.length === 0) {
1289
1445
  dispatchToBlock({
1290
- type: 'ESTIMATE_RESULT',
1291
- // A SUCCESSFUL snapshot that simply has no price — no `error` to
1292
- // explain it, which is what makes this producer the harder of the
1293
- // two to diagnose from the block side.
1294
- payload: { requestId, snapshot: { workflowId: 'wf_estimate', status: 'pending' } },
1446
+ type: 'ESTIMATE_BATCH_RESULT',
1447
+ payload: { requestId, error: 'invalid estimate batch' },
1295
1448
  });
1296
1449
  return;
1297
1450
  }
1298
- // TRAINING ESTIMATE — KIND-FAITHFUL, the one exception to the
1299
- // kind-agnostic rule above, because the real arm is not a priced
1300
- // pass-through: it needs the spend scope, names a dataset the server
1301
- // must hold, refuses a price above the per-run ceiling, and STORES a
1302
- // quote the run is later checked against. Each refusal is the
1303
- // server's message in the host's `failureSnapshot` shape.
1304
- if (body.kind === 'training') {
1305
- const refuse = (error) => dispatchToBlock({
1306
- type: 'ESTIMATE_RESULT',
1307
- payload: { requestId, snapshot: { workflowId: 'failed', status: 'failed', error } },
1308
- });
1309
- if (!consentGranted)
1310
- return refuse(MOCK_TRAINING_SCOPE_ERROR);
1311
- const imageCount = trainingDatasets.get(body.datasetId);
1312
- if (imageCount === undefined)
1313
- return refuse(MOCK_TRAINING_DATASET_GONE_ERROR);
1314
- const total = trainingQuoteTotal;
1315
- if (total > MOCK_TRAINING_BOUNDS.maxBuzzPerRun) {
1316
- return refuse(`this training run costs ${total} Buzz, above the per-run limit of ${MOCK_TRAINING_BOUNDS.maxBuzzPerRun} for apps`);
1317
- }
1318
- const quoteId = trainingHandle('tq_');
1319
- const expiresAtMs = Date.now() + TRAINING_QUOTE_TTL_MS;
1320
- trainingQuotes.set(quoteId, {
1321
- datasetId: body.datasetId,
1322
- total,
1323
- expiresAtMs,
1324
- spent: false,
1325
- });
1451
+ if (bodies.length > MOCK_ESTIMATE_BATCH_MAX_CELLS) {
1326
1452
  dispatchToBlock({
1327
- type: 'ESTIMATE_RESULT',
1328
- payload: {
1329
- requestId,
1330
- snapshot: {
1331
- workflowId: 'wf_estimate',
1332
- status: 'pending',
1333
- cost: { total },
1334
- trainingQuote: {
1335
- quoteId,
1336
- total,
1337
- imageCount,
1338
- expiresAt: new Date(expiresAtMs).toISOString(),
1339
- },
1340
- },
1341
- },
1453
+ type: 'ESTIMATE_BATCH_RESULT',
1454
+ payload: { requestId, error: 'estimate batch too large' },
1342
1455
  });
1343
1456
  return;
1344
1457
  }
1458
+ const snapshots = bodies.map((cell, index) => {
1459
+ // The host's snapshot for a cell its call budget ran out on.
1460
+ if (gen.batchEstimateTimedOutCells?.includes(index)) {
1461
+ return {
1462
+ workflowId: 'failed',
1463
+ status: 'failed',
1464
+ error: BATCH_ESTIMATE_CELL_TIMEOUT_ERROR,
1465
+ };
1466
+ }
1467
+ // The server refuses a training cell in a batch: a training
1468
+ // estimate stores the quote the viewer later confirms, so it is
1469
+ // estimated on its own. Refused BEFORE `estimateSnapshotFor`, which
1470
+ // would store one.
1471
+ if (cell?.kind === 'training') {
1472
+ return {
1473
+ workflowId: 'failed',
1474
+ status: 'failed',
1475
+ error: MOCK_TRAINING_IN_BATCH_ERROR,
1476
+ };
1477
+ }
1478
+ return estimateSnapshotFor(cell ?? {});
1479
+ });
1480
+ // The server's aggregate rule: the cells that priced, i.e. not
1481
+ // `'failed'` and carrying a numeric `cost.total`.
1482
+ let total = 0;
1483
+ let pricedCells = 0;
1484
+ for (const snapshot of snapshots) {
1485
+ if (snapshot.status === 'failed' || typeof snapshot.cost?.total !== 'number')
1486
+ continue;
1487
+ total += snapshot.cost.total;
1488
+ pricedCells += 1;
1489
+ }
1345
1490
  dispatchToBlock({
1346
- type: 'ESTIMATE_RESULT',
1491
+ type: 'ESTIMATE_BATCH_RESULT',
1347
1492
  payload: {
1348
1493
  requestId,
1349
- snapshot: {
1350
- workflowId: 'wf_estimate',
1351
- status: 'pending',
1352
- cost: { total: costFor(body) },
1353
- },
1494
+ snapshots,
1495
+ aggregate: { total, pricedCells, cellCount: snapshots.length },
1354
1496
  },
1355
1497
  });
1356
1498
  return;
@@ -1386,6 +1528,8 @@ export function createMockHost(options = {}) {
1386
1528
  return;
1387
1529
  }
1388
1530
  const cost = costFor(body);
1531
+ // What the viewer pays: the generation plus the app fee.
1532
+ const charged = cost + (authorFeeFor(body) ?? 0);
1389
1533
  // CAUGHT-SERVER-EXCEPTION simulation (`generation.failSubmitException`).
1390
1534
  // Reproduces the host's `failureSnapshot(err)` byte-for-byte: the
1391
1535
  // 'failed' sentinel id and NO `cost`, which is what separates an
@@ -1453,7 +1597,7 @@ export function createMockHost(options = {}) {
1453
1597
  // presence is what makes `submit()` RESOLVE this rather than
1454
1598
  // reject it as an errored submit
1455
1599
  // (civitai/civitai-app-starters#251). Do not drop it.
1456
- cost: { total: cost },
1600
+ cost: quotedCost(body),
1457
1601
  error: gen.submitCapRefusal === true
1458
1602
  ? DEFAULT_SUBMIT_CAP_REFUSAL
1459
1603
  : gen.submitCapRefusal,
@@ -1468,7 +1612,7 @@ export function createMockHost(options = {}) {
1468
1612
  const insufficient = failMode === 'all' ||
1469
1613
  failMode === 'insufficient' ||
1470
1614
  buzz.insufficient === true ||
1471
- (balanceSimulated && buzz.balance < cost);
1615
+ (balanceSimulated && buzz.balance < charged);
1472
1616
  // Generic generation failure: failNext countdown, failRate dice, or
1473
1617
  // the legacy 'some' (~1 in 3) mode.
1474
1618
  let genericFail = false;
@@ -1548,7 +1692,7 @@ export function createMockHost(options = {}) {
1548
1692
  }
1549
1693
  // Success path: debit the simulated balance + remember body/cost.
1550
1694
  if (balanceSimulated)
1551
- buzz.balance = buzz.balance - cost;
1695
+ buzz.balance = buzz.balance - charged;
1552
1696
  const workflowId = `wf_${submitCount}_${Date.now()}`;
1553
1697
  workflows.set(workflowId, { polls: 0, cost, body });
1554
1698
  dispatchToBlock({
@@ -2065,6 +2209,37 @@ export function createMockHost(options = {}) {
2065
2209
  }
2066
2210
  case 'OPEN_RESOURCE_PICKER': {
2067
2211
  const rtype = typed.payload?.resourceType;
2212
+ // MULTI-select (`multiple: { max }`) — the real host's rules, in the
2213
+ // real host's order: LoRA family only, `max` a whole number ≥ 1
2214
+ // (clamped to 5), and a LIST reply (`selectedResources`, `[]` when
2215
+ // dismissed) with no `selected`. A request WITHOUT the key falls
2216
+ // through to the single-pick reply below, unchanged.
2217
+ const multiple = typed.payload?.multiple;
2218
+ if (multiple !== undefined && multiple !== null) {
2219
+ const refuse = (error) => dispatchToBlock({ type: 'RESOURCE_PICKER_RESULT', payload: { requestId, error } });
2220
+ if (!['lora', 'locon', 'dora'].includes(String(rtype).trim().toLowerCase())) {
2221
+ refuse(`OPEN_RESOURCE_PICKER: multiple is only supported for LoRA-family resource types, not ${String(rtype)}.`);
2222
+ return;
2223
+ }
2224
+ const max = typeof multiple === 'object' ? multiple.max : undefined;
2225
+ if (typeof max !== 'number' || !Number.isInteger(max) || max < 1) {
2226
+ refuse('OPEN_RESOURCE_PICKER: multiple.max must be a whole number of at least 1.');
2227
+ return;
2228
+ }
2229
+ const picks = cannedMultiPicks !== undefined
2230
+ ? (cannedMultiPicks ?? [])
2231
+ : cannedPicks.LORA
2232
+ ? [cannedPicks.LORA, DEFAULT_SECOND_LORA_PICK]
2233
+ : [];
2234
+ dispatchToBlock({
2235
+ type: 'RESOURCE_PICKER_RESULT',
2236
+ payload: {
2237
+ requestId,
2238
+ selectedResources: picks.slice(0, Math.min(max, MOCK_RESOURCE_PICKER_MULTI_MAX)),
2239
+ },
2240
+ });
2241
+ return;
2242
+ }
2068
2243
  const selected = rtype ? cannedPicks[rtype] : undefined;
2069
2244
  dispatchToBlock({
2070
2245
  type: 'RESOURCE_PICKER_RESULT',
@@ -2355,6 +2530,14 @@ export function createMockHost(options = {}) {
2355
2530
  return;
2356
2531
  }
2357
2532
  case 'SHARED_APPEND': {
2533
+ const appendLimited = takeSharedRateLimit('submissions');
2534
+ if (appendLimited) {
2535
+ dispatchToBlock({
2536
+ type: 'SHARED_APPEND_RESULT',
2537
+ payload: { requestId, key: '', ...appendLimited },
2538
+ });
2539
+ return;
2540
+ }
2358
2541
  if (sharedFailNext > 0) {
2359
2542
  sharedFailNext -= 1;
2360
2543
  dispatchToBlock({
@@ -2397,6 +2580,14 @@ export function createMockHost(options = {}) {
2397
2580
  }
2398
2581
  case 'SHARED_VOTE': {
2399
2582
  const key = typed.payload?.key ?? '';
2583
+ const voteLimited = takeSharedRateLimit('votes');
2584
+ if (voteLimited) {
2585
+ dispatchToBlock({
2586
+ type: 'SHARED_VOTE_RESULT',
2587
+ payload: { requestId, count: 0, ...voteLimited },
2588
+ });
2589
+ return;
2590
+ }
2400
2591
  if (sharedFailNext > 0) {
2401
2592
  sharedFailNext -= 1;
2402
2593
  dispatchToBlock({
@@ -2424,6 +2615,14 @@ export function createMockHost(options = {}) {
2424
2615
  }
2425
2616
  case 'SHARED_UNVOTE': {
2426
2617
  const key = typed.payload?.key ?? '';
2618
+ const unvoteLimited = takeSharedRateLimit('votes');
2619
+ if (unvoteLimited) {
2620
+ dispatchToBlock({
2621
+ type: 'SHARED_UNVOTE_RESULT',
2622
+ payload: { requestId, count: 0, ...unvoteLimited },
2623
+ });
2624
+ return;
2625
+ }
2427
2626
  if (sharedFailNext > 0) {
2428
2627
  sharedFailNext -= 1;
2429
2628
  dispatchToBlock({
@@ -2450,6 +2649,14 @@ export function createMockHost(options = {}) {
2450
2649
  }
2451
2650
  case 'SHARED_WITHDRAW': {
2452
2651
  const key = typed.payload?.key ?? '';
2652
+ const withdrawLimited = takeSharedRateLimit('withdrawals');
2653
+ if (withdrawLimited) {
2654
+ dispatchToBlock({
2655
+ type: 'SHARED_WITHDRAW_RESULT',
2656
+ payload: { requestId, ok: false, deleted: false, ...withdrawLimited },
2657
+ });
2658
+ return;
2659
+ }
2453
2660
  if (sharedFailNext > 0) {
2454
2661
  sharedFailNext -= 1;
2455
2662
  dispatchToBlock({
@@ -2467,6 +2674,14 @@ export function createMockHost(options = {}) {
2467
2674
  }
2468
2675
  case 'SHARED_UPDATE': {
2469
2676
  const key = typed.payload?.key ?? '';
2677
+ const updateLimited = takeSharedRateLimit('submissions');
2678
+ if (updateLimited) {
2679
+ dispatchToBlock({
2680
+ type: 'SHARED_UPDATE_RESULT',
2681
+ payload: { requestId, ok: false, ...updateLimited },
2682
+ });
2683
+ return;
2684
+ }
2470
2685
  if (sharedFailNext > 0) {
2471
2686
  sharedFailNext -= 1;
2472
2687
  dispatchToBlock({
@@ -2537,6 +2752,14 @@ export function createMockHost(options = {}) {
2537
2752
  }
2538
2753
  case 'SHARED_REPORT': {
2539
2754
  const key = typed.payload?.key ?? '';
2755
+ const reportLimited = takeSharedRateLimit('reports');
2756
+ if (reportLimited) {
2757
+ dispatchToBlock({
2758
+ type: 'SHARED_REPORT_RESULT',
2759
+ payload: { requestId, ok: false, ...reportLimited },
2760
+ });
2761
+ return;
2762
+ }
2540
2763
  if (sharedFailNext > 0) {
2541
2764
  sharedFailNext -= 1;
2542
2765
  dispatchToBlock({
@@ -2725,6 +2948,9 @@ export function createMockHost(options = {}) {
2725
2948
  if (patch.cannedPicks !== undefined)
2726
2949
  cannedPicks = patch.cannedPicks;
2727
2950
  // `null` is a meaningful value (dismissed), so check for the KEY's presence.
2951
+ if ('cannedMultiPicks' in patch)
2952
+ cannedMultiPicks = patch.cannedMultiPicks;
2953
+ // `null` is a meaningful value (dismissed), so check for the KEY's presence.
2728
2954
  if ('cannedImageUpload' in patch)
2729
2955
  cannedImageUpload = patch.cannedImageUpload ?? null;
2730
2956
  if ('cannedGenerationSourceUpload' in patch)
@@ -2804,10 +3030,14 @@ export function createMockHost(options = {}) {
2804
3030
  storageFailNext = patch.storage.failNext;
2805
3031
  }
2806
3032
  if (patch.shared !== undefined) {
2807
- // Only `failNext` is live-tunable; `seed` is install-time (re-install to
2808
- // change the backing store).
3033
+ // `failNext`, `rateLimitNext` and `rateLimit` are live-tunable; `seed` is
3034
+ // install-time (re-install to change the backing store).
2809
3035
  if (patch.shared.failNext !== undefined)
2810
3036
  sharedFailNext = patch.shared.failNext;
3037
+ if (patch.shared.rateLimitNext !== undefined)
3038
+ sharedRateLimitNext = patch.shared.rateLimitNext;
3039
+ if (patch.shared.rateLimit !== undefined)
3040
+ sharedRateLimitMeta = patch.shared.rateLimit;
2811
3041
  }
2812
3042
  }
2813
3043
  const buzzHandle = {
@@ -69,6 +69,15 @@ export type PickerSelection = {
69
69
  } | {
70
70
  channel: 'RESOURCE_PICKER_RESULT';
71
71
  selected: BlockResourceInfo;
72
+ }
73
+ /**
74
+ * MULTI-select (`OPEN_RESOURCE_PICKER` with `multiple`): the staged resources
75
+ * in the order they were staged. Only ever produced when
76
+ * {@link OpenPickerOptions.multiple} is set.
77
+ */
78
+ | {
79
+ channel: 'RESOURCE_PICKER_RESULT';
80
+ selectedResources: BlockResourceInfo[];
72
81
  };
73
82
  export interface OpenPickerOptions {
74
83
  /**
@@ -104,6 +113,16 @@ export interface OpenPickerOptions {
104
113
  baseModelGroup?: string;
105
114
  /** Currently-selected versionId so the overlay can pre-highlight it. */
106
115
  currentVersionId?: number;
116
+ /**
117
+ * MULTI-select mode (`OPEN_RESOURCE_PICKER` with `multiple: { max }`). A card
118
+ * click STAGES (or un-stages) that resource instead of resolving; the "Add"
119
+ * button resolves with everything staged, in staging order, as
120
+ * `selectedResources`. At most `max` can be staged. Only meaningful on the
121
+ * `RESOURCE_PICKER_RESULT` channel — the checkpoint channel has no list reply.
122
+ */
123
+ multiple?: {
124
+ max: number;
125
+ };
107
126
  /** Advisory-SFW for the PUBLIC fallback read. Defaults true (fail-closed SFW). */
108
127
  anonSfwOnly?: boolean;
109
128
  /** The document to mount into. Defaults to `globalThis.document`. */
@@ -129,6 +148,19 @@ export interface PickerOverlayHandle {
129
148
  selectByVersionId(versionId: number): void;
130
149
  /** Dismiss without a selection. Resolves `null` + tears down. */
131
150
  dismiss(): void;
151
+ /**
152
+ * MULTI-select only: stage the card with this versionId, or un-stage it if it
153
+ * already is. No-op when absent, when not in multi-select mode, or when
154
+ * staging would exceed `max`. Does NOT resolve.
155
+ */
156
+ toggleStagedByVersionId(versionId: number): void;
157
+ /** MULTI-select only: the staged versionIds, in staging order. */
158
+ readonly stagedVersionIds: readonly number[];
159
+ /**
160
+ * MULTI-select only: resolve with everything staged (in staging order) and
161
+ * tear down. No-op while nothing is staged, or outside multi-select mode.
162
+ */
163
+ commitStaged(): void;
132
164
  /** Force teardown (host teardown path). Resolves `null` if not already resolved. */
133
165
  close(): void;
134
166
  /** True once the overlay has resolved (selection or dismissal). */