plotlink-ows 1.0.32 → 1.2.94
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 +4 -0
- package/app/lib/agent-command.ts +85 -0
- package/app/lib/agent-readiness.ts +133 -0
- package/app/lib/apply-schema.ts +55 -0
- package/app/lib/bubble-text.ts +160 -0
- package/app/lib/cartoon-coach.ts +198 -0
- package/app/lib/cartoon-markdown.ts +83 -0
- package/app/lib/cartoon-prompt.ts +122 -0
- package/app/lib/cartoon-readiness.ts +811 -0
- package/app/lib/clean-image-sync.ts +245 -0
- package/app/lib/codex-images.ts +152 -0
- package/app/lib/cut-asset-diagnostics.ts +120 -0
- package/app/lib/cuts.ts +302 -0
- package/app/lib/fonts.ts +109 -0
- package/app/lib/generate-claude-md.ts +10 -3
- package/app/lib/generate-story-instructions.ts +731 -0
- package/app/lib/image-asset-validate.ts +123 -0
- package/app/lib/lettering-status.ts +133 -0
- package/app/lib/overlays.ts +637 -0
- package/app/lib/paths.ts +10 -0
- package/app/lib/public-title.ts +65 -0
- package/app/lib/publish.ts +16 -2
- package/app/lib/story-progress.ts +243 -0
- package/app/lib/terminal-protocol.ts +16 -0
- package/app/lib/terminal-redact.ts +50 -0
- package/app/prisma/schema.sql +25 -0
- package/app/routes/agent.ts +42 -0
- package/app/routes/codex-images.ts +67 -0
- package/app/routes/publish.ts +209 -28
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonPreview.tsx +267 -0
- package/app/web/components/CartoonPublishPage.tsx +407 -0
- package/app/web/components/CartoonPublishPreview.tsx +121 -0
- package/app/web/components/CartoonStepGuide.tsx +90 -0
- package/app/web/components/CartoonWorkflowNav.tsx +68 -0
- package/app/web/components/CodexImportPicker.tsx +230 -0
- package/app/web/components/CutListPanel.tsx +1299 -0
- package/app/web/components/EpisodesPage.tsx +80 -0
- package/app/web/components/FinishEpisodePanel.tsx +151 -0
- package/app/web/components/Layout.tsx +7 -4
- package/app/web/components/LetteringEditor.tsx +1141 -0
- package/app/web/components/PreviewPanel.tsx +1017 -144
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +710 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +516 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WorkflowCoach.tsx +128 -0
- package/app/web/components/asset-image.tsx +114 -0
- package/app/web/components/asset-test-utils.ts +44 -0
- package/app/web/components/export-cut.ts +320 -0
- package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
- package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
- package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/app/web/lib/cartoon-publish-summary.ts +43 -0
- package/app/web/lib/codex-import.ts +94 -0
- package/app/web/lib/image-compress.ts +53 -0
- package/app/web/lib/import-image.ts +58 -0
- package/app/web/lib/publish-helpers.ts +385 -0
- package/app/web/lib/upload-retry.ts +130 -0
- package/app/web/lib/verify-public-title.ts +105 -0
- package/app/web/styles.css +9 -0
- package/bin/plotlink-ows.js +53 -16
- package/bin/startup-plan.cjs +58 -0
- package/lib/genres.ts +92 -0
- package/package.json +60 -20
- package/scripts/gen-schema-sql.mjs +49 -0
- package/scripts/package-hygiene.mjs +116 -0
- package/scripts/preflight.mjs +173 -0
- package/scripts/start-smoke.mjs +128 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/client.js +0 -5
- package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/default.js +0 -5
- package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/edge.js +0 -184
- package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
- package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
- package/app/node_modules/.prisma/local-client/index.js +0 -207
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +0 -183
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
- package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
- package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/wasm.js +0 -191
- package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
- package/app/web/dist/assets/index-BFw-v-OZ.js +0 -134
- package/packages/cli/node_modules/commander/LICENSE +0 -22
- package/packages/cli/node_modules/commander/Readme.md +0 -1149
- package/packages/cli/node_modules/commander/esm.mjs +0 -16
- package/packages/cli/node_modules/commander/index.js +0 -24
- package/packages/cli/node_modules/commander/lib/argument.js +0 -149
- package/packages/cli/node_modules/commander/lib/command.js +0 -2662
- package/packages/cli/node_modules/commander/lib/error.js +0 -39
- package/packages/cli/node_modules/commander/lib/help.js +0 -709
- package/packages/cli/node_modules/commander/lib/option.js +0 -367
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
- package/packages/cli/node_modules/commander/package-support.json +0 -16
- package/packages/cli/node_modules/commander/package.json +0 -82
- package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
- package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
- package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
- package/packages/cli/node_modules/resolve-from/index.js +0 -47
- package/packages/cli/node_modules/resolve-from/license +0 -9
- package/packages/cli/node_modules/resolve-from/package.json +0 -36
- package/packages/cli/node_modules/resolve-from/readme.md +0 -72
- package/packages/cli/node_modules/tsup/LICENSE +0 -21
- package/packages/cli/node_modules/tsup/README.md +0 -75
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
- package/packages/cli/node_modules/tsup/assets/package.json +0 -3
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
- package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
- package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
- package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
- package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
- package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
- package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
- package/packages/cli/node_modules/tsup/package.json +0 -99
- package/packages/cli/node_modules/tsup/schema.json +0 -362
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- package/scripts/e2e-verify.ts +0 -1100
|
@@ -3,6 +3,17 @@ import { Terminal } from "@xterm/xterm";
|
|
|
3
3
|
import { FitAddon } from "@xterm/addon-fit";
|
|
4
4
|
import { SerializeAddon } from "@xterm/addon-serialize";
|
|
5
5
|
import "@xterm/xterm/css/xterm.css";
|
|
6
|
+
import { isCodexAuthUnclear, CODEX_AUTH_UNCLEAR_MESSAGE, type AgentReadiness } from "@app-lib/agent-readiness";
|
|
7
|
+
import { FRESH_SPAWN_SIGNAL } from "@app-lib/terminal-protocol";
|
|
8
|
+
import { redactTerminalSecrets } from "@app-lib/terminal-redact";
|
|
9
|
+
|
|
10
|
+
/** Story metadata persisted with a `_new_*` → real-folder rename (#295). */
|
|
11
|
+
export interface RenameMeta {
|
|
12
|
+
contentType?: "fiction" | "cartoon";
|
|
13
|
+
language?: string;
|
|
14
|
+
agentMode?: "normal" | "bypass";
|
|
15
|
+
agentProvider?: "claude" | "codex";
|
|
16
|
+
}
|
|
6
17
|
|
|
7
18
|
interface TerminalPanelProps {
|
|
8
19
|
token: string;
|
|
@@ -12,7 +23,49 @@ interface TerminalPanelProps {
|
|
|
12
23
|
onDestroySession?: (storyName: string) => void;
|
|
13
24
|
onArchiveStory?: (storyName: string) => void;
|
|
14
25
|
confirmedStories?: Set<string>;
|
|
15
|
-
|
|
26
|
+
// The optional `meta` is persisted to the confirmed story's .story.json
|
|
27
|
+
// atomically with the rename so a fresh story's provider/contentType survive (#295).
|
|
28
|
+
renameRef?: React.RefObject<((oldName: string, newName: string, meta?: RenameMeta) => Promise<boolean>) | null>;
|
|
29
|
+
bypassStories?: Record<string, boolean>;
|
|
30
|
+
agentProviders?: Record<string, "claude" | "codex">;
|
|
31
|
+
/** Local agent (Codex) readiness. null/undefined = not yet loaded (fail-open). */
|
|
32
|
+
readiness?: AgentReadiness | null;
|
|
33
|
+
/** Content type of the currently-selected story (undefined = unknown). */
|
|
34
|
+
contentType?: "fiction" | "cartoon";
|
|
35
|
+
/**
|
|
36
|
+
* True only for the selected real (non-`_new_*`) cartoon story whose
|
|
37
|
+
* `.story.json` has no `agentProvider` recorded (legacy). When true, show the
|
|
38
|
+
* explicit provider-repair CTA instead of auto-spawning, so the writer sets
|
|
39
|
+
* the provider to Codex before launching. Never true for fiction or a cartoon
|
|
40
|
+
* that already has a provider.
|
|
41
|
+
*/
|
|
42
|
+
needsProviderRepair?: boolean;
|
|
43
|
+
/** Set this story's provider to Codex (scoped, non-destructive repair). */
|
|
44
|
+
onRepairProvider?: () => void | Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const CODEX_ENABLE_CMD = "codex features enable image_generation";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Pure predicate: should the cartoon agent LAUNCH be blocked?
|
|
51
|
+
*
|
|
52
|
+
* Blocked ONLY when ALL of:
|
|
53
|
+
* - the selected story is a cartoon, AND
|
|
54
|
+
* - readiness has loaded (non-null), AND
|
|
55
|
+
* - Codex is NOT ready (not installed OR image generation not enabled).
|
|
56
|
+
*
|
|
57
|
+
* Fail-open: readiness null/undefined => NOT blocked (a probe failure must never
|
|
58
|
+
* brick terminals). Fiction / undefined contentType => NEVER blocked.
|
|
59
|
+
*/
|
|
60
|
+
export function isCartoonLaunchBlocked(
|
|
61
|
+
contentType: "fiction" | "cartoon" | undefined,
|
|
62
|
+
readiness: AgentReadiness | null | undefined,
|
|
63
|
+
): boolean {
|
|
64
|
+
if (contentType !== "cartoon") return false;
|
|
65
|
+
if (!readiness) return false; // fail-open until readiness resolves
|
|
66
|
+
const codexReady =
|
|
67
|
+
readiness.codex.installed && readiness.codex.imageGeneration === "enabled";
|
|
68
|
+
return !codexReady;
|
|
16
69
|
}
|
|
17
70
|
|
|
18
71
|
interface TerminalSession {
|
|
@@ -106,13 +159,21 @@ async function deleteScrollback(storyName: string): Promise<void> {
|
|
|
106
159
|
// Sessions live outside React state to avoid ref-in-effect lint issues
|
|
107
160
|
const sessions = new Map<string, TerminalSession>();
|
|
108
161
|
|
|
109
|
-
export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDestroySession, onArchiveStory, confirmedStories, renameRef }: TerminalPanelProps) {
|
|
162
|
+
export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDestroySession, onArchiveStory, confirmedStories, renameRef, bypassStories, agentProviders, readiness, contentType, needsProviderRepair, onRepairProvider }: TerminalPanelProps) {
|
|
110
163
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
111
164
|
const authFetchRef = useRef(authFetch);
|
|
112
165
|
const [sessionList, setSessionList] = useState<string[]>([]);
|
|
113
166
|
const [disconnected, setDisconnected] = useState<Set<string>>(new Set());
|
|
114
167
|
const [confirmingDiscard, setConfirmingDiscard] = useState<string | null>(null);
|
|
115
168
|
const [confirmingArchive, setConfirmingArchive] = useState<string | null>(null);
|
|
169
|
+
const [copiedEnableCmd, setCopiedEnableCmd] = useState(false);
|
|
170
|
+
const [repairing, setRepairing] = useState(false);
|
|
171
|
+
|
|
172
|
+
// Gate the cartoon agent launch for the currently-selected story.
|
|
173
|
+
const cartoonLaunchBlocked = isCartoonLaunchBlocked(contentType, readiness);
|
|
174
|
+
// Legacy cartoon (no provider recorded) ⇒ require explicit provider repair
|
|
175
|
+
// before auto-spawning a terminal. Scoped to the selected story only.
|
|
176
|
+
const showProviderRepair = !!needsProviderRepair;
|
|
116
177
|
|
|
117
178
|
const connectWsRef = useRef<(name: string, session: TerminalSession, resume: boolean) => void>(() => {});
|
|
118
179
|
|
|
@@ -142,10 +203,19 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
|
|
|
142
203
|
}
|
|
143
204
|
}, [safeFit]);
|
|
144
205
|
|
|
206
|
+
const bypassRef = useRef<Record<string, boolean>>({});
|
|
207
|
+
useEffect(() => { bypassRef.current = bypassStories || {}; }, [bypassStories]);
|
|
208
|
+
|
|
209
|
+
const providerRef = useRef<Record<string, "claude" | "codex">>({});
|
|
210
|
+
useEffect(() => { providerRef.current = agentProviders || {}; }, [agentProviders]);
|
|
211
|
+
|
|
145
212
|
const connectWs = useCallback((name: string, session: TerminalSession, resume: boolean) => {
|
|
146
213
|
const wsProto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
214
|
+
const bypass = bypassRef.current[name] ? "&bypass=true" : "";
|
|
215
|
+
const provider = providerRef.current[name];
|
|
216
|
+
const providerParam = provider ? `&provider=${encodeURIComponent(provider)}` : "";
|
|
147
217
|
const ws = new WebSocket(
|
|
148
|
-
`${wsProto}//${window.location.host}/ws/terminal?story=${encodeURIComponent(name)}&token=${token}&resume=${resume}`
|
|
218
|
+
`${wsProto}//${window.location.host}/ws/terminal?story=${encodeURIComponent(name)}&token=${token}&resume=${resume}${bypass}${providerParam}`
|
|
149
219
|
);
|
|
150
220
|
|
|
151
221
|
ws.onopen = () => {
|
|
@@ -155,8 +225,22 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
|
|
|
155
225
|
ws.send(JSON.stringify({ type: "resize", cols: session.term.cols, rows: session.term.rows }));
|
|
156
226
|
};
|
|
157
227
|
|
|
228
|
+
// The very first frame may be a control signal (#453). A fresh server spawn
|
|
229
|
+
// (the agent process reprints its banner/history) sends FRESH_SPAWN_SIGNAL —
|
|
230
|
+
// drop the restored scrollback so the banner isn't duplicated. Any other
|
|
231
|
+
// first frame (a live-PTY reconnect) is normal PTY output and is kept.
|
|
232
|
+
let firstFrame = true;
|
|
158
233
|
ws.onmessage = (e) => {
|
|
159
|
-
|
|
234
|
+
if (firstFrame) {
|
|
235
|
+
firstFrame = false;
|
|
236
|
+
if (typeof e.data === "string" && e.data === FRESH_SPAWN_SIGNAL) {
|
|
237
|
+
session.term.reset();
|
|
238
|
+
deleteScrollback(name).catch(() => {});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Mask obvious auth secrets before they reach the terminal / scrollback (#454).
|
|
243
|
+
session.term.write(typeof e.data === "string" ? redactTerminalSecrets(e.data) : e.data);
|
|
160
244
|
};
|
|
161
245
|
|
|
162
246
|
ws.onclose = (event) => {
|
|
@@ -242,11 +326,16 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
|
|
|
242
326
|
sessions.set(name, session);
|
|
243
327
|
setSessionList((prev) => [...prev, name]);
|
|
244
328
|
|
|
245
|
-
// Restore scrollback from IndexedDB
|
|
329
|
+
// Restore scrollback from IndexedDB, masking any auth secrets first so an OLD
|
|
330
|
+
// transcript can't re-display previously-persisted tokens/passphrases (#454).
|
|
331
|
+
// If redaction changed it, re-save the masked copy so the raw value is gone
|
|
332
|
+
// from storage and never re-persisted later.
|
|
246
333
|
try {
|
|
247
334
|
const saved = await loadScrollback(name);
|
|
248
335
|
if (saved) {
|
|
249
|
-
|
|
336
|
+
const redacted = redactTerminalSecrets(saved);
|
|
337
|
+
term.write(redacted);
|
|
338
|
+
if (redacted !== saved) saveScrollback(name, redacted).catch(() => {});
|
|
250
339
|
}
|
|
251
340
|
} catch { /* ignore */ }
|
|
252
341
|
|
|
@@ -330,15 +419,16 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
|
|
|
330
419
|
|
|
331
420
|
/** Rename a session key (e.g. _new_123 → paper-chair) without killing the PTY.
|
|
332
421
|
* Returns true on success, false on failure. */
|
|
333
|
-
const renameSession = useCallback(async (oldName: string, newName: string): Promise<boolean> => {
|
|
422
|
+
const renameSession = useCallback(async (oldName: string, newName: string, meta?: RenameMeta): Promise<boolean> => {
|
|
334
423
|
const session = sessions.get(oldName);
|
|
335
424
|
if (!session || sessions.has(newName)) return false;
|
|
336
425
|
|
|
337
|
-
// Rename on the server first
|
|
426
|
+
// Rename on the server first. Forward the confirmed story's metadata so the
|
|
427
|
+
// server persists contentType/provider atomically with the rename (#295).
|
|
338
428
|
const res = await authFetchRef.current("/api/terminal/rename", {
|
|
339
429
|
method: "POST",
|
|
340
430
|
headers: { "Content-Type": "application/json" },
|
|
341
|
-
body: JSON.stringify({ oldName, newName }),
|
|
431
|
+
body: JSON.stringify({ oldName, newName, ...(meta ?? {}) }),
|
|
342
432
|
});
|
|
343
433
|
if (!res.ok) return false;
|
|
344
434
|
|
|
@@ -363,6 +453,24 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
|
|
|
363
453
|
return next;
|
|
364
454
|
});
|
|
365
455
|
|
|
456
|
+
// #377: the renamed session's live PTY keeps the cwd it was SPAWNED in, so
|
|
457
|
+
// after the story folder moves (e.g. _new_* → final, or a partial slug →
|
|
458
|
+
// full title) the agent's trust prompt / working directory still points at
|
|
459
|
+
// the old (now-renamed) folder — confusing, and the old folder may no longer
|
|
460
|
+
// exist. If this session is live, move the terminal into the FINAL folder:
|
|
461
|
+
// kill the stale-cwd PTY and reconnect WITH RESUME, so the new spawn runs in
|
|
462
|
+
// stories/<newName> (correct cwd/trust prompt) while the conversation is
|
|
463
|
+
// preserved (claude --resume / codex resume). The server reads the provider
|
|
464
|
+
// from the .story.json it just persisted, so the respawn stays provider-aware.
|
|
465
|
+
// If resume can't recover, the existing code-4000 path reconnects fresh in
|
|
466
|
+
// the same (correct) folder. A never-connected session needs no respawn — its
|
|
467
|
+
// first connect already uses the final name.
|
|
468
|
+
if (session.connected || session.ws) {
|
|
469
|
+
await authFetchRef.current(`/api/terminal/${encodeURIComponent(newName)}`, { method: "DELETE" }).catch(() => {});
|
|
470
|
+
if (session.ws) { session.ws.close(); session.ws = null; }
|
|
471
|
+
connectWsRef.current(newName, session, true);
|
|
472
|
+
}
|
|
473
|
+
|
|
366
474
|
return true;
|
|
367
475
|
}, []);
|
|
368
476
|
|
|
@@ -375,6 +483,20 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
|
|
|
375
483
|
// Auto-spawn + show/hide when story changes
|
|
376
484
|
useEffect(() => {
|
|
377
485
|
if (!storyName) return;
|
|
486
|
+
// Cartoon readiness gate: never spawn/connect a terminal for a cartoon
|
|
487
|
+
// story whose Codex/image_generation is known-not-ready. Show guidance
|
|
488
|
+
// instead (rendered below). Fail-open when readiness is null/undefined.
|
|
489
|
+
if (cartoonLaunchBlocked) {
|
|
490
|
+
showSession(null);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
// Legacy cartoon with no recorded provider: do NOT auto-spawn. Show the
|
|
494
|
+
// explicit repair CTA so the writer sets the provider to Codex first. After
|
|
495
|
+
// repair, `needsProviderRepair` flips false and normal gating/launch applies.
|
|
496
|
+
if (showProviderRepair) {
|
|
497
|
+
showSession(null);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
378
500
|
if (!sessions.has(storyName)) {
|
|
379
501
|
// Check if a previous session exists — if so, show overlay instead of auto-connecting
|
|
380
502
|
authFetchRef.current(`/api/terminal/session/${encodeURIComponent(storyName)}`)
|
|
@@ -395,7 +517,7 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
|
|
|
395
517
|
} else {
|
|
396
518
|
showSession(storyName);
|
|
397
519
|
}
|
|
398
|
-
}, [storyName, createSession, showSession]);
|
|
520
|
+
}, [storyName, createSession, showSession, cartoonLaunchBlocked, showProviderRepair]);
|
|
399
521
|
|
|
400
522
|
// Periodic scrollback save (every 30s for active session)
|
|
401
523
|
useEffect(() => {
|
|
@@ -496,7 +618,7 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
|
|
|
496
618
|
<div ref={wrapperRef} className="h-full" />
|
|
497
619
|
|
|
498
620
|
{/* Empty state overlay */}
|
|
499
|
-
{isEmpty && (
|
|
621
|
+
{isEmpty && !cartoonLaunchBlocked && !showProviderRepair && (
|
|
500
622
|
<div className="absolute inset-0 flex items-center justify-center text-muted">
|
|
501
623
|
<div className="text-center">
|
|
502
624
|
<p className="text-lg font-serif">Select a story on the left menu</p>
|
|
@@ -505,6 +627,106 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
|
|
|
505
627
|
</div>
|
|
506
628
|
)}
|
|
507
629
|
|
|
630
|
+
{/* Cartoon launch gated: Codex / image generation not ready */}
|
|
631
|
+
{cartoonLaunchBlocked && (
|
|
632
|
+
<div
|
|
633
|
+
data-testid="cartoon-launch-blocked"
|
|
634
|
+
className="absolute inset-0 flex items-center justify-center"
|
|
635
|
+
style={{ background: "rgba(240, 235, 225, 0.9)" }}
|
|
636
|
+
>
|
|
637
|
+
<div className="space-y-3 p-6 bg-surface border border-border rounded-lg shadow-lg max-w-md">
|
|
638
|
+
<p className="text-sm font-serif text-foreground font-medium">
|
|
639
|
+
Cartoon agent can't launch yet
|
|
640
|
+
</p>
|
|
641
|
+
<p className="text-xs text-muted">
|
|
642
|
+
This is a cartoon story. The writing agent needs Codex with image
|
|
643
|
+
generation enabled before it can start, because the clean-image
|
|
644
|
+
step relies on image generation support.
|
|
645
|
+
</p>
|
|
646
|
+
{readiness && !readiness.codex.installed ? (
|
|
647
|
+
<p className="text-xs text-amber-700">
|
|
648
|
+
Codex was not detected. Install the Codex CLI and sign in
|
|
649
|
+
(e.g. <span className="font-mono">npm i -g @openai/codex</span> then{" "}
|
|
650
|
+
<span className="font-mono">codex login</span>), then reopen this story.
|
|
651
|
+
</p>
|
|
652
|
+
) : isCodexAuthUnclear(readiness) ? (
|
|
653
|
+
<p className="text-xs text-amber-700" data-testid="codex-auth-unknown-launch">
|
|
654
|
+
{CODEX_AUTH_UNCLEAR_MESSAGE} Then reopen this story.
|
|
655
|
+
</p>
|
|
656
|
+
) : (
|
|
657
|
+
<div className="space-y-1">
|
|
658
|
+
<p className="text-xs text-amber-700">
|
|
659
|
+
Codex is installed but image generation isn't enabled. Enable
|
|
660
|
+
it, then reopen this story:
|
|
661
|
+
</p>
|
|
662
|
+
<div className="flex items-center gap-1">
|
|
663
|
+
<code className="flex-1 truncate rounded border border-border bg-surface px-1.5 py-1 text-left text-[10px] font-mono text-foreground">
|
|
664
|
+
{CODEX_ENABLE_CMD}
|
|
665
|
+
</code>
|
|
666
|
+
<button
|
|
667
|
+
type="button"
|
|
668
|
+
data-testid="copy-codex-enable-launch"
|
|
669
|
+
onClick={async () => {
|
|
670
|
+
try {
|
|
671
|
+
await navigator.clipboard.writeText(CODEX_ENABLE_CMD);
|
|
672
|
+
setCopiedEnableCmd(true);
|
|
673
|
+
setTimeout(() => setCopiedEnableCmd(false), 2000);
|
|
674
|
+
} catch { /* clipboard unavailable */ }
|
|
675
|
+
}}
|
|
676
|
+
className="rounded border border-border px-2 py-1 text-[10px] text-muted hover:border-accent hover:text-accent transition-colors"
|
|
677
|
+
>
|
|
678
|
+
{copiedEnableCmd ? "Copied!" : "Copy"}
|
|
679
|
+
</button>
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
)}
|
|
683
|
+
</div>
|
|
684
|
+
</div>
|
|
685
|
+
)}
|
|
686
|
+
|
|
687
|
+
{/* Legacy cartoon: no provider recorded — explicit, scoped repair CTA.
|
|
688
|
+
Separate from readiness gating; about a MISSING provider on this one
|
|
689
|
+
story. Setting it to Codex never touches other stories or fiction. */}
|
|
690
|
+
{showProviderRepair && !cartoonLaunchBlocked && (
|
|
691
|
+
<div
|
|
692
|
+
data-testid="legacy-cartoon-provider-repair"
|
|
693
|
+
className="absolute inset-0 flex items-center justify-center"
|
|
694
|
+
style={{ background: "rgba(240, 235, 225, 0.9)" }}
|
|
695
|
+
>
|
|
696
|
+
<div className="space-y-3 p-6 bg-surface border border-border rounded-lg shadow-lg max-w-md">
|
|
697
|
+
<p className="text-sm font-serif text-foreground font-medium">
|
|
698
|
+
Set this cartoon story's provider
|
|
699
|
+
</p>
|
|
700
|
+
<p className="text-xs text-muted">
|
|
701
|
+
This cartoon story was created before provider tracking, so it has
|
|
702
|
+
no provider recorded and would launch with Claude — which can't
|
|
703
|
+
generate the clean images cartoons need. Set this story's
|
|
704
|
+
provider to Codex to continue.
|
|
705
|
+
</p>
|
|
706
|
+
<p className="text-[11px] text-muted">
|
|
707
|
+
Only this story is changed. Other stories and fiction are not affected.
|
|
708
|
+
</p>
|
|
709
|
+
<button
|
|
710
|
+
type="button"
|
|
711
|
+
data-testid="repair-provider-codex"
|
|
712
|
+
disabled={repairing}
|
|
713
|
+
onClick={async () => {
|
|
714
|
+
if (repairing) return;
|
|
715
|
+
setRepairing(true);
|
|
716
|
+
try {
|
|
717
|
+
await onRepairProvider?.();
|
|
718
|
+
} finally {
|
|
719
|
+
setRepairing(false);
|
|
720
|
+
}
|
|
721
|
+
}}
|
|
722
|
+
className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
|
|
723
|
+
>
|
|
724
|
+
{repairing ? "Setting…" : "Set this story's provider to Codex"}
|
|
725
|
+
</button>
|
|
726
|
+
</div>
|
|
727
|
+
</div>
|
|
728
|
+
)}
|
|
729
|
+
|
|
508
730
|
{/* Discard confirmation overlay */}
|
|
509
731
|
{confirmingDiscard && (
|
|
510
732
|
<div className="absolute inset-0 flex items-center justify-center z-10" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { CartoonCoach, CoachUiAction } from "@app-lib/cartoon-coach";
|
|
3
|
+
import type { StoryProgress } from "@app-lib/story-progress";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Persistent cartoon workflow coach (#429). Converts the current story/episode
|
|
7
|
+
* state into one stage label + one primary next action — an agent copy-paste
|
|
8
|
+
* prompt or a direct in-app UI action — so a normal writer always knows the next
|
|
9
|
+
* step without reading terminal logs or technical warnings. It never blocks the
|
|
10
|
+
* terminal or advanced controls; it just makes the normal path obvious.
|
|
11
|
+
*
|
|
12
|
+
* Two pieces:
|
|
13
|
+
* - `WorkflowCoachView` is presentational (takes an already-loaded coach) so the
|
|
14
|
+
* progress overview, which already fetches the progress payload, can render it
|
|
15
|
+
* with no extra request.
|
|
16
|
+
* - `WorkflowCoach` is the self-loading container the file views use.
|
|
17
|
+
*
|
|
18
|
+
* Fiction is unaffected: the coach is null for fiction, so the view renders
|
|
19
|
+
* nothing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface WorkflowCoachViewProps {
|
|
23
|
+
coach: CartoonCoach | null | undefined;
|
|
24
|
+
/** Run an app-driven step (the agent steps copy a prompt instead). */
|
|
25
|
+
onAction: (action: CoachUiAction, episodeFile: string | null) => void;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function WorkflowCoachView({ coach, onAction, className = "" }: WorkflowCoachViewProps) {
|
|
30
|
+
// Track the prompt that was copied rather than a bare boolean, so the "Copied!"
|
|
31
|
+
// confirmation derives to false the moment the coach (and its prompt) changes —
|
|
32
|
+
// no reset effect, no stale confirmation under a new stage.
|
|
33
|
+
const [copiedPrompt, setCopiedPrompt] = useState<string | null>(null);
|
|
34
|
+
const copied = copiedPrompt !== null && copiedPrompt === coach?.prompt;
|
|
35
|
+
|
|
36
|
+
if (!coach) return null;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
className={`flex items-center gap-2 px-3 py-2 bg-accent/5 border-b border-accent/30 text-xs ${className}`}
|
|
41
|
+
data-testid="workflow-coach"
|
|
42
|
+
data-stage={coach.stageLabel}
|
|
43
|
+
data-action-kind={coach.actionKind}
|
|
44
|
+
data-ui-action={coach.uiAction ?? ""}
|
|
45
|
+
>
|
|
46
|
+
<span className="rounded-full bg-background px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-accent flex-shrink-0" data-testid="workflow-coach-stage">
|
|
47
|
+
{coach.stageLabel}
|
|
48
|
+
</span>
|
|
49
|
+
<span className="min-w-0 flex-1 text-foreground" data-testid="workflow-coach-action">
|
|
50
|
+
<span className="text-muted">Next: </span>
|
|
51
|
+
<span className="font-medium">{coach.action}</span>
|
|
52
|
+
</span>
|
|
53
|
+
{coach.actionKind === "agent" && coach.prompt ? (
|
|
54
|
+
<button
|
|
55
|
+
onClick={() => {
|
|
56
|
+
if (!coach.prompt) return;
|
|
57
|
+
const prompt = coach.prompt;
|
|
58
|
+
navigator.clipboard?.writeText(prompt).then(() => setCopiedPrompt(prompt)).catch(() => {});
|
|
59
|
+
}}
|
|
60
|
+
data-testid="workflow-coach-copy"
|
|
61
|
+
className="flex-shrink-0 rounded bg-accent px-2.5 py-1 text-[11px] font-medium text-white hover:bg-accent-dim transition-colors"
|
|
62
|
+
>
|
|
63
|
+
{copied ? "Copied!" : "Copy prompt"}
|
|
64
|
+
</button>
|
|
65
|
+
) : coach.actionKind === "ui" && coach.uiAction ? (
|
|
66
|
+
<button
|
|
67
|
+
onClick={() => onAction(coach.uiAction!, coach.episodeFile)}
|
|
68
|
+
data-testid="workflow-coach-do"
|
|
69
|
+
className="flex-shrink-0 rounded bg-accent px-2.5 py-1 text-[11px] font-medium text-white hover:bg-accent-dim transition-colors"
|
|
70
|
+
>
|
|
71
|
+
{coach.action}
|
|
72
|
+
</button>
|
|
73
|
+
) : null}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface WorkflowCoachProps {
|
|
79
|
+
storyName: string;
|
|
80
|
+
/** The file currently in focus, so the coach speaks about that episode (#429). */
|
|
81
|
+
fileName?: string | null;
|
|
82
|
+
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
83
|
+
/** Bumped by the parent to reload after a state change (cut edit / publish). */
|
|
84
|
+
refreshKey?: number;
|
|
85
|
+
onAction: (action: CoachUiAction, episodeFile: string | null) => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Self-loading coach for the file views. Fetches the story progress (scoped to
|
|
90
|
+
* the focused file) and renders the coach bar. The coach is cleared in EVERY
|
|
91
|
+
* load exit path — at the start, on a non-OK response, and on error — so a
|
|
92
|
+
* previous file's coach can never linger under a different file when the new
|
|
93
|
+
* request fails or 404s (the stale-state-on-error class flagged on #420/#427).
|
|
94
|
+
*/
|
|
95
|
+
export function WorkflowCoach({ storyName, fileName, authFetch, refreshKey = 0, onAction }: WorkflowCoachProps) {
|
|
96
|
+
const [coach, setCoach] = useState<CartoonCoach | null>(null);
|
|
97
|
+
|
|
98
|
+
// Reset the coach the instant the target changes (file switch / refresh),
|
|
99
|
+
// during render — React's recommended way to reset state on a changing input.
|
|
100
|
+
// This clears the prior file's coach BEFORE the new load resolves; and because
|
|
101
|
+
// the effect below sets the coach to null on a non-OK response and never sets
|
|
102
|
+
// it on error, it also STAYS cleared when the new load fails or 404s (the
|
|
103
|
+
// stale-state-on-error class flagged on #420/#427).
|
|
104
|
+
//
|
|
105
|
+
// JSON.stringify keeps the key printable and source-safe (#437): it changes
|
|
106
|
+
// whenever any input changes — identical reset semantics — and it escapes the
|
|
107
|
+
// parts so a separator can never collide with the values' own content.
|
|
108
|
+
const targetKey = JSON.stringify([storyName, fileName ?? "", refreshKey]);
|
|
109
|
+
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
|
110
|
+
if (loadedKey !== targetKey) {
|
|
111
|
+
setCoach(null);
|
|
112
|
+
setLoadedKey(targetKey);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
let cancelled = false;
|
|
117
|
+
const focus = fileName ? `?focus=${encodeURIComponent(fileName)}` : "";
|
|
118
|
+
authFetch(`/api/stories/${storyName}/progress${focus}`)
|
|
119
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
120
|
+
.then((data: (StoryProgress & { coach?: CartoonCoach | null }) | null) => {
|
|
121
|
+
if (!cancelled) setCoach(data?.coach ?? null);
|
|
122
|
+
})
|
|
123
|
+
.catch(() => { /* leave it cleared — the coach is best-effort */ });
|
|
124
|
+
return () => { cancelled = true; };
|
|
125
|
+
}, [storyName, fileName, authFetch, refreshKey]);
|
|
126
|
+
|
|
127
|
+
return <WorkflowCoachView coach={coach} onAction={onAction} />;
|
|
128
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
type AuthFetch = (url: string, opts?: RequestInit) => Promise<Response>;
|
|
4
|
+
|
|
5
|
+
/** Resolve a story-relative asset path to its auth-protected API URL. */
|
|
6
|
+
export function assetUrl(storyName: string, assetPath: string): string {
|
|
7
|
+
const relative = assetPath.startsWith("assets/") ? assetPath.slice(7) : assetPath;
|
|
8
|
+
return `/api/stories/${storyName}/asset/${relative}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface AssetState {
|
|
12
|
+
/** Same-origin blob object URL safe to use as an <img src>, or null. */
|
|
13
|
+
url: string | null;
|
|
14
|
+
loading: boolean;
|
|
15
|
+
error: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load an auth-protected story asset as a blob object URL.
|
|
20
|
+
*
|
|
21
|
+
* Story asset routes sit behind `requireAuth`, but a browser `<img src>`
|
|
22
|
+
* request never carries the `Authorization: Bearer` header that `authFetch`
|
|
23
|
+
* adds, so the raw URL 401s and the image breaks. Instead we fetch the asset
|
|
24
|
+
* via `authFetch`, turn the response into a blob, and hand back an object URL.
|
|
25
|
+
* The object URL is revoked when the asset path changes or the component
|
|
26
|
+
* unmounts so we don't leak blobs across cut selections.
|
|
27
|
+
*/
|
|
28
|
+
export function useAuthedAsset(
|
|
29
|
+
storyName: string,
|
|
30
|
+
assetPath: string | null | undefined,
|
|
31
|
+
authFetch: AuthFetch,
|
|
32
|
+
): AssetState {
|
|
33
|
+
const [state, setState] = useState<AssetState>({
|
|
34
|
+
url: null,
|
|
35
|
+
loading: !!assetPath,
|
|
36
|
+
error: false,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!assetPath) {
|
|
41
|
+
setState({ url: null, loading: false, error: false });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let objectUrl: string | null = null;
|
|
46
|
+
let cancelled = false;
|
|
47
|
+
setState({ url: null, loading: true, error: false });
|
|
48
|
+
|
|
49
|
+
(async () => {
|
|
50
|
+
try {
|
|
51
|
+
const res = await authFetch(assetUrl(storyName, assetPath));
|
|
52
|
+
if (!res.ok) throw new Error(`asset request failed (${res.status})`);
|
|
53
|
+
const blob = await res.blob();
|
|
54
|
+
if (cancelled) return;
|
|
55
|
+
objectUrl = URL.createObjectURL(blob);
|
|
56
|
+
setState({ url: objectUrl, loading: false, error: false });
|
|
57
|
+
} catch {
|
|
58
|
+
if (!cancelled) setState({ url: null, loading: false, error: true });
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
|
|
62
|
+
return () => {
|
|
63
|
+
cancelled = true;
|
|
64
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
65
|
+
};
|
|
66
|
+
}, [storyName, assetPath, authFetch]);
|
|
67
|
+
|
|
68
|
+
return state;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface AssetImageProps {
|
|
72
|
+
storyName: string;
|
|
73
|
+
assetPath: string;
|
|
74
|
+
authFetch: AuthFetch;
|
|
75
|
+
alt: string;
|
|
76
|
+
className?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Render an auth-protected story asset as an image, loading it through
|
|
81
|
+
* `useAuthedAsset`. Shows a neutral placeholder while loading and a clear
|
|
82
|
+
* "Image not available" state on failure so a broken auth boundary surfaces
|
|
83
|
+
* instead of a broken-image glyph.
|
|
84
|
+
*/
|
|
85
|
+
export function AssetImage({ storyName, assetPath, authFetch, alt, className }: AssetImageProps) {
|
|
86
|
+
const { url, loading, error } = useAuthedAsset(storyName, assetPath, authFetch);
|
|
87
|
+
|
|
88
|
+
if (error || (!loading && !url)) {
|
|
89
|
+
return (
|
|
90
|
+
<div className="w-full aspect-video bg-surface border border-border rounded flex items-center justify-center">
|
|
91
|
+
<span className="text-xs text-muted">Image not available</span>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!url) {
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
className="w-full aspect-video bg-surface border border-border rounded flex items-center justify-center"
|
|
100
|
+
data-testid="asset-loading"
|
|
101
|
+
>
|
|
102
|
+
<span className="text-xs text-muted">Loading image…</span>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<img
|
|
109
|
+
src={url}
|
|
110
|
+
alt={alt}
|
|
111
|
+
className={className ?? "w-full rounded border border-border"}
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
/** Object URL returned by the stubbed `URL.createObjectURL` in jsdom tests. */
|
|
4
|
+
export const MOCK_BLOB_URL = "blob:mock-asset-url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* jsdom doesn't implement the object-URL APIs that `useAuthedAsset` relies on.
|
|
8
|
+
* Stub them so blob-backed asset loading works in component tests, and so a
|
|
9
|
+
* test can assert that an `<img>` points at the object URL rather than the
|
|
10
|
+
* raw auth-protected API route.
|
|
11
|
+
*/
|
|
12
|
+
export function installObjectUrlStub(): void {
|
|
13
|
+
const u = URL as unknown as {
|
|
14
|
+
createObjectURL: (b: Blob) => string;
|
|
15
|
+
revokeObjectURL: (s: string) => void;
|
|
16
|
+
};
|
|
17
|
+
u.createObjectURL = vi.fn(() => MOCK_BLOB_URL);
|
|
18
|
+
u.revokeObjectURL = vi.fn();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `authFetch` double for tests that render asset-loading components. Asset
|
|
23
|
+
* routes (`/asset/`) resolve to an image blob the way the real authenticated
|
|
24
|
+
* route does; every other route resolves to `jsonData`. This mirrors the real
|
|
25
|
+
* world where the Bearer header reaches both the data route and the image —
|
|
26
|
+
* the bug being fixed was a raw `<img src>` that never sent that header.
|
|
27
|
+
*/
|
|
28
|
+
export function makeAssetAuthFetch(jsonData: unknown = {}) {
|
|
29
|
+
return vi.fn((url: string) =>
|
|
30
|
+
Promise.resolve(
|
|
31
|
+
url.includes("/asset/")
|
|
32
|
+
? {
|
|
33
|
+
ok: true,
|
|
34
|
+
status: 200,
|
|
35
|
+
blob: () => Promise.resolve(new Blob(["img-bytes"], { type: "image/webp" })),
|
|
36
|
+
}
|
|
37
|
+
: {
|
|
38
|
+
ok: true,
|
|
39
|
+
status: 200,
|
|
40
|
+
json: () => Promise.resolve(jsonData),
|
|
41
|
+
},
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
}
|