@civitai/blocks-react 0.76.1 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1229,6 +1229,67 @@ if (error instanceof WildcardPackError && error.code === 'busy') void refetch();
1229
1229
  if (!loading && pack) console.log(Object.keys(pack.lists));
1230
1230
  ```
1231
1231
 
1232
+ ### `useResourceFile(modelVersionId, fileId?)`
1233
+
1234
+ Read a resource file's parsed JSON — for a workflow, its graphs — by model
1235
+ version. The host resolves, fetches, unzips and parses the file **in the
1236
+ viewer's own page session**, enforcing the real download gates, so the iframe
1237
+ never sees the session, a signed URL or the raw bytes. No block scope is needed.
1238
+ Returns `{ file, loading, error, refetch }`.
1239
+
1240
+ - `file.meta` names what was served (`modelId`, `modelVersionId`, `fileId`,
1241
+ `fileName`, `modelName`, `versionName`, `creatorUsername`).
1242
+ - `file.entries` holds one `{ path, format, graph }` per JSON document: one for a
1243
+ plain `.json`, several for a `.zip` (non-JSON entries are skipped). `format`
1244
+ is `'ui'` (a `nodes` array), `'api'` (every top-level value has `class_type`
1245
+ and `inputs`) or `'unknown'`; check it before treating `graph` as a graph.
1246
+ `file.truncated` is `true` when an entry was dropped: past 20 entries, over
1247
+ the 4 MB per-entry or 16 MB total cap, unreadable, or not valid JSON.
1248
+ - Pass `fileId` to pin one file of a multi-file version. Without it the host
1249
+ picks one.
1250
+ - Changing `modelVersionId` or `fileId` resets `file` and `error` to `null`
1251
+ before the new fetch, so a file never shows for ids it does not belong to.
1252
+ - The request waits up to 120 s, because the host downloads and unzips the file.
1253
+
1254
+ On failure `error` is a `ResourceFileError` whose `code` is one of a closed set:
1255
+
1256
+ | `code` | Means | Retry? |
1257
+ |---|---|---|
1258
+ | `not-found` | No such version or file, not a workflow, or not downloadable for free (see below) | No |
1259
+ | `forbidden` | Above the viewer's maturity ceiling on this site, or the app is in review preview | No |
1260
+ | `unauthorized` | The viewer is signed out | No |
1261
+ | `too-large` | Over 32 MB, or every entry was over a size cap (4 MB per entry, 16 MB in total) | No |
1262
+ | `parse-failed` | Not a `.json`/`.zip`, corrupt, or no valid JSON — **or** the host's catch-all: a network error, a failed fetch, no answer within its 110 s deadline, a transient storage failure, or another server error | Once, maybe: it can be transient |
1263
+ | `busy` | Three fetches already in flight in this tab | Yes |
1264
+ | `rate-limited` | The viewer's 30-a-minute budget is spent | Yes, later |
1265
+
1266
+ 🔴 **Full-page app hosts only — not the model-page slot, not `dev:live`.** The
1267
+ model-page slot does not answer it (the request times out), and `dev:live`
1268
+ replies `parse-failed`. Develop against `dev:mock`: `createMockHost` takes a
1269
+ `resourceFile` (the canned reply) and a `resourceFileError` (force a code);
1270
+ `setScenario({ resourceFileError: undefined })` clears the forced error.
1271
+
1272
+ 🔴 **Only free, public versions are served.** An unpublished draft (the owner's
1273
+ included) and an early-access or paid version answer `not-found` — even for a
1274
+ viewer who bought it. Only the owner gets their own published early-access
1275
+ version. A POI model, or a model or version whose availability is not `Public`
1276
+ or `Unsearchable` (e.g. `Private`), is refused to everyone but the owner,
1277
+ moderators included.
1278
+
1279
+ ```tsx
1280
+ const { file, loading, error, refetch } = useResourceFile(modelVersionId);
1281
+ // Retry a retryable code from an effect, after a delay — never during render.
1282
+ const retryable =
1283
+ error instanceof ResourceFileError && (error.code === 'busy' || error.code === 'rate-limited');
1284
+ useEffect(() => {
1285
+ if (!retryable) return;
1286
+ const t = setTimeout(refetch, 2_000);
1287
+ return () => clearTimeout(t);
1288
+ }, [retryable, refetch]);
1289
+ const graphs = file?.entries.filter((e) => e.format !== 'unknown') ?? [];
1290
+ if (!loading && file) console.log(file.meta.fileName, graphs.length, file.truncated);
1291
+ ```
1292
+
1232
1293
  ### `useCollectionFollow()`
1233
1294
 
1234
1295
  Follow / unfollow a collection **for the viewer**, host-mediated over
@@ -1463,43 +1524,55 @@ options drive both arms (including `declined`). **`dev:live` refuses this bridge
1463
1524
  it has no civitai chrome to render the server-resolved confirm in, and driving
1464
1525
  the write without it would let dev prove out a flow production does not have.
1465
1526
 
1466
- ### `usePrepareTrainingDataset()` and `useRunTraining()` — LoRA training
1467
-
1468
- Train a LoRA with the ai-toolkit engine on the **viewer's own images**, from
1469
- inside a page app — the App Blocks `kind: 'training'` flow. Four steps, three
1470
- hooks:
1471
-
1472
- 1. **Dataset** — `usePrepareTrainingDataset().prepareDataset([{ imageId, caption }])`
1473
- (`PREPARE_TRAINING_DATASET`). Only the viewer's own scanned, unflagged images
1474
- within the page's maturity ceiling are admitted; the rest come back in
1475
- `rejected` with a reason (`unavailable`, `unsupported-media`, `not-eligible`,
1476
- `pending-scan`, `import-failed`, `import-unavailable`; `pending-scan` and
1477
- `import-unavailable` are retryable). You get an opaque `datasetId` and the
1478
- admitted `count`, always at least 1 — if **nothing** is admitted the call
1479
- rejects with the server's message instead. 1–50 images, captions up to 1,000 characters; captions are
1480
- moderated. No dialog, no charge.
1527
+ ### `useImageUpload({ purpose: 'trainingData' })` and `useRunTraining()` — LoRA training
1528
+
1529
+ Train a LoRA with the ai-toolkit engine on **images the viewer uploads**, from
1530
+ inside a page app — the App Blocks `kind: 'training'` flow, with the same upload
1531
+ path and `{ air, caption }` dataset shape as Civitai's own training form. Four
1532
+ steps, three hooks:
1533
+
1534
+ 1. **Upload** — `useImageUpload({ purpose: 'trainingData' }).open()`
1535
+ (`OPEN_IMAGE_UPLOAD { purpose: 'trainingData' }`). Civitai opens an upload
1536
+ modal in **its own chrome**; the viewer picks up to 50 PNG, JPEG or WebP
1537
+ images, and each is uploaded as a private blob (not published). `open()`
1538
+ resolves with every image that uploaded, `[{ url, air }]`, or `null` when
1539
+ the viewer cancels. Pair each `air` with your caption — captions up to 8,192
1540
+ characters, one blob at most once. The host answers only when its modal
1541
+ closes, after every original has uploaded, so `open()` waits up to an hour,
1542
+ not the ten minutes the other pickers use.
1481
1543
  2. **Quote** — `useBuzzWorkflow().estimate(body)` with a `WorkflowBodyTraining`
1482
- (`kind: 'training'`, no `quoteId`). The reply carries `trainingQuote:
1483
- { quoteId, total, imageCount, expiresAt }` — the orchestrator's own price for
1484
- exactly this run, stored server-side for 15 minutes.
1544
+ (`kind: 'training'`, `items: [{ air, caption }]`, no `quoteId`). The reply
1545
+ carries `trainingQuote: { quoteId, total, imageCount, expiresAt }` — the
1546
+ orchestrator's own price for exactly this run, stored server-side for 15
1547
+ minutes.
1485
1548
  3. **Run** — `useRunTraining().runTraining({ ...body, quoteId })` (`RUN_TRAINING`).
1486
1549
  **Civitai shows the viewer a consent dialog in its own chrome** — price, base
1487
1550
  model, length and dataset size, all read back from the server, never from
1488
- your body — and submits only on their click. Change nothing in the body
1489
- between the estimate and the run; the server checks it against the quoted one.
1551
+ your body, with the host's own copies of the uploads as thumbnails — and
1552
+ submits only on their click. Change nothing in the body between the estimate
1553
+ and the run; the server checks it against the quoted one.
1490
1554
  4. **Follow** — `useBuzzWorkflow().watch(workflowId)`. Once the run's moderation
1491
1555
  status is approved, `trainedEpochs` lists the epochs whose checkpoint is
1492
1556
  ready; send the viewer to the publish wizard with one of them (see
1493
1557
  `trainedEpochs` on `BlockWorkflowSnapshot`).
1494
1558
 
1559
+ 🔴 **Upload and run in the same page session.** The host remembers the blobs
1560
+ its own upload modal uploaded for this block, and **refuses a quote naming any
1561
+ other** with `training data not uploaded here` — before any dialog, so no run
1562
+ and no charge. A page reload forgets the uploads: ask the viewer to upload
1563
+ again, then estimate again.
1564
+
1495
1565
  🔴 **Availability.** Behind the host flag **`app-blocks-training-kind`**, which
1496
1566
  ships **off** and is evaluated per viewer. **Page apps only** — the model slot
1497
- answers both messages with an error. The manifest must declare
1567
+ answers `RUN_TRAINING` with an error. The manifest must declare
1498
1568
  **`ai:write:budgeted`** and the viewer must be signed in and have granted it.
1499
1569
  **Refused from `dev:live` and from review sessions** (the server refuses
1500
1570
  development and review tokens); build the flow against the mock host. Requires a
1501
1571
  civitai.com host carrying civitai/civitai#5434 (`kind: 'training'`,
1502
- `RUN_TRAINING`) and civitai/civitai#5438 (`PREPARE_TRAINING_DATASET`).
1572
+ `RUN_TRAINING`) and civitai/civitai#6461 (uploaded blobs as the dataset). The
1573
+ server-prepared dataset this flow used before #6461 (`usePrepareTrainingDataset`,
1574
+ `PREPARE_TRAINING_DATASET`) is **removed**; a host carrying #6461 answers that
1575
+ message with the error `removed`.
1503
1576
 
1504
1577
  🔴 **Money.** There is no `maxBuzz` and no timeout knob — the price is the quote.
1505
1578
  A confirmed run may cost more than the token's per-call `buzzBudget` (the viewer
@@ -1516,32 +1589,26 @@ saying anything about money:
1516
1589
  | `err.declined` | the viewer dismissed the dialog — **no run was submitted** | revert, say nothing |
1517
1590
  | `err.unconfirmed` | `submission-unconfirmed`, or no reply within the 10-min bound (`err.timedOut`) — the run **may be running and charged** | 🔴 **never retry automatically**: check the viewer's trainings first (`useAppWorkflows()` lists this app's runs). Re-running the same body after the server did start it is a **second, separately charged run** |
1518
1591
  | `err.refused` (`code: 'refused'`) | the server refused the submit after the viewer confirmed — a **spend cap** (their daily or private-run Buzz cap, the per-app consent budget, the app's daily spend or rate limit, a dev-session cap) or a **temporary-availability** deny. Refunded: **no run, nothing charged**; the quote is used up | show `err.message` (the server's reason, e.g. `daily Buzz cap reached: …`); estimate again before any retry — buying Buzz does not lift these caps |
1592
+ | `err.trainingDataNotUploaded` (`code: 'training data not uploaded here'`) | the quote names an image this host did not upload for this block (typically after a reload) — refused before any dialog: **no run, nothing charged** | ask the viewer to upload the images again, then estimate and run the new quote |
1519
1593
  | `err.signInRequired` | no session | route into `useRequestSignIn()` |
1520
1594
  | `err.code` set otherwise | a host refusal (`review-mode` / `block is not ready` / `invalid training request` / `no block token`) | show or ignore per case |
1521
- | `err.code === undefined`, no flag | a server refusal before any submit (expired or used quote, changed body, ineligible image) | estimate again, then retry |
1595
+ | `err.code === undefined`, no flag | a server refusal before any submit (expired or used quote, changed body) | estimate again, then retry |
1522
1596
 
1523
- `prepareDataset` rejects with a `PrepareTrainingDatasetError` (`.code` for the
1524
- host's refusals — `review-mode`, `block is not ready`, `sign in to train`,
1525
- `invalid training dataset`, `no block token` — plus `.signInRequired` and
1526
- `.timedOut`). Preparing charges nothing, so none of them cost Buzz. It prompts
1527
- for `ai:write:budgeted` and retries once on a grant (see the table below);
1528
- `runTraining` deliberately does not — it carries no idempotency key and has an
1529
- outcome where a run may exist, so it never re-sends.
1597
+ `runTraining` never prompts for `ai:write:budgeted` or re-sends — it carries no
1598
+ idempotency key and has an outcome where a run may exist.
1530
1599
 
1531
1600
  ```tsx
1532
- const { prepareDataset } = usePrepareTrainingDataset();
1601
+ const { open } = useImageUpload({ purpose: 'trainingData' });
1533
1602
  const { estimate, watch } = useBuzzWorkflow();
1534
1603
  const { runTraining } = useRunTraining();
1535
1604
 
1536
- async function train(images: Array<{ id: number; caption: string }>) {
1537
- // Rejects (no `.code`) when NOTHING is admitted, e.g. "none of the requested
1538
- // images can be used for training"; a resolved dataset always has count >= 1.
1539
- const dataset = await prepareDataset(images.map((i) => ({ imageId: i.id, caption: i.caption })));
1540
- if (dataset.rejected.length > 0) showNotice(`${dataset.rejected.length} image(s) left out`);
1605
+ async function train(captionFor: (image: BlockTrainingDataImage) => string) {
1606
+ const images = await open();
1607
+ if (!images) return; // the viewer cancelled
1541
1608
 
1542
1609
  const body: WorkflowBodyTraining = {
1543
1610
  kind: 'training',
1544
- datasetId: dataset.datasetId,
1611
+ items: images.map((image) => ({ air: image.air, caption: captionFor(image) })),
1545
1612
  engine: 'ai-toolkit',
1546
1613
  model: baseModelKey, // a key from Civitai's training catalog
1547
1614
  params: aiToolkitParams, // AiToolkitTrainingParams
@@ -1561,6 +1628,7 @@ async function train(images: Array<{ id: number; caption: string }>) {
1561
1628
  if (err.declined) return; // no run
1562
1629
  if (err.unconfirmed) return showCheckYourTrainings(); // may be running — never auto-retry
1563
1630
  if (err.refused) return showError(err.message); // a cap / availability refusal; no run, nothing charged
1631
+ if (err.trainingDataNotUploaded) return showError('Upload the images again.'); // no run
1564
1632
  if (err.signInRequired) return requestSignIn();
1565
1633
  showError('Could not start training. Get a new price and try again.');
1566
1634
  }
@@ -1569,19 +1637,22 @@ async function train(images: Array<{ id: number; caption: string }>) {
1569
1637
 
1570
1638
  With the mock host — `createMockHost` or `Harness` from
1571
1639
  `@civitai/blocks-react/testing` — the training path is **kind-faithful**: it
1572
- holds the datasets it prepared and the quotes it stored, refuses an unknown
1573
- dataset, a dataset with nothing admitted, a quote above 5,000 and a quote run
1574
- twice, and needs
1575
- `ai:write:budgeted` on the token and a signed-in viewer. Its knobs are
1576
- `trainingDatasetRejected`, `trainingDatasetError`, `trainingQuoteTotal`,
1577
- `runTrainingError` (`'declined'`, `'submission-unconfirmed'`, or any server
1578
- message), `runTrainingCapRefusal` (the server's resolved cap / availability refusal, which consumes the quote →
1640
+ remembers the blob keys its uploads handed out and refuses a run naming any
1641
+ other (`training data not uploaded here`, quote kept), refuses items outside the
1642
+ server's schema at the estimate, a quote above 5,000, a quote run twice and a
1643
+ body that differs from the quoted one, and needs `ai:write:budgeted` on the
1644
+ token and a signed-in viewer. Its knobs are `cannedTrainingDataUpload` (the
1645
+ images an upload returns; default three fresh ones per upload, `null` for a
1646
+ cancel), `trainingDataNotUploadedHere` (refuse every run as a host that forgot
1647
+ its uploads would), `trainingQuoteTotal`, `runTrainingError` (`'declined'`,
1648
+ `'submission-unconfirmed'`, or any server message), `runTrainingCapRefusal` (the
1649
+ server's resolved cap / availability refusal, which consumes the quote →
1579
1650
  `err.refused`) and `generation.trainedEpochs` for the finished run. It has no
1580
- consent dialog, so `runTraining` settles at once where the real host waits on a
1581
- click, and it checks a run's body against its quote by `datasetId` only — the
1582
- server compares the whole body.
1583
- **`dev:live` refuses both bridges**, because the server refuses every training
1584
- request from a dev token.
1651
+ upload modal and no consent dialog, so `open()` and `runTraining` settle at once
1652
+ where the real host waits on the viewer; and it accepts any https blob url as an
1653
+ `air`, where the server accepts only Civitai-hosted ones — name items by `air`.
1654
+ **`dev:live` refuses the run** (the server refuses every training request from a
1655
+ dev token) and answers the upload as a cancel.
1585
1656
 
1586
1657
  ### `useRunConfirmedStep()` — one job above the per-call budget
1587
1658
 
@@ -2213,8 +2284,10 @@ answers a raw `multiple` + Checkpoint request with the host's error.
2213
2284
 
2214
2285
  Host-mediated image upload — the host opens its native upload modal and the
2215
2286
  iframe never handles the bytes. Resolves with a moderated image (or `null` on
2216
- dismiss); pass `{ purpose: 'generationSource' }` for an unscanned img2img source
2217
- or `{ asyncScan: true }` for the early-resolve + `scanStatus()` flow.
2287
+ dismiss); pass `{ purpose: 'generationSource' }` for an unscanned img2img source,
2288
+ `{ purpose: 'trainingData' }` for several private training images in one call
2289
+ (`[{ url, air }] | null` — see the LoRA training section), or
2290
+ `{ asyncScan: true }` for the early-resolve + `scanStatus()` flow.
2218
2291
 
2219
2292
  ```tsx
2220
2293
  const { open } = useImageUpload();
@@ -2863,7 +2936,6 @@ had just worked.
2863
2936
  |---|---|---|
2864
2937
  | `useBuzzWorkflow()` | `submit()` | `ai:write:budgeted` |
2865
2938
  | `useCreatePostFromApp()` | `createPost()` | `posts:write:self` |
2866
- | `usePrepareTrainingDataset()` | `prepareDataset()` | `ai:write:budgeted` |
2867
2939
  | `useGoodPurchase()` | `purchase()` | `goods:purchase:self` |
2868
2940
  | `useTip()` | `tip()` | `social:tip:self` |
2869
2941
 
@@ -3819,6 +3891,7 @@ Runnable, minimal blocks — one per feature, each with its own README:
3819
3891
 
3820
3892
  | `@civitai/blocks-react` | pairs with `@civitai/app-sdk` | adds |
3821
3893
  |---|---|---|
3894
+ | `0.78.x` | `^0.79.0` | `useResourceFile()` (`GET_RESOURCE_FILE`). Its types (`BlockResourceFile` and friends) come from the SDK's `0.79.0`; the hook imports no SDK value, so the declared peer floor is unchanged. |
3822
3895
  | `0.50.x` | `^0.40.0` | `useCreatePostFromApp()` (`CREATE_POST_FROM_APP`) + the `posts:write:self` scope. Pairing `0.50.x` with an SDK below `0.40.0` fails at `tsc` with `Cannot find name 'BlockCreatePostHostError'`, not at install. |
3823
3896
  | `0.48.x` | `^0.38.0` | `useCollectionFollow()` + `<FollowButton>` / `<TipButton>` (`SET_COLLECTION_FOLLOW`). Pairing `0.48.x` with an SDK below `0.38.0` fails at `tsc` with `Cannot find name 'BlockCollectionFollowErrorCode'`, not at install — the floor those releases declared was deliberately wide (#206 — a per-minor floor forced a major on consumers). |
3824
3897
  | `0.36.x` | `^0.27.0` | auto-installs the SDK's opaque-origin web-storage shim (`@civitai/app-sdk/safe-storage`) on import |
@@ -1,11 +1,12 @@
1
- import type { BlockGenerationSourceImageInfo, BlockImageScanResult, BlockPendingImageInfo, BlockUploadedImageInfo, BlockUploadPurpose } from '@civitai/app-sdk/blocks';
1
+ import type { BlockGenerationSourceImageInfo, BlockImageScanResult, BlockPendingImageInfo, BlockTrainingDataImage, BlockUploadedImageInfo, BlockUploadPurpose } from '@civitai/app-sdk/blocks';
2
+ export type { BlockTrainingDataImage };
2
3
  /**
3
4
  * What {@link useImageUpload} returns for the DEFAULT (`purpose: 'display'`,
4
5
  * blocking-scan) call — `useImageUpload()` with no arguments.
5
6
  *
6
7
  * 🔴 THIS HOOK IS OVERLOADED, SO ITS RETURN TYPE IS A FAMILY, NOT A SINGLETON
7
8
  * (#380). Each overload names its own: {@link UseImageUploadGenerationSource},
8
- * {@link UseImageUploadAsyncScan}, and this one. A single union would be worse
9
+ * {@link UseImageUploadTrainingData}, {@link UseImageUploadAsyncScan}, and this one. A single union would be worse
9
10
  * than the anonymous literals it replaces — it would hand every caller a
10
11
  * `scanStatus` that only one overload actually provides.
11
12
  */
@@ -16,6 +17,14 @@ export interface UseImageUpload {
16
17
  export interface UseImageUploadGenerationSource {
17
18
  open: () => Promise<BlockGenerationSourceImageInfo | null>;
18
19
  }
20
+ /** What {@link useImageUpload} returns for `{ purpose: 'trainingData' }`. */
21
+ export interface UseImageUploadTrainingData {
22
+ /**
23
+ * Every image the viewer uploaded, in the host's order, or `null` when they
24
+ * cancelled. Each `air` is what a `BlockTrainingDatasetItem` names.
25
+ */
26
+ open: () => Promise<BlockTrainingDataImage[] | null>;
27
+ }
19
28
  /** What {@link useImageUpload} returns for `{ asyncScan: true }`. */
20
29
  export interface UseImageUploadAsyncScan {
21
30
  /** Early-resolve handle (image persisted, NOT yet scanned) or `null` (dismissed). */
@@ -37,7 +46,7 @@ export interface UseImageUploadAsyncScan {
37
46
  * compatible; the async overload types it required.
38
47
  */
39
48
  export interface UseImageUploadImplementation {
40
- open: () => Promise<BlockUploadedImageInfo | BlockGenerationSourceImageInfo | BlockPendingImageInfo | null>;
49
+ open: () => Promise<BlockUploadedImageInfo | BlockGenerationSourceImageInfo | BlockPendingImageInfo | BlockTrainingDataImage[] | null>;
41
50
  scanStatus?: (handle: BlockPendingImageInfo) => Promise<BlockImageScanResult>;
42
51
  }
43
52
  /** Options for {@link useImageUpload}. */
@@ -49,14 +58,18 @@ export interface UseImageUploadOptions {
49
58
  * - `'generationSource'` — a PRIVATE img2img source; UNSCANNED, so the host
50
59
  * returns ONLY the source shape {@link BlockGenerationSourceImageInfo}
51
60
  * (`{ url, width, height }`).
61
+ * - `'trainingData'` — PRIVATE training images for a `kind: 'training'` run
62
+ * (page apps only): up to 50 picked in one host modal, returned together as
63
+ * {@link BlockTrainingDataImage}`[]`.
52
64
  */
53
65
  purpose?: BlockUploadPurpose;
54
66
  /**
55
67
  * Opt in to the NON-BLOCKING `'display'` flow. When `true`, `open()` resolves
56
68
  * EARLY (on persist) with a {@link BlockPendingImageInfo} and the scan verdict
57
69
  * is delivered separately via `scanStatus()` (see the async overload). Only
58
- * meaningful for `'display'`; ignored for `'generationSource'` (that path has
59
- * no host-side scan). Absent/`false` keeps the legacy blocking behavior.
70
+ * meaningful for `'display'`; ignored for `'generationSource'` and
71
+ * `'trainingData'` (neither has a host-side scan). Absent/`false` keeps the
72
+ * legacy blocking behavior.
60
73
  */
61
74
  asyncScan?: boolean;
62
75
  }
@@ -82,9 +95,20 @@ export interface UseImageUploadOptions {
82
95
  * {@link BlockGenerationSourceImageInfo} (`{ url, width, height }`, UNSCANNED —
83
96
  * the orchestrator scans it at gen time), or `null` on dismiss. Feed it
84
97
  * straight into a `WorkflowBody.sourceImage` for an img2img graph.
98
+ * - `'trainingData'` → resolves with EVERY image the viewer uploaded,
99
+ * {@link BlockTrainingDataImage}`[]` (`{ url, air }` each), or `null` on
100
+ * cancel. Step 1 of a `kind: 'training'` run: pair each `air` with a caption
101
+ * for `WorkflowBodyTraining.items`. The host remembers the keys it uploaded
102
+ * for this block and refuses a run naming any other
103
+ * (`training data not uploaded here`), so upload and run in the same page
104
+ * session. A host that predates the purpose answers with one display image,
105
+ * which this resolves as `null` — never as a list it did not upload.
85
106
  *
86
107
  * Human-interactive, so it uses the same generous {@link HUMAN_INTERACTION_TIMEOUT_MS}
87
- * as the pickers (the host still resolves earlier on upload/dismiss/close).
108
+ * as the pickers (the host still resolves earlier on upload/dismiss/close) — except
109
+ * `'trainingData'`, which waits up to {@link TRAINING_DATA_UPLOAD_TIMEOUT_MS} (one
110
+ * hour): the host replies only after uploading up to 50 originals, which can take
111
+ * longer than ten minutes.
88
112
  * Host-mediated, same trust model as `useResourcePicker` / `useBuzzWorkflow`.
89
113
  *
90
114
  * Backward-compatible: when `purpose` is omitted the hook sends NO `purpose`
@@ -117,10 +141,20 @@ export interface UseImageUploadOptions {
117
141
  * if (!src) return;
118
142
  * await submit({ kind: 'textToImage', modelId, modelVersionId,
119
143
  * sourceImage: src, params: { prompt } });
144
+ *
145
+ * @example
146
+ * // trainingData (private training images, several in one reply)
147
+ * const { open } = useImageUpload({ purpose: 'trainingData' });
148
+ * const images = await open(); // [{ url, air }, …] | null
149
+ * if (!images) return;
150
+ * const items = images.map((img) => ({ air: img.air, caption: captionFor(img) }));
120
151
  */
121
152
  export declare function useImageUpload(options: {
122
153
  purpose: 'generationSource';
123
154
  }): UseImageUploadGenerationSource;
155
+ export declare function useImageUpload(options: {
156
+ purpose: 'trainingData';
157
+ }): UseImageUploadTrainingData;
124
158
  export declare function useImageUpload(options: {
125
159
  purpose?: 'display';
126
160
  asyncScan: true;
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef } from 'react';
2
- import { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
2
+ import { HUMAN_INTERACTION_TIMEOUT_MS, TRAINING_DATA_UPLOAD_TIMEOUT_MS, } from '../transport/requestTimeouts.js';
3
3
  import { getTransport } from '../transport/singleton.js';
4
4
  import { sendTypedRequest, subscribeTyped } from '../transport/transport.js';
5
5
  import { useTransportConnection } from './useTransportConnection.js';
@@ -31,7 +31,7 @@ function isTerminalVerdict(v) {
31
31
  export function useImageUpload(options) {
32
32
  useTransportConnection();
33
33
  const purpose = options?.purpose;
34
- const asyncScan = options?.asyncScan === true && purpose !== 'generationSource';
34
+ const asyncScan = options?.asyncScan === true && (purpose === undefined || purpose === 'display');
35
35
  // Per-instance scan tracking, keyed by imageId. Persists across renders; a
36
36
  // verdict is correlated back to its upload by imageId + the generated requestId.
37
37
  const scansRef = useRef(new Map());
@@ -129,6 +129,19 @@ export function useImageUpload(options) {
129
129
  // payload) → treat as dismissed rather than fabricate a handle.
130
130
  return null;
131
131
  }
132
+ if (purpose === 'trainingData') {
133
+ const { selected } = await sendTypedRequest(getTransport(), { type: 'OPEN_IMAGE_UPLOAD', payload: { purpose } }, 'IMAGE_UPLOAD_RESULT',
134
+ // NOT the 10-minute human bound: the host answers only after uploading
135
+ // every original, which can outlast it — see TRAINING_DATA_UPLOAD_TIMEOUT_MS.
136
+ { timeoutMs: TRAINING_DATA_UPLOAD_TIMEOUT_MS });
137
+ // Only the multi-image shape is an answer to THIS purpose. A host that
138
+ // predates `trainingData` normalises it to `display` and replies with one
139
+ // moderated image — nothing a training item can name — so that, an empty
140
+ // list and a cancel all resolve `null` rather than a fabricated list.
141
+ if (!selected || !('images' in selected) || selected.images.length === 0)
142
+ return null;
143
+ return selected.images.map(({ url, air }) => ({ url, air }));
144
+ }
132
145
  // ---- Existing BLOCKING behavior (unchanged) ----
133
146
  // Omit `purpose` for the default/display mode so the wire stays
134
147
  // byte-compatible with an older host that predates the field.
@@ -136,7 +149,10 @@ export function useImageUpload(options) {
136
149
  const { selected } = await sendTypedRequest(getTransport(), { type: 'OPEN_IMAGE_UPLOAD', payload }, 'IMAGE_UPLOAD_RESULT', { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
137
150
  // Normalize the "dismissed" case to an explicit null so callers can
138
151
  // `if (!img) return;` without an `undefined` ambiguity.
139
- return selected ?? null;
152
+ // A multi-image training reply is not an answer to a single-image purpose.
153
+ if (!selected || 'images' in selected)
154
+ return null;
155
+ return selected;
140
156
  }, [asyncScan, purpose]);
141
157
  const scanStatus = useCallback((handle) => {
142
158
  const entry = scansRef.current.get(handle.imageId);
@@ -0,0 +1,71 @@
1
+ import type { BlockResourceFile, BlockResourceFileEntry, BlockResourceFileEntryFormat, BlockResourceFileErrorCode, BlockResourceFileMeta } from '@civitai/app-sdk/blocks';
2
+ export type { BlockResourceFile, BlockResourceFileEntry, BlockResourceFileEntryFormat, BlockResourceFileErrorCode, BlockResourceFileMeta, };
3
+ /**
4
+ * A resource-file read failure. The host returns one code from a CLOSED set, and
5
+ * this error carries it on `.code` so a block can branch on the exact reason:
6
+ * `busy` and `rate-limited` are retryable; `not-found`, `forbidden`,
7
+ * `unauthorized`, `too-large` and `parse-failed` are terminal. `.message` equals
8
+ * the code.
9
+ */
10
+ export declare class ResourceFileError extends Error {
11
+ readonly code: BlockResourceFileErrorCode;
12
+ constructor(code: BlockResourceFileErrorCode);
13
+ }
14
+ /**
15
+ * What {@link useResourceFile} returns.
16
+ */
17
+ export interface UseResourceFile {
18
+ /**
19
+ * The parsed file for the CURRENT `modelVersionId` / `fileId`, or `null` until
20
+ * it loads. Reset to `null` the moment either id changes, so a file is never
21
+ * shown for an id it does not belong to. A `refetch()` of the same ids keeps it
22
+ * while reloading.
23
+ */
24
+ file: BlockResourceFile | null;
25
+ /** `true` while a fetch (initial or `refetch`) is in flight. */
26
+ loading: boolean;
27
+ /**
28
+ * The last fetch's failure as a {@link ResourceFileError} (`.code` is the
29
+ * reason), or a plain `Error` (a `RequestTimeoutError`) when the host never
30
+ * answered. Cleared at the start of the next fetch, and when either id changes.
31
+ */
32
+ error: Error | null;
33
+ /** Re-request the file. */
34
+ refetch: () => void;
35
+ }
36
+ /**
37
+ * Read a resource file's parsed JSON (for a workflow: its graphs) by model-version
38
+ * id, through the host-mediated `GET_RESOURCE_FILE` → `RESOURCE_FILE_RESULT`
39
+ * bridge. TOKEN-INDEPENDENT (no block scope): the host resolves, fetches, unzips
40
+ * and parses the file in the viewer's own page session, enforcing the real
41
+ * download gates, and returns only the parsed JSON. The iframe never sees the
42
+ * session, the signed URL or the raw bytes.
43
+ *
44
+ * Pass `fileId` to pin one file of a multi-file version; without it the host
45
+ * picks one. Only free, public versions are served: an unpublished draft or an
46
+ * early-access / paid version answers `not-found`, even for a viewer who bought
47
+ * it. Full-page app hosts only — the model-page slot does not answer it, and
48
+ * `dev:live` replies `parse-failed`; use `dev:mock` to develop against it.
49
+ *
50
+ * Fetches on mount and whenever `modelVersionId` or `fileId` changes; a change
51
+ * clears `file` and `error` first. A non-positive or non-integer
52
+ * `modelVersionId` (or `fileId`, when given) fetches nothing. The request waits
53
+ * up to 120 s (`RESOURCE_FILE_REQUEST_TIMEOUT_MS`), since the host downloads and
54
+ * unzips the file; a host that never answers surfaces as a `RequestTimeoutError`
55
+ * after that. Only the LATEST request may write state: a superseded or
56
+ * post-unmount reply is dropped (#392).
57
+ *
58
+ * @example
59
+ * const { file, loading, error, refetch } = useResourceFile(modelVersionId);
60
+ * // Retry a retryable code from an effect, after a delay — never during render.
61
+ * const retryable =
62
+ * error instanceof ResourceFileError && (error.code === 'busy' || error.code === 'rate-limited');
63
+ * useEffect(() => {
64
+ * if (!retryable) return;
65
+ * const t = setTimeout(refetch, 2_000);
66
+ * return () => clearTimeout(t);
67
+ * }, [retryable, refetch]);
68
+ * const graphs = file?.entries.filter((e) => e.format !== 'unknown') ?? [];
69
+ */
70
+ export declare function useResourceFile(modelVersionId: number, fileId?: number): UseResourceFile;
71
+ //# sourceMappingURL=useResourceFile.d.ts.map
@@ -0,0 +1,113 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { RESOURCE_FILE_REQUEST_TIMEOUT_MS } from '../transport/requestTimeouts.js';
3
+ import { getTransport } from '../transport/singleton.js';
4
+ import { sendTypedRequest } from '../transport/transport.js';
5
+ import { useRequestSequencer } from './useRequestSequencer.js';
6
+ import { useTransportConnection } from './useTransportConnection.js';
7
+ /**
8
+ * A resource-file read failure. The host returns one code from a CLOSED set, and
9
+ * this error carries it on `.code` so a block can branch on the exact reason:
10
+ * `busy` and `rate-limited` are retryable; `not-found`, `forbidden`,
11
+ * `unauthorized`, `too-large` and `parse-failed` are terminal. `.message` equals
12
+ * the code.
13
+ */
14
+ export class ResourceFileError extends Error {
15
+ code;
16
+ constructor(code) {
17
+ super(code);
18
+ this.name = 'ResourceFileError';
19
+ this.code = code;
20
+ }
21
+ }
22
+ /**
23
+ * Read a resource file's parsed JSON (for a workflow: its graphs) by model-version
24
+ * id, through the host-mediated `GET_RESOURCE_FILE` → `RESOURCE_FILE_RESULT`
25
+ * bridge. TOKEN-INDEPENDENT (no block scope): the host resolves, fetches, unzips
26
+ * and parses the file in the viewer's own page session, enforcing the real
27
+ * download gates, and returns only the parsed JSON. The iframe never sees the
28
+ * session, the signed URL or the raw bytes.
29
+ *
30
+ * Pass `fileId` to pin one file of a multi-file version; without it the host
31
+ * picks one. Only free, public versions are served: an unpublished draft or an
32
+ * early-access / paid version answers `not-found`, even for a viewer who bought
33
+ * it. Full-page app hosts only — the model-page slot does not answer it, and
34
+ * `dev:live` replies `parse-failed`; use `dev:mock` to develop against it.
35
+ *
36
+ * Fetches on mount and whenever `modelVersionId` or `fileId` changes; a change
37
+ * clears `file` and `error` first. A non-positive or non-integer
38
+ * `modelVersionId` (or `fileId`, when given) fetches nothing. The request waits
39
+ * up to 120 s (`RESOURCE_FILE_REQUEST_TIMEOUT_MS`), since the host downloads and
40
+ * unzips the file; a host that never answers surfaces as a `RequestTimeoutError`
41
+ * after that. Only the LATEST request may write state: a superseded or
42
+ * post-unmount reply is dropped (#392).
43
+ *
44
+ * @example
45
+ * const { file, loading, error, refetch } = useResourceFile(modelVersionId);
46
+ * // Retry a retryable code from an effect, after a delay — never during render.
47
+ * const retryable =
48
+ * error instanceof ResourceFileError && (error.code === 'busy' || error.code === 'rate-limited');
49
+ * useEffect(() => {
50
+ * if (!retryable) return;
51
+ * const t = setTimeout(refetch, 2_000);
52
+ * return () => clearTimeout(t);
53
+ * }, [retryable, refetch]);
54
+ * const graphs = file?.entries.filter((e) => e.format !== 'unknown') ?? [];
55
+ */
56
+ export function useResourceFile(modelVersionId, fileId) {
57
+ useTransportConnection();
58
+ const [file, setFile] = useState(null);
59
+ const [loading, setLoading] = useState(true);
60
+ const [error, setError] = useState(null);
61
+ const seq = useRequestSequencer();
62
+ // The ids the current `file` / `error` belong to. A fetch for different ids
63
+ // clears both first, so `{ file: A, error: B }` can never be returned.
64
+ const key = `${modelVersionId}|${fileId ?? ''}`;
65
+ const keyRef = useRef(null);
66
+ const isPositiveInt = (n) => Number.isInteger(n) && n > 0;
67
+ const valid = isPositiveInt(modelVersionId) && (fileId === undefined || isPositiveInt(fileId));
68
+ const refetch = useCallback(() => {
69
+ // `begin()` runs BEFORE the validity gate, as in `useWildcardPack`: moving to
70
+ // an invalid id must also supersede an in-flight request for the old one.
71
+ const token = seq.begin();
72
+ if (keyRef.current !== key) {
73
+ keyRef.current = key;
74
+ setFile(null);
75
+ setError(null);
76
+ }
77
+ if (!valid) {
78
+ setLoading(false);
79
+ return;
80
+ }
81
+ setLoading(true);
82
+ setError(null);
83
+ sendTypedRequest(getTransport(), {
84
+ type: 'GET_RESOURCE_FILE',
85
+ payload: fileId === undefined ? { modelVersionId } : { modelVersionId, fileId },
86
+ }, 'RESOURCE_FILE_RESULT', { timeoutMs: RESOURCE_FILE_REQUEST_TIMEOUT_MS })
87
+ .then((result) => {
88
+ if (!seq.isCurrent(token))
89
+ return;
90
+ if (result.error || !result.file) {
91
+ // `??`, as in `useWildcardPack`: `isValidResourceFileResult` admits only
92
+ // the closed code set, so `error: ''` never reaches here. The fallback
93
+ // covers only a reply with neither field, which the validator drops too.
94
+ setError(new ResourceFileError(result.error ?? 'parse-failed'));
95
+ setLoading(false);
96
+ return;
97
+ }
98
+ setFile(result.file);
99
+ setLoading(false);
100
+ })
101
+ .catch((err) => {
102
+ if (!seq.isCurrent(token))
103
+ return;
104
+ setError(err instanceof Error ? err : new Error(String(err)));
105
+ setLoading(false);
106
+ });
107
+ }, [valid, key, modelVersionId, fileId, seq]);
108
+ useEffect(() => {
109
+ refetch();
110
+ }, [refetch]);
111
+ return { file, loading, error, refetch };
112
+ }
113
+ //# sourceMappingURL=useResourceFile.js.map