diff --git a/craft/scripts/e2e-sitesmith.mjs b/craft/scripts/e2e-sitesmith.mjs index 777a3b4..0a4ce80 100644 --- a/craft/scripts/e2e-sitesmith.mjs +++ b/craft/scripts/e2e-sitesmith.mjs @@ -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); diff --git a/craft/src/hooks/useSitesmith.test.ts b/craft/src/hooks/useSitesmith.test.ts index 07d4196..62b81ee 100644 --- a/craft/src/hooks/useSitesmith.test.ts +++ b/craft/src/hooks/useSitesmith.test.ts @@ -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) => { diff --git a/craft/src/hooks/useSitesmith.ts b/craft/src/hooks/useSitesmith.ts index cf084f2..2a86450 100644 --- a/craft/src/hooks/useSitesmith.ts +++ b/craft/src/hooks/useSitesmith.ts @@ -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 = ({ 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; - /** 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): 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) { diff --git a/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx b/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx index 10355cc..25af50b 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.flows.test.tsx @@ -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/); diff --git a/craft/src/panels/sitesmith/SitesmithModal.tsx b/craft/src/panels/sitesmith/SitesmithModal.tsx index e021220..dccbfa8 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ onClose, target }) => { setPendingReplace(null)} /> diff --git a/craft/src/panels/sitesmith/selfReview.ts b/craft/src/panels/sitesmith/selfReview.ts index 150949c..ef4a6ab 100644 --- a/craft/src/panels/sitesmith/selfReview.ts +++ b/craft/src/panels/sitesmith/selfReview.ts @@ -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. ' + diff --git a/craft/src/panels/sitesmith/statusMessages.test.ts b/craft/src/panels/sitesmith/statusMessages.test.ts new file mode 100644 index 0000000..273d0ac --- /dev/null +++ b/craft/src/panels/sitesmith/statusMessages.test.ts @@ -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(); + 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: 'x' })).toBe(STATUS_MESSAGES.AI_ERROR.text); + }); +}); diff --git a/craft/src/panels/sitesmith/statusMessages.ts b/craft/src/panels/sitesmith/statusMessages.ts new file mode 100644 index 0000000..5603781 --- /dev/null +++ b/craft/src/panels/sitesmith/statusMessages.ts @@ -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 = { + 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; +} diff --git a/craft/src/types/sitesmith.ts b/craft/src/types/sitesmith.ts index 106332e..ec7360f 100644 --- a/craft/src/types/sitesmith.ts +++ b/craft/src/types/sitesmith.ts @@ -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 = new Set([...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;