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:
1 parent
7a58921ba3
commit
6c406ca99b
9 files changed
+202
-33
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user