@civitai/blocks-react 0.63.2 → 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 +188 -6
- package/dist/hooks/useBuzzWorkflow.d.ts +20 -0
- package/dist/hooks/useBuzzWorkflow.js +32 -12
- package/dist/hooks/useCreatePostFromApp.d.ts +7 -2
- package/dist/hooks/useCreatePostFromApp.js +8 -3
- package/dist/hooks/useNestedDocument.d.ts +73 -0
- package/dist/hooks/useNestedDocument.js +160 -0
- package/dist/hooks/usePrepareTrainingDataset.d.ts +99 -0
- package/dist/hooks/usePrepareTrainingDataset.js +157 -0
- package/dist/hooks/usePublishGenerationOutputs.d.ts +6 -0
- package/dist/hooks/usePublishGenerationOutputs.js +6 -0
- package/dist/hooks/useRunTraining.d.ts +151 -0
- package/dist/hooks/useRunTraining.js +212 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/internal/liveHost.d.ts +18 -2
- package/dist/internal/liveHost.js +89 -23
- package/dist/internal/mockHost.d.ts +146 -2
- package/dist/internal/mockHost.js +309 -0
- package/dist/transport/requestTimeouts.d.ts +19 -0
- package/dist/transport/requestTimeouts.js +30 -0
- package/dist/transport/validate.d.ts +27 -0
- package/dist/transport/validate.js +189 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -111,6 +111,11 @@ writing: `useGatedImages()`, `usePublishGenerationOutputs()`, `useSaveImage()`,
|
|
|
111
111
|
[`TipButton`](#the-ui-subexport) row documents the intended usage and the
|
|
112
112
|
allowance-sharing rule. `useDirectLoad()` is covered under
|
|
113
113
|
[Direct-load fallback](#direct-load-fallback-open-on-civitai) rather than here.
|
|
114
|
+
**`dev:live` refuses `usePublishGenerationOutputs()`**: publishing requires the
|
|
115
|
+
viewer's signed-in civitai.com session, which the local harness does not have, so
|
|
116
|
+
`publish()` rejects with a message saying so. Test publishing against the mock
|
|
117
|
+
host — `createMockHost` or `Harness` from `@civitai/blocks-react/testing`, with
|
|
118
|
+
the `publishImageIds` / `publishError` options.
|
|
114
119
|
|
|
115
120
|
### `useBlockContext()`
|
|
116
121
|
|
|
@@ -724,11 +729,132 @@ async function share() {
|
|
|
724
729
|
}
|
|
725
730
|
```
|
|
726
731
|
|
|
727
|
-
|
|
728
|
-
|
|
732
|
+
With the mock host — `createMockHost` or `Harness` from
|
|
733
|
+
`@civitai/blocks-react/testing` — the `createPostResult` / `createPostError`
|
|
734
|
+
options drive both arms (including `declined`). **`dev:live` refuses this bridge on purpose** —
|
|
729
735
|
it has no civitai chrome to render the server-resolved confirm in, and driving
|
|
730
736
|
the write without it would let dev prove out a flow production does not have.
|
|
731
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
|
+
|
|
732
858
|
### `useAppWorkflows(params?)`
|
|
733
859
|
|
|
734
860
|
The calling app's **own** generator subqueue — the tag-scoped list of generations
|
|
@@ -882,10 +1008,9 @@ ceiling that lands on `null` like everything else. Two earlier drafts of this
|
|
|
882
1008
|
section tried instead to enumerate the non-ceiling strings, and **both lists
|
|
883
1009
|
were short**; see the header of `blocks/appStorageErrors.ts` for what they
|
|
884
1010
|
missed and why no third list replaced them. `invalid block token` (an expired
|
|
885
|
-
token mid-session)
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
before writing copy for it.
|
|
1011
|
+
token mid-session) and `block instance revoked` are *illustrations* of what
|
|
1012
|
+
lands on `null`, never a bound on it. The practical consequence: `null` is a
|
|
1013
|
+
busy bucket, so see the `default` arm note below before writing copy for it.
|
|
889
1014
|
|
|
890
1015
|
⚠️ **The mock reaches four of the six.** It models no app-wide umbrella
|
|
891
1016
|
([#368](https://github.com/civitai/civitai-app-starters/issues/368)), so
|
|
@@ -1222,6 +1347,7 @@ had just worked.
|
|
|
1222
1347
|
|---|---|---|
|
|
1223
1348
|
| `useBuzzWorkflow()` | `submit()` | `ai:write:budgeted` |
|
|
1224
1349
|
| `useCreatePostFromApp()` | `createPost()` | `posts:write:self` |
|
|
1350
|
+
| `usePrepareTrainingDataset()` | `prepareDataset()` | `ai:write:budgeted` |
|
|
1225
1351
|
| `useGoodPurchase()` | `purchase()` | `goods:purchase:self` |
|
|
1226
1352
|
| `useTip()` | `tip()` | `social:tip:self` |
|
|
1227
1353
|
|
|
@@ -1506,6 +1632,62 @@ const directLoad = useDirectLoad(); // true iff top-level AND no BLOC
|
|
|
1506
1632
|
const runUrl = hostToRunUrl('my-app.civit.ai'); // 'https://civitai.com/apps/run/my-app' | null (null = not a civit.ai host)
|
|
1507
1633
|
```
|
|
1508
1634
|
|
|
1635
|
+
## Embedding your own nested document
|
|
1636
|
+
|
|
1637
|
+
🔴 **A nested `<iframe src="…">` pointing at your own bundled content cannot
|
|
1638
|
+
load, and no manifest change fixes it** — your block document's origin is opaque
|
|
1639
|
+
and the static host stamps `frame-ancestors` + `X-Frame-Options` on every path
|
|
1640
|
+
it serves. The full derivation, the dated external browser matrix (an
|
|
1641
|
+
observation of the platform — nothing in this repo verifies it) and the known
|
|
1642
|
+
limits
|
|
1643
|
+
live in one place: the header of `@civitai/app-sdk`'s
|
|
1644
|
+
`src/blocks/nestedDocument.ts`.
|
|
1645
|
+
|
|
1646
|
+
**Prefer a single-document design.** An engine build (Defold, Unity WebGL,
|
|
1647
|
+
Phaser) is a `<canvas>` plus a JS loader that normally mounts straight into the
|
|
1648
|
+
document you hand it; point it at your block's own root element and the problem
|
|
1649
|
+
class disappears.
|
|
1650
|
+
|
|
1651
|
+
When a separate document is genuinely required, fetch it and inline it as the
|
|
1652
|
+
iframe's `srcdoc` — a `srcdoc` frame inherits your opaque origin and carries no
|
|
1653
|
+
response headers, so neither policy applies:
|
|
1654
|
+
|
|
1655
|
+
```tsx
|
|
1656
|
+
import { useNestedDocument } from '@civitai/blocks-react';
|
|
1657
|
+
|
|
1658
|
+
function Game() {
|
|
1659
|
+
const { srcDoc, status, error } = useNestedDocument({ src: '/game/index.html' });
|
|
1660
|
+
if (status === 'error') return <p>Could not load: {error?.message}</p>;
|
|
1661
|
+
if (status !== 'ready') return <p>Loading…</p>;
|
|
1662
|
+
// Your own sandbox — the nested frame does not inherit the outer one's.
|
|
1663
|
+
return <iframe title="game" sandbox="allow-scripts" srcDoc={srcDoc ?? undefined} />;
|
|
1664
|
+
}
|
|
1665
|
+
```
|
|
1666
|
+
|
|
1667
|
+
`status` is `'idle'` (no `src`), `'loading'`, `'ready'` or `'error'`; `srcDoc` is
|
|
1668
|
+
non-null exactly when `ready`, `error` exactly when `error`. Changing `src`
|
|
1669
|
+
restarts the fetch and aborts the previous one — `status` returns to `'loading'`
|
|
1670
|
+
in the same render that changes `src`, so no frame ever shows the previous
|
|
1671
|
+
document as `'ready'` beside a new `src`. Unmounting aborts it and writes no
|
|
1672
|
+
state. The fetch has a **30-second deadline**: a response that never arrives
|
|
1673
|
+
becomes `status: 'error'` with a message naming the timeout, rather than
|
|
1674
|
+
`'loading'` forever.
|
|
1675
|
+
|
|
1676
|
+
The helper injects an absolute `<base href="…/game/">` — the directory of the
|
|
1677
|
+
**response's final URL**, so a redirect is followed rather than resolved one
|
|
1678
|
+
directory too high — because a `srcdoc` document's base URL is `about:srcdoc`,
|
|
1679
|
+
inheriting yours, so relative asset loads would otherwise resolve to nothing. A
|
|
1680
|
+
document that already declares a `<base href>` has that one resolved against the
|
|
1681
|
+
final URL and replaced, so the base the parser uses is always absolute. It is
|
|
1682
|
+
**not** a de-duplication: markup declaring two `<base href>` tags comes back
|
|
1683
|
+
with two, the first (the only one a parser honours) rewritten and the second
|
|
1684
|
+
left inert. Only real markup counts — a `<base>` or a `<head>` inside a comment
|
|
1685
|
+
or an inline `<script>`/`<style>` body is ignored.
|
|
1686
|
+
|
|
1687
|
+
**The string is not sanitized**, on purpose: it is your own markup and scripts,
|
|
1688
|
+
verbatim. Only pass a `src` you control. Outside React, `fetchNestedDocument`
|
|
1689
|
+
from `@civitai/app-sdk/blocks` is the same thing without the hook.
|
|
1690
|
+
|
|
1509
1691
|
## The `/ui` subexport
|
|
1510
1692
|
|
|
1511
1693
|
Opinionated components, imported separately so a transport-only block stays lean.
|
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
@@ -58,8 +58,13 @@ export declare class CreatePostError extends Error {
|
|
|
58
58
|
*/
|
|
59
59
|
readonly declined: boolean;
|
|
60
60
|
/**
|
|
61
|
-
* There is no session
|
|
62
|
-
*
|
|
61
|
+
* There is no session — the viewer is signed out (`sign in to post`), or
|
|
62
|
+
* their session ended, e.g. signed out in another tab (a bare
|
|
63
|
+
* `UNAUTHORIZED`). Route this into `useRequestSignIn()` rather than showing
|
|
64
|
+
* an error — the viewer's next step is signing in, not retrying.
|
|
65
|
+
*
|
|
66
|
+
* A session that now belongs to a different account is NOT this: `.message`
|
|
67
|
+
* asks the viewer to reload the page, and is safe to render.
|
|
63
68
|
*/
|
|
64
69
|
readonly signInRequired: boolean;
|
|
65
70
|
constructor(error: string, opts?: {
|
|
@@ -79,8 +79,13 @@ export class CreatePostError extends Error {
|
|
|
79
79
|
*/
|
|
80
80
|
declined;
|
|
81
81
|
/**
|
|
82
|
-
* There is no session
|
|
83
|
-
*
|
|
82
|
+
* There is no session — the viewer is signed out (`sign in to post`), or
|
|
83
|
+
* their session ended, e.g. signed out in another tab (a bare
|
|
84
|
+
* `UNAUTHORIZED`). Route this into `useRequestSignIn()` rather than showing
|
|
85
|
+
* an error — the viewer's next step is signing in, not retrying.
|
|
86
|
+
*
|
|
87
|
+
* A session that now belongs to a different account is NOT this: `.message`
|
|
88
|
+
* asks the viewer to reload the page, and is safe to render.
|
|
84
89
|
*/
|
|
85
90
|
signInRequired;
|
|
86
91
|
constructor(error, opts) {
|
|
@@ -90,7 +95,7 @@ export class CreatePostError extends Error {
|
|
|
90
95
|
if (isCreatePostErrorCode(error))
|
|
91
96
|
this.code = error;
|
|
92
97
|
this.declined = error === 'declined';
|
|
93
|
-
this.signInRequired = error === 'sign in to post';
|
|
98
|
+
this.signInRequired = error === 'sign in to post' || error === 'UNAUTHORIZED';
|
|
94
99
|
}
|
|
95
100
|
}
|
|
96
101
|
/**
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the fetch has got to. `idle` is the no-`src` state, not a
|
|
3
|
+
* pre-fetch tick: a mount WITH a `src` goes straight to `loading`.
|
|
4
|
+
*/
|
|
5
|
+
export type NestedDocumentStatus = 'idle' | 'loading' | 'ready' | 'error';
|
|
6
|
+
/**
|
|
7
|
+
* What {@link useNestedDocument} returns. The three fields are driven from ONE
|
|
8
|
+
* state object, so they can never disagree: `srcDoc` is non-null exactly when
|
|
9
|
+
* `status === 'ready'`, and `error` is non-null exactly when
|
|
10
|
+
* `status === 'error'`.
|
|
11
|
+
*/
|
|
12
|
+
export interface UseNestedDocument {
|
|
13
|
+
/** The string to hand an iframe's `srcdoc`. Non-null iff `status === 'ready'`. */
|
|
14
|
+
srcDoc: string | null;
|
|
15
|
+
status: NestedDocumentStatus;
|
|
16
|
+
/** Non-null iff `status === 'error'`. A `NestedDocumentError` carries a `.code`. */
|
|
17
|
+
error: Error | null;
|
|
18
|
+
}
|
|
19
|
+
/** Options for {@link useNestedDocument}. */
|
|
20
|
+
export interface UseNestedDocumentOptions {
|
|
21
|
+
/**
|
|
22
|
+
* The nested document to inline. Relative URLs resolve against `baseUrl`, or
|
|
23
|
+
* the current document. `undefined` or `''` keeps the hook `idle` and fetches
|
|
24
|
+
* nothing — the shape to use while you are still deciding what to embed.
|
|
25
|
+
*/
|
|
26
|
+
src?: string | undefined;
|
|
27
|
+
/** Explicit base for resolving a relative `src`. */
|
|
28
|
+
baseUrl?: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Fetch one of your OWN bundled documents and get back a `srcdoc` string with
|
|
32
|
+
* an absolute `<base href>` injected — the only way a block can embed a nested
|
|
33
|
+
* document of its own.
|
|
34
|
+
*
|
|
35
|
+
* 🔴 PREFER NOT TO NEED THIS. A plain `<iframe src="/game/index.html">` of your
|
|
36
|
+
* own bundle **cannot load**, and no manifest change fixes it. An engine build
|
|
37
|
+
* (Defold, Unity, Phaser) is a `<canvas>` plus a JS loader and normally mounts
|
|
38
|
+
* directly into the block's own document, which avoids the whole problem class;
|
|
39
|
+
* reach for this hook only when a separate document is genuinely required.
|
|
40
|
+
*
|
|
41
|
+
* WHY, measured, in ONE place: the header of `@civitai/app-sdk`'s
|
|
42
|
+
* `src/blocks/nestedDocument.ts`. It carries the opaque-origin /
|
|
43
|
+
* `frame-ancestors` / `X-Frame-Options` derivation, the five-row DATED
|
|
44
|
+
* EXTERNAL platform matrix (verified by nothing in this repo — re-measure it if
|
|
45
|
+
* the platform's sandbox tiers or response headers change), and the known
|
|
46
|
+
* limits of the `<base>` rewrite. Do not restate any of it here: it was
|
|
47
|
+
* duplicated across 11 surfaces with nothing in the repo checking them for
|
|
48
|
+
* agreement, which is exactly how two measured claims in these files' own test
|
|
49
|
+
* headers went stale.
|
|
50
|
+
*
|
|
51
|
+
* 🔴 THE STRING IS NOT SANITIZED. It is your own markup and scripts, verbatim;
|
|
52
|
+
* pass a `src` you control, and put a `sandbox` attribute on the iframe.
|
|
53
|
+
*
|
|
54
|
+
* Changing `src` restarts the fetch and aborts the previous one — `status` goes
|
|
55
|
+
* back to `'loading'` in the SAME render that changes `src`, so no paint ever
|
|
56
|
+
* shows a stale `srcDoc` as `'ready'` beside a new `src`. Unmounting aborts the
|
|
57
|
+
* in-flight fetch, and no state is written after it.
|
|
58
|
+
*
|
|
59
|
+
* A fetch that never settles is bounded: after {@link NESTED_DOCUMENT_TIMEOUT_MS}
|
|
60
|
+
* it is aborted and `status` becomes `'error'` with a message naming the
|
|
61
|
+
* timeout. A timeout is distinguished from the hook's own aborts (unmount, a
|
|
62
|
+
* `src` change) — those stay silent, as they must.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```tsx
|
|
66
|
+
* const { srcDoc, status, error } = useNestedDocument({ src: '/game/index.html' });
|
|
67
|
+
* if (status === 'error') return <p>Could not load the game: {error?.message}</p>;
|
|
68
|
+
* if (status !== 'ready') return <p>Loading…</p>;
|
|
69
|
+
* return <iframe title="game" sandbox="allow-scripts" srcDoc={srcDoc ?? undefined} />;
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export declare function useNestedDocument(options?: UseNestedDocumentOptions): UseNestedDocument;
|
|
73
|
+
//# sourceMappingURL=useNestedDocument.d.ts.map
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { fetchNestedDocument } from '@civitai/app-sdk/blocks';
|
|
3
|
+
const IDLE = { status: 'idle', srcDoc: null, error: null };
|
|
4
|
+
const LOADING = { status: 'loading', srcDoc: null, error: null };
|
|
5
|
+
/**
|
|
6
|
+
* How long the fetch is given before it is aborted and the hook reports an
|
|
7
|
+
* error. Without a deadline a stalled response leaves `status === 'loading'`
|
|
8
|
+
* FOREVER: the hook owns its `AbortController` and never exposes it, so a
|
|
9
|
+
* caller has nothing to cancel with and no timer of its own to hang one off.
|
|
10
|
+
*
|
|
11
|
+
* 30 seconds, equal to `transport/requestTimeouts.ts`'s
|
|
12
|
+
* `DEFAULT_REQUEST_TIMEOUT_MS` and to `useEntitlements`' `ENTITLEMENTS_TIMEOUT_MS`
|
|
13
|
+
* — this is a `'protocol'`-class wait in that file's vocabulary: a static-host
|
|
14
|
+
* round trip with NO PERSON IN THE LOOP, so `HUMAN_INTERACTION_TIMEOUT_MS` is
|
|
15
|
+
* the wrong bound. Declared locally rather than imported for the same reason
|
|
16
|
+
* `useEntitlements` declares its own: `DEFAULT_REQUEST_TIMEOUT_MS` documents the
|
|
17
|
+
* default of `IframeTransport.sendRequest`, and a `fetch` of a bundled document
|
|
18
|
+
* is not a bridge request. Equal by intent, not derived.
|
|
19
|
+
*/
|
|
20
|
+
const NESTED_DOCUMENT_TIMEOUT_MS = 30_000;
|
|
21
|
+
/**
|
|
22
|
+
* Fetch one of your OWN bundled documents and get back a `srcdoc` string with
|
|
23
|
+
* an absolute `<base href>` injected — the only way a block can embed a nested
|
|
24
|
+
* document of its own.
|
|
25
|
+
*
|
|
26
|
+
* 🔴 PREFER NOT TO NEED THIS. A plain `<iframe src="/game/index.html">` of your
|
|
27
|
+
* own bundle **cannot load**, and no manifest change fixes it. An engine build
|
|
28
|
+
* (Defold, Unity, Phaser) is a `<canvas>` plus a JS loader and normally mounts
|
|
29
|
+
* directly into the block's own document, which avoids the whole problem class;
|
|
30
|
+
* reach for this hook only when a separate document is genuinely required.
|
|
31
|
+
*
|
|
32
|
+
* WHY, measured, in ONE place: the header of `@civitai/app-sdk`'s
|
|
33
|
+
* `src/blocks/nestedDocument.ts`. It carries the opaque-origin /
|
|
34
|
+
* `frame-ancestors` / `X-Frame-Options` derivation, the five-row DATED
|
|
35
|
+
* EXTERNAL platform matrix (verified by nothing in this repo — re-measure it if
|
|
36
|
+
* the platform's sandbox tiers or response headers change), and the known
|
|
37
|
+
* limits of the `<base>` rewrite. Do not restate any of it here: it was
|
|
38
|
+
* duplicated across 11 surfaces with nothing in the repo checking them for
|
|
39
|
+
* agreement, which is exactly how two measured claims in these files' own test
|
|
40
|
+
* headers went stale.
|
|
41
|
+
*
|
|
42
|
+
* 🔴 THE STRING IS NOT SANITIZED. It is your own markup and scripts, verbatim;
|
|
43
|
+
* pass a `src` you control, and put a `sandbox` attribute on the iframe.
|
|
44
|
+
*
|
|
45
|
+
* Changing `src` restarts the fetch and aborts the previous one — `status` goes
|
|
46
|
+
* back to `'loading'` in the SAME render that changes `src`, so no paint ever
|
|
47
|
+
* shows a stale `srcDoc` as `'ready'` beside a new `src`. Unmounting aborts the
|
|
48
|
+
* in-flight fetch, and no state is written after it.
|
|
49
|
+
*
|
|
50
|
+
* A fetch that never settles is bounded: after {@link NESTED_DOCUMENT_TIMEOUT_MS}
|
|
51
|
+
* it is aborted and `status` becomes `'error'` with a message naming the
|
|
52
|
+
* timeout. A timeout is distinguished from the hook's own aborts (unmount, a
|
|
53
|
+
* `src` change) — those stay silent, as they must.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```tsx
|
|
57
|
+
* const { srcDoc, status, error } = useNestedDocument({ src: '/game/index.html' });
|
|
58
|
+
* if (status === 'error') return <p>Could not load the game: {error?.message}</p>;
|
|
59
|
+
* if (status !== 'ready') return <p>Loading…</p>;
|
|
60
|
+
* return <iframe title="game" sandbox="allow-scripts" srcDoc={srcDoc ?? undefined} />;
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function useNestedDocument(options) {
|
|
64
|
+
const src = options?.src;
|
|
65
|
+
const baseUrl = options?.baseUrl;
|
|
66
|
+
const [state, setState] = useState(src ? LOADING : IDLE);
|
|
67
|
+
// 🔴 RESET DURING RENDER, NOT IN THE EFFECT. The effect runs AFTER the commit,
|
|
68
|
+
// so between the render that changes `src` and the effect that sets `LOADING`
|
|
69
|
+
// the hook used to return the PREVIOUS document still labelled `'ready'` —
|
|
70
|
+
// one painted frame in which `(src, srcDoc)` disagree, and a consumer that
|
|
71
|
+
// keys an iframe off `src` would show the wrong document in it. This is
|
|
72
|
+
// React's documented "adjust state when a prop changes" pattern: the
|
|
73
|
+
// `setState` below re-renders this component immediately, before the browser
|
|
74
|
+
// paints, so the stale pair is never committed.
|
|
75
|
+
//
|
|
76
|
+
// The key carries BOTH inputs because both are effect dependencies. They are
|
|
77
|
+
// joined by a NUL rather than by a printable character: two distinct
|
|
78
|
+
// `(src, baseUrl)` pairs can only collide on one key if the separator itself
|
|
79
|
+
// occurs inside one of them, and a NUL is the byte least likely to appear in
|
|
80
|
+
// a URL anyone passes here. A collision would mean a missed reset, not a
|
|
81
|
+
// wrong document — the effect's own `[src, baseUrl]` deps still re-run it.
|
|
82
|
+
const key = `${src ?? ''}\u0000${baseUrl ?? ''}`;
|
|
83
|
+
const [stateKey, setStateKey] = useState(key);
|
|
84
|
+
let current = state;
|
|
85
|
+
if (stateKey !== key) {
|
|
86
|
+
setStateKey(key);
|
|
87
|
+
// 🔴 RETURNED FROM THIS PASS, not merely queued. React discards the JSX of
|
|
88
|
+
// a render that calls `setState` and retries, so queueing alone already
|
|
89
|
+
// keeps the stale pair off the screen — but it would still be the value
|
|
90
|
+
// this call RETURNS, which is the hook's contract and the thing a test can
|
|
91
|
+
// observe. Assigning locally makes every render consistent, committed or
|
|
92
|
+
// not.
|
|
93
|
+
current = src ? LOADING : IDLE;
|
|
94
|
+
// `IDLE`/`LOADING` are module constants, so when the effect sets the same
|
|
95
|
+
// one a moment later React bails out on reference equality.
|
|
96
|
+
setState(current);
|
|
97
|
+
}
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!src) {
|
|
100
|
+
setState(IDLE);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// `cancelled` is belt AND braces alongside `abort()`: an abort only rejects
|
|
104
|
+
// a fetch that is still in flight, so a response that has already resolved
|
|
105
|
+
// can still land in a `.then` after unmount. Both are needed.
|
|
106
|
+
let cancelled = false;
|
|
107
|
+
// 🔴 A SEPARATE FLAG, NOT `controller.signal.aborted`. The timeout aborts
|
|
108
|
+
// THIS controller, and the `.catch` below drops anything whose signal is
|
|
109
|
+
// aborted — so a timeout wired to the same controller would be SWALLOWED
|
|
110
|
+
// and the hook would sit on `'loading'` exactly as it did with no timeout
|
|
111
|
+
// at all. The flag is what tells "the deadline passed" (report it) from
|
|
112
|
+
// "we unmounted / the src changed" (stay silent).
|
|
113
|
+
let timedOut = false;
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
const timeoutId = setTimeout(() => {
|
|
116
|
+
timedOut = true;
|
|
117
|
+
controller.abort();
|
|
118
|
+
}, NESTED_DOCUMENT_TIMEOUT_MS);
|
|
119
|
+
setState(LOADING);
|
|
120
|
+
fetchNestedDocument({ src, baseUrl, signal: controller.signal })
|
|
121
|
+
.then((doc) => {
|
|
122
|
+
if (cancelled)
|
|
123
|
+
return;
|
|
124
|
+
setState({ status: 'ready', srcDoc: doc.srcDoc, error: null });
|
|
125
|
+
})
|
|
126
|
+
.catch((err) => {
|
|
127
|
+
if (cancelled)
|
|
128
|
+
return;
|
|
129
|
+
if (timedOut) {
|
|
130
|
+
setState({
|
|
131
|
+
status: 'error',
|
|
132
|
+
srcDoc: null,
|
|
133
|
+
error: new Error(`useNestedDocument: fetching ${src} timed out after ` +
|
|
134
|
+
`${NESTED_DOCUMENT_TIMEOUT_MS}ms.`),
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Any OTHER abort is this hook's own cleanup, never something to
|
|
139
|
+
// surface: the component is gone, or a newer `src` already owns the
|
|
140
|
+
// state.
|
|
141
|
+
if (controller.signal.aborted)
|
|
142
|
+
return;
|
|
143
|
+
setState({
|
|
144
|
+
status: 'error',
|
|
145
|
+
srcDoc: null,
|
|
146
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
147
|
+
});
|
|
148
|
+
})
|
|
149
|
+
.finally(() => {
|
|
150
|
+
clearTimeout(timeoutId);
|
|
151
|
+
});
|
|
152
|
+
return () => {
|
|
153
|
+
cancelled = true;
|
|
154
|
+
clearTimeout(timeoutId);
|
|
155
|
+
controller.abort();
|
|
156
|
+
};
|
|
157
|
+
}, [src, baseUrl]);
|
|
158
|
+
return current;
|
|
159
|
+
}
|
|
160
|
+
//# sourceMappingURL=useNestedDocument.js.map
|