@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.
- package/README.md +386 -13
- package/dist/hooks/useBatchEstimate.d.ts +190 -0
- package/dist/hooks/useBatchEstimate.js +195 -0
- package/dist/hooks/useBlockAnalytics.d.ts +46 -4
- package/dist/hooks/useBlockAnalytics.js +41 -4
- package/dist/hooks/useBuzzWorkflow.d.ts +4 -1
- package/dist/hooks/useBuzzWorkflow.js +4 -1
- package/dist/hooks/useResourcePicker.d.ts +98 -37
- package/dist/hooks/useResourcePicker.js +77 -7
- package/dist/hooks/useSharedStorage.d.ts +24 -0
- package/dist/hooks/useSharedStorage.js +55 -0
- package/dist/hooks/useUploadImageBytes.d.ts +2 -6
- package/dist/hooks/useUploadImageBytes.js +2 -6
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -2
- package/dist/internal/liveHost.d.ts +27 -2
- package/dist/internal/liveHost.js +133 -15
- package/dist/internal/mockHost.d.ts +81 -2
- package/dist/internal/mockHost.js +308 -78
- package/dist/internal/pickerOverlay.d.ts +32 -0
- package/dist/internal/pickerOverlay.js +99 -2
- package/dist/internal/trackEventCheck.d.ts +34 -0
- package/dist/internal/trackEventCheck.js +194 -0
- package/dist/transport/requestTimeouts.js +3 -0
- package/dist/transport/validate.d.ts +49 -6
- package/dist/transport/validate.js +132 -28
- package/package.json +4 -4
|
@@ -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
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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: '
|
|
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
|
-
|
|
1443
|
+
const bodies = typed.payload?.bodies;
|
|
1444
|
+
if (!Array.isArray(bodies) || bodies.length === 0) {
|
|
1289
1445
|
dispatchToBlock({
|
|
1290
|
-
type: '
|
|
1291
|
-
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
1491
|
+
type: 'ESTIMATE_BATCH_RESULT',
|
|
1347
1492
|
payload: {
|
|
1348
1493
|
requestId,
|
|
1349
|
-
|
|
1350
|
-
|
|
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:
|
|
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 <
|
|
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 -
|
|
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
|
-
//
|
|
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). */
|