@civitai/blocks-react 0.76.0 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1463,43 +1463,55 @@ options drive both arms (including `declined`). **`dev:live` refuses this bridge
1463
1463
  it has no civitai chrome to render the server-resolved confirm in, and driving
1464
1464
  the write without it would let dev prove out a flow production does not have.
1465
1465
 
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.
1466
+ ### `useImageUpload({ purpose: 'trainingData' })` and `useRunTraining()` — LoRA training
1467
+
1468
+ Train a LoRA with the ai-toolkit engine on **images the viewer uploads**, from
1469
+ inside a page app — the App Blocks `kind: 'training'` flow, with the same upload
1470
+ path and `{ air, caption }` dataset shape as Civitai's own training form. Four
1471
+ steps, three hooks:
1472
+
1473
+ 1. **Upload** — `useImageUpload({ purpose: 'trainingData' }).open()`
1474
+ (`OPEN_IMAGE_UPLOAD { purpose: 'trainingData' }`). Civitai opens an upload
1475
+ modal in **its own chrome**; the viewer picks up to 50 PNG, JPEG or WebP
1476
+ images, and each is uploaded as a private blob (not published). `open()`
1477
+ resolves with every image that uploaded, `[{ url, air }]`, or `null` when
1478
+ the viewer cancels. Pair each `air` with your caption — captions up to 8,192
1479
+ characters, one blob at most once. The host answers only when its modal
1480
+ closes, after every original has uploaded, so `open()` waits up to an hour,
1481
+ not the ten minutes the other pickers use.
1481
1482
  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.
1483
+ (`kind: 'training'`, `items: [{ air, caption }]`, no `quoteId`). The reply
1484
+ carries `trainingQuote: { quoteId, total, imageCount, expiresAt }` — the
1485
+ orchestrator's own price for exactly this run, stored server-side for 15
1486
+ minutes.
1485
1487
  3. **Run** — `useRunTraining().runTraining({ ...body, quoteId })` (`RUN_TRAINING`).
1486
1488
  **Civitai shows the viewer a consent dialog in its own chrome** — price, base
1487
1489
  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.
1490
+ your body, with the host's own copies of the uploads as thumbnails — and
1491
+ submits only on their click. Change nothing in the body between the estimate
1492
+ and the run; the server checks it against the quoted one.
1490
1493
  4. **Follow** — `useBuzzWorkflow().watch(workflowId)`. Once the run's moderation
1491
1494
  status is approved, `trainedEpochs` lists the epochs whose checkpoint is
1492
1495
  ready; send the viewer to the publish wizard with one of them (see
1493
1496
  `trainedEpochs` on `BlockWorkflowSnapshot`).
1494
1497
 
1498
+ 🔴 **Upload and run in the same page session.** The host remembers the blobs
1499
+ its own upload modal uploaded for this block, and **refuses a quote naming any
1500
+ other** with `training data not uploaded here` — before any dialog, so no run
1501
+ and no charge. A page reload forgets the uploads: ask the viewer to upload
1502
+ again, then estimate again.
1503
+
1495
1504
  🔴 **Availability.** Behind the host flag **`app-blocks-training-kind`**, which
1496
1505
  ships **off** and is evaluated per viewer. **Page apps only** — the model slot
1497
- answers both messages with an error. The manifest must declare
1506
+ answers `RUN_TRAINING` with an error. The manifest must declare
1498
1507
  **`ai:write:budgeted`** and the viewer must be signed in and have granted it.
1499
1508
  **Refused from `dev:live` and from review sessions** (the server refuses
1500
1509
  development and review tokens); build the flow against the mock host. Requires a
1501
1510
  civitai.com host carrying civitai/civitai#5434 (`kind: 'training'`,
1502
- `RUN_TRAINING`) and civitai/civitai#5438 (`PREPARE_TRAINING_DATASET`).
1511
+ `RUN_TRAINING`) and civitai/civitai#6461 (uploaded blobs as the dataset). The
1512
+ server-prepared dataset this flow used before #6461 (`usePrepareTrainingDataset`,
1513
+ `PREPARE_TRAINING_DATASET`) is **removed**; a host carrying #6461 answers that
1514
+ message with the error `removed`.
1503
1515
 
1504
1516
  🔴 **Money.** There is no `maxBuzz` and no timeout knob — the price is the quote.
1505
1517
  A confirmed run may cost more than the token's per-call `buzzBudget` (the viewer
@@ -1516,32 +1528,26 @@ saying anything about money:
1516
1528
  | `err.declined` | the viewer dismissed the dialog — **no run was submitted** | revert, say nothing |
1517
1529
  | `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
1530
  | `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 |
1531
+ | `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
1532
  | `err.signInRequired` | no session | route into `useRequestSignIn()` |
1520
1533
  | `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 |
1534
+ | `err.code === undefined`, no flag | a server refusal before any submit (expired or used quote, changed body) | estimate again, then retry |
1522
1535
 
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.
1536
+ `runTraining` never prompts for `ai:write:budgeted` or re-sends — it carries no
1537
+ idempotency key and has an outcome where a run may exist.
1530
1538
 
1531
1539
  ```tsx
1532
- const { prepareDataset } = usePrepareTrainingDataset();
1540
+ const { open } = useImageUpload({ purpose: 'trainingData' });
1533
1541
  const { estimate, watch } = useBuzzWorkflow();
1534
1542
  const { runTraining } = useRunTraining();
1535
1543
 
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`);
1544
+ async function train(captionFor: (image: BlockTrainingDataImage) => string) {
1545
+ const images = await open();
1546
+ if (!images) return; // the viewer cancelled
1541
1547
 
1542
1548
  const body: WorkflowBodyTraining = {
1543
1549
  kind: 'training',
1544
- datasetId: dataset.datasetId,
1550
+ items: images.map((image) => ({ air: image.air, caption: captionFor(image) })),
1545
1551
  engine: 'ai-toolkit',
1546
1552
  model: baseModelKey, // a key from Civitai's training catalog
1547
1553
  params: aiToolkitParams, // AiToolkitTrainingParams
@@ -1561,6 +1567,7 @@ async function train(images: Array<{ id: number; caption: string }>) {
1561
1567
  if (err.declined) return; // no run
1562
1568
  if (err.unconfirmed) return showCheckYourTrainings(); // may be running — never auto-retry
1563
1569
  if (err.refused) return showError(err.message); // a cap / availability refusal; no run, nothing charged
1570
+ if (err.trainingDataNotUploaded) return showError('Upload the images again.'); // no run
1564
1571
  if (err.signInRequired) return requestSignIn();
1565
1572
  showError('Could not start training. Get a new price and try again.');
1566
1573
  }
@@ -1569,19 +1576,22 @@ async function train(images: Array<{ id: number; caption: string }>) {
1569
1576
 
1570
1577
  With the mock host — `createMockHost` or `Harness` from
1571
1578
  `@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 →
1579
+ remembers the blob keys its uploads handed out and refuses a run naming any
1580
+ other (`training data not uploaded here`, quote kept), refuses items outside the
1581
+ server's schema at the estimate, a quote above 5,000, a quote run twice and a
1582
+ body that differs from the quoted one, and needs `ai:write:budgeted` on the
1583
+ token and a signed-in viewer. Its knobs are `cannedTrainingDataUpload` (the
1584
+ images an upload returns; default three fresh ones per upload, `null` for a
1585
+ cancel), `trainingDataNotUploadedHere` (refuse every run as a host that forgot
1586
+ its uploads would), `trainingQuoteTotal`, `runTrainingError` (`'declined'`,
1587
+ `'submission-unconfirmed'`, or any server message), `runTrainingCapRefusal` (the
1588
+ server's resolved cap / availability refusal, which consumes the quote →
1579
1589
  `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.
1590
+ upload modal and no consent dialog, so `open()` and `runTraining` settle at once
1591
+ where the real host waits on the viewer; and it accepts any https blob url as an
1592
+ `air`, where the server accepts only Civitai-hosted ones — name items by `air`.
1593
+ **`dev:live` refuses the run** (the server refuses every training request from a
1594
+ dev token) and answers the upload as a cancel.
1585
1595
 
1586
1596
  ### `useRunConfirmedStep()` — one job above the per-call budget
1587
1597
 
@@ -2213,8 +2223,10 @@ answers a raw `multiple` + Checkpoint request with the host's error.
2213
2223
 
2214
2224
  Host-mediated image upload — the host opens its native upload modal and the
2215
2225
  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.
2226
+ dismiss); pass `{ purpose: 'generationSource' }` for an unscanned img2img source,
2227
+ `{ purpose: 'trainingData' }` for several private training images in one call
2228
+ (`[{ url, air }] | null` — see the LoRA training section), or
2229
+ `{ asyncScan: true }` for the early-resolve + `scanStatus()` flow.
2218
2230
 
2219
2231
  ```tsx
2220
2232
  const { open } = useImageUpload();
@@ -2253,7 +2265,7 @@ render), with no picker, so the app can then post it. Returns `{ upload }`.
2253
2265
  sniffed type, whatever `filename` says.
2254
2266
  - **The cap is 40 MiB.** The hook refuses a larger buffer before sending it,
2255
2267
  with the host's error `file exceeds the maximum upload size`. The host also
2256
- allows at most 3 uploads and 80 MiB per 60 seconds per page, and replies
2268
+ allows at most 8 uploads and 80 MiB per 60 seconds per page, and replies
2257
2269
  `busy` past that.
2258
2270
  - **The upload is blocking.** The host replies once the image is scanned, so
2259
2271
  allow for a wait of up to a few minutes. An image above the SFW ceiling or
@@ -2863,7 +2875,6 @@ had just worked.
2863
2875
  |---|---|---|
2864
2876
  | `useBuzzWorkflow()` | `submit()` | `ai:write:budgeted` |
2865
2877
  | `useCreatePostFromApp()` | `createPost()` | `posts:write:self` |
2866
- | `usePrepareTrainingDataset()` | `prepareDataset()` | `ai:write:budgeted` |
2867
2878
  | `useGoodPurchase()` | `purchase()` | `goods:purchase:self` |
2868
2879
  | `useTip()` | `tip()` | `social:tip:self` |
2869
2880
 
@@ -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);
@@ -1,5 +1,5 @@
1
- import type { BlockRunTrainingHostError, BlockTrainingQuote, BlockWorkflowSnapshot, WorkflowBodyTraining } from '@civitai/app-sdk/blocks';
2
- export type { BlockRunTrainingHostError, BlockTrainingQuote, WorkflowBodyTraining };
1
+ import type { BlockRunTrainingHostError, BlockTrainingDatasetItem, BlockTrainingQuote, BlockWorkflowSnapshot, WorkflowBodyTraining } from '@civitai/app-sdk/blocks';
2
+ export type { BlockRunTrainingHostError, BlockTrainingDatasetItem, BlockTrainingQuote, WorkflowBodyTraining, };
3
3
  /**
4
4
  * A training body ready to RUN: {@link WorkflowBodyTraining} with the `quoteId`
5
5
  * its estimate returned. Required here so a body without one is a compile error
@@ -16,7 +16,7 @@ export type QuotedTrainingBody = WorkflowBodyTraining & {
16
16
  * a body that differs from the quoted one) arrive in the same field — see
17
17
  * {@link isRunTrainingErrorCode}.
18
18
  */
19
- export declare const RUN_TRAINING_ERROR_CODES: readonly ["review-mode", "block is not ready", "sign in to train", "invalid training request", "no block token", "declined", "submission-unconfirmed"];
19
+ export declare const RUN_TRAINING_ERROR_CODES: readonly ["review-mode", "block is not ready", "sign in to train", "invalid training request", "no block token", "training data not uploaded here", "declined", "submission-unconfirmed"];
20
20
  /**
21
21
  * The `code` a {@link RunTrainingError} can carry: one of the host's closed
22
22
  * refusal codes, or `'refused'` — assigned by THIS SDK, never sent by the host,
@@ -49,9 +49,15 @@ export declare function isRunTrainingErrorCode(error: string): error is BlockRun
49
49
  * `.snapshot` the refusal it arrived in. Retrying needs a new estimate and
50
50
  * usually a later time; buying Buzz does not lift these caps.
51
51
  * - `.signInRequired` — no session. Route into `useRequestSignIn()`.
52
+ * - `.trainingDataNotUploaded` (`code: 'training data not uploaded here'`) —
53
+ * the quote names an image THIS host did not upload for this block. The host
54
+ * remembers only the blobs its own `useImageUpload({ purpose: 'trainingData' })`
55
+ * uploaded, and forgets them on a page reload. Refused before any dialog:
56
+ * NO RUN, nothing charged. Ask the viewer to upload the images again, then
57
+ * estimate and run the new quote.
52
58
  * - anything else — a refusal before any submit (an expired or used quote, a
53
- * body that no longer matches its quote, an ineligible image). Estimate again
54
- * before retrying; `.message` is the host's or server's text.
59
+ * body that no longer matches its quote). Estimate again before retrying;
60
+ * `.message` is the host's or server's text.
55
61
  */
56
62
  export declare class RunTrainingError extends Error {
57
63
  /**
@@ -78,6 +84,12 @@ export declare class RunTrainingError extends Error {
78
84
  readonly timedOut: boolean;
79
85
  /** There is no session (`sign in to train`, or a bare `UNAUTHORIZED`). */
80
86
  readonly signInRequired: boolean;
87
+ /**
88
+ * The quote names an image this host did not upload for this block
89
+ * (`training data not uploaded here`). No run, nothing charged: upload the
90
+ * images again, estimate again, and run that quote.
91
+ */
92
+ readonly trainingDataNotUploaded: boolean;
81
93
  constructor(error: string, opts?: {
82
94
  timedOut?: boolean;
83
95
  refusedSnapshot?: BlockWorkflowSnapshot;
@@ -112,15 +124,19 @@ export interface UseRunTraining {
112
124
  * viewer. Refused from `dev:live` and from review sessions — build the flow
113
125
  * against the mock host. See `WorkflowBodyTraining` in `@civitai/app-sdk`.
114
126
  *
115
- * The flow: `usePrepareTrainingDataset()` → `useBuzzWorkflow().estimate(body)`
116
- * (its `trainingQuote.quoteId`) → `runTraining({ ...body, quoteId })` →
117
- * `useBuzzWorkflow().watch(workflowId)`. Change nothing in the body between the
118
- * estimate and the run: the server checks it against the quoted one.
127
+ * The flow: `useImageUpload({ purpose: 'trainingData' }).open()` (the viewer
128
+ * uploads the images; you caption them into `items: [{ air, caption }]`) →
129
+ * `useBuzzWorkflow().estimate(body)` (its `trainingQuote.quoteId`) →
130
+ * `runTraining({ ...body, quoteId })` → `useBuzzWorkflow().watch(workflowId)`.
131
+ * Change nothing in the body between the estimate and the run: the server checks
132
+ * it against the quoted one. Upload and run in the same page session: the host
133
+ * refuses a quote naming an image it did not upload for this block.
119
134
  *
120
135
  * 🔴 THE CONSENT DIALOG IS CIVITAI'S, AND IT IS THE CHARGE. Every number on it is
121
136
  * read back from the server's stored quote — never from your body. A run may cost
122
137
  * more than the token's per-call budget (the viewer confirms the exact price), up
123
- * to `BLOCK_TRAINING_MAX_BUZZ_PER_RUN` (5,000 Buzz). Images are the viewer's own.
138
+ * to `BLOCK_TRAINING_MAX_BUZZ_PER_RUN` (5,000 Buzz). Images are the ones the
139
+ * viewer uploaded through the host.
124
140
  *
125
141
  * 🔴 NO AUTOMATIC CONSENT RETRY, unlike the other consent-gated hooks — on
126
142
  * purpose. `RUN_TRAINING` carries no idempotency key and has an outcome
@@ -132,8 +148,12 @@ export interface UseRunTraining {
132
148
  * the viewer clicks or dismisses its dialog.
133
149
  *
134
150
  * @example
151
+ * const { open } = useImageUpload({ purpose: 'trainingData' });
135
152
  * const { estimate, watch } = useBuzzWorkflow();
136
153
  * const { runTraining } = useRunTraining();
154
+ * const images = await open();
155
+ * if (!images) return; // cancelled
156
+ * const body = { ...settings, items: images.map((i) => ({ air: i.air, caption: captionFor(i) })) };
137
157
  * const quote = (await estimate(body)).trainingQuote!;
138
158
  * try {
139
159
  * const started = await runTraining({ ...body, quoteId: quote.quoteId });
@@ -142,6 +162,7 @@ export interface UseRunTraining {
142
162
  * if (!(e instanceof RunTrainingError)) throw e;
143
163
  * if (e.declined) return; // no run — say nothing
144
164
  * if (e.signInRequired) return requestSignIn();
165
+ * if (e.trainingDataNotUploaded) return askToUploadAgain(); // no run — re-upload, re-estimate
145
166
  * if (e.unconfirmed) return showCheckYourTrainings(); // may be running — never auto-retry
146
167
  * if (e.refused) return showError(e.message); // a cap / availability refusal; no run, nothing charged
147
168
  * showError('Could not start training. Get a new price and try again.');
@@ -17,9 +17,12 @@ export const RUN_TRAINING_ERROR_CODES = [
17
17
  'sign in to train',
18
18
  'invalid training request',
19
19
  'no block token',
20
+ 'training data not uploaded here',
20
21
  'declined',
21
22
  'submission-unconfirmed',
22
23
  ];
24
+ /** The host code for a quote naming an image this host did not upload — see {@link RunTrainingError.trainingDataNotUploaded}. */
25
+ const NOT_UPLOADED_HERE = 'training data not uploaded here';
23
26
  const CODE_SET = new Set(RUN_TRAINING_ERROR_CODES);
24
27
  /**
25
28
  * The `workflowId` the host's training submit stamps on a snapshot that is NOT a
@@ -55,9 +58,15 @@ export function isRunTrainingErrorCode(error) {
55
58
  * `.snapshot` the refusal it arrived in. Retrying needs a new estimate and
56
59
  * usually a later time; buying Buzz does not lift these caps.
57
60
  * - `.signInRequired` — no session. Route into `useRequestSignIn()`.
61
+ * - `.trainingDataNotUploaded` (`code: 'training data not uploaded here'`) —
62
+ * the quote names an image THIS host did not upload for this block. The host
63
+ * remembers only the blobs its own `useImageUpload({ purpose: 'trainingData' })`
64
+ * uploaded, and forgets them on a page reload. Refused before any dialog:
65
+ * NO RUN, nothing charged. Ask the viewer to upload the images again, then
66
+ * estimate and run the new quote.
58
67
  * - anything else — a refusal before any submit (an expired or used quote, a
59
- * body that no longer matches its quote, an ineligible image). Estimate again
60
- * before retrying; `.message` is the host's or server's text.
68
+ * body that no longer matches its quote). Estimate again before retrying;
69
+ * `.message` is the host's or server's text.
61
70
  */
62
71
  export class RunTrainingError extends Error {
63
72
  /**
@@ -84,6 +93,12 @@ export class RunTrainingError extends Error {
84
93
  timedOut;
85
94
  /** There is no session (`sign in to train`, or a bare `UNAUTHORIZED`). */
86
95
  signInRequired;
96
+ /**
97
+ * The quote names an image this host did not upload for this block
98
+ * (`training data not uploaded here`). No run, nothing charged: upload the
99
+ * images again, estimate again, and run that quote.
100
+ */
101
+ trainingDataNotUploaded;
87
102
  constructor(error, opts) {
88
103
  super(error);
89
104
  this.name = 'RunTrainingError';
@@ -99,6 +114,7 @@ export class RunTrainingError extends Error {
99
114
  this.declined = error === 'declined';
100
115
  this.unconfirmed = this.timedOut || error === 'submission-unconfirmed';
101
116
  this.signInRequired = error === 'sign in to train' || error === 'UNAUTHORIZED';
117
+ this.trainingDataNotUploaded = error === NOT_UPLOADED_HERE;
102
118
  }
103
119
  }
104
120
  /**
@@ -110,15 +126,19 @@ export class RunTrainingError extends Error {
110
126
  * viewer. Refused from `dev:live` and from review sessions — build the flow
111
127
  * against the mock host. See `WorkflowBodyTraining` in `@civitai/app-sdk`.
112
128
  *
113
- * The flow: `usePrepareTrainingDataset()` → `useBuzzWorkflow().estimate(body)`
114
- * (its `trainingQuote.quoteId`) → `runTraining({ ...body, quoteId })` →
115
- * `useBuzzWorkflow().watch(workflowId)`. Change nothing in the body between the
116
- * estimate and the run: the server checks it against the quoted one.
129
+ * The flow: `useImageUpload({ purpose: 'trainingData' }).open()` (the viewer
130
+ * uploads the images; you caption them into `items: [{ air, caption }]`) →
131
+ * `useBuzzWorkflow().estimate(body)` (its `trainingQuote.quoteId`) →
132
+ * `runTraining({ ...body, quoteId })` → `useBuzzWorkflow().watch(workflowId)`.
133
+ * Change nothing in the body between the estimate and the run: the server checks
134
+ * it against the quoted one. Upload and run in the same page session: the host
135
+ * refuses a quote naming an image it did not upload for this block.
117
136
  *
118
137
  * 🔴 THE CONSENT DIALOG IS CIVITAI'S, AND IT IS THE CHARGE. Every number on it is
119
138
  * read back from the server's stored quote — never from your body. A run may cost
120
139
  * more than the token's per-call budget (the viewer confirms the exact price), up
121
- * to `BLOCK_TRAINING_MAX_BUZZ_PER_RUN` (5,000 Buzz). Images are the viewer's own.
140
+ * to `BLOCK_TRAINING_MAX_BUZZ_PER_RUN` (5,000 Buzz). Images are the ones the
141
+ * viewer uploaded through the host.
122
142
  *
123
143
  * 🔴 NO AUTOMATIC CONSENT RETRY, unlike the other consent-gated hooks — on
124
144
  * purpose. `RUN_TRAINING` carries no idempotency key and has an outcome
@@ -130,8 +150,12 @@ export class RunTrainingError extends Error {
130
150
  * the viewer clicks or dismisses its dialog.
131
151
  *
132
152
  * @example
153
+ * const { open } = useImageUpload({ purpose: 'trainingData' });
133
154
  * const { estimate, watch } = useBuzzWorkflow();
134
155
  * const { runTraining } = useRunTraining();
156
+ * const images = await open();
157
+ * if (!images) return; // cancelled
158
+ * const body = { ...settings, items: images.map((i) => ({ air: i.air, caption: captionFor(i) })) };
135
159
  * const quote = (await estimate(body)).trainingQuote!;
136
160
  * try {
137
161
  * const started = await runTraining({ ...body, quoteId: quote.quoteId });
@@ -140,6 +164,7 @@ export class RunTrainingError extends Error {
140
164
  * if (!(e instanceof RunTrainingError)) throw e;
141
165
  * if (e.declined) return; // no run — say nothing
142
166
  * if (e.signInRequired) return requestSignIn();
167
+ * if (e.trainingDataNotUploaded) return askToUploadAgain(); // no run — re-upload, re-estimate
143
168
  * if (e.unconfirmed) return showCheckYourTrainings(); // may be running — never auto-retry
144
169
  * if (e.refused) return showError(e.message); // a cap / availability refusal; no run, nothing charged
145
170
  * showError('Could not start training. Get a new price and try again.');
@@ -58,7 +58,7 @@ export interface UseUploadImageBytes {
58
58
  * scope, so ask for it first with `useRequestConsent()`: the upload runs
59
59
  * before any `useCreatePostFromApp()` call that would otherwise prompt.
60
60
  * - **Images only:** PNG, WebP or JPEG by magic bytes. The cap is 40 MiB, and
61
- * the host also allows at most 3 uploads and 80 MiB per 60 s per page.
61
+ * the host also allows at most 8 uploads and 80 MiB per 60 s per page.
62
62
  * - The buffer is COPIED across `postMessage`, never transferred, so the block
63
63
  * can keep displaying it. Pass an `ArrayBuffer` (`await blob.arrayBuffer()`),
64
64
  * not a `Blob` or a typed array, which the host refuses.
@@ -25,7 +25,7 @@ export const UPLOAD_IMAGE_BYTES_NO_IMAGE_ERROR = 'the host returned no uploaded
25
25
  * scope, so ask for it first with `useRequestConsent()`: the upload runs
26
26
  * before any `useCreatePostFromApp()` call that would otherwise prompt.
27
27
  * - **Images only:** PNG, WebP or JPEG by magic bytes. The cap is 40 MiB, and
28
- * the host also allows at most 3 uploads and 80 MiB per 60 s per page.
28
+ * the host also allows at most 8 uploads and 80 MiB per 60 s per page.
29
29
  * - The buffer is COPIED across `postMessage`, never transferred, so the block
30
30
  * can keep displaying it. Pass an `ArrayBuffer` (`await blob.arrayBuffer()`),
31
31
  * not a `Blob` or a typed array, which the host refuses.
package/dist/index.d.ts CHANGED
@@ -73,10 +73,8 @@ export { useReactions, ReactionError, REACTIONS, REACTION_ERROR_CODES, isReactio
73
73
  export type { UseReactions, UseReactionsOptions } from './hooks/useReactions.js';
74
74
  export { useCreatePostFromApp, CreatePostError, CREATE_POST_ERROR_CODES, isCreatePostErrorCode, } from './hooks/useCreatePostFromApp.js';
75
75
  export type { UseCreatePostFromApp, BlockPostSource, BlockCreatePostRequest, BlockCreatePostResult, BlockCreatePostHostError, } from './hooks/useCreatePostFromApp.js';
76
- export { usePrepareTrainingDataset, PrepareTrainingDatasetError, PREPARE_TRAINING_DATASET_ERROR_CODES, isPrepareTrainingDatasetErrorCode, } from './hooks/usePrepareTrainingDataset.js';
77
- export type { UsePrepareTrainingDataset, BlockPrepareTrainingDatasetHostError, BlockTrainingDatasetItem, BlockTrainingDatasetResult, BlockTrainingRejectionReason, } from './hooks/usePrepareTrainingDataset.js';
78
76
  export { useRunTraining, RunTrainingError, RUN_TRAINING_ERROR_CODES, isRunTrainingErrorCode, } from './hooks/useRunTraining.js';
79
- export type { UseRunTraining, QuotedTrainingBody, RunTrainingErrorCode, BlockRunTrainingHostError, BlockTrainingQuote, WorkflowBodyTraining, } from './hooks/useRunTraining.js';
77
+ export type { UseRunTraining, QuotedTrainingBody, RunTrainingErrorCode, BlockRunTrainingHostError, BlockTrainingQuote, BlockTrainingDatasetItem, WorkflowBodyTraining, } from './hooks/useRunTraining.js';
80
78
  export { useRunConfirmedStep, RunConfirmedStepError, RUN_CONFIRMED_STEP_ERROR_CODES, isRunConfirmedStepErrorCode, isStepQuoteId, } from './hooks/useRunConfirmedStep.js';
81
79
  export type { UseRunConfirmedStep, QuotedStepBody, RunConfirmedStepErrorCode, BlockRunConfirmedStepHostError, BlockStepQuote, WorkflowBodyPassThroughStep, } from './hooks/useRunConfirmedStep.js';
82
80
  export type { BlockBuzzTransaction, BlockBuzzAccount, BlockDailyCompensationResource, BlockWildcardPack, BlockWildcardPackErrorCode, AppWorkflow, AppWorkflowImage, AppWorkflowsParams, BlockGatedImage, BlockCollectionFollowErrorCode, BlockCollectionFollowResult, BlockAppReaction, BlockStoredReaction, BlockReactionEntityType, BlockEntityReactionErrorCode, BlockReactionCounts, BlockEntityReactionState, BlockEntityReactionResult, BlockEntityReactionsResult, } from '@civitai/app-sdk/blocks';
@@ -85,7 +83,7 @@ export type { UseCheckpointPicker } from './hooks/useCheckpointPicker.js';
85
83
  export { useResourcePicker, RESOURCE_PICKER_MULTIPLE_MAX } from './hooks/useResourcePicker.js';
86
84
  export type { UseResourcePicker, ResourcePickerOpenOptions, ResourcePickerMultiple, } from './hooks/useResourcePicker.js';
87
85
  export { useImageUpload } from './hooks/useImageUpload.js';
88
- export type { UseImageUpload, UseImageUploadAsyncScan, UseImageUploadGenerationSource, UseImageUploadOptions, } from './hooks/useImageUpload.js';
86
+ export type { UseImageUpload, UseImageUploadAsyncScan, UseImageUploadGenerationSource, UseImageUploadTrainingData, UseImageUploadOptions, BlockTrainingDataImage, } from './hooks/useImageUpload.js';
89
87
  export { useUploadImageBytes } from './hooks/useUploadImageBytes.js';
90
88
  export type { UploadImageBytesOptions, UseUploadImageBytes, } from './hooks/useUploadImageBytes.js';
91
89
  export { useMediaUpload } from './hooks/useMediaUpload.js';
package/dist/index.js CHANGED
@@ -62,7 +62,6 @@ export { InvalidIdempotencyKeyError } from './transport/transport.js';
62
62
  export { useCollectionFollow, CollectionFollowError, COLLECTION_FOLLOW_ERROR_CODES, isCollectionFollowErrorCode, } from './hooks/useCollectionFollow.js';
63
63
  export { useReactions, ReactionError, REACTIONS, REACTION_ERROR_CODES, isReactionErrorCode, } from './hooks/useReactions.js';
64
64
  export { useCreatePostFromApp, CreatePostError, CREATE_POST_ERROR_CODES, isCreatePostErrorCode, } from './hooks/useCreatePostFromApp.js';
65
- export { usePrepareTrainingDataset, PrepareTrainingDatasetError, PREPARE_TRAINING_DATASET_ERROR_CODES, isPrepareTrainingDatasetErrorCode, } from './hooks/usePrepareTrainingDataset.js';
66
65
  export { useRunTraining, RunTrainingError, RUN_TRAINING_ERROR_CODES, isRunTrainingErrorCode, } from './hooks/useRunTraining.js';
67
66
  export { useRunConfirmedStep, RunConfirmedStepError, RUN_CONFIRMED_STEP_ERROR_CODES, isRunConfirmedStepErrorCode, isStepQuoteId, } from './hooks/useRunConfirmedStep.js';
68
67
  export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';