Batch, confirmed-step and training hooks
Estimating and running batches, running one job above the per-call budget, and LoRA training.
This page documents 5 of the 47 hooks in the Hooks reference, which indexes every hook by name. Generated from the published @civitai/blocks-react@0.73.0 package's type definitions and README.
Other pages: Context, frame and analytics hooks · Generation hooks · Buzz, purchase and tip hooks · Viewer, consent and navigation hooks · Storage, picker and media hooks
useBatchEstimate
useBatchEstimate(): UseBatchEstimatePrice a grid of generations in one request. `estimateBatch(bodies)` takes a list
of workflow bodies, each exactly what `useBuzzWorkflow().estimate()` takes, and
resolves with one cell per body, in order, plus the run total. Returns
`{ estimateBatch, pending, result, error }`.
- **One request against the estimate allowance** however many cells it carries.
On a page app that allowance is shared by every viewer of the app and by the
host's other reads for it, so a 16-cell grid priced cell by cell used 16 of it.
- **At most 16 bodies per call** (`BATCH_ESTIMATE_MAX_CELLS`). Price a larger grid
in several calls.
- **Cells are metered too**, against an allowance of their own that is shared
the same way (on a page app, by every viewer of the app), so a batch is not a
way around the estimate allowance. A call over either limit is refused whole:
it rejects with `BatchEstimateError` code `'failed'`. Retry in a few seconds.
- **Every rule of a single estimate applies to each cell.** A cell that one
`estimate()` would reject comes back as `ok: false` with the same
`WorkflowEstimateError`, and the other cells still price.
- **`kind: 'training'` bodies are refused per cell.** Estimate a training run
with `useBuzzWorkflow().estimate()`.
- **A cell never carries a `stepQuote`.** A batch stores nothing, so a
pass-through step priced above the per-call budget comes back with its cost and
no confirmable quote. To run it with
[`useRunConfirmedStep()`](#userunconfirmedstep--one-job-above-the-per-call-budget),
estimate that one body with `useBuzzWorkflow().estimate()`.
- **Estimate only.** Nothing is submitted and no Buzz is held or spent. There is
no batch submit. Submit each cell with
`useBuzzWorkflow().submit()`, which asks the viewer and checks the budget per
cell. `aggregate.total` is a quote, not a lock: each cell is priced again when
it is submitted. To follow the submitted cells and cancel them together, see
[`useWorkflowBatch()`](#useworkflowbatch).
`aggregate.total` sums the cells that priced. It covers the whole grid only when
`aggregate.pricedCells === aggregate.cellCount`; otherwise show it as a partial
total.
**The host bounds the time one call takes.** If its budget runs out, the call
still resolves: the cells that priced are returned, and every other cell is
`ok: false` with `timedOut: true`. Show those cells as unavailable and let the
viewer retry later. Branch on `timedOut`, not on `snapshot.error`: the host's
wording is not a contract.
`estimateBatch` rejects with `BatchEstimateError` only when the whole call
failed. Branch on `err.code`:
| `code` | Meaning | What to do |
|---|---|---|
| `'unsupported'` | The host answered `unsupported on this host`. | Price each cell with `estimate()`. |
| `'timeout'` | No reply in time. A host that supports the batch replies within its own time budget, well inside the default wait, so this usually means a slow host, not an old one. | Show the estimate as unavailable and retry later. **Do not** fall back to one `estimate()` per cell: that sends up to 16 more estimates at a host that is already slow. |
| `'invalid-request'` | Empty list, or more than 16 bodies. Refused before sending. | Fix the list. |
| `'failed'` | Any other whole-call refusal: the scope, a rate limit, review preview. The reason is on `err.hostError`; log it, don't render it. | Show your own copy and retry later. |
A 2 × 2 grid (two prompts × two checkpoints), falling back to per-cell estimates
only on a host that answers `'unsupported'`:import { useState } from 'react';
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
import {
BatchEstimateError,
useBatchEstimate,
useBuzzWorkflow,
WorkflowEstimateError,
} from '@civitai/blocks-react';
const PROMPTS = ['a lighthouse at dusk', 'a lighthouse in fog'];
const CHECKPOINTS = [
{ modelId: 101, modelVersionId: 1001 },
{ modelId: 202, modelVersionId: 2002 },
];
/** One cell's quote: a price, or why it has none. */
type CellQuote = { total: number } | { unavailable: string };
export function GridQuote() {
const { estimateBatch } = useBatchEstimate();
const { estimate } = useBuzzWorkflow();
const [quotes, setQuotes] = useState<CellQuote[]>([]);
const [runTotal, setRunTotal] = useState<number | null>(null);
const bodies: WorkflowBody[] = PROMPTS.flatMap((prompt) =>
CHECKPOINTS.map((cp) => ({ kind: 'textToImage' as const, ...cp, params: { prompt } })),
);
// Your copy, chosen by `code` (and, for a batch cell, `timedOut`). Never
// render or match `err.message` or `snapshot.error`.
const why = (err: WorkflowEstimateError, timedOut = false) =>
timedOut
? 'Price not ready, try again shortly'
: err.code === 'no-cost'
? 'No price for this cell'
: 'This cell cannot run';
async function priceGrid() {
try {
const { cells, aggregate } = await estimateBatch(bodies);
setQuotes(
cells.map((c) => (c.ok ? { total: c.cost.total } : { unavailable: why(c.error, c.timedOut) })),
);
setRunTotal(aggregate.pricedCells === aggregate.cellCount ? aggregate.total : null);
return;
} catch (err) {
if (!(err instanceof BatchEstimateError)) throw err;
if (err.code !== 'unsupported') {
// 'timeout' is a slow host, not an old one: never fan out per cell here.
setQuotes([]);
setRunTotal(null);
return; // show "pricing unavailable, try again" in your UI
}
}
// This host has no batch estimate: one estimate() per cell, as before.
const fallback: CellQuote[] = [];
for (const body of bodies) {
try {
const snapshot = await estimate(body);
fallback.push({ total: snapshot.cost!.total });
} catch (err) {
if (!(err instanceof WorkflowEstimateError)) throw err;
fallback.push({ unavailable: why(err) });
}
}
setQuotes(fallback);
const priced = fallback.filter((q): q is { total: number } => 'total' in q);
setRunTotal(
priced.length === fallback.length ? priced.reduce((sum, q) => sum + q.total, 0) : null,
);
}
return (
<div>
<button onClick={() => void priceGrid()}>Price grid</button>
<ul>
{quotes.map((q, i) => (
<li key={i}>{'total' in q ? `${q.total} Buzz` : q.unavailable}</li>
))}
</ul>
{runTotal !== null && <p>Run total: {runTotal} Buzz</p>}
</div>
);
}useWorkflowBatch
useWorkflowBatch(workflowIds: readonly string[], options?: WorkflowBatchOptions): UseWorkflowBatchWatch a set of already-submitted workflows as one batch: per-cell status, a summary, and cancel-all. Built for grid apps that submit each cell with `useBuzzWorkflow().submit()` (one submit per cell, unchanged) and then have to follow all of them. `useWorkflowBatch(workflowIds, options?)` returns `{ cells, summary, cancelAll, rewatch }`. - **The array of ids is the batch.** There is no server-side batch id. Append each id as its `submit()` resolves, and persist the same array (plain strings) to re-attach after a reload by passing it back. Skip a submit that resolved with the `'failed'` placeholder id: nothing was created. - **Every cell is one ordinary `watch()`**, so each workflow has exactly one poll in flight. When a cell's loop restarts (the list changes, `rewatch()`, a StrictMode remount) the new loop waits for the old loop's poll to return. `options` (`intervalMs`, `waitSeconds`, `timeoutMs`, `maxRetries`) are passed to every cell's `watch()`. - **At most 30 ids** (`WORKFLOW_BATCH_MAX_CELLS`): the poll allowance (1,200 per 60 s per block instance and viewer) divided by one watch's 40 polls a minute. A longer list, a duplicate, an empty id or a `'failed'`/`'whatif'` placeholder throws. - **`summary`** counts `succeeded`, `failed`, `canceled`, `expired` and `running` (not terminal, including cells with no snapshot yet) from each cell's latest snapshot, plus `realisedCost`: the sum of `cost.total` on the terminal snapshots that carry one. That is the generation cost the host reported, not a ledger read; the author fee is charged separately and is not included. `settled` is `running === 0`. - **A host failure placeholder is not a workflow outcome.** When the host fails a poll it answers `{ workflowId: 'failed', status: 'failed' }`. That says nothing about the workflow, so the cell is not counted as failed: its `phase` becomes `'stopped'` with `error` set, and `summary.stopped` counts it. A stopped cell (also a watch that timed out or ran out of retries) is not polled again until you call `rewatch()`. - **`cancelAll()` cancels every cell not known to be finished** and resolves (never rejects) with `{ canceled, alreadySettled, refused, notAttempted, failed, complete }`, each a list of ids. Cancels charge the estimate allowance (150 per 10 s per block instance; on a page app every viewer shares it), so at most four are in flight. A refused cancel resolves `status: 'processing'`: it was not sent and the workflow is still running. After the first refusal the rest of the call is not sent (`notAttempted`). A cancel answered with the host's failure placeholder is `failed`, not a failed workflow. Nothing is retried automatically. When `complete` is `false`, wait a few seconds and call `cancelAll()` again; it targets only cells that are still not finished. - **Unmounting stops every poll.** A `cancelAll()` already running keeps sending.
import { useEffect, useState } from 'react';
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
import { useBuzzWorkflow, useWorkflowBatch } from '@civitai/blocks-react';
const SAVED = 'grid-run-ids';
function GridRun({ bodies }: { bodies: WorkflowBody[] }) {
const { submit } = useBuzzWorkflow();
const [ids, setIds] = useState<string[]>(() => {
try {
return JSON.parse(localStorage.getItem(SAVED) ?? '[]') as string[];
} catch {
return [];
}
});
useEffect(() => {
try {
localStorage.setItem(SAVED, JSON.stringify(ids));
} catch {
// Storage can be unavailable; the run still works, it just won't re-attach.
}
}, [ids]);
const { cells, summary, cancelAll, rewatch } = useWorkflowBatch(ids);
const [cancelNote, setCancelNote] = useState<string | null>(null);
async function runCell(body: WorkflowBody, key: string) {
const snap = await submit(body, { idempotencyKey: key });
if (snap.workflowId !== 'failed') setIds((prev) => [...prev, snap.workflowId]);
}
async function onCancelAll() {
const result = await cancelAll();
setCancelNote(
result.complete
? null
: `${result.refused.length + result.notAttempted.length + result.failed.length} cells are still running. Try again in a few seconds.`,
);
}
return (
<div>
<button onClick={() => bodies.forEach((b, i) => void runCell(b, `cell-${i}`))}>Run grid</button>
<p>
{summary.succeeded} done, {summary.failed} failed, {summary.canceled} cancelled,{' '}
{summary.running} running. Spent {summary.realisedCost} Buzz.
</p>
{!summary.settled && <button onClick={onCancelAll}>Cancel all</button>}
{summary.stopped > 0 && <button onClick={rewatch}>Refresh stalled cells</button>}
{cancelNote && <p>{cancelNote}</p>}
<ul>
{cells.map((cell) => (
<li key={cell.workflowId}>{cell.snapshot?.status ?? 'queued'}</li>
))}
</ul>
</div>
);
}usePrepareTrainingDataset
usePrepareTrainingDataset(): UsePrepareTrainingDatasetTrain a LoRA with the ai-toolkit engine on the **viewer's own images**, from
inside a page app — the App Blocks `kind: 'training'` flow. Four steps, three
hooks:
1. **Dataset** — `usePrepareTrainingDataset().prepareDataset([{ imageId, caption }])`
(`PREPARE_TRAINING_DATASET`). Only the viewer's own scanned, unflagged images
within the page's maturity ceiling are admitted; the rest come back in
`rejected` with a reason (`unavailable`, `unsupported-media`, `not-eligible`,
`pending-scan`, `import-failed`, `import-unavailable`; `pending-scan` and
`import-unavailable` are retryable). You get an opaque `datasetId` and the
admitted `count`, always at least 1 — if **nothing** is admitted the call
rejects with the server's message instead. 1–50 images, captions up to 1,000 characters; captions are
moderated. No dialog, no charge.
2. **Quote** — `useBuzzWorkflow().estimate(body)` with a `WorkflowBodyTraining`
(`kind: 'training'`, no `quoteId`). The reply carries `trainingQuote:
{ quoteId, total, imageCount, expiresAt }` — the orchestrator's own price for
exactly this run, stored server-side for 15 minutes.
3. **Run** — `useRunTraining().runTraining({ ...body, quoteId })` (`RUN_TRAINING`).
**Civitai shows the viewer a consent dialog in its own chrome** — price, base
model, length and dataset size, all read back from the server, never from
your body — and submits only on their click. Change nothing in the body
between the estimate and the run; the server checks it against the quoted one.
4. **Follow** — `useBuzzWorkflow().watch(workflowId)`. Once the run's moderation
status is approved, `trainedEpochs` lists the epochs whose checkpoint is
ready; send the viewer to the publish wizard with one of them (see
`trainedEpochs` on `BlockWorkflowSnapshot`).
🔴 **Availability.** Behind the host flag **`app-blocks-training-kind`**, which
ships **off** and is evaluated per viewer. **Page apps only** — the model slot
answers both messages with an error. The manifest must declare
**`ai:write:budgeted`** and the viewer must be signed in and have granted it.
**Refused from `dev:live` and from review sessions** (the server refuses
development and review tokens); build the flow against the mock host. Requires a
civitai.com host carrying civitai/civitai#5434 (`kind: 'training'`,
`RUN_TRAINING`) and civitai/civitai#5438 (`PREPARE_TRAINING_DATASET`).
🔴 **Money.** There is no `maxBuzz` and no timeout knob — the price is the quote.
A confirmed run may cost more than the token's per-call `buzzBudget` (the viewer
confirms the exact price), up to `BLOCK_TRAINING_MAX_BUZZ_PER_RUN` (5,000 Buzz);
an estimate above that is refused. `useBuzzWorkflow().submit()` **refuses** a
training body before sending anything: only `RUN_TRAINING`'s dialog can confirm a
quote, so a training run starts nowhere else.
`runTraining` **rejects** with a `RunTrainingError`; read the flags before
saying anything about money:
| | meaning | what to do |
|---|---|---|
| `err.declined` | the viewer dismissed the dialog — **no run was submitted** | revert, say nothing |
| `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** |
| `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 |
| `err.signInRequired` | no session | route into `useRequestSignIn()` |
| `err.code` set otherwise | a host refusal (`review-mode` / `block is not ready` / `invalid training request` / `no block token`) | show or ignore per case |
| `err.code === undefined`, no flag | a server refusal before any submit (expired or used quote, changed body, ineligible image) | estimate again, then retry |
`prepareDataset` rejects with a `PrepareTrainingDatasetError` (`.code` for the
host's refusals — `review-mode`, `block is not ready`, `sign in to train`,
`invalid training dataset`, `no block token` — plus `.signInRequired` and
`.timedOut`). Preparing charges nothing, so none of them cost Buzz. It prompts
for `ai:write:budgeted` and retries once on a grant (see the table below);
`runTraining` deliberately does not — it carries no idempotency key and has an
outcome where a run may exist, so it never re-sends.const { prepareDataset } = usePrepareTrainingDataset();
const { estimate, watch } = useBuzzWorkflow();
const { runTraining } = useRunTraining();
async function train(images: Array<{ id: number; caption: string }>) {
// Rejects (no `.code`) when NOTHING is admitted, e.g. "none of the requested
// images can be used for training"; a resolved dataset always has count >= 1.
const dataset = await prepareDataset(images.map((i) => ({ imageId: i.id, caption: i.caption })));
if (dataset.rejected.length > 0) showNotice(`${dataset.rejected.length} image(s) left out`);
const body: WorkflowBodyTraining = {
kind: 'training',
datasetId: dataset.datasetId,
engine: 'ai-toolkit',
model: baseModelKey, // a key from Civitai's training catalog
params: aiToolkitParams, // AiToolkitTrainingParams
triggerWord: 'mystyle',
samplePrompts: ['mystyle, a lighthouse at dusk'],
};
const quote = (await estimate(body)).trainingQuote;
if (!quote) return showError('No training price came back.');
// Show quote.total; the host's dialog will show the same number.
try {
const started = await runTraining({ ...body, quoteId: quote.quoteId });
const done = await watch(started.workflowId, { onUpdate: render });
setEpochs(done.trainedEpochs ?? []);
} catch (err) {
if (!(err instanceof RunTrainingError)) throw err;
if (err.declined) return; // no run
if (err.unconfirmed) return showCheckYourTrainings(); // may be running — never auto-retry
if (err.refused) return showError(err.message); // a cap / availability refusal; no run, nothing charged
if (err.signInRequired) return requestSignIn();
showError('Could not start training. Get a new price and try again.');
}
}useRunConfirmedStep
useRunConfirmedStep(): UseRunConfirmedStepA pass-through `step` job (`{ kind: 'step', $type, input, maxBuzz }`) that costs
more than the app may spend without asking is refused by `submit()` — unless the
**viewer confirms that exact price** in Civitai's own dialog. This hook is that
path. It is the training run's confirm applied to any pass-through `$type`
(`polyGen`, a long video job, …). Three steps, two hooks:
1. **Quote** — `useBuzzWorkflow().estimate(body)`, the body you would submit. When
the job qualifies the reply carries `stepQuote: { quoteId, total, stepType,
expiresAt }`. `total` is the price the viewer will confirm and be charged.
2. **Run** — `useRunConfirmedStep().runConfirmedStep({ ...body, quoteId })`
(`RUN_CONFIRMED_STEP`). Civitai reads the quote back from the server, shows
the viewer the price in **its** chrome and submits on their click. Resolves
with the submitted workflow's snapshot.
3. **Watch** — `useBuzzWorkflow().watch(snapshot.workflowId)`, as for any job.
No `stepQuote` on the estimate means the ordinary rules apply: submit it if it
fits the budget, and otherwise it cannot run from an app. Change nothing in the
body between the estimate and the run — the server checks it against the quoted
one.
🔴 **Only a fixed price is confirmable.** `stepQuote` is present when the
orchestrator quoted a **fixed** price (positive, not variable or metered) that is
above the token's per-call `buzzBudget` and at most
`BLOCK_CONFIRMED_STEP_MAX_BUZZ_PER_JOB` (5,000 Buzz). The viewer confirms what
they are charged, so a price that may move, or no price at all, gets no quote.
`stepQuote.total` can differ from `cost.total` on the same snapshot (which stays
what an unconfirmed submit would reserve) — put `stepQuote.total` on your button.
🔴 **Estimate it on its own.** Only a single `estimate()` returns a `stepQuote`.
A batch estimate (`useBatchEstimate()`) stores nothing, so its cells never carry
one: price the grid in a batch, then estimate the one body you want to run.
🔴 **Two bodies make the estimate reject instead of returning a price.** Only
where a quote would otherwise be granted, so not below the budget and not in a
batch. `estimate()` rejects with a `WorkflowEstimateError` whose
`snapshot.error` is the server's message; the same text is the
`RunConfirmedStepError`'s `message` if such a body is run. Neither has a code or
a flag — they are server messages, so match on the text:
| `snapshot.error` / `err.message` | cause | fix |
|---|---|---|
| `a training step type cannot run as a confirmed step — use kind:'training'` | `$type` is `training` or `imageResourceTraining`, in any casing | train with [`kind: 'training'`](#usepreparetrainingdataset-and-useruntraining--lora-training), which has its own dataset and checks |
| starts with `a confirmed step needs plain JSON input (objects, arrays, strings, finite numbers, booleans, null) — ` and ends with the offending path, e.g. `body.input.when is a Date` | `input` holds something that is not plain JSON: a `Date`, `Map`, `Set`, class instance, `BigInt`, `NaN`/`Infinity`, an `undefined` array element, or an empty slot in a sparse array (`… is an empty array slot`). The quote is bound to the exact body, so the body must be exactly representable | send plain JSON (an ISO string for a date, an array for a set). The suffix varies, so match on the part **before the dash** |
🔴 **Single-use, and it expires.** A quote is good for one run and for **15
minutes** (`stepQuote.expiresAt`). The server prices the job again at submit and
refuses a higher price. Estimate again to get a new one.
🔴 **Availability.** Behind the host flag **`app-blocks-confirmed-step`**, which
ships **off** and is evaluated per viewer; requires a civitai.com host carrying
civitai/civitai#5786. **Page apps only** — the model slot answers with an error.
The manifest must declare **`ai:write:budgeted`** and the viewer must be signed
in and have granted it. **Refused for development, dev-tunnel and review
tokens**, so **`dev:live` cannot exercise it**: the estimate there never carries
a `stepQuote` and the bridge replies with a refusal. Build the flow against the
mock host (below). Where the feature is unavailable nothing errors — the estimate
simply has no `stepQuote`, which is also what an older host sends.
🔴 **No retry, ever.** Every confirmation is its own job: there is no idempotency
key, and confirming the same body twice starts and charges two jobs. The hook
never re-sends and never prompts for consent, and `useBuzzWorkflow().submit()`
**refuses** a step body that carries a `quoteId` before sending anything — only
`RUN_CONFIRMED_STEP`'s dialog can confirm a quote. `runConfirmedStep` also
refuses, before sending, a body whose `quoteId` is missing or is not an
`sq_`-shaped id (a training `tq_…` quote, or `undefined` from an estimate that
returned no `stepQuote`).
`runConfirmedStep` **rejects** with a `RunConfirmedStepError`; read the flags
before saying anything about money:
| | meaning | what to do |
|---|---|---|
| `err.declined` | the viewer dismissed the confirm — **no job was submitted**; the quote is still good until it expires | revert, say nothing |
| `err.dailyLimitExceeded` | the job would exceed what remains today of the daily Buzz limit **the viewer** set for this app — **no job, nothing charged**. Civitai has already told the viewer and linked to where the limit is raised | say nothing more; do **not** retry and do **not** offer a Buzz purchase (more Buzz does not lift a limit) |
| `err.unconfirmed` | `submission-unconfirmed`, or no reply within the 10-min bound (`err.timedOut`) — the job **may be running and charged** | 🔴 **never retry automatically**: check the viewer's generations first (`useAppWorkflows()` lists this app's runs). A retry needs a new estimate and a new confirmation, and is a **second, separately charged job** if the first one started |
| `err.refused` (`code: 'refused'`) | the server refused the submit after the viewer confirmed — a **spend cap** (their daily or private-run Buzz cap, the app's daily spend or rate limit). Refunded: **no job, nothing charged**; the quote is used up | show `err.message` (the server's reason); a retry needs a new estimate and usually a later time |
| `err.signInRequired` | no session | route into `useRequestSignIn()` |
| `err.code` set otherwise | a host refusal (`review-mode` / `block is not ready` / `invalid confirmed step request` / `no block token`) | show or ignore per case |
| `err.code === undefined`, no flag | a server refusal with no job started (expired or used quote, changed body, changed price, a training `$type`, non-plain-JSON `input`, the flag is off) | fix the body if the message names it, estimate again, then retry |const { estimate, submit, watch } = useBuzzWorkflow();
const { runConfirmedStep } = useRunConfirmedStep();
const { requestSignIn } = useRequestSignIn();
async function generate3d(prompt: string) {
const body: WorkflowBodyPassThroughStep = {
kind: 'step',
$type: 'polyGen',
input: { prompt },
maxBuzz: 250,
};
const quote = (await estimate(body)).stepQuote;
try {
const started = quote
? // Over the per-call budget: the viewer confirms `quote.total` in Civitai's dialog.
await runConfirmedStep({ ...body, quoteId: quote.quoteId })
: // Within it (or not confirmable): the ordinary path.
await submit(body);
return await watch(started.workflowId);
} catch (err) {
if (!(err instanceof RunConfirmedStepError)) throw err;
if (err.declined) return; // no job — say nothing
if (err.dailyLimitExceeded) return; // no job — Civitai already told the viewer
if (err.signInRequired) return requestSignIn();
if (err.unconfirmed) return showCheckYourGenerations(); // may be running — never auto-retry
if (err.refused) return showError(err.message); // a cap refusal; no job, nothing charged
showError('Could not start this job. Get a new price and try again.');
}
}import { createMockHost } from '@civitai/blocks-react/testing';
const host = createMockHost({
consentGranted: true,
generation: { costPerGen: 800 }, // above the default 200 per-call budget → quoted
runConfirmedStepError: 'declined',
});
const uninstall = host.install();
// …later: the viewer confirms this time.
host.setScenario({ runConfirmedStepError: undefined });function modelsOf(snapshot: BlockWorkflowSnapshot) {
const models = snapshot.model3dOutputs ?? [];
return {
// The plain mesh, in the format a web viewer loads.
glb: models.find((m) => m.format === 'glb' && m.variant === 'model')?.url,
// The rigged export, for a download button.
riggedFbx: models.find((m) => m.variant === 'riggedFbxModel')?.url,
// Every animation clip, whatever the host named them.
animations: models.filter((m) => m.variant.startsWith('basicAnimations.')),
};
}useRunTraining
useRunTraining(): UseRunTrainingRun an App Blocks `kind: 'training'` LoRA training — the only way one starts — through the host-mediated `RUN_TRAINING` → `TRAINING_RESULT` bridge. 🔴 AVAILABILITY: page apps only, behind the host flag `app-blocks-training-kind` (ships off), with `ai:write:budgeted` declared and granted by a signed-in viewer. Refused from `dev:live` and from review sessions — build the flow against the mock host. See `WorkflowBodyTraining` in `@civitai/app-sdk`. The flow: `usePrepareTrainingDataset()` → `useBuzzWorkflow().estimate(body)` (its `trainingQuote.quoteId`) → `runTraining({ ...body, quoteId })` → `useBuzzWorkflow().watch(workflowId)`. Change nothing in the body between the estimate and the run: the server checks it against the quoted one. 🔴 THE CONSENT DIALOG IS CIVITAI'S, AND IT IS THE CHARGE. Every number on it is read back from the server's stored quote — never from your body. A run may cost more than the token's per-call budget (the viewer confirms the exact price), up to `BLOCK_TRAINING_MAX_BUZZ_PER_RUN` (5,000 Buzz). Images are the viewer's own. 🔴 NO AUTOMATIC CONSENT RETRY, unlike the other consent-gated hooks — on purpose. `RUN_TRAINING` carries no idempotency key and has an outcome (`submission-unconfirmed`) where a run may exist, so this hook never re-sends. It is also unreachable in practice: a block holding a `quoteId` got it from an estimate that already required `ai:write:budgeted`. Sent under the 10-minute human-interaction bound: the host replies only when the viewer clicks or dismisses its dialog.
const { estimate, watch } = useBuzzWorkflow();
const { runTraining } = useRunTraining();
const quote = (await estimate(body)).trainingQuote!;
try {
const started = await runTraining({ ...body, quoteId: quote.quoteId });
await watch(started.workflowId, { onUpdate: render });
} catch (e) {
if (!(e instanceof RunTrainingError)) throw e;
if (e.declined) return; // no run — say nothing
if (e.signInRequired) return requestSignIn();
if (e.unconfirmed) return showCheckYourTrainings(); // may be running — never auto-retry
if (e.refused) return showError(e.message); // a cap / availability refusal; no run, nothing charged
showError('Could not start training. Get a new price and try again.');
}