@civitai/blocks-react
v0.35.2
Published
React hooks and iframe transport for Civitai Apps. Pairs with @civitai/app-sdk/blocks.
Readme
@civitai/blocks-react
React hooks and iframe transport for Civitai Apps.
Pairs with @civitai/app-sdk's
/blocks subpath, which carries the framework-agnostic manifest, scope, and
postMessage contract. This package adds the transport that actually moves bytes
and the React hooks block authors call.
Install
pnpm add @civitai/blocks-react @civitai/app-sdk reactreact and @civitai/app-sdk are peer dependencies — bring them yourself so
your block app and the SDK share a single React tree.
Quick start
Building a UI? The
/uisubexport ships a drop-in, Civitai-themed component pack (Button, TextInput, Textarea, Card, Stack, Group, Alert, Loader, Badge, Modal +injectBlocksStyles) — zero CSS setup, auto-themed via your block'sdata-theme. See The/uisubexport.
import { useRef } from 'react';
import { useBlockContext, useBlockResize, useBuzzWorkflow } from '@civitai/blocks-react';
import { Button } from '@civitai/blocks-react/ui';
import type { ModelSlotContext } from '@civitai/app-sdk/blocks';
export function App() {
const { ready, context, viewer, theme } = useBlockContext();
const { submit, status, result } = useBuzzWorkflow();
const rootRef = useRef<HTMLDivElement>(null);
useBlockResize(rootRef); // host fits the iframe to content
if (!ready) return <div ref={rootRef}>Loading…</div>;
const model = context as ModelSlotContext;
return (
// GOTCHA #60: set data-theme on YOUR OWN root — the host can't reach into
// the iframe to set it. Without this any [data-theme="dark"] CSS is dormant.
<div ref={rootRef} data-theme={theme}>
<p>Block for model {model.modelName} ({viewer?.username ?? 'anon'})</p>
{/* `/ui` Button — themed by the data-theme above; `loading` disables + shows a spinner */}
<Button
loading={status === 'submitting' || status === 'polling'}
onClick={() =>
submit({
kind: 'textToImage',
modelId: model.modelId,
modelVersionId: model.modelVersionId,
params: { prompt: 'a cat' },
})
}
>
Generate
</Button>
{status === 'done' && result?.imageUrls?.map((u) => <img key={u} src={u} />)}
</div>
);
}
submittakes a fullWorkflowBody({ kind, modelId, modelVersionId, params }), not{ prompt }. Both ids come fromuseBlockContext().contextnarrowed toModelSlotContext.
The hooks
All hooks build on a singleton transport, so they're safe to call from any component without prop-drilling. Below: one minimal snippet each.
useBlockContext()
The primary hook. Returns everything the host delivered in BLOCK_INIT plus a
ready gate — fields are sentinel-empty before init, so gate your UI on ready.
const { ready, context, viewer, theme, settings, blockId, blockInstanceId, appId, token, renderMode } =
useBlockContext();context—BlockContext({ slotId, … }); narrow toModelSlotContextfor model-page slots.viewer—ViewerInfo | null(null= anonymous).theme—'light' | 'dark'. Setdata-theme={theme}on your root (gotcha #60).settings—{ publisherSettings, userSettings }.
useBlockResize(ref)
Attach to your root element. Observes its height and posts RESIZE_IFRAME so the
host sizes the iframe to fit. No-op on the inline transport (host DOM reflows
naturally).
const rootRef = useRef<HTMLDivElement>(null);
useBlockResize(rootRef);Also set
iframe.minHeightin your manifest to the block's real rendered height — a too-small minHeight makes the iframe seed short and grow-jump onBLOCK_READY(CLS). Measure it in the dev harness (gotcha #53).
useBlockToken()
Current block-scoped JWT, auto-refreshing ~2 min before expiry. Returns the token
fields plus a refresh() for the 401-retry path.
const { raw, scopes, expiresAt, buzzBudget, refresh } = useBlockToken();
// after a 401: await refresh(); then retry the request once with the new `raw`.useHostOrigin()
The validated host origin to direct-fetch the App Blocks HTTP API against —
undefined until init. Use it as the base URL when you need to bypass the host
bridge, always paired with the bearer token from useBlockToken().
const host = useHostOrigin(); // e.g. "https://civitai.com" (undefined until BLOCK_INIT)
const { raw } = useBlockToken();
// Once `host` is set, fetch the API on that validated origin with the block token:
if (host) {
const res = await fetch(`${host}/api/v1/blocks/me`, {
headers: { authorization: `Bearer ${raw}` },
});
}Security: this is ONLY ever the origin that passed the SDK's origin allowlist (the same gate
BLOCK_INITpasses) — neverdocument.referrerorwindow.locationof the parent. The block token is a money-scoped bearer credential, so always send it to this origin. Never derive the API host from a spoofable browser signal.
useBlockSettings()
Shorthand for useBlockContext().settings. Read-only from the iframe — settings
are written on the platform /apps/installed page, not via a bridge message.
const { publisherSettings, userSettings } = useBlockSettings();useBuzzWorkflow()
The generation flow: estimate → submit → poll, host-mediated. Returns
{ estimate, submit, poll, status, result, error }.
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
const { estimate, submit, poll, status, result } = useBuzzWorkflow();
declare const modelId: number, modelVersionId: number, userPrompt: string;
const body: WorkflowBody = {
kind: 'textToImage',
modelId,
modelVersionId,
params: { prompt: userPrompt },
};
await estimate(body); // status 'estimating' → 'confirming' (cost in result.cost.total)
const snap = await submit(body); // status 'submitting' → 'polling'; returns a workflowId
await poll(snap.workflowId); // you loop this on a backoff until terminalStatus semantics (gotcha #8/#9/#10):
status === 'confirming'is IDLE (estimate landed, user reviewing the cost) — keep the Generate button enabled. Onlyestimating | submitting | pollingare busy.resultis populated afterestimate()too — don't treat a non-nullresultas "something is queued."- The hook does not auto-poll. After
submitflips status to'polling', the caller runs auseEffectthat callspoll(workflowId)on a backoff until the snapshot is terminal (succeeded | failed | canceled | expired). - An over-budget / rejected submit comes back as a resolved snapshot with
status: 'failed'+ anerrorstring — the transport resolves the reply, it doesn't throw. Checksnap.status, not justtry/catch.
Estimate must mirror submit (gotcha #59): build the params for
estimatewith the exact same logic assubmit— same seed decision especially. The orchestrator whatif prices a cache hit (identical workflow) at 0 and a fresh job at full cost, and the seed decides which. A drifting estimate silently mis-quotes. See thebuzz-workflowexample.
cancel —
@civitai/[email protected]+addsuseBuzzWorkflow().cancel(workflowId)for a real server-side orchestrator cancel (gotcha #51), so a running workflow stops spending Buzz. Before that, cancel was client-side only (stop polling). If your installed version predates 0.5.0, do the client-side half and add thecancel(...)call after upgrading.
useBuzzPurchase()
Open the Buzz purchase modal — the insufficient-budget recovery path.
const { openPurchaseModal } = useBuzzPurchase();
const { purchased, newBalance } = await openPurchaseModal(suggestedAmount);
if (purchased) { /* retry the generation */ }useBuzzBalance()
The signed-in viewer's per-pool Buzz balance ({ blue, green, yellow } — the
domain-clamped pools a block may read; never the platform-internal red/purple).
Host-mediated over GET_BUZZ_BALANCE → BUZZ_BALANCE_RESULT; same trust model as
useBuzzWorkflow/useBuzzPurchase (the host resolves the viewer from the block
token — the block never touches the balance API). Fetches on mount; refetch for
on-demand refreshes.
const { balance, loading, error, refetch } = useBuzzBalance();
// `balance` is null until the first successful fetch. refetch() after a
// generation debits it. An anon viewer / missing scope / host failure → `error`.
if (!loading && balance) console.log(`Yellow: ${balance.yellow}`);Per-account Buzz:
useBuzzWorkflow().submit(body)also takes an optionalbody.accountType('blue' | 'green' | 'yellow') — a preference for which pool funds the generation; the host clamps it server-side.
useViewer()
The signed-in viewer as an on-demand authoritative self-read ({ id, username,
status, buzzBudget }) — distinct from useBlockContext().viewer, the coarse
BLOCK_INIT-time snapshot. status is 'active' | 'muted'; username
(string | null) and buzzBudget (number | null) are present-but-nullable, so
handle the null case. Host-mediated over GET_VIEWER → VIEWER_RESULT (the host
resolves the viewer from the block token via blocks.getMyViewer); an anonymous /
banned viewer comes back as error. Fetches on mount; refetch for on-demand
refreshes.
const { viewer, loading, error, refetch } = useViewer();
// `viewer` is null until the first successful fetch. An anon / banned viewer,
// missing scope, or host failure → `error`. `username`/`buzzBudget` may be null.
if (!loading && viewer) console.log(`${viewer.username ?? 'anon'} · budget ${viewer.buzzBudget ?? 0}`);useBuzzTransactions(params?)
The signed-in viewer's Buzz-transaction ledger (a paged, host-projected read of
the Buzz dashboard). Returns { transactions, cursor, loading, error, refetch };
transactions rows are rehydrated so date is a Date. Pass the returned
cursor back as params.cursor to page forward. Requires the buzz:read:self
scope; host-mediated over GET_BUZZ_TRANSACTIONS.
const { transactions, cursor, loading, error } = useBuzzTransactions({ type: 'Tip', limit: 20 });
if (!loading && transactions) transactions.forEach((t) => console.log(t.type, t.amount, t.date));useBuzzAccounts()
The viewer's all-pool Buzz balances — the three spendable pools plus the
creator payout pools ({ accountType, balance }[]), a superset of
useBuzzBalance. Returns { accounts, loading, error, refetch }. Requires
buzz:read:self; host-mediated over GET_BUZZ_ACCOUNTS.
const { accounts, loading, error } = useBuzzAccounts();
if (!loading && accounts) accounts.forEach((a) => console.log(a.accountType, a.balance));useDailyCompensation(params)
Per-modelVersion generation-compensation for the month containing params.date
(Buzz totals + cash totals in pennies). Returns { resources,
hasPublishedResources, loading, error, refetch }. Requires buzz:read:self;
host-mediated over GET_DAILY_COMPENSATION.
const { resources, hasPublishedResources } = useDailyCompensation({ date: '2026-07-01' });useWildcardPack(modelVersionId)
Import a wildcard pack's parsed prompt lists by model version — the host
resolves + fetches + unzips + parses it in the user's own page session (every
download gate enforced), so the untrusted iframe never sees the bytes. Returns
{ pack, loading, error, refetch }. On failure error is a WildcardPackError
with a discriminated code (not-found / forbidden / too-large /
parse-failed / busy — busy is retryable), not free text.
const { pack, loading, error, refetch } = useWildcardPack(modelVersionId);
// `error.code === 'busy'` is retryable — call refetch(); the other codes are terminal.
if (error instanceof WildcardPackError && error.code === 'busy') void refetch();
if (!loading && pack) console.log(Object.keys(pack.lists));useAppWorkflows(params?)
The calling app's own generator subqueue — the tag-scoped list of generations
this app produced for the viewer (newest-first), plus a fail-closed cancel.
The host self-binds the account off the block token and forces the per-app tag
filter, so a block only ever sees the queue it produced — never the viewer's
personal queue or another app's. Returns { workflows, cursor, loading, error,
refetch, cancel }; each AppWorkflow is { workflowId, status, images[], cost,
createdAt }. Pass the returned cursor back as params.cursor to page forward.
Requires ai:write:budgeted (same trust boundary as submit); host-mediated over
QUERY_APP_WORKFLOWS / CANCEL_APP_WORKFLOW.
cancel(workflowId) sends CANCEL_APP_WORKFLOW, resolves once the host confirms
the terminal state (which is optimistically spliced into workflows in place — no
refetch round-trip), and rejects with the host's error on failure.
const { workflows, cursor, loading, error, refetch, cancel } = useAppWorkflows({ limit: 20 });
if (!loading && !error) {
workflows.forEach((w) => console.log(w.workflowId, w.status, w.images.length, w.cost));
}
async function onCancel(id: string) {
try {
await cancel(id); // optimistically flips the row to `canceled`
} catch (err) {
console.error('cancel failed', err);
}
}useAppStorage()
Per-(block instance, viewer) KV datastore, host-mediated. 64 KB per value, 50 MB + ~1M rows per app.
const storage = useAppStorage();
await storage.set('key', { any: 'json' }); // throws "PAYLOAD_TOO_LARGE" over a limit
const v = await storage.get<MyShape>('key'); // null if unset / anon
await storage.delete('key'); // idempotent
const { keys } = await storage.list({ prefix: 'note-' });
const quota = await storage.getQuota(); // { usedBytes, rowCount, limitBytes, limitRows }useSharedStorage()
App-scoped, append-only, community-votable SHARED datastore (every viewer sees
the same list). Sibling of useAppStorage; anonymous viewers get the read path
and a hard reject on mutations.
const shared = useSharedStorage();
const { key } = await shared.append({ title: 'Add dark mode', body: 'please' });
const { items } = await shared.list({ limit: 20 }); // newest-first
const count = await shared.vote(key); // idempotent up-vote
await shared.unvote(key);
await shared.withdraw(key); // remove my own entryuseCheckpointPicker()
Drive the platform Checkpoint picker + persist a viewer override.
const { open, persist } = useCheckpointPicker();
const { selected } = await open({ baseModelGroup: 'SDXL', currentVersionId });
if (selected) await persist(selected.versionId); // null clears the overrideuseResourcePicker()
Drive the platform resource picker for page blocks — 'Checkpoint' | 'LORA'.
The viewer searches in host chrome; the block only ever sees the one resource it
picked. DISCOVERY ONLY — the returned versionId is re-validated + re-priced
server-side at estimate/submit.
const { open } = useResourcePicker();
const picked = await open({ resourceType: 'LORA', baseModelGroup: 'SDXL' });
if (picked) {
const versionId = picked.versionId; // feed into body.additionalResources
const weight = picked.strength; // recommended default weight (may be undefined)
}useImageUpload()
Host-mediated image upload — the host opens its native upload modal and the
iframe never handles the bytes. Resolves with a moderated image (or null on
dismiss); pass { purpose: 'generationSource' } for an unscanned img2img source
or { asyncScan: true } for the early-resolve + scanStatus() flow.
const { open } = useImageUpload();
const img = await open(); // BlockUploadedImageInfo | null
if (img) {
await submit({
kind: 'textToImage',
modelId,
modelVersionId,
sourceImage: { url: img.url, width: 1024, height: 1024 },
params: { prompt },
});
}useGenerationResources()
Rehydrate a saved set of generation resources by version id — WITHOUT re-opening
the picker. Returns the same widened projection useResourcePicker yields
(recommended weights, trigger words, clipSkip). DISCOVERY ONLY.
const { fetch } = useGenerationResources();
const resources = await fetch([691639, 666002]); // by saved versionIds
const first = resources[0]; // .versionId / .strength / .trainedWords / .clipSkipuseCivitaiNavigate()
Request a navigation within civitai.com (host-mediated; fire-and-forget).
const { navigate } = useCivitaiNavigate();
navigate('/models/12345', 'new_tab'); // 'new_tab' needs allow-popups* in the manifest sandboxuseBlockAnalytics()
Fire-and-forget event tracking into the host's analytics pipeline.
const { track } = useBlockAnalytics();
track('generate_clicked', { modelId });useRequestSignIn()
Ask the host to open its sign-in flow for an ANONYMOUS viewer (fire-and-forget). On sign-in the host re-inits the block with the now-authenticated viewer.
const { requestSignIn } = useRequestSignIn();
// e.g. onClick of a "Sign in to generate" button:
requestSignIn();useRequestConsent()
Lazy consent: ask the host to open its consent UI when a LOGGED-IN viewer takes
an action whose consent-gated scope the block token is missing (e.g. Generate
needs ai:write:budgeted but the viewer hasn't granted it). Fire-and-forget —
on grant the host pushes a new token; observe useBlockToken().scopes and retry.
const { requestConsent } = useRequestConsent();
requestConsent({ scopes: ['ai:write:budgeted', 'buzz:read:self'] });useDomainMaturity()
Read the surrounding color-domain's maturity ceiling (civitai #2670) so a block
can hide/blur mature affordances on a SFW domain. Fail-closed SFW until
BLOCK_INIT lands or against a host that predates the field.
const { isSfw, isLevelAllowed } = useDomainMaturity();
const showRSlider = isLevelAllowed(BrowsingLevel.R); // false on a SFW domainSfwGate
Convenience component that renders children only when the domain permits the
maturity — no level prop gates on the SFW ceiling, a level prop gates on that
browsing-level bit. Fail-closed SFW.
function MatureSection() {
return (
<SfwGate level={BrowsingLevel.R} fallback={<SafePlaceholder />}>
<RRatedControl />
</SfwGate>
);
}Direct-load fallback ("Open on Civitai")
A block is served from its own origin <slug>.civit.ai but is designed to run
embedded in the Civitai host iframe at civitai.com/apps/run/<slug>, which
delivers the runtime context via the BLOCK_INIT handshake. If someone opens the
bare <slug>.civit.ai URL directly (a shared link, a social crawl), no parent
ever sends BLOCK_INIT, so ready never flips and the block hangs on its loading
spinner forever.
Wrap your app root once in <BlockGate> (from /ui) to degrade that into a
branded landing instead:
import { BlockGate } from '@civitai/blocks-react/ui';
// A DIRECT (unembedded) top-level load shows an "Open on Civitai" card linking to
// civitai.com/apps/run/<slug>. Embedded — and the dev harness, which posts a fake
// BLOCK_INIT — are a transparent pass-through, so the happy path is unchanged.
createRoot(container).render(
<BlockGate>
<App />
</BlockGate>,
);The trigger is precise: the fallback shows only when the block is top-level
(window.self === window.top) and no BLOCK_INIT arrives within a short
timeout (~2s, override with <BlockGate timeoutMs={…}>). Framed blocks never
trip it; the harness posts BLOCK_INIT immediately, so it never trips there
either. On a non-*.civit.ai host (e.g. localhost in dev), it shows a neutral
"waiting for the host" state — never a broken apps/run/localhost link.
Building your own landing? The primitives are exported from the package root:
import { useDirectLoad, hostToRunUrl } from '@civitai/blocks-react';
const directLoad = useDirectLoad(); // true iff top-level AND no BLOCK_INIT within the timeout
const runUrl = hostToRunUrl('my-app.civit.ai'); // 'https://civitai.com/apps/run/my-app' | null (null = not a civit.ai host)The /ui subexport
Opinionated components, imported separately so a transport-only block stays lean. Two surfaces live here:
- The W6 component pack — a small, Civitai-looking, self-styled component set you drop straight into a block.
- The headless, manifest-driven
SettingsForm(host-themed native controls).
W6 component pack
A drop-in set of primitives that match Civitai's look (8px radius, the blue primary, the dark/light surfaces) — with zero setup:
- No Mantine dependency, no CSS import, no setup step. The pack ships its
CSS as a string and injects it into your block document's
<head>the first time you render any component (idempotent). There's nothing to wire up. - First paint is briefly unstyled (FOUC). Because the CSS injects in a
useEffect(after the first paint), the very first frame of a pack component renders unstyled, then snaps to themed. It's a single frame and usually unnoticeable. To eliminate it, callinjectBlocksStyles()at module init in your entry file (before the first render) so the stylesheet is present up front — seeinjectBlocksStylesbelow (already exported). - Auto-themed via your block's
data-theme. Setdata-theme={theme}on your block's own root (fromuseBlockContext().theme— gotcha #60; the host can't reach across the iframe to set it for you). The components read an ancestor[data-theme='dark']/[data-theme='light']; no attribute = light, matching the starter palette.
import { useRef } from 'react';
import { useBlockContext } from '@civitai/blocks-react';
import {
Button, TextInput, Textarea, Card, Stack, Group,
Alert, Loader, Badge, Modal,
Slider, NumberInput, Select, Collapse,
} from '@civitai/blocks-react/ui';
export function App() {
const { ready, theme } = useBlockContext();
const rootRef = useRef<HTMLDivElement>(null);
if (!ready) return <div ref={rootRef}>Loading…</div>;
return (
// GOTCHA #60 — theme your OWN root; that's what the pack reads.
<div ref={rootRef} data-theme={theme}>
<Card>
<Stack gap={12}>
<Group justify="space-between">
<strong>My block</strong>
<Badge color="success">ready</Badge>
</Group>
<TextInput label="Prompt" description="What to generate" />
<Alert color="info" title="Heads up">Costs Buzz.</Alert>
<Button fullWidth onClick={() => {/* … */}}>Generate</Button>
</Stack>
</Card>
</div>
);
}The components (each with an exported props interface):
| Component | Highlights |
|---|---|
| Button | variant (filled/light/outline/subtle), size, color, loading (shows a Loader, disables + aria-busy), fullWidth, leftSection/rightSection. Defaults to type="button". |
| TextInput / Textarea | label / description / error / required, wired via htmlFor + aria-describedby + aria-invalid. Textarea takes minRows. |
| Card | themed surface; withBorder, padding, radius. |
| Stack / Group | vertical / horizontal flex; gap, align, justify (+ Group wrap). |
| Alert | color (info/success/warning/error), title, withCloseButton + onClose. ARIA role defaults by color — error/warning → role="alert" (assertive), info/success → role="status" (polite); pass role to override. |
| Loader | CSS-keyframe spinner; size, color; role="status". |
| Badge | variant, size, color. |
| Modal | opened + onClose, title, size; role="dialog" + aria-modal, Escape- and overlay-click-to-close, focuses the panel on open and restores focus on close. |
| Slider | controlled range (value: number, onChange, min/max/step, showValue). Native input[type=range] — keyboard-operable, implicit role="slider"; accent tracks --civitai-color-primary. Same label/description/error/required wiring as TextInput. |
| NumberInput | controlled numeric (value: number \| null, onChange, min/max/step). Rejects non-numeric (never emits NaN), clamps to [min,max] on blur, empty → null. Same label/description/error wiring. |
| Select | controlled dropdown (value: string, onChange, options: {value,label,disabled}[] or <option> children, placeholder). Native <select>, role="combobox". Same label/description/error wiring. |
| Collapse | controlled disclosure (open + onOpenChange, title, disabled) for the "advanced params reveal". aria-expanded + aria-controls; content region role="region", hidden when closed. |
Each component forwards className + style, forwards a ref to its DOM
node (where it wraps one), and carries a data-civitai-ui="<name>" hook. Need
to inject the CSS yourself (SSR, or a non-React shell)? Call
injectBlocksStyles(doc?) once, or read the raw BLOCKS_UI_STYLES string.
useBlocksStyles() is the hook the components call internally.
Modal focus limitation (v0): the modal focuses its panel on open and restores focus on close, but it does not trap focus — Tab can still reach content behind the overlay. That's fine for a simple confirm/settings dialog inside the sandboxed block; a full focus-trap is a v1 follow-up (kept dependency-free here on purpose).
SettingsForm
The headless, manifest-driven settings form (its contract is intentionally unstyled native controls — the host page themes it, so it does not use the W6 pack):
import { SettingsForm } from '@civitai/blocks-react/ui';
<SettingsForm
manifestSettings={manifest.settings}
declaredScopes={manifest.scopes}
forScope="viewer" // or "publisher"
initialValues={settings.userSettings}
onSubmit={async (values) => { /* persist (platform page) */ }}
/>isFieldVisible + SettingsFormError are also exported. See the settings
example.
Lower-level transport
For non-React or advanced use, the transport primitives are exported too:
IframeTransport, InlineTransport, BlockTransportDetector,
readAllowedOriginsFromEnv, getTransport, and sendTypedRequest. Hooks are the
recommended surface; reach for these only when a hook doesn't fit.
Examples
Runnable, minimal blocks — one per feature, each with its own README:
hello-world—useBlockContext, lifecycle,data-theme(#60)settings— manifest settings +SettingsFormbuzz-workflow—useBuzzWorkflow(#59, #8/#9/#10, #19)kv-storage—useAppStoragescopes-api— scopes + REST +useBlockTokenbuzz-purchase—useBuzzPurchase
Version compatibility
| @civitai/blocks-react | pairs with @civitai/app-sdk | adds |
|---|---|---|
| 0.29.x | ^0.24.0 | useAppWorkflows() — app generator subqueue read + cancel (QUERY_APP_WORKFLOWS / CANCEL_APP_WORKFLOW) |
| 0.27.x–0.28.x | ^0.23.0 | async-scan image upload; transport validators for all SHARED_* / APP_STORAGE_* / picker replies |
| 0.26.x | ^0.22.0 | useViewer() (GET_VIEWER) |
| 0.25.x | ^0.21.0 | useBuzzTransactions, useBuzzAccounts, useDailyCompensation, useWildcardPack |
| 0.5.0 | ^0.7.0 | useBuzzWorkflow().cancel() (real server-side cancel, gotcha #51) |
| 0.4.x | ^0.6.0 | useAppStorage, SettingsForm (/ui) |
| 0.3.x | ^0.5.0 | earlier hook set |
Always keep @civitai/app-sdk at or above the paired minor — the React package
peer-depends on the SDK's message/type contract.
License
MIT — see LICENSE.
