@civitai/blocks-react 0.64.0 → 0.65.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
@@ -735,6 +735,126 @@ options drive both arms (including `declined`). **`dev:live` refuses this bridge
735
735
  it has no civitai chrome to render the server-resolved confirm in, and driving
736
736
  the write without it would let dev prove out a flow production does not have.
737
737
 
738
+ ### `usePrepareTrainingDataset()` and `useRunTraining()` — LoRA training
739
+
740
+ Train a LoRA with the ai-toolkit engine on the **viewer's own images**, from
741
+ inside a page app — the App Blocks `kind: 'training'` flow. Four steps, three
742
+ hooks:
743
+
744
+ 1. **Dataset** — `usePrepareTrainingDataset().prepareDataset([{ imageId, caption }])`
745
+ (`PREPARE_TRAINING_DATASET`). Only the viewer's own scanned, unflagged images
746
+ within the page's maturity ceiling are admitted; the rest come back in
747
+ `rejected` with a reason (`unavailable`, `unsupported-media`, `not-eligible`,
748
+ `pending-scan`, `import-failed`, `import-unavailable`; `pending-scan` and
749
+ `import-unavailable` are retryable). You get an opaque `datasetId` and the
750
+ admitted `count`, always at least 1 — if **nothing** is admitted the call
751
+ rejects with the server's message instead. 1–50 images, captions up to 1,000 characters; captions are
752
+ moderated. No dialog, no charge.
753
+ 2. **Quote** — `useBuzzWorkflow().estimate(body)` with a `WorkflowBodyTraining`
754
+ (`kind: 'training'`, no `quoteId`). The reply carries `trainingQuote:
755
+ { quoteId, total, imageCount, expiresAt }` — the orchestrator's own price for
756
+ exactly this run, stored server-side for 15 minutes.
757
+ 3. **Run** — `useRunTraining().runTraining({ ...body, quoteId })` (`RUN_TRAINING`).
758
+ **Civitai shows the viewer a consent dialog in its own chrome** — price, base
759
+ model, length and dataset size, all read back from the server, never from
760
+ your body — and submits only on their click. Change nothing in the body
761
+ between the estimate and the run; the server checks it against the quoted one.
762
+ 4. **Follow** — `useBuzzWorkflow().watch(workflowId)`. Once the run's moderation
763
+ status is approved, `trainedEpochs` lists the epochs whose checkpoint is
764
+ ready; send the viewer to the publish wizard with one of them (see
765
+ `trainedEpochs` on `BlockWorkflowSnapshot`).
766
+
767
+ 🔴 **Availability.** Behind the host flag **`app-blocks-training-kind`**, which
768
+ ships **off** and is evaluated per viewer. **Page apps only** — the model slot
769
+ answers both messages with an error. The manifest must declare
770
+ **`ai:write:budgeted`** and the viewer must be signed in and have granted it.
771
+ **Refused from `dev:live` and from review sessions** (the server refuses
772
+ development and review tokens); build the flow against the mock host. Requires a
773
+ civitai.com host carrying civitai/civitai#5434 (`kind: 'training'`,
774
+ `RUN_TRAINING`) and civitai/civitai#5438 (`PREPARE_TRAINING_DATASET`).
775
+
776
+ 🔴 **Money.** There is no `maxBuzz` and no timeout knob — the price is the quote.
777
+ A confirmed run may cost more than the token's per-call `buzzBudget` (the viewer
778
+ confirms the exact price), up to `BLOCK_TRAINING_MAX_BUZZ_PER_RUN` (5,000 Buzz);
779
+ an estimate above that is refused. `useBuzzWorkflow().submit()` **refuses** a
780
+ training body before sending anything: only `RUN_TRAINING`'s dialog can confirm a
781
+ quote, so a training run starts nowhere else.
782
+
783
+ `runTraining` **rejects** with a `RunTrainingError`; read the flags before
784
+ saying anything about money:
785
+
786
+ | | meaning | what to do |
787
+ |---|---|---|
788
+ | `err.declined` | the viewer dismissed the dialog — **no run was submitted** | revert, say nothing |
789
+ | `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** |
790
+ | `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 |
791
+ | `err.signInRequired` | no session | route into `useRequestSignIn()` |
792
+ | `err.code` set otherwise | a host refusal (`review-mode` / `block is not ready` / `invalid training request` / `no block token`) | show or ignore per case |
793
+ | `err.code === undefined`, no flag | a server refusal before any submit (expired or used quote, changed body, ineligible image) | estimate again, then retry |
794
+
795
+ `prepareDataset` rejects with a `PrepareTrainingDatasetError` (`.code` for the
796
+ host's refusals — `review-mode`, `block is not ready`, `sign in to train`,
797
+ `invalid training dataset`, `no block token` — plus `.signInRequired` and
798
+ `.timedOut`). Preparing charges nothing, so none of them cost Buzz. It prompts
799
+ for `ai:write:budgeted` and retries once on a grant (see the table below);
800
+ `runTraining` deliberately does not — it carries no idempotency key and has an
801
+ outcome where a run may exist, so it never re-sends.
802
+
803
+ ```tsx
804
+ const { prepareDataset } = usePrepareTrainingDataset();
805
+ const { estimate, watch } = useBuzzWorkflow();
806
+ const { runTraining } = useRunTraining();
807
+
808
+ async function train(images: Array<{ id: number; caption: string }>) {
809
+ // Rejects (no `.code`) when NOTHING is admitted, e.g. "none of the requested
810
+ // images can be used for training"; a resolved dataset always has count >= 1.
811
+ const dataset = await prepareDataset(images.map((i) => ({ imageId: i.id, caption: i.caption })));
812
+ if (dataset.rejected.length > 0) showNotice(`${dataset.rejected.length} image(s) left out`);
813
+
814
+ const body: WorkflowBodyTraining = {
815
+ kind: 'training',
816
+ datasetId: dataset.datasetId,
817
+ engine: 'ai-toolkit',
818
+ model: baseModelKey, // a key from Civitai's training catalog
819
+ params: aiToolkitParams, // AiToolkitTrainingParams
820
+ triggerWord: 'mystyle',
821
+ samplePrompts: ['mystyle, a lighthouse at dusk'],
822
+ };
823
+ const quote = (await estimate(body)).trainingQuote;
824
+ if (!quote) return showError('No training price came back.');
825
+ // Show quote.total; the host's dialog will show the same number.
826
+
827
+ try {
828
+ const started = await runTraining({ ...body, quoteId: quote.quoteId });
829
+ const done = await watch(started.workflowId, { onUpdate: render });
830
+ setEpochs(done.trainedEpochs ?? []);
831
+ } catch (err) {
832
+ if (!(err instanceof RunTrainingError)) throw err;
833
+ if (err.declined) return; // no run
834
+ if (err.unconfirmed) return showCheckYourTrainings(); // may be running — never auto-retry
835
+ if (err.refused) return showError(err.message); // a cap / availability refusal; no run, nothing charged
836
+ if (err.signInRequired) return requestSignIn();
837
+ showError('Could not start training. Get a new price and try again.');
838
+ }
839
+ }
840
+ ```
841
+
842
+ With the mock host — `createMockHost` or `Harness` from
843
+ `@civitai/blocks-react/testing` — the training path is **kind-faithful**: it
844
+ holds the datasets it prepared and the quotes it stored, refuses an unknown
845
+ dataset, a dataset with nothing admitted, a quote above 5,000 and a quote run
846
+ twice, and needs
847
+ `ai:write:budgeted` on the token and a signed-in viewer. Its knobs are
848
+ `trainingDatasetRejected`, `trainingDatasetError`, `trainingQuoteTotal`,
849
+ `runTrainingError` (`'declined'`, `'submission-unconfirmed'`, or any server
850
+ message), `runTrainingCapRefusal` (the server's resolved cap / availability refusal, which consumes the quote →
851
+ `err.refused`) and `generation.trainedEpochs` for the finished run. It has no
852
+ consent dialog, so `runTraining` settles at once where the real host waits on a
853
+ click, and it checks a run's body against its quote by `datasetId` only — the
854
+ server compares the whole body.
855
+ **`dev:live` refuses both bridges**, because the server refuses every training
856
+ request from a dev token.
857
+
738
858
  ### `useAppWorkflows(params?)`
739
859
 
740
860
  The calling app's **own** generator subqueue — the tag-scoped list of generations
@@ -1227,6 +1347,7 @@ had just worked.
1227
1347
  |---|---|---|
1228
1348
  | `useBuzzWorkflow()` | `submit()` | `ai:write:budgeted` |
1229
1349
  | `useCreatePostFromApp()` | `createPost()` | `posts:write:self` |
1350
+ | `usePrepareTrainingDataset()` | `prepareDataset()` | `ai:write:budgeted` |
1230
1351
  | `useGoodPurchase()` | `purchase()` | `goods:purchase:self` |
1231
1352
  | `useTip()` | `tip()` | `social:tip:self` |
1232
1353
 
@@ -1,5 +1,18 @@
1
1
  import type { BlockWorkflowSnapshot, WorkflowBody, WorkflowStatus } from '@civitai/app-sdk/blocks';
2
2
  import type { ConsentRetryOptions } from './consentRetryOptions.js';
3
+ /**
4
+ * Why {@link UseBuzzWorkflow.submit} refuses a `kind: 'training'` body.
5
+ *
6
+ * 🔴 REFUSED BEFORE THE SEND, NOT LEFT TO THE SERVER. The server charges a
7
+ * training run only against a quote the viewer confirmed from a signed-in
8
+ * session, and only `RUN_TRAINING`'s host-chrome dialog can record that — so a
9
+ * `SUBMIT_WORKFLOW` carrying this body can never start a run. Refusing here
10
+ * turns a server round-trip ending in a confusing refusal into an immediate
11
+ * error that names the hook to use. Nothing is sent and nothing is spent, which
12
+ * is why it is a plain `Error` and not a {@link WorkflowSubmitError} (every code
13
+ * on that class is money-ambiguous by design).
14
+ */
15
+ export declare const TRAINING_BODY_SUBMIT_REFUSAL = "useBuzzWorkflow.submit() does not run a kind:'training' body \u2014 estimate it here, then run it with useRunTraining().runTraining({ ...body, quoteId }). Nothing was sent.";
3
16
  /**
4
17
  * Default orchestrator-side hold per {@link UseBuzzWorkflow.watch} poll,
5
18
  * in SECONDS.
@@ -465,6 +478,10 @@ export interface UseBuzzWorkflow {
465
478
  * consent dialog with no user gesture behind it, once per edit. A missing
466
479
  * scope surfaces as an ordinary rejection; show no price and let `submit()`
467
480
  * do the asking.
481
+ *
482
+ * A `kind: 'training'` body's reply also carries
483
+ * {@link BlockWorkflowSnapshot.trainingQuote} — the `quoteId` to run it with
484
+ * through `useRunTraining()`.
468
485
  */
469
486
  estimate: (body: WorkflowBody) => Promise<BlockWorkflowSnapshot>;
470
487
  /**
@@ -512,6 +529,9 @@ export interface UseBuzzWorkflow {
512
529
  * `CONSENT_UNAVAILABLE` environment is never retried, and a second consent
513
530
  * failure reaches you unchanged. Opt out with
514
531
  * {@link ConsentRetryOptions.autoRequestConsent}`: false`.
532
+ *
533
+ * 🔴 A `kind: 'training'` BODY IS REFUSED before anything is sent — it runs
534
+ * only through `useRunTraining()`. See {@link TRAINING_BODY_SUBMIT_REFUSAL}.
515
535
  */
516
536
  submit: (body: WorkflowBody, options?: SubmitWorkflowOptions) => Promise<BlockWorkflowSnapshot>;
517
537
  /**
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { BLOCK_SCOPES } from '@civitai/app-sdk/blocks';
3
3
  import { withConsentRetry } from '../internal/withConsentRetry.js';
4
+ import { WORKFLOW_REQUEST_TIMEOUT_MS } from '../transport/requestTimeouts.js';
4
5
  import { getTransport } from '../transport/singleton.js';
5
6
  import { resolveIdempotencyKey, sendTypedRequest } from '../transport/transport.js';
6
7
  /**
@@ -16,6 +17,19 @@ import { resolveIdempotencyKey, sendTypedRequest } from '../transport/transport.
16
17
  * automatic prompt silently do nothing.
17
18
  */
18
19
  const WORKFLOW_SCOPES = [BLOCK_SCOPES.AI_WRITE_BUDGETED];
20
+ /**
21
+ * Why {@link UseBuzzWorkflow.submit} refuses a `kind: 'training'` body.
22
+ *
23
+ * 🔴 REFUSED BEFORE THE SEND, NOT LEFT TO THE SERVER. The server charges a
24
+ * training run only against a quote the viewer confirmed from a signed-in
25
+ * session, and only `RUN_TRAINING`'s host-chrome dialog can record that — so a
26
+ * `SUBMIT_WORKFLOW` carrying this body can never start a run. Refusing here
27
+ * turns a server round-trip ending in a confusing refusal into an immediate
28
+ * error that names the hook to use. Nothing is sent and nothing is spent, which
29
+ * is why it is a plain `Error` and not a {@link WorkflowSubmitError} (every code
30
+ * on that class is money-ambiguous by design).
31
+ */
32
+ export const TRAINING_BODY_SUBMIT_REFUSAL = "useBuzzWorkflow.submit() does not run a kind:'training' body — estimate it here, then run it with useRunTraining().runTraining({ ...body, quoteId }). Nothing was sent.";
19
33
  /**
20
34
  * Snapshot statuses that mean "no further polling is needed."
21
35
  * Used by both `submit` (a host can return an instant-fail / cached result)
@@ -27,18 +41,16 @@ const TERMINAL_STATUSES = new Set([
27
41
  'canceled',
28
42
  'expired',
29
43
  ]);
30
- /**
31
- * The workflow requests (estimate / submit / poll) are orchestrator-bound:
32
- * the host forwards them to the generation orchestrator and only replies
33
- * once it answers. `submit` is the slowest — server-side it does a whatif
34
- * cost-preflight AND the real submit (two orchestrator round-trips) plus a
35
- * prompt audit before responding, which legitimately exceeds the
36
- * transport's 30s `DEFAULT_REQUEST_TIMEOUT_MS` (tuned for fast bridge
37
- * messages) when the orchestrator queue is busy. Give these calls a
38
- * generous ceiling so a busy-but-healthy orchestrator doesn't surface as
39
- * a spurious `request "SUBMIT_WORKFLOW" timed out` rejection.
40
- */
41
- const WORKFLOW_REQUEST_TIMEOUT_MS = 120_000;
44
+ // The workflow requests (estimate / submit / poll) are orchestrator-bound:
45
+ // the host forwards them to the generation orchestrator and only replies
46
+ // once it answers. `submit` is the slowest — server-side it does a whatif
47
+ // cost-preflight AND the real submit (two orchestrator round-trips) plus a
48
+ // prompt audit before responding, which legitimately exceeds the
49
+ // transport's 30s `DEFAULT_REQUEST_TIMEOUT_MS` (tuned for fast bridge
50
+ // messages) when the orchestrator queue is busy. `WORKFLOW_REQUEST_TIMEOUT_MS`
51
+ // (imported above, from `transport/requestTimeouts.ts`) gives these calls a
52
+ // generous ceiling so a busy-but-healthy orchestrator doesn't surface as a
53
+ // spurious `request "SUBMIT_WORKFLOW" timed out` rejection.
42
54
  /**
43
55
  * Default orchestrator-side hold per {@link UseBuzzWorkflow.watch} poll,
44
56
  * in SECONDS.
@@ -720,6 +732,14 @@ export function useBuzzWorkflow() {
720
732
  return snapshot;
721
733
  }, []);
722
734
  const submit = useCallback(async (body, options) => {
735
+ // See `TRAINING_BODY_SUBMIT_REFUSAL`. Before the status flip, the key
736
+ // mint and the consent retry: nothing about this call may reach the wire.
737
+ if (body.kind === 'training') {
738
+ const refused = new Error(TRAINING_BODY_SUBMIT_REFUSAL);
739
+ setError(refused);
740
+ setStatus('error');
741
+ throw refused;
742
+ }
723
743
  setError(null);
724
744
  setStatus('submitting');
725
745
  // Idempotency: reuse a caller-supplied stable key across a retry (→ one Buzz
@@ -0,0 +1,99 @@
1
+ import type { BlockPrepareTrainingDatasetHostError, BlockTrainingDatasetItem, BlockTrainingDatasetResult, BlockTrainingRejectionReason } from '@civitai/app-sdk/blocks';
2
+ import type { ConsentRetryOptions } from './consentRetryOptions.js';
3
+ export type { BlockPrepareTrainingDatasetHostError, BlockTrainingDatasetItem, BlockTrainingDatasetResult, BlockTrainingRejectionReason, };
4
+ /**
5
+ * The host's CLOSED refusal codes for `PREPARE_TRAINING_DATASET`, as a runtime
6
+ * list. Mirrors civitai/civitai's `PREPARE_TRAINING_DATASET_HOST_ERRORS`.
7
+ *
8
+ * 🔴 THE ERROR CHANNEL IS NOT AN ENUM: it also carries free-text server
9
+ * messages, so "is this a code?" is a membership question at runtime — see
10
+ * {@link isPrepareTrainingDatasetErrorCode}.
11
+ */
12
+ export declare const PREPARE_TRAINING_DATASET_ERROR_CODES: readonly ["review-mode", "block is not ready", "sign in to train", "invalid training dataset", "no block token"];
13
+ /** `true` when `error` is one of the host's closed refusal codes rather than a server message. */
14
+ export declare function isPrepareTrainingDatasetErrorCode(error: string): error is BlockPrepareTrainingDatasetHostError;
15
+ /**
16
+ * A dataset-preparation failure.
17
+ *
18
+ * `.code` is the host's refusal code when the HOST refused, `undefined` for a
19
+ * server message or a transport timeout. Check `.timedOut` before rendering
20
+ * `.message` — a timeout's message is an SDK-internal string.
21
+ *
22
+ * Preparing charges nothing, so no failure here costs the viewer Buzz.
23
+ */
24
+ export declare class PrepareTrainingDatasetError extends Error {
25
+ /** The closed host refusal code, or `undefined` for a server/transport error. */
26
+ readonly code?: BlockPrepareTrainingDatasetHostError;
27
+ /**
28
+ * No reply arrived within the bound. The server may still have prepared a
29
+ * dataset; nothing was charged either way, and preparing again simply makes a
30
+ * new handle.
31
+ */
32
+ readonly timedOut: boolean;
33
+ /**
34
+ * There is no session — the viewer is signed out (`sign in to train`), or their
35
+ * session ended (a bare `UNAUTHORIZED`). Route into `useRequestSignIn()`.
36
+ */
37
+ readonly signInRequired: boolean;
38
+ constructor(error: string, opts?: {
39
+ timedOut?: boolean;
40
+ });
41
+ }
42
+ /** What {@link usePrepareTrainingDataset} returns. */
43
+ export interface UsePrepareTrainingDataset {
44
+ /**
45
+ * Ask the host to prepare a training dataset from the viewer's OWN images.
46
+ * Resolves with the opaque `datasetId`, the admitted `count` and the
47
+ * `rejected` images. REJECTS with a {@link PrepareTrainingDatasetError}.
48
+ *
49
+ * A resolved result admitted AT LEAST ONE image (`count >= 1`) — when nothing
50
+ * is admitted the server refuses instead, and this rejects with its message
51
+ * (`none of the requested images can be used for training` / `… could be
52
+ * prepared for training`, no `.code`). Read `rejected` for what was left out:
53
+ * `pending-scan` and `import-unavailable` are retryable; the rest are not.
54
+ * The per-image reasons are not available when everything was rejected.
55
+ */
56
+ prepareDataset: (items: BlockTrainingDatasetItem[], options?: ConsentRetryOptions) => Promise<BlockTrainingDatasetResult>;
57
+ /** `true` while a request is in flight. */
58
+ pending: boolean;
59
+ /** The last request's failure, or `null`. Cleared at the start of the next call. */
60
+ error: PrepareTrainingDatasetError | null;
61
+ }
62
+ /**
63
+ * Step 1 of the App Blocks `kind: 'training'` flow: turn the viewer's own images
64
+ * and your captions into a dataset handle for `WorkflowBodyTraining.datasetId`,
65
+ * through the host-mediated `PREPARE_TRAINING_DATASET` → `TRAINING_DATASET_RESULT`
66
+ * bridge.
67
+ *
68
+ * 🔴 AVAILABILITY: page apps only, behind the host flag `app-blocks-training-kind`
69
+ * (ships off), with `ai:write:budgeted` declared in the manifest and granted by a
70
+ * signed-in viewer. Refused from `dev:live` and from review sessions — build the
71
+ * flow against the mock host. See `WorkflowBodyTraining` in `@civitai/app-sdk`.
72
+ *
73
+ * Only the viewer's OWN scanned, unflagged images within the page's maturity
74
+ * ceiling are admitted; captions are moderated. Bounds: 1–50 items, captions up
75
+ * to 1,000 characters (`BLOCK_TRAINING_DATASET_MAX_ITEMS`,
76
+ * `BLOCK_TRAINING_CAPTION_MAX_CHARS`); the host refuses anything else with
77
+ * `invalid training dataset` before calling the server.
78
+ *
79
+ * No dialog and no charge. When the token lacks `ai:write:budgeted`, the call
80
+ * opens the host's consent prompt and retries once on a grant (opt out with
81
+ * `{ autoRequestConsent: false }`).
82
+ *
83
+ * Sent under the 120s server-work bound, not the 30s protocol default: the server
84
+ * imports up to 50 images into the orchestrator within its own 60s budget.
85
+ *
86
+ * @example
87
+ * const { prepareDataset } = usePrepareTrainingDataset();
88
+ * try {
89
+ * const dataset = await prepareDataset(
90
+ * picked.map((img) => ({ imageId: img.id, caption: img.caption })),
91
+ * );
92
+ * if (dataset.rejected.length > 0) showNotice(`${dataset.rejected.length} image(s) left out`);
93
+ * } catch (e) {
94
+ * // Includes "none of the requested images can be used for training".
95
+ * if (e instanceof PrepareTrainingDatasetError) showError(e.message);
96
+ * }
97
+ */
98
+ export declare function usePrepareTrainingDataset(): UsePrepareTrainingDataset;
99
+ //# sourceMappingURL=usePrepareTrainingDataset.d.ts.map
@@ -0,0 +1,157 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { BLOCK_SCOPES } from '@civitai/app-sdk/blocks';
3
+ import { withConsentRetry } from '../internal/withConsentRetry.js';
4
+ import { WORKFLOW_REQUEST_TIMEOUT_MS } from '../transport/requestTimeouts.js';
5
+ import { getTransport } from '../transport/singleton.js';
6
+ import { RequestTimeoutError, sendTypedRequest } from '../transport/transport.js';
7
+ /**
8
+ * The consent-gated scope the training flow requires — `ai:write:budgeted`. The
9
+ * server refuses a dataset request from a token without it (it is the spend
10
+ * scope, and preparing is gated exactly like the run it prepares for). From
11
+ * {@link BLOCK_SCOPES}, never a literal: a typo would make the automatic consent
12
+ * prompt silently do nothing.
13
+ */
14
+ const TRAINING_SCOPES = [BLOCK_SCOPES.AI_WRITE_BUDGETED];
15
+ /**
16
+ * The host's CLOSED refusal codes for `PREPARE_TRAINING_DATASET`, as a runtime
17
+ * list. Mirrors civitai/civitai's `PREPARE_TRAINING_DATASET_HOST_ERRORS`.
18
+ *
19
+ * 🔴 THE ERROR CHANNEL IS NOT AN ENUM: it also carries free-text server
20
+ * messages, so "is this a code?" is a membership question at runtime — see
21
+ * {@link isPrepareTrainingDatasetErrorCode}.
22
+ */
23
+ export const PREPARE_TRAINING_DATASET_ERROR_CODES = [
24
+ 'review-mode',
25
+ 'block is not ready',
26
+ 'sign in to train',
27
+ 'invalid training dataset',
28
+ 'no block token',
29
+ ];
30
+ const CODE_SET = new Set(PREPARE_TRAINING_DATASET_ERROR_CODES);
31
+ /** `true` when `error` is one of the host's closed refusal codes rather than a server message. */
32
+ export function isPrepareTrainingDatasetErrorCode(error) {
33
+ return CODE_SET.has(error);
34
+ }
35
+ /**
36
+ * A dataset-preparation failure.
37
+ *
38
+ * `.code` is the host's refusal code when the HOST refused, `undefined` for a
39
+ * server message or a transport timeout. Check `.timedOut` before rendering
40
+ * `.message` — a timeout's message is an SDK-internal string.
41
+ *
42
+ * Preparing charges nothing, so no failure here costs the viewer Buzz.
43
+ */
44
+ export class PrepareTrainingDatasetError extends Error {
45
+ /** The closed host refusal code, or `undefined` for a server/transport error. */
46
+ code;
47
+ /**
48
+ * No reply arrived within the bound. The server may still have prepared a
49
+ * dataset; nothing was charged either way, and preparing again simply makes a
50
+ * new handle.
51
+ */
52
+ timedOut;
53
+ /**
54
+ * There is no session — the viewer is signed out (`sign in to train`), or their
55
+ * session ended (a bare `UNAUTHORIZED`). Route into `useRequestSignIn()`.
56
+ */
57
+ signInRequired;
58
+ constructor(error, opts) {
59
+ super(error);
60
+ this.name = 'PrepareTrainingDatasetError';
61
+ this.timedOut = opts?.timedOut === true;
62
+ if (isPrepareTrainingDatasetErrorCode(error))
63
+ this.code = error;
64
+ this.signInRequired = error === 'sign in to train' || error === 'UNAUTHORIZED';
65
+ }
66
+ }
67
+ /**
68
+ * Step 1 of the App Blocks `kind: 'training'` flow: turn the viewer's own images
69
+ * and your captions into a dataset handle for `WorkflowBodyTraining.datasetId`,
70
+ * through the host-mediated `PREPARE_TRAINING_DATASET` → `TRAINING_DATASET_RESULT`
71
+ * bridge.
72
+ *
73
+ * 🔴 AVAILABILITY: page apps only, behind the host flag `app-blocks-training-kind`
74
+ * (ships off), with `ai:write:budgeted` declared in the manifest and granted by a
75
+ * signed-in viewer. Refused from `dev:live` and from review sessions — build the
76
+ * flow against the mock host. See `WorkflowBodyTraining` in `@civitai/app-sdk`.
77
+ *
78
+ * Only the viewer's OWN scanned, unflagged images within the page's maturity
79
+ * ceiling are admitted; captions are moderated. Bounds: 1–50 items, captions up
80
+ * to 1,000 characters (`BLOCK_TRAINING_DATASET_MAX_ITEMS`,
81
+ * `BLOCK_TRAINING_CAPTION_MAX_CHARS`); the host refuses anything else with
82
+ * `invalid training dataset` before calling the server.
83
+ *
84
+ * No dialog and no charge. When the token lacks `ai:write:budgeted`, the call
85
+ * opens the host's consent prompt and retries once on a grant (opt out with
86
+ * `{ autoRequestConsent: false }`).
87
+ *
88
+ * Sent under the 120s server-work bound, not the 30s protocol default: the server
89
+ * imports up to 50 images into the orchestrator within its own 60s budget.
90
+ *
91
+ * @example
92
+ * const { prepareDataset } = usePrepareTrainingDataset();
93
+ * try {
94
+ * const dataset = await prepareDataset(
95
+ * picked.map((img) => ({ imageId: img.id, caption: img.caption })),
96
+ * );
97
+ * if (dataset.rejected.length > 0) showNotice(`${dataset.rejected.length} image(s) left out`);
98
+ * } catch (e) {
99
+ * // Includes "none of the requested images can be used for training".
100
+ * if (e instanceof PrepareTrainingDatasetError) showError(e.message);
101
+ * }
102
+ */
103
+ export function usePrepareTrainingDataset() {
104
+ const [pending, setPending] = useState(false);
105
+ const [error, setError] = useState(null);
106
+ // The consent wait can outlive the component; see `useCreatePostFromApp`.
107
+ const mountedRef = useRef(true);
108
+ useEffect(() => {
109
+ mountedRef.current = true;
110
+ return () => {
111
+ mountedRef.current = false;
112
+ };
113
+ }, []);
114
+ /** ONE round-trip + its result contract. Re-invoked verbatim on a consent retry. */
115
+ const prepareOnce = useCallback(async (items) => {
116
+ const reply = await sendTypedRequest(getTransport(), { type: 'PREPARE_TRAINING_DATASET', payload: { items } }, 'TRAINING_DATASET_RESULT', { timeoutMs: WORKFLOW_REQUEST_TIMEOUT_MS }).catch((err) => {
117
+ // Stamped INSIDE the closure `withConsentRetry` re-invokes, so its rule 4
118
+ // sees it: a 120s wait is not repeated behind a consent prompt.
119
+ if (err instanceof RequestTimeoutError) {
120
+ throw new PrepareTrainingDatasetError(err.message, { timedOut: true });
121
+ }
122
+ throw err instanceof Error ? err : new Error(String(err));
123
+ });
124
+ if (reply.error || !reply.result) {
125
+ // `||`, not `??`: the validator shape-checks `error`, so `''` is a valid
126
+ // reply that must not become an Error with an empty message. The
127
+ // fallback is deliberately NOT a host code — `invalid training dataset`
128
+ // would claim "nothing reached the server", which an empty error does
129
+ // not tell us.
130
+ throw new PrepareTrainingDatasetError(reply.error || 'training dataset could not be prepared');
131
+ }
132
+ return reply.result;
133
+ }, []);
134
+ const prepareDataset = useCallback(async (items, options) => {
135
+ if (mountedRef.current) {
136
+ setPending(true);
137
+ setError(null);
138
+ }
139
+ try {
140
+ return await withConsentRetry(getTransport(), TRAINING_SCOPES, () => prepareOnce(items), options, () => mountedRef.current);
141
+ }
142
+ catch (err) {
143
+ const wrapped = err instanceof PrepareTrainingDatasetError
144
+ ? err
145
+ : new PrepareTrainingDatasetError(err instanceof Error ? err.message : String(err));
146
+ if (mountedRef.current)
147
+ setError(wrapped);
148
+ throw wrapped;
149
+ }
150
+ finally {
151
+ if (mountedRef.current)
152
+ setPending(false);
153
+ }
154
+ }, [prepareOnce]);
155
+ return { prepareDataset, pending, error };
156
+ }
157
+ //# sourceMappingURL=usePrepareTrainingDataset.js.map
@@ -0,0 +1,151 @@
1
+ import type { BlockRunTrainingHostError, BlockTrainingQuote, BlockWorkflowSnapshot, WorkflowBodyTraining } from '@civitai/app-sdk/blocks';
2
+ export type { BlockRunTrainingHostError, BlockTrainingQuote, WorkflowBodyTraining };
3
+ /**
4
+ * A training body ready to RUN: {@link WorkflowBodyTraining} with the `quoteId`
5
+ * its estimate returned. Required here so a body without one is a compile error
6
+ * rather than a host refusal.
7
+ */
8
+ export type QuotedTrainingBody = WorkflowBodyTraining & {
9
+ quoteId: string;
10
+ };
11
+ /**
12
+ * The host's CLOSED refusal codes for `RUN_TRAINING`, as a runtime list. Mirrors
13
+ * civitai/civitai's `RUN_TRAINING_HOST_ERRORS`.
14
+ *
15
+ * 🔴 THE ERROR CHANNEL IS NOT AN ENUM: server messages (an expired or used quote,
16
+ * a body that differs from the quoted one) arrive in the same field — see
17
+ * {@link isRunTrainingErrorCode}.
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"];
20
+ /**
21
+ * The `code` a {@link RunTrainingError} can carry: one of the host's closed
22
+ * refusal codes, or `'refused'` — assigned by THIS SDK, never sent by the host,
23
+ * for a submit the server refused at a spend cap or as temporarily unavailable
24
+ * (see {@link RunTrainingError.refused}).
25
+ */
26
+ export type RunTrainingErrorCode = BlockRunTrainingHostError | 'refused';
27
+ /** `true` when `error` is one of the host's closed refusal codes rather than a server message. */
28
+ export declare function isRunTrainingErrorCode(error: string): error is BlockRunTrainingHostError;
29
+ /**
30
+ * A training run that did not come back with a snapshot.
31
+ *
32
+ * 🔴 READ THE FLAGS BEFORE SAYING ANYTHING ABOUT MONEY — they are the only part of
33
+ * this error with a money meaning, and they point in opposite directions:
34
+ *
35
+ * - `.declined` — the viewer dismissed the consent dialog. NO RUN WAS
36
+ * SUBMITTED, guaranteed by the host's consent latch. Render nothing.
37
+ * - `.unconfirmed` — the run MAY BE RUNNING AND CHARGED: the host sent the
38
+ * submit and could not learn its outcome (`submission-unconfirmed`), or no
39
+ * reply arrived at all (`.timedOut` — the viewer may have clicked Train on a
40
+ * dialog this block had stopped waiting for). Do NOT retry automatically and
41
+ * do NOT tell the viewer it failed: check their trainings first
42
+ * (`useAppWorkflows()` lists this app's runs), because re-running the same
43
+ * body after the server did start it is a SECOND, separately charged run.
44
+ * - `.refused` (`code: 'refused'`) — the server REFUSED the submit at a spend
45
+ * cap after the viewer confirmed (the viewer's daily or private-run Buzz cap,
46
+ * the per-app consent budget, the app's daily spend or rate limit, a dev
47
+ * session cap). Its reservation was refunded: NO RUN, nothing charged.
48
+ * `.message` is the server's reason (e.g. `daily Buzz cap reached: …`) and
49
+ * `.snapshot` the refusal it arrived in. Retrying needs a new estimate and
50
+ * usually a later time; buying Buzz does not lift these caps.
51
+ * - `.signInRequired` — no session. Route into `useRequestSignIn()`.
52
+ * - 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.
55
+ */
56
+ export declare class RunTrainingError extends Error {
57
+ /**
58
+ * The closed host refusal code, `'refused'` for a cap refusal, or `undefined`
59
+ * for a server/transport error.
60
+ */
61
+ readonly code?: RunTrainingErrorCode;
62
+ /**
63
+ * The server refused the submit (a spend cap, or a temporary-availability deny)
64
+ * and refunded it — no run exists. The quote is used up: estimate again.
65
+ * See the class docs; `.message` is the server's reason.
66
+ */
67
+ readonly refused: boolean;
68
+ /** The refusal snapshot the host replied with, when {@link refused}. */
69
+ readonly snapshot?: BlockWorkflowSnapshot;
70
+ /** The viewer dismissed the consent dialog — no run was submitted. */
71
+ readonly declined: boolean;
72
+ /**
73
+ * The run may exist and may have been charged — `submission-unconfirmed`, or a
74
+ * transport timeout. Check the viewer's trainings before retrying.
75
+ */
76
+ readonly unconfirmed: boolean;
77
+ /** No reply arrived within the 10-minute consent bound. Implies {@link unconfirmed}. */
78
+ readonly timedOut: boolean;
79
+ /** There is no session (`sign in to train`, or a bare `UNAUTHORIZED`). */
80
+ readonly signInRequired: boolean;
81
+ constructor(error: string, opts?: {
82
+ timedOut?: boolean;
83
+ refusedSnapshot?: BlockWorkflowSnapshot;
84
+ });
85
+ }
86
+ /** What {@link useRunTraining} returns. */
87
+ export interface UseRunTraining {
88
+ /**
89
+ * Ask the host to run a quoted training body. Civitai shows the viewer a
90
+ * consent dialog with the server's price and details; on their click it
91
+ * submits, and this resolves with the submitted workflow's snapshot — poll it
92
+ * with `useBuzzWorkflow().watch(snapshot.workflowId)`.
93
+ *
94
+ * REJECTS with a {@link RunTrainingError} — including `declined` (no run),
95
+ * `refused` (a spend-cap or temporary-availability refusal; no run) and
96
+ * `unconfirmed` (a run may
97
+ * exist). Read the flags before rendering anything. A resolved snapshot is
98
+ * always a submitted run with a pollable `workflowId`.
99
+ */
100
+ runTraining: (body: QuotedTrainingBody) => Promise<BlockWorkflowSnapshot>;
101
+ /** `true` while a request is in flight (including the viewer's dialog). */
102
+ pending: boolean;
103
+ /** The last request's failure, or `null`. Cleared at the start of the next call. */
104
+ error: RunTrainingError | null;
105
+ }
106
+ /**
107
+ * Run an App Blocks `kind: 'training'` LoRA training — the only way one starts —
108
+ * through the host-mediated `RUN_TRAINING` → `TRAINING_RESULT` bridge.
109
+ *
110
+ * 🔴 AVAILABILITY: page apps only, behind the host flag `app-blocks-training-kind`
111
+ * (ships off), with `ai:write:budgeted` declared and granted by a signed-in
112
+ * viewer. Refused from `dev:live` and from review sessions — build the flow
113
+ * against the mock host. See `WorkflowBodyTraining` in `@civitai/app-sdk`.
114
+ *
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.
119
+ *
120
+ * 🔴 THE CONSENT DIALOG IS CIVITAI'S, AND IT IS THE CHARGE. Every number on it is
121
+ * read back from the server's stored quote — never from your body. A run may cost
122
+ * 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.
124
+ *
125
+ * 🔴 NO AUTOMATIC CONSENT RETRY, unlike the other consent-gated hooks — on
126
+ * purpose. `RUN_TRAINING` carries no idempotency key and has an outcome
127
+ * (`submission-unconfirmed`) where a run may exist, so this hook never re-sends.
128
+ * It is also unreachable in practice: a block holding a `quoteId` got it from an
129
+ * estimate that already required `ai:write:budgeted`.
130
+ *
131
+ * Sent under the 10-minute human-interaction bound: the host replies only when
132
+ * the viewer clicks or dismisses its dialog.
133
+ *
134
+ * @example
135
+ * const { estimate, watch } = useBuzzWorkflow();
136
+ * const { runTraining } = useRunTraining();
137
+ * const quote = (await estimate(body)).trainingQuote!;
138
+ * try {
139
+ * const started = await runTraining({ ...body, quoteId: quote.quoteId });
140
+ * await watch(started.workflowId, { onUpdate: render });
141
+ * } catch (e) {
142
+ * if (!(e instanceof RunTrainingError)) throw e;
143
+ * if (e.declined) return; // no run — say nothing
144
+ * if (e.signInRequired) return requestSignIn();
145
+ * if (e.unconfirmed) return showCheckYourTrainings(); // may be running — never auto-retry
146
+ * if (e.refused) return showError(e.message); // a cap / availability refusal; no run, nothing charged
147
+ * showError('Could not start training. Get a new price and try again.');
148
+ * }
149
+ */
150
+ export declare function useRunTraining(): UseRunTraining;
151
+ //# sourceMappingURL=useRunTraining.d.ts.map