@civitai/blocks-react 0.67.1 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +520 -8
- package/dist/hooks/useBatchEstimate.d.ts +191 -0
- package/dist/hooks/useBatchEstimate.js +196 -0
- package/dist/hooks/useBlockAnalytics.d.ts +46 -4
- package/dist/hooks/useBlockAnalytics.js +41 -4
- package/dist/hooks/useBuzzWorkflow.d.ts +4 -1
- package/dist/hooks/useBuzzWorkflow.js +4 -1
- package/dist/hooks/useResourcePicker.d.ts +98 -37
- package/dist/hooks/useResourcePicker.js +77 -7
- package/dist/hooks/useSharedStorage.d.ts +24 -0
- package/dist/hooks/useSharedStorage.js +55 -0
- package/dist/hooks/useWorkflowBatch.d.ts +196 -0
- package/dist/hooks/useWorkflowBatch.js +352 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -2
- package/dist/internal/liveHost.d.ts +27 -2
- package/dist/internal/liveHost.js +133 -15
- package/dist/internal/mockHost.d.ts +104 -2
- package/dist/internal/mockHost.js +369 -84
- package/dist/internal/pickerOverlay.d.ts +32 -0
- package/dist/internal/pickerOverlay.js +99 -2
- package/dist/internal/trackEventCheck.d.ts +34 -0
- package/dist/internal/trackEventCheck.js +194 -0
- package/dist/transport/requestTimeouts.js +3 -0
- package/dist/transport/validate.d.ts +49 -6
- package/dist/transport/validate.js +138 -28
- package/package.json +2 -2
|
@@ -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)
|
|
@@ -1007,6 +1084,9 @@ export function createMockHost(options = {}) {
|
|
|
1007
1084
|
let tokenSerial = 0;
|
|
1008
1085
|
let submitCount = 0;
|
|
1009
1086
|
// body + cost remembered per workflow so the succeeded snapshot can echo them.
|
|
1087
|
+
// `body` is absent for an id this mock never submitted (a workflow id an app
|
|
1088
|
+
// restored after a reload). `canceled` is set by `CANCEL_WORKFLOW`, so later
|
|
1089
|
+
// polls keep answering `canceled` instead of resuming the countdown.
|
|
1010
1090
|
const workflows = new Map();
|
|
1011
1091
|
// Training: datasets this install prepared (id → admitted count) and the
|
|
1012
1092
|
// quotes its estimates stored (id → what a run is checked against).
|
|
@@ -1156,6 +1236,79 @@ export function createMockHost(options = {}) {
|
|
|
1156
1236
|
return;
|
|
1157
1237
|
}
|
|
1158
1238
|
}
|
|
1239
|
+
/**
|
|
1240
|
+
* The snapshot ONE estimate of `body` replies with. Shared by
|
|
1241
|
+
* ESTIMATE_WORKFLOW and ESTIMATE_WORKFLOW_BATCH so a batch cell is priced
|
|
1242
|
+
* exactly as a single estimate of the same body.
|
|
1243
|
+
*/
|
|
1244
|
+
const estimateSnapshotFor = (body) => {
|
|
1245
|
+
// UNUSABLE-ESTIMATE simulation (`generation.failEstimate`). Reproduces
|
|
1246
|
+
// the two real producers byte-for-byte so a block's `catch` around
|
|
1247
|
+
// `estimate()` is exercisable locally — see the knob's docs for why
|
|
1248
|
+
// that was previously impossible (civitai/civitai#4159).
|
|
1249
|
+
if (gen.failEstimate === 'failed') {
|
|
1250
|
+
// Mirrors the host's `failureSnapshot(err)` exactly: the 'failed'
|
|
1251
|
+
// sentinel id (a real one would be empty and get dropped by the
|
|
1252
|
+
// inbound validator), no `cost`.
|
|
1253
|
+
return {
|
|
1254
|
+
workflowId: 'failed',
|
|
1255
|
+
status: 'failed',
|
|
1256
|
+
error: gen.failEstimateMessage ?? 'mock: estimate failed',
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
if (gen.failEstimate === 'no-cost') {
|
|
1260
|
+
// A SUCCESSFUL snapshot that simply has no price — no `error` to
|
|
1261
|
+
// explain it, which is what makes this producer the harder of the
|
|
1262
|
+
// two to diagnose from the block side.
|
|
1263
|
+
return { workflowId: 'wf_estimate', status: 'pending' };
|
|
1264
|
+
}
|
|
1265
|
+
// TRAINING ESTIMATE — KIND-FAITHFUL, the one exception to the
|
|
1266
|
+
// kind-agnostic rule above, because the real arm is not a priced
|
|
1267
|
+
// pass-through: it needs the spend scope, names a dataset the server
|
|
1268
|
+
// must hold, refuses a price above the per-run ceiling, and STORES a
|
|
1269
|
+
// quote the run is later checked against. Each refusal is the
|
|
1270
|
+
// server's message in the host's `failureSnapshot` shape.
|
|
1271
|
+
if (body.kind === 'training') {
|
|
1272
|
+
const refuse = (error) => ({
|
|
1273
|
+
workflowId: 'failed',
|
|
1274
|
+
status: 'failed',
|
|
1275
|
+
error,
|
|
1276
|
+
});
|
|
1277
|
+
if (!consentGranted)
|
|
1278
|
+
return refuse(MOCK_TRAINING_SCOPE_ERROR);
|
|
1279
|
+
const imageCount = trainingDatasets.get(body.datasetId);
|
|
1280
|
+
if (imageCount === undefined)
|
|
1281
|
+
return refuse(MOCK_TRAINING_DATASET_GONE_ERROR);
|
|
1282
|
+
const total = trainingQuoteTotal;
|
|
1283
|
+
if (total > MOCK_TRAINING_BOUNDS.maxBuzzPerRun) {
|
|
1284
|
+
return refuse(`this training run costs ${total} Buzz, above the per-run limit of ${MOCK_TRAINING_BOUNDS.maxBuzzPerRun} for apps`);
|
|
1285
|
+
}
|
|
1286
|
+
const quoteId = trainingHandle('tq_');
|
|
1287
|
+
const expiresAtMs = Date.now() + TRAINING_QUOTE_TTL_MS;
|
|
1288
|
+
trainingQuotes.set(quoteId, {
|
|
1289
|
+
datasetId: body.datasetId,
|
|
1290
|
+
total,
|
|
1291
|
+
expiresAtMs,
|
|
1292
|
+
spent: false,
|
|
1293
|
+
});
|
|
1294
|
+
return {
|
|
1295
|
+
workflowId: 'wf_estimate',
|
|
1296
|
+
status: 'pending',
|
|
1297
|
+
cost: { total },
|
|
1298
|
+
trainingQuote: {
|
|
1299
|
+
quoteId,
|
|
1300
|
+
total,
|
|
1301
|
+
imageCount,
|
|
1302
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
1303
|
+
},
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
return {
|
|
1307
|
+
workflowId: 'wf_estimate',
|
|
1308
|
+
status: 'pending',
|
|
1309
|
+
cost: quotedCost(body),
|
|
1310
|
+
};
|
|
1311
|
+
};
|
|
1159
1312
|
switch (typed.type) {
|
|
1160
1313
|
case 'REQUEST_TOKEN':
|
|
1161
1314
|
dispatchToBlock({
|
|
@@ -1263,94 +1416,86 @@ export function createMockHost(options = {}) {
|
|
|
1263
1416
|
// a registry (that's server-only); the mock accepts any id, fail-open. The
|
|
1264
1417
|
// sentinel `workflowId` is non-empty so the snapshot survives the
|
|
1265
1418
|
// SDK inbound validator (which drops empty-workflowId snapshots).
|
|
1419
|
+
// The pricing itself is `estimateSnapshotFor`, shared with the batch.
|
|
1266
1420
|
const body = typed.payload?.body ?? {};
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1421
|
+
dispatchToBlock({
|
|
1422
|
+
type: 'ESTIMATE_RESULT',
|
|
1423
|
+
payload: { requestId, snapshot: estimateSnapshotFor(body) },
|
|
1424
|
+
});
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
case 'ESTIMATE_WORKFLOW_BATCH': {
|
|
1428
|
+
// The batch twin of ESTIMATE_WORKFLOW, in the host's order: the
|
|
1429
|
+
// request shape, then the list, then each cell through
|
|
1430
|
+
// `estimateSnapshotFor` — the function ESTIMATE_WORKFLOW prices with,
|
|
1431
|
+
// so a cell is priced exactly as one estimate of that body would be.
|
|
1432
|
+
// Nothing is submitted and no Buzz moves.
|
|
1433
|
+
if (!isRoutableRequestId(requestId))
|
|
1434
|
+
return;
|
|
1435
|
+
const mode = gen.batchEstimate ?? 'supported';
|
|
1436
|
+
// A host that predates the message sends nothing at all.
|
|
1437
|
+
if (mode === 'silent')
|
|
1438
|
+
return;
|
|
1439
|
+
if (mode === 'unsupported') {
|
|
1272
1440
|
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
|
-
},
|
|
1441
|
+
type: 'ESTIMATE_BATCH_RESULT',
|
|
1442
|
+
payload: { requestId, error: 'unsupported on this host' },
|
|
1285
1443
|
});
|
|
1286
1444
|
return;
|
|
1287
1445
|
}
|
|
1288
|
-
|
|
1446
|
+
const bodies = typed.payload?.bodies;
|
|
1447
|
+
if (!Array.isArray(bodies) || bodies.length === 0) {
|
|
1289
1448
|
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' } },
|
|
1449
|
+
type: 'ESTIMATE_BATCH_RESULT',
|
|
1450
|
+
payload: { requestId, error: 'invalid estimate batch' },
|
|
1295
1451
|
});
|
|
1296
1452
|
return;
|
|
1297
1453
|
}
|
|
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
|
-
});
|
|
1454
|
+
if (bodies.length > MOCK_ESTIMATE_BATCH_MAX_CELLS) {
|
|
1326
1455
|
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
|
-
},
|
|
1456
|
+
type: 'ESTIMATE_BATCH_RESULT',
|
|
1457
|
+
payload: { requestId, error: 'estimate batch too large' },
|
|
1342
1458
|
});
|
|
1343
1459
|
return;
|
|
1344
1460
|
}
|
|
1461
|
+
const snapshots = bodies.map((cell, index) => {
|
|
1462
|
+
// The host's snapshot for a cell its call budget ran out on.
|
|
1463
|
+
if (gen.batchEstimateTimedOutCells?.includes(index)) {
|
|
1464
|
+
return {
|
|
1465
|
+
workflowId: 'failed',
|
|
1466
|
+
status: 'failed',
|
|
1467
|
+
error: BATCH_ESTIMATE_CELL_TIMEOUT_ERROR,
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
// The server refuses a training cell in a batch: a training
|
|
1471
|
+
// estimate stores the quote the viewer later confirms, so it is
|
|
1472
|
+
// estimated on its own. Refused BEFORE `estimateSnapshotFor`, which
|
|
1473
|
+
// would store one.
|
|
1474
|
+
if (cell?.kind === 'training') {
|
|
1475
|
+
return {
|
|
1476
|
+
workflowId: 'failed',
|
|
1477
|
+
status: 'failed',
|
|
1478
|
+
error: MOCK_TRAINING_IN_BATCH_ERROR,
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
return estimateSnapshotFor(cell ?? {});
|
|
1482
|
+
});
|
|
1483
|
+
// The server's aggregate rule: the cells that priced, i.e. not
|
|
1484
|
+
// `'failed'` and carrying a numeric `cost.total`.
|
|
1485
|
+
let total = 0;
|
|
1486
|
+
let pricedCells = 0;
|
|
1487
|
+
for (const snapshot of snapshots) {
|
|
1488
|
+
if (snapshot.status === 'failed' || typeof snapshot.cost?.total !== 'number')
|
|
1489
|
+
continue;
|
|
1490
|
+
total += snapshot.cost.total;
|
|
1491
|
+
pricedCells += 1;
|
|
1492
|
+
}
|
|
1345
1493
|
dispatchToBlock({
|
|
1346
|
-
type: '
|
|
1494
|
+
type: 'ESTIMATE_BATCH_RESULT',
|
|
1347
1495
|
payload: {
|
|
1348
1496
|
requestId,
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
status: 'pending',
|
|
1352
|
-
cost: { total: costFor(body) },
|
|
1353
|
-
},
|
|
1497
|
+
snapshots,
|
|
1498
|
+
aggregate: { total, pricedCells, cellCount: snapshots.length },
|
|
1354
1499
|
},
|
|
1355
1500
|
});
|
|
1356
1501
|
return;
|
|
@@ -1386,6 +1531,8 @@ export function createMockHost(options = {}) {
|
|
|
1386
1531
|
return;
|
|
1387
1532
|
}
|
|
1388
1533
|
const cost = costFor(body);
|
|
1534
|
+
// What the viewer pays: the generation plus the app fee.
|
|
1535
|
+
const charged = cost + (authorFeeFor(body) ?? 0);
|
|
1389
1536
|
// CAUGHT-SERVER-EXCEPTION simulation (`generation.failSubmitException`).
|
|
1390
1537
|
// Reproduces the host's `failureSnapshot(err)` byte-for-byte: the
|
|
1391
1538
|
// 'failed' sentinel id and NO `cost`, which is what separates an
|
|
@@ -1453,7 +1600,7 @@ export function createMockHost(options = {}) {
|
|
|
1453
1600
|
// presence is what makes `submit()` RESOLVE this rather than
|
|
1454
1601
|
// reject it as an errored submit
|
|
1455
1602
|
// (civitai/civitai-app-starters#251). Do not drop it.
|
|
1456
|
-
cost:
|
|
1603
|
+
cost: quotedCost(body),
|
|
1457
1604
|
error: gen.submitCapRefusal === true
|
|
1458
1605
|
? DEFAULT_SUBMIT_CAP_REFUSAL
|
|
1459
1606
|
: gen.submitCapRefusal,
|
|
@@ -1468,7 +1615,7 @@ export function createMockHost(options = {}) {
|
|
|
1468
1615
|
const insufficient = failMode === 'all' ||
|
|
1469
1616
|
failMode === 'insufficient' ||
|
|
1470
1617
|
buzz.insufficient === true ||
|
|
1471
|
-
(balanceSimulated && buzz.balance <
|
|
1618
|
+
(balanceSimulated && buzz.balance < charged);
|
|
1472
1619
|
// Generic generation failure: failNext countdown, failRate dice, or
|
|
1473
1620
|
// the legacy 'some' (~1 in 3) mode.
|
|
1474
1621
|
let genericFail = false;
|
|
@@ -1548,7 +1695,7 @@ export function createMockHost(options = {}) {
|
|
|
1548
1695
|
}
|
|
1549
1696
|
// Success path: debit the simulated balance + remember body/cost.
|
|
1550
1697
|
if (balanceSimulated)
|
|
1551
|
-
buzz.balance = buzz.balance -
|
|
1698
|
+
buzz.balance = buzz.balance - charged;
|
|
1552
1699
|
const workflowId = `wf_${submitCount}_${Date.now()}`;
|
|
1553
1700
|
workflows.set(workflowId, { polls: 0, cost, body });
|
|
1554
1701
|
dispatchToBlock({
|
|
@@ -1559,11 +1706,35 @@ export function createMockHost(options = {}) {
|
|
|
1559
1706
|
}
|
|
1560
1707
|
case 'POLL_WORKFLOW': {
|
|
1561
1708
|
const workflowId = typed.payload?.workflowId ?? '';
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
if (
|
|
1709
|
+
// An id this mock did not submit (e.g. restored from storage after a
|
|
1710
|
+
// reload) gets a record on its first poll, so it counts down to done
|
|
1711
|
+
// like any other instead of answering `processing` forever.
|
|
1712
|
+
let wf = workflows.get(workflowId);
|
|
1713
|
+
if (!wf) {
|
|
1714
|
+
wf = { polls: 0, cost: legacyCost };
|
|
1715
|
+
workflows.set(workflowId, wf);
|
|
1716
|
+
}
|
|
1717
|
+
if (wf.canceled) {
|
|
1718
|
+
dispatchToBlock({
|
|
1719
|
+
type: 'WORKFLOW_STATUS',
|
|
1720
|
+
payload: { requestId, snapshot: { workflowId, status: 'canceled' } },
|
|
1721
|
+
});
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
const polls = wf.polls + 1;
|
|
1725
|
+
wf.polls = polls;
|
|
1726
|
+
const outcome = polls >= pollsUntilDone ? (gen.workflowOutcome?.(workflowId, wf.body) ?? 'succeeded') : null;
|
|
1727
|
+
if (outcome === 'failed' || outcome === 'expired') {
|
|
1728
|
+
dispatchToBlock({
|
|
1729
|
+
type: 'WORKFLOW_STATUS',
|
|
1730
|
+
payload: {
|
|
1731
|
+
requestId,
|
|
1732
|
+
snapshot: { workflowId, status: outcome, error: `mock: workflow ${outcome}` },
|
|
1733
|
+
},
|
|
1734
|
+
});
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
if (outcome === 'succeeded') {
|
|
1567
1738
|
// Apply synthetic latency on the terminal (succeeded) poll only.
|
|
1568
1739
|
const delay = latencyFor();
|
|
1569
1740
|
if (delay > 0) {
|
|
@@ -1589,7 +1760,35 @@ export function createMockHost(options = {}) {
|
|
|
1589
1760
|
}
|
|
1590
1761
|
case 'CANCEL_WORKFLOW': {
|
|
1591
1762
|
const workflowId = typed.payload?.workflowId ?? '';
|
|
1592
|
-
|
|
1763
|
+
const refused = gen.cancelRefused;
|
|
1764
|
+
if (refused === true || (Array.isArray(refused) && refused.includes(workflowId))) {
|
|
1765
|
+
// The real host's answer when the estimate allowance is used up: a
|
|
1766
|
+
// NON-terminal snapshot, and the cancel is not sent.
|
|
1767
|
+
dispatchToBlock({
|
|
1768
|
+
type: 'WORKFLOW_CANCELED',
|
|
1769
|
+
payload: { requestId, snapshot: { workflowId, status: 'processing' } },
|
|
1770
|
+
});
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
const wf = workflows.get(workflowId);
|
|
1774
|
+
if (wf && !wf.canceled && wf.polls >= pollsUntilDone) {
|
|
1775
|
+
// Already finished: like the real host, report the real terminal
|
|
1776
|
+
// status rather than pretending the cancel took.
|
|
1777
|
+
const outcome = gen.workflowOutcome?.(workflowId, wf.body) ?? 'succeeded';
|
|
1778
|
+
dispatchToBlock({
|
|
1779
|
+
type: 'WORKFLOW_CANCELED',
|
|
1780
|
+
payload: {
|
|
1781
|
+
requestId,
|
|
1782
|
+
snapshot: outcome === 'succeeded'
|
|
1783
|
+
? succeededSnapshot(workflowId)
|
|
1784
|
+
: { workflowId, status: outcome, error: `mock: workflow ${outcome}` },
|
|
1785
|
+
},
|
|
1786
|
+
});
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
// Keep the record, marked canceled, so later polls answer `canceled`
|
|
1790
|
+
// rather than counting down to `succeeded`.
|
|
1791
|
+
workflows.set(workflowId, { ...(wf ?? { polls: 0, cost: legacyCost }), canceled: true });
|
|
1593
1792
|
dispatchToBlock({
|
|
1594
1793
|
type: 'WORKFLOW_CANCELED',
|
|
1595
1794
|
payload: { requestId, snapshot: { workflowId, status: 'canceled' } },
|
|
@@ -2065,6 +2264,37 @@ export function createMockHost(options = {}) {
|
|
|
2065
2264
|
}
|
|
2066
2265
|
case 'OPEN_RESOURCE_PICKER': {
|
|
2067
2266
|
const rtype = typed.payload?.resourceType;
|
|
2267
|
+
// MULTI-select (`multiple: { max }`) — the real host's rules, in the
|
|
2268
|
+
// real host's order: LoRA family only, `max` a whole number ≥ 1
|
|
2269
|
+
// (clamped to 5), and a LIST reply (`selectedResources`, `[]` when
|
|
2270
|
+
// dismissed) with no `selected`. A request WITHOUT the key falls
|
|
2271
|
+
// through to the single-pick reply below, unchanged.
|
|
2272
|
+
const multiple = typed.payload?.multiple;
|
|
2273
|
+
if (multiple !== undefined && multiple !== null) {
|
|
2274
|
+
const refuse = (error) => dispatchToBlock({ type: 'RESOURCE_PICKER_RESULT', payload: { requestId, error } });
|
|
2275
|
+
if (!['lora', 'locon', 'dora'].includes(String(rtype).trim().toLowerCase())) {
|
|
2276
|
+
refuse(`OPEN_RESOURCE_PICKER: multiple is only supported for LoRA-family resource types, not ${String(rtype)}.`);
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2279
|
+
const max = typeof multiple === 'object' ? multiple.max : undefined;
|
|
2280
|
+
if (typeof max !== 'number' || !Number.isInteger(max) || max < 1) {
|
|
2281
|
+
refuse('OPEN_RESOURCE_PICKER: multiple.max must be a whole number of at least 1.');
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
const picks = cannedMultiPicks !== undefined
|
|
2285
|
+
? (cannedMultiPicks ?? [])
|
|
2286
|
+
: cannedPicks.LORA
|
|
2287
|
+
? [cannedPicks.LORA, DEFAULT_SECOND_LORA_PICK]
|
|
2288
|
+
: [];
|
|
2289
|
+
dispatchToBlock({
|
|
2290
|
+
type: 'RESOURCE_PICKER_RESULT',
|
|
2291
|
+
payload: {
|
|
2292
|
+
requestId,
|
|
2293
|
+
selectedResources: picks.slice(0, Math.min(max, MOCK_RESOURCE_PICKER_MULTI_MAX)),
|
|
2294
|
+
},
|
|
2295
|
+
});
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2068
2298
|
const selected = rtype ? cannedPicks[rtype] : undefined;
|
|
2069
2299
|
dispatchToBlock({
|
|
2070
2300
|
type: 'RESOURCE_PICKER_RESULT',
|
|
@@ -2355,6 +2585,14 @@ export function createMockHost(options = {}) {
|
|
|
2355
2585
|
return;
|
|
2356
2586
|
}
|
|
2357
2587
|
case 'SHARED_APPEND': {
|
|
2588
|
+
const appendLimited = takeSharedRateLimit('submissions');
|
|
2589
|
+
if (appendLimited) {
|
|
2590
|
+
dispatchToBlock({
|
|
2591
|
+
type: 'SHARED_APPEND_RESULT',
|
|
2592
|
+
payload: { requestId, key: '', ...appendLimited },
|
|
2593
|
+
});
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2358
2596
|
if (sharedFailNext > 0) {
|
|
2359
2597
|
sharedFailNext -= 1;
|
|
2360
2598
|
dispatchToBlock({
|
|
@@ -2397,6 +2635,14 @@ export function createMockHost(options = {}) {
|
|
|
2397
2635
|
}
|
|
2398
2636
|
case 'SHARED_VOTE': {
|
|
2399
2637
|
const key = typed.payload?.key ?? '';
|
|
2638
|
+
const voteLimited = takeSharedRateLimit('votes');
|
|
2639
|
+
if (voteLimited) {
|
|
2640
|
+
dispatchToBlock({
|
|
2641
|
+
type: 'SHARED_VOTE_RESULT',
|
|
2642
|
+
payload: { requestId, count: 0, ...voteLimited },
|
|
2643
|
+
});
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2400
2646
|
if (sharedFailNext > 0) {
|
|
2401
2647
|
sharedFailNext -= 1;
|
|
2402
2648
|
dispatchToBlock({
|
|
@@ -2424,6 +2670,14 @@ export function createMockHost(options = {}) {
|
|
|
2424
2670
|
}
|
|
2425
2671
|
case 'SHARED_UNVOTE': {
|
|
2426
2672
|
const key = typed.payload?.key ?? '';
|
|
2673
|
+
const unvoteLimited = takeSharedRateLimit('votes');
|
|
2674
|
+
if (unvoteLimited) {
|
|
2675
|
+
dispatchToBlock({
|
|
2676
|
+
type: 'SHARED_UNVOTE_RESULT',
|
|
2677
|
+
payload: { requestId, count: 0, ...unvoteLimited },
|
|
2678
|
+
});
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2427
2681
|
if (sharedFailNext > 0) {
|
|
2428
2682
|
sharedFailNext -= 1;
|
|
2429
2683
|
dispatchToBlock({
|
|
@@ -2450,6 +2704,14 @@ export function createMockHost(options = {}) {
|
|
|
2450
2704
|
}
|
|
2451
2705
|
case 'SHARED_WITHDRAW': {
|
|
2452
2706
|
const key = typed.payload?.key ?? '';
|
|
2707
|
+
const withdrawLimited = takeSharedRateLimit('withdrawals');
|
|
2708
|
+
if (withdrawLimited) {
|
|
2709
|
+
dispatchToBlock({
|
|
2710
|
+
type: 'SHARED_WITHDRAW_RESULT',
|
|
2711
|
+
payload: { requestId, ok: false, deleted: false, ...withdrawLimited },
|
|
2712
|
+
});
|
|
2713
|
+
return;
|
|
2714
|
+
}
|
|
2453
2715
|
if (sharedFailNext > 0) {
|
|
2454
2716
|
sharedFailNext -= 1;
|
|
2455
2717
|
dispatchToBlock({
|
|
@@ -2467,6 +2729,14 @@ export function createMockHost(options = {}) {
|
|
|
2467
2729
|
}
|
|
2468
2730
|
case 'SHARED_UPDATE': {
|
|
2469
2731
|
const key = typed.payload?.key ?? '';
|
|
2732
|
+
const updateLimited = takeSharedRateLimit('submissions');
|
|
2733
|
+
if (updateLimited) {
|
|
2734
|
+
dispatchToBlock({
|
|
2735
|
+
type: 'SHARED_UPDATE_RESULT',
|
|
2736
|
+
payload: { requestId, ok: false, ...updateLimited },
|
|
2737
|
+
});
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2470
2740
|
if (sharedFailNext > 0) {
|
|
2471
2741
|
sharedFailNext -= 1;
|
|
2472
2742
|
dispatchToBlock({
|
|
@@ -2537,6 +2807,14 @@ export function createMockHost(options = {}) {
|
|
|
2537
2807
|
}
|
|
2538
2808
|
case 'SHARED_REPORT': {
|
|
2539
2809
|
const key = typed.payload?.key ?? '';
|
|
2810
|
+
const reportLimited = takeSharedRateLimit('reports');
|
|
2811
|
+
if (reportLimited) {
|
|
2812
|
+
dispatchToBlock({
|
|
2813
|
+
type: 'SHARED_REPORT_RESULT',
|
|
2814
|
+
payload: { requestId, ok: false, ...reportLimited },
|
|
2815
|
+
});
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2540
2818
|
if (sharedFailNext > 0) {
|
|
2541
2819
|
sharedFailNext -= 1;
|
|
2542
2820
|
dispatchToBlock({
|
|
@@ -2725,6 +3003,9 @@ export function createMockHost(options = {}) {
|
|
|
2725
3003
|
if (patch.cannedPicks !== undefined)
|
|
2726
3004
|
cannedPicks = patch.cannedPicks;
|
|
2727
3005
|
// `null` is a meaningful value (dismissed), so check for the KEY's presence.
|
|
3006
|
+
if ('cannedMultiPicks' in patch)
|
|
3007
|
+
cannedMultiPicks = patch.cannedMultiPicks;
|
|
3008
|
+
// `null` is a meaningful value (dismissed), so check for the KEY's presence.
|
|
2728
3009
|
if ('cannedImageUpload' in patch)
|
|
2729
3010
|
cannedImageUpload = patch.cannedImageUpload ?? null;
|
|
2730
3011
|
if ('cannedGenerationSourceUpload' in patch)
|
|
@@ -2804,10 +3085,14 @@ export function createMockHost(options = {}) {
|
|
|
2804
3085
|
storageFailNext = patch.storage.failNext;
|
|
2805
3086
|
}
|
|
2806
3087
|
if (patch.shared !== undefined) {
|
|
2807
|
-
//
|
|
2808
|
-
// change the backing store).
|
|
3088
|
+
// `failNext`, `rateLimitNext` and `rateLimit` are live-tunable; `seed` is
|
|
3089
|
+
// install-time (re-install to change the backing store).
|
|
2809
3090
|
if (patch.shared.failNext !== undefined)
|
|
2810
3091
|
sharedFailNext = patch.shared.failNext;
|
|
3092
|
+
if (patch.shared.rateLimitNext !== undefined)
|
|
3093
|
+
sharedRateLimitNext = patch.shared.rateLimitNext;
|
|
3094
|
+
if (patch.shared.rateLimit !== undefined)
|
|
3095
|
+
sharedRateLimitMeta = patch.shared.rateLimit;
|
|
2811
3096
|
}
|
|
2812
3097
|
}
|
|
2813
3098
|
const buzzHandle = {
|