Sitesmith: self-review is intent 'review' (not a mockup); typed statuses with specific messages (G3, G6)

- The self-review follow-up sends intent:'review' + review_image_ids instead of
  putting the render in mockup_ids, so the backend builds a critique+patch
  instruction instead of 'build toward this mockup'. Render still deleted after.
- types/sitesmith.ts: SitesmithStatus = server statuses (adds IMAGES_BUSY,
  TOO_MANY_JOBS, SERVER_BUSY, ...) + client statuses (TIMEOUT, ABORTED, ...);
  network status strings are narrowed with a type guard (unknown -> ERROR);
  the hook builds SendResultErr without casts.
- statusMessages.ts: one specific message per status (a Record over the union,
  so a new status without a message fails tsc), used by the modal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 11:10:30 -08:00
1 parent 7a58921ba3
commit 6c406ca99b
9 files changed
+202 -33

No files matched your search

+1 -1
View File
@@ -324,7 +324,7 @@ scenario('8', 'self-review round trip (one round)', async ({ page, name, ok }) =
!!up && up.mime === 'image/jpeg' && up.kind === 'mockup' && up.name === 'self-review-render.jpg' && up.size > 2000, JSON.stringify(up));
const s2 = srv.starts[1] ?? {};
ok('second job message starts "Review the attached screenshot"', typeof s2.message === 'string' && s2.message.startsWith('Review the attached screenshot'), String(s2.message).slice(0, 60));
ok('second job carries the upload id in mockup_ids', Array.isArray(s2.mockup_ids) && s2.mockup_ids.includes(up?.id), JSON.stringify(s2.mockup_ids));
ok('second job is intent "review" carrying the upload id in review_image_ids (not mockup_ids)', s2.intent === 'review' && Array.isArray(s2.review_image_ids) && s2.review_image_ids.includes(up?.id) && !s2.mockup_ids, JSON.stringify({ intent: s2.intent, review_image_ids: s2.review_image_ids, mockup_ids: s2.mockup_ids }));
ok('second job has use_assets false', s2.use_assets === false, String(s2.use_assets));
ok('render image deleted afterwards', srv.imageDeletes.includes(up?.id), JSON.stringify(srv.imageDeletes));
await page.waitForTimeout(2500);
+31
View File
@@ -430,6 +430,37 @@ describe('request payload: design, assets and attachments', () => {
});
});
test('self-review: intent review + review_image_ids (the render is never sent as a mockup); typed failure statuses', async () => {
const bodies: any[] = [];
override = (url, init) => {
if (url.includes('action=start')) { bodies.push(JSON.parse(init.body)); return { status: 200, json: async () => ({ ok: true, job_id: 'j8', status: 'running', phase: 'queued' }) }; }
if (url.includes('action=poll')) return { status: 200, json: async () => ({ ok: true, status: 'error', phase: 'error', result: { ok: false, status: 'IMAGES_BUSY', message: 'busy images' } }) };
return undefined;
};
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
let res: any;
try {
await act(async () => {
const p = api.send('Review', 'canvas', undefined, { intent: 'review', reviewImageIds: ['b'.repeat(32)], useAssets: false });
await vi.advanceTimersByTimeAsync(1200);
res = await p;
});
} finally { vi.useRealTimers(); }
expect(bodies[0]).toMatchObject({ intent: 'review', review_image_ids: ['b'.repeat(32)], use_assets: false });
expect(bodies[0].mockup_ids).toBeUndefined();
expect(res).toEqual({ ok: false, status: 'IMAGES_BUSY', message: 'busy images' });
});
test('an unknown server status is narrowed to a known one (ERROR), never passed through', async () => {
override = (url) => {
if (url.includes('action=start')) return { status: 200, json: async () => ({ ok: false, status: 'WEIRD_NEW', message: 'Something odd happened.' }) };
return undefined;
};
let res: any;
await act(async () => { res = await api.send('hi', 'c'); });
expect(res).toMatchObject({ ok: false, status: 'ERROR', message: 'Something odd happened.' });
});
test('an empty design is not sent', async () => {
const bodies: any[] = [];
override = (url, init) => {
+32 -20
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useEditorConfig } from '../state/EditorConfigContext';
import { useProjectExtras } from '../state/ProjectExtrasContext';
import { PROFILE_EXTRAS_KEY, isProfileComplete, normalizeProfile, profileToPrompt } from '../utils/sitesmith-profile';
import { SitesmithSummary, SitesmithMessage, SendResult } from '../types/sitesmith';
import { SitesmithSummary, SitesmithMessage, SendResult, SendResultErr, SitesmithStatus, SitesmithClientStatus, toSitesmithStatus } from '../types/sitesmith';
function apiBase(apiUrl: string): string {
return apiUrl.replace(/site-builder\.php$/, 'sitesmith.php');
@@ -57,22 +57,23 @@ export type SitesmithFailure = { ok: false; message: string; status?: string; er
export type SitesmithApiResult<T> = ({ ok: true } & T) | SitesmithFailure;
export interface SitesmithSendOpts {
intent?: 'regenerate_section';
/** 'review' = the self-review follow-up: `reviewImageIds` is the render of the current page (never a mockup). */
intent?: 'regenerate_section' | 'review';
mockupIds?: string[];
/** intent 'review' only: uploaded render image ids (sent as review_image_ids). */
reviewImageIds?: string[];
referenceIds?: number[];
useAssets?: boolean;
/** Current site design (see utils/sitesmith-design.ts); read by the backend prompt builder. */
design?: Record<string, unknown>;
/** Uploaded assets Sitesmith may see (relative filenames). Omitted = all. Needs backend support; see report. */
/** Uploaded assets Sitesmith may see (their filenames, validated by the backend against the site's assets). Omitted = all. */
assetFilenames?: string[];
onProgress?: (phase: string) => void;
signal?: AbortSignal;
}
/** Extra statuses this hook can produce beyond SitesmithStatus (returned cast as SendResult). */
export type SitesmithHookStatus =
| 'NETWORK' | 'TIMEOUT' | 'ABORTED' | 'JOB_IN_PROGRESS' | 'LOST_CONNECTION' | 'AUTH_REQUIRED'
| 'FORBIDDEN' | 'TOO_LARGE' | 'SERVER_ERROR' | 'BAD_RESPONSE' | 'JOB_NOT_FOUND' | 'CANCELLED' | 'ERROR';
/** Statuses this hook produces itself (now part of SitesmithStatus in types/sitesmith.ts). */
export type SitesmithHookStatus = SitesmithClientStatus;
// ------------------------------------------------------------ constants
@@ -118,15 +119,14 @@ function sensibleMessage(body: any): string | null {
}
/** Map any non-success HTTP/body combination to a specific, plain-language failure. */
export function describeFailure(http: number, body: any | null): { status: SitesmithHookStatus | string; message: string } {
export function describeFailure(http: number, body: any | null): { status: SitesmithStatus; message: string } {
const err = typeof body?.error === 'string' ? body.error : '';
if (http === 401 || AUTH_ERRORS.has(err)) return { status: 'AUTH_REQUIRED', message: MSG_AUTH };
const server = sensibleMessage(body);
const byHttp = (): SitesmithHookStatus =>
http === 413 ? 'TOO_LARGE' : http === 403 ? 'FORBIDDEN' : http === 429 ? 'RATE_LIMITED' as any : http >= 500 ? 'SERVER_ERROR' : 'ERROR';
const byHttp = (): SitesmithStatus =>
http === 413 ? 'TOO_LARGE' : http === 403 ? 'FORBIDDEN' : http === 429 ? 'RATE_LIMITED' : http >= 500 ? 'SERVER_ERROR' : 'ERROR';
if (server) {
const st = typeof body?.status === 'string' && body.status ? body.status : byHttp();
return { status: st, message: server };
return { status: toSitesmithStatus(body?.status, byHttp()), message: server };
}
if (!body) {
if (http === 403) return { status: 'FORBIDDEN', message: 'The request was blocked (HTTP 403) by a security filter or proxy before it reached Sitesmith. Try again; if it keeps happening, contact support.' };
@@ -139,11 +139,22 @@ export function describeFailure(http: number, body: any | null): { status: Sites
if (http === 413) return { status: 'TOO_LARGE', message: 'That request was too large for the server to accept (HTTP 413). Try a shorter message or a smaller image.' };
if (http === 429) return { status: 'RATE_LIMITED', message: 'Too many requests (HTTP 429). Wait a moment and try again.' };
if (http >= 500) return { status: 'SERVER_ERROR', message: `The server had a problem (HTTP ${http}). Try again in a minute.` };
return { status: typeof body.status === 'string' && body.status ? body.status : byHttp(), message: `Sitesmith could not complete that request (HTTP ${http}${err ? `, ${err}` : ''}).` };
return { status: toSitesmithStatus(body.status, byHttp()), message: `Sitesmith could not complete that request (HTTP ${http}${err ? `, ${err}` : ''}).` };
}
const failure = (status: string, message: string, extra?: Record<string, unknown>): SendResult =>
({ ok: false, status, message, ...(extra ?? {}) } as unknown as SendResult);
const failure = (status: SitesmithStatus, message: string, extra?: { jobId?: string }): SendResultErr =>
(extra?.jobId ? { ok: false, status, message, jobId: extra.jobId } : { ok: false, status, message });
/** A job/send result from the server (untrusted JSON) as a typed SendResult. */
function asSendResult(res: any): SendResult {
if (res && res.ok === true && res.response && typeof res.response === 'object' && typeof res.response.type === 'string') {
return Array.isArray(res.notes) ? { ok: true, response: res.response, notes: res.notes.filter((n: unknown) => typeof n === 'string') } : { ok: true, response: res.response };
}
return failure(toSitesmithStatus(res?.status, 'ERROR'), sensibleMessage(res) ?? 'Sitesmith could not complete that request. Please try again.');
}
/** The status of a failed result, or null for a success. */
const statusOf = (r: SendResult): SitesmithStatus | null => (r.ok ? null : r.status);
const isTransientPoll = (p: Parsed): boolean =>
p.net || p.body === null || p.http === 429 || p.http >= 500 || p.http === 408;
@@ -291,7 +302,7 @@ export function useSitesmith(siteId: number) {
}
if (body.status && body.status !== 'running') {
const res = body.result;
if (res && typeof res === 'object') return res as SendResult;
if (res && typeof res === 'object') return asSendResult(res);
if (body.status === 'cancelled') return failure('CANCELLED', 'The request was cancelled.', { jobId });
return failure('ERROR', 'Sitesmith finished without a result. Check the history.', { jobId });
}
@@ -316,7 +327,7 @@ export function useSitesmith(siteId: number) {
body: JSON.stringify(body),
});
if (p.net) return failure('NETWORK', MSG_NETWORK);
if (p.body && (p.body.ok === true || (p.body.ok === false && sensibleMessage(p.body) && p.http < 400))) return p.body as SendResult;
if (p.body && (p.body.ok === true || (p.body.ok === false && sensibleMessage(p.body) && p.http < 400))) return asSendResult(p.body);
const f = describeFailure(p.http, p.body);
return failure(f.status, f.message);
}, [whpConfig]);
@@ -349,6 +360,7 @@ export function useSitesmith(siteId: number) {
if (opts?.mockupIds?.length) startBody.mockup_ids = opts.mockupIds;
if (opts?.referenceIds?.length) startBody.reference_ids = opts.referenceIds;
if (opts?.useAssets !== undefined) startBody.use_assets = opts.useAssets;
if (opts?.intent === 'review' && opts.reviewImageIds?.length) startBody.review_image_ids = opts.reviewImageIds;
if (opts?.assetFilenames) startBody.asset_filenames = opts.assetFilenames;
const p = await request(`${apiBase(whpConfig.apiUrl)}?action=start`, {
method: 'POST',
@@ -376,7 +388,7 @@ export function useSitesmith(siteId: number) {
result = await pollJob(jobId, { onProgress: opts?.onProgress, signal: opts?.signal });
} else if (p.body && p.body.ok === false && p.http < 400 && p.body.status) {
// Entitlement / limit refusal: HTTP 200 {ok:false,status,message}
result = failure(p.body.status, sensibleMessage(p.body) ?? 'Sitesmith is not available right now.');
result = failure(toSitesmithStatus(p.body.status, 'ERROR'), sensibleMessage(p.body) ?? 'Sitesmith is not available right now.');
} else {
const f = describeFailure(p.http, p.body);
result = failure(f.status, f.message);
@@ -385,7 +397,7 @@ export function useSitesmith(siteId: number) {
result = await legacySend(body);
}
void usedStart;
const st = (result as any).status;
const st = statusOf(result);
// Keep the job resumable when we merely stopped waiting.
if (st !== 'ABORTED' && st !== 'TIMEOUT' && st !== 'LOST_CONNECTION' && st !== 'JOB_IN_PROGRESS') finalize();
else if (st !== 'JOB_IN_PROGRESS' && alive.current) { void fetchHistory(); }
@@ -402,7 +414,7 @@ export function useSitesmith(siteId: number) {
const pr = (async () => {
try {
const res = await pollJob(id, opts);
const st = (res as any).status;
const st = statusOf(res);
if (st !== 'ABORTED' && st !== 'TIMEOUT' && st !== 'LOST_CONNECTION') finalize();
return res;
} catch (e: any) {
@@ -229,7 +229,10 @@ describe('Sitesmith modal: self-review', () => {
expect(net.uploads).toBe(1);
expect(net.starts).toHaveLength(2);
expect(net.starts[1].message).toMatch(/^Review the attached screenshot/);
expect(net.starts[1].mockup_ids).toEqual(['f'.repeat(32)]);
// G3: the render is a REVIEW of the current page, not a mockup to build toward.
expect(net.starts[1].intent).toBe('review');
expect(net.starts[1].review_image_ids).toEqual(['f'.repeat(32)]);
expect(net.starts[1].mockup_ids).toBeUndefined();
expect(net.starts[1].use_assets).toBe(false);
expect(net.deleted).toEqual(['f'.repeat(32)]);
expect(q('[data-testid=ss-review-note]')!.textContent).toMatch(/looked at the finished page/);
@@ -17,6 +17,7 @@ import { buildDesignPayload } from '../../utils/sitesmith-design';
import { composeReviewHtml } from '../../utils/compose-review-html';
import { captureHtml, resolveAssetUrls } from '../../utils/self-review-capture';
import { runSelfReview } from './selfReview';
import { failureMessage } from './statusMessages';
import { useSitesmithRefs } from './useSitesmithRefs';
import { Section, warnBox, errBox as errBoxStyle, note as noteStyle } from './Section';
import { AttachmentTray } from './AttachmentTray';
@@ -201,7 +202,8 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
return r.ok ? { ok: true, id: r.image.id } : { ok: false, message: r.message };
},
review: (msg, imageId) => send(msg, summarizeCanvas(query.getSerializedNodes()), undefined, {
design: buildDesignPayload(design), mockupIds: [imageId], useAssets: false,
// G3: a REVIEW of the current page (critique + patch), never "build toward this mockup".
intent: 'review', reviewImageIds: [imageId], design: buildDesignPayload(design), useAssets: false,
onProgress: setPhase, signal: abortRef.current?.signal,
}),
applyResult: async (res) => {
@@ -229,10 +231,9 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
const handleResult = async (res: SendResult, review: boolean) => {
if (!res.ok) {
const st = String((res as { status?: string }).status ?? '');
if (st === 'ABORTED') return;
if (st === 'CANCELLED') { setNotes(['Cancelled. Nothing was changed on your page.']); return; }
setError(friendly(res.message));
if (res.status === 'ABORTED') return;
if (res.status === 'CANCELLED') { setNotes([failureMessage(res)]); return; }
setError(failureMessage(res));
return;
}
setNotes(res.notes ?? []);
@@ -286,7 +287,7 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
try {
const canvas = summarizeCanvas(query.getSerializedNodes());
const result = await send('Regenerate this section for my business.', canvas, targetPayload, { intent: 'regenerate_section', design: buildDesignPayload(design), useAssets: useAssetsFlag, assetFilenames: assetFilter });
if (!result.ok) { setError(friendly(result.message)); return; }
if (!result.ok) { setError(failureMessage(result)); return; }
const r = applyRegeneratedSection(actions, query, repairForCanvas(result.response, design, query).response, target.nodeId);
if (!r.ok) { setError(r.message || 'Apply failed'); return; }
notify({
@@ -509,7 +510,7 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
</div>
<ScopeConfirmDialog
open={!!pendingReplace}
pendingMessage={pendingReplace && 'message' in pendingReplace.resp ? (pendingReplace.resp as any).message : undefined}
pendingMessage={pendingReplace && 'message' in pendingReplace.resp ? pendingReplace.resp.message : undefined}
onConfirm={confirmReplace}
onCancel={() => setPendingReplace(null)}
/>
+5 -1
View File
@@ -1,7 +1,11 @@
import type { SendResult } from '../../types/sitesmith';
import type { CaptureResult } from '../../utils/self-review-capture';
/** Instruction for the one follow-up job. The render rides in the mockup slot, so say plainly what it is. */
/**
* Instruction for the one follow-up job. It is sent with intent 'review' and the render as
* review_image_ids, so the backend adds its SELF-REVIEW block (critique + patch) -- the render is
* never treated as a mockup. This text is what the user sees in the history.
*/
export const REVIEW_MESSAGE =
'Review the attached screenshot of my page as it is built now (it is a render of the current page, not a mockup to copy). ' +
'Fix only real problems with a small patch: hard-to-read text, cramped or uneven spacing, broken or misaligned layout, badly cropped images, missing icons. ' +
@@ -0,0 +1,39 @@
import { describe, test, expect } from 'vitest';
import { SITESMITH_CLIENT_STATUSES, SITESMITH_SERVER_STATUSES, type SitesmithStatus } from '../../types/sitesmith';
import { failureMessage, STATUS_MESSAGES } from './statusMessages';
const FAILURES: SitesmithStatus[] = [...SITESMITH_SERVER_STATUSES, ...SITESMITH_CLIENT_STATUSES].filter((s) => s !== 'OK_BONUS' && s !== 'OK_MONTHLY');
describe('Sitesmith failure messages (G6)', () => {
test('every failure status has its own specific, plain message', () => {
const seen = new Map<string, string>();
for (const st of FAILURES) {
const m = STATUS_MESSAGES[st].text;
expect(m.length, st).toBeGreaterThan(20);
expect(m, st).not.toMatch(/undefined|null|NetworkError|HTTP 0/);
expect(seen.has(m), `${st} duplicates ${seen.get(m)}`).toBe(false);
seen.set(m, st);
}
});
test('the new job/limit statuses say what to do', () => {
expect(failureMessage({ ok: false, status: 'TOO_MANY_JOBS', message: '' })).toMatch(/other requests/i);
expect(failureMessage({ ok: false, status: 'SERVER_BUSY', message: '' })).toMatch(/busy/i);
expect(failureMessage({ ok: false, status: 'IMAGES_BUSY', message: '' })).toMatch(/images/i);
expect(failureMessage({ ok: false, status: 'JOB_IN_PROGRESS', message: '' })).toMatch(/still working/i);
expect(failureMessage({ ok: false, status: 'TIMEOUT', message: '' })).toMatch(/longer than expected/i);
expect(failureMessage({ ok: false, status: 'ABORTED', message: '' })).toMatch(/stopped waiting/i);
expect(failureMessage({ ok: false, status: 'INTERRUPTED', message: '' })).toMatch(/stopped unexpectedly/i);
expect(failureMessage({ ok: false, status: 'AI_INVALID', message: '' })).toMatch(/could not use/i);
});
test('statuses whose server text carries the detail keep it; fixed-meaning statuses use the specific text', () => {
expect(failureMessage({ ok: false, status: 'BAD_ATTACHMENT', message: 'Self-review needs an AI model that can see images.' }))
.toBe('Self-review needs an AI model that can see images.');
expect(failureMessage({ ok: false, status: 'TOO_MANY_JOBS', message: 'generic' })).toBe(STATUS_MESSAGES.TOO_MANY_JOBS.text);
// a raw browser error never reaches the user
expect(failureMessage({ ok: false, status: 'ERROR', message: 'NetworkError when attempting to fetch resource.' })).toBe(STATUS_MESSAGES.NETWORK.text);
// HTML-looking server text is not shown
expect(failureMessage({ ok: false, status: 'AI_ERROR', message: '<b>x</b>' })).toBe(STATUS_MESSAGES.AI_ERROR.text);
});
});
@@ -0,0 +1,50 @@
/**
* One specific, plain-language message per Sitesmith failure status (backend statuses from
* WHP docs/sitesmith-backend.md + the editor transport's own). `preferServer`: the server's text
* for that status carries the useful detail (which image, why), so it is shown when present.
* A Record over the full union, so adding a status without a message fails `tsc`.
*/
import type { SendResultErr, SitesmithStatus } from '../../types/sitesmith';
import { MSG_LOST, MSG_NETWORK, MSG_TIMEOUT } from '../../hooks/useSitesmith';
export const STATUS_MESSAGES: Record<SitesmithStatus, { text: string; preferServer?: boolean }> = {
OK_BONUS: { text: 'Sitesmith is ready (using a bonus credit).' },
OK_MONTHLY: { text: 'Sitesmith is ready (using this month\'s allowance).' },
DISABLED: { text: 'Sitesmith is not enabled for your account. Contact support to turn it on.' },
CAP_REACHED: { text: 'You have used all of this month\'s Sitesmith requests. They reset at the start of next month.' },
USER_KILLSWITCH: { text: 'You have reached today\'s Sitesmith spending limit. It resets tomorrow.' },
SERVER_KILLSWITCH: { text: 'Sitesmith has reached this server\'s daily limit. Please try again tomorrow.' },
RATE_LIMITED: { text: 'Too many Sitesmith requests in a short time. Wait a minute and try again.' },
BLOCKED: { text: 'Your message was blocked by the content guard. Please rephrase it and try again.', preferServer: true },
AI_ERROR: { text: 'The AI service had a problem answering. Please try again in a moment.', preferServer: true },
AI_INVALID: { text: 'The AI returned something Sitesmith could not use. Please try again, perhaps with a simpler request.' },
BAD_ATTACHMENT: { text: 'One of the attached images or example sites could not be used. Remove it and try again.', preferServer: true },
IMAGES_BUSY: { text: 'The server is busy preparing other images, so your attached images could not be prepared. Try again in a moment.' },
INTERRUPTED: { text: 'Sitesmith stopped unexpectedly (the server may have restarted). Nothing was changed; please try again.' },
CANCELLED: { text: 'Cancelled. Nothing was changed on your page.' },
JOB_IN_PROGRESS: { text: 'Sitesmith is still working on your previous request for this site. Wait for it to finish, or reopen Sitesmith to follow it.' },
TOO_MANY_JOBS: { text: 'Sitesmith is already working on your other requests. Wait for one of them to finish, then try again.' },
SERVER_BUSY: { text: 'Sitesmith is busy on this server right now. Please try again in a minute.' },
NETWORK: { text: MSG_NETWORK },
TIMEOUT: { text: MSG_TIMEOUT },
ABORTED: { text: 'Stopped waiting for Sitesmith. It may still finish in the background; reopen Sitesmith to see the result.' },
LOST_CONNECTION: { text: MSG_LOST },
AUTH_REQUIRED: { text: 'Your session has expired. Refresh the page and sign in again, then retry.' },
FORBIDDEN: { text: 'The request was blocked by a security filter or proxy before it reached Sitesmith. Try again; if it keeps happening, contact support.', preferServer: true },
TOO_LARGE: { text: 'That request was too large for the server to accept. Try a shorter message or a smaller image.', preferServer: true },
SERVER_ERROR: { text: 'The server had a problem handling the request. Try again in a minute.', preferServer: true },
BAD_RESPONSE: { text: 'The server sent a reply Sitesmith could not read. Try again, and refresh the page if it persists.', preferServer: true },
JOB_NOT_FOUND: { text: 'That Sitesmith request was not found; it may have expired. Check the history.', preferServer: true },
ERROR: { text: 'Something went wrong while talking to Sitesmith. Please try again.', preferServer: true },
};
const BROWSER_ERROR = /NetworkError|Failed to fetch|Load failed|network request failed|AbortError/i;
/** The message to show for a failed send/poll result. */
export function failureMessage(res: SendResultErr): string {
const entry = STATUS_MESSAGES[res.status] ?? STATUS_MESSAGES.ERROR;
const server = (res.message ?? '').trim();
if (BROWSER_ERROR.test(server)) return STATUS_MESSAGES.NETWORK.text;
if (entry.preferServer && server && server.length <= 600 && !/<\/?[a-z][^>]*>/i.test(server)) return server;
return entry.text;
}
+32 -3
View File
@@ -1,11 +1,40 @@
import { SerializedNodes } from '@craftjs/core';
export type SitesmithStatus =
/** Statuses the backend returns (WHP docs/sitesmith-backend.md "Conventions"). */
export type SitesmithServerStatus =
| 'OK_BONUS' | 'OK_MONTHLY'
| 'DISABLED' | 'CAP_REACHED'
| 'USER_KILLSWITCH' | 'SERVER_KILLSWITCH'
| 'RATE_LIMITED' | 'BLOCKED' | 'AI_ERROR' | 'AI_INVALID'
| 'BAD_ATTACHMENT' | 'INTERRUPTED' | 'CANCELLED' | 'JOB_IN_PROGRESS';
| 'BAD_ATTACHMENT' | 'IMAGES_BUSY' | 'INTERRUPTED' | 'CANCELLED'
| 'JOB_IN_PROGRESS' | 'TOO_MANY_JOBS' | 'SERVER_BUSY';
/** Statuses the editor's transport (hooks/useSitesmith.ts) produces itself. */
export type SitesmithClientStatus =
| 'NETWORK' | 'TIMEOUT' | 'ABORTED' | 'LOST_CONNECTION' | 'AUTH_REQUIRED'
| 'FORBIDDEN' | 'TOO_LARGE' | 'SERVER_ERROR' | 'BAD_RESPONSE' | 'JOB_NOT_FOUND' | 'ERROR';
export type SitesmithStatus = SitesmithServerStatus | SitesmithClientStatus;
export const SITESMITH_SERVER_STATUSES: readonly SitesmithServerStatus[] = [
'OK_BONUS', 'OK_MONTHLY', 'DISABLED', 'CAP_REACHED', 'USER_KILLSWITCH', 'SERVER_KILLSWITCH',
'RATE_LIMITED', 'BLOCKED', 'AI_ERROR', 'AI_INVALID', 'BAD_ATTACHMENT', 'IMAGES_BUSY', 'INTERRUPTED',
'CANCELLED', 'JOB_IN_PROGRESS', 'TOO_MANY_JOBS', 'SERVER_BUSY',
];
export const SITESMITH_CLIENT_STATUSES: readonly SitesmithClientStatus[] = [
'NETWORK', 'TIMEOUT', 'ABORTED', 'LOST_CONNECTION', 'AUTH_REQUIRED', 'FORBIDDEN', 'TOO_LARGE',
'SERVER_ERROR', 'BAD_RESPONSE', 'JOB_NOT_FOUND', 'ERROR',
];
const ALL_STATUSES: ReadonlySet<string> = new Set<string>([...SITESMITH_SERVER_STATUSES, ...SITESMITH_CLIENT_STATUSES]);
export function isSitesmithStatus(v: unknown): v is SitesmithStatus {
return typeof v === 'string' && ALL_STATUSES.has(v);
}
/** A status string from the network, narrowed to a known status (unknown values -> `fallback`). */
export function toSitesmithStatus(v: unknown, fallback: SitesmithStatus): SitesmithStatus {
return isSitesmithStatus(v) ? v : fallback;
}
export interface SitesmithSummary {
enabled: boolean;
@@ -46,5 +75,5 @@ export type SitesmithResponse =
| { type: 'ask'; question: string; options?: string[]; };
export interface SendResultOk { ok: true; response: SitesmithResponse; /** Non-blocking server messages (e.g. images skipped). */ notes?: string[]; }
export interface SendResultErr { ok: false; status: SitesmithStatus | 'BLOCKED'; message: string; }
export interface SendResultErr { ok: false; status: SitesmithStatus; message: string; /** The job this failure belongs to (resumable when we only stopped waiting). */ jobId?: string; }
export type SendResult = SendResultOk | SendResultErr;