diff --git a/craft/src/hooks/useSitesmith.test.ts b/craft/src/hooks/useSitesmith.test.ts index 06eba11..daf0645 100644 --- a/craft/src/hooks/useSitesmith.test.ts +++ b/craft/src/hooks/useSitesmith.test.ts @@ -16,14 +16,23 @@ let root: Root; let container: HTMLDivElement; let sends: any[]; let entitlement: any; +// Per-test override: return a response-ish object to take over, or undefined to fall through. +let override: ((url: string, init?: any) => any) | undefined; +let calls: Array<{ url: string; init?: any }>; const Probe: React.FC = () => { api = useSitesmith(7); extras = useProjectExtras(); return null; }; beforeEach(async () => { sends = []; + override = undefined; + calls = []; entitlement = { ok: true, summary: { enabled: true, status: 'OK_MONTHLY' } }; vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => { - const json = (v: unknown) => ({ json: async () => v }); + const json = (v: unknown) => ({ status: 200, json: async () => v }); + calls.push({ url, init }); + if (override) { const o = await override(url, init); if (o !== undefined) return o; } + // Legacy backend: no `start` action, so existing tests exercise the blocking `send` path. + if (url.includes('action=start')) return { status: 400, json: async () => ({ ok: false, error: 'unknown_action' }) }; if (url.includes('action=send')) { sends.push(JSON.parse(init.body)); return json({ ok: true, response: { type: 'ask', question: 'q' } }); } if (url.includes('action=entitlement')) return json(entitlement); return json({ ok: true, messages: [] }); @@ -39,7 +48,7 @@ beforeEach(async () => { ); }); }); -afterEach(() => { act(() => root.unmount()); container.remove(); vi.unstubAllGlobals(); }); +afterEach(() => { vi.useRealTimers(); act(() => root.unmount()); container.remove(); vi.unstubAllGlobals(); }); describe('useSitesmith send body', () => { test('omits profile and intent when none is stored or given', async () => { @@ -79,3 +88,316 @@ describe('useSitesmith send body', () => { expect(sends[0].profile).toEqual(PROFILE); }); }); + +// --------------------------------------------------------------------------- +// start + poll transport +// --------------------------------------------------------------------------- + +type R = { status?: number; body?: any; raw?: 'nonjson' | 'throw' }; +const resp = (r: R) => { + if (r.raw === 'throw') throw new TypeError('Failed to fetch'); + return { status: r.status ?? 200, json: async () => { if (r.raw === 'nonjson') throw new SyntaxError('Unexpected token <'); return r.body; } }; +}; +const JOB = 'a'.repeat(32); +let starts: any[]; +let polls: number; +let history: any; +let historyFetches: number; + +/** Scripted backend: start -> startRes, poll -> pollScript (last entry repeats). */ +function script(startRes: R, pollScript: R[]) { + starts = []; polls = 0; historyFetches = 0; + history = { ok: true, messages: [] }; + override = (url, init) => { + if (url.includes('action=start')) { starts.push(JSON.parse(init.body)); return resp(startRes); } + if (url.includes('action=poll')) { const r = pollScript[Math.min(polls, pollScript.length - 1)]; polls++; return resp(r); } + if (url.includes('action=history')) { historyFetches++; return resp({ body: history }); } + return undefined; + }; +} +const running = (phase = 'generating', extra: any = {}): R => ({ body: { ok: true, job_id: JOB, status: 'running', phase, elapsed_s: 1, ...extra } }); +const done = (result: any, status = 'done'): R => ({ body: { ok: true, job_id: JOB, status, phase: 'done', elapsed_s: 9, result } }); +const OKSTART: R = { body: { ok: true, job_id: JOB, status: 'running', phase: 'queued', created_at: 1 } }; +const DONE_OK = done({ ok: true, response: { type: 'ask', question: 'q' } }); + +/** Start a send, drive fake timers for `ms`, and return its result. */ +async function sendFor(ms: number, ...args: any[]): Promise { + vi.useFakeTimers(); + let p!: Promise; + await act(async () => { p = (api.send as any)(...args); await vi.advanceTimersByTimeAsync(ms); }); + return await p; +} + +describe('useSitesmith start+poll', () => { + test('happy path: start then poll to done, reports phases, refreshes history', async () => { + script(OKSTART, [running('captioning'), running('generating'), running('generating'), DONE_OK]); + const phases: string[] = []; + const before = calls.filter((c) => c.url.includes('action=history')).length; + const res = await sendFor(30000, 'hi', 'canvas', undefined, { mockupIds: ['m1'], referenceIds: [3], useAssets: false, onProgress: (p: string) => phases.push(p) }); + expect(res).toEqual({ ok: true, response: { type: 'ask', question: 'q' } }); + expect(phases).toEqual(['queued', 'captioning', 'generating', 'done']); + expect(starts[0]).toMatchObject({ message: 'hi', mockup_ids: ['m1'], reference_ids: [3], use_assets: false }); + expect(calls.filter((c) => c.url.includes('action=history')).length).toBeGreaterThan(before); + expect(calls.some((c) => c.url.includes('action=send'))).toBe(false); + }); + + test('polls back off from ~1s towards a 5s cap', async () => { + script(OKSTART, [running()]); + vi.useFakeTimers(); + let p!: Promise; + await act(async () => { p = api.send('hi', 'c'); await vi.advanceTimersByTimeAsync(900); }); + expect(polls).toBe(0); + await act(async () => { await vi.advanceTimersByTimeAsync(200); }); + expect(polls).toBe(1); + await act(async () => { await vi.advanceTimersByTimeAsync(60000); }); + // 60s at <=5s spacing -> well under the 60 a fixed 1s interval would give + expect(polls).toBeLessThan(25); + expect(polls).toBeGreaterThan(10); + await act(async () => { await vi.advanceTimersByTimeAsync(400000); }); + await p; + }); + + test('retry_after_ms from the server is honoured', async () => { + script(OKSTART, [running('generating', { retry_after_ms: 4000 }), DONE_OK]); + vi.useFakeTimers(); + let p!: Promise; + await act(async () => { p = api.send('hi', 'c'); await vi.advanceTimersByTimeAsync(1100); }); + expect(polls).toBe(1); + await act(async () => { await vi.advanceTimersByTimeAsync(3000); }); + expect(polls).toBe(1); + await act(async () => { await vi.advanceTimersByTimeAsync(1100); }); + expect(polls).toBe(2); + expect((await p).ok).toBe(true); + }); + + test('job error result maps to {ok:false,status,message}', async () => { + script(OKSTART, [done({ ok: false, status: 'AI_ERROR', message: 'The model failed.' }, 'error')]); + const res = await sendFor(3000, 'hi', 'c'); + expect(res).toMatchObject({ ok: false, status: 'AI_ERROR', message: 'The model failed.' }); + }); + + test('cancelled job resolves CANCELLED', async () => { + script(OKSTART, [done({ ok: false, status: 'CANCELLED', message: 'Cancelled.' }, 'cancelled')]); + const res = await sendFor(3000, 'hi', 'c'); + expect(res).toMatchObject({ ok: false, status: 'CANCELLED' }); + }); + + test('entitlement refusal from start (HTTP 200) passes through', async () => { + script({ body: { ok: false, status: 'CAP_REACHED', message: 'Monthly cap reached.' } }, [DONE_OK]); + const res = await sendFor(10, 'hi', 'c'); + expect(res).toMatchObject({ ok: false, status: 'CAP_REACHED', message: 'Monthly cap reached.' }); + expect(polls).toBe(0); + }); + + test('network error on start gives a friendly message and does not throw', async () => { + script({ raw: 'throw' }, [DONE_OK]); + const res = await sendFor(10, 'hi', 'c'); + expect(res).toEqual({ ok: false, status: 'NETWORK', message: 'Could not reach the server. Check your connection and try again.' }); + }); + + test('transient poll failures are retried and recovered', async () => { + script(OKSTART, [{ raw: 'throw' }, { status: 502, raw: 'nonjson' }, { raw: 'throw' }, DONE_OK]); + const res = await sendFor(60000, 'hi', 'c'); + expect(res.ok).toBe(true); + expect(polls).toBe(4); + }); + + test('poll failures exhausted -> LOST_CONNECTION mentioning reopening', async () => { + script(OKSTART, [{ raw: 'throw' }]); + const res = await sendFor(120000, 'hi', 'c'); + expect(res).toMatchObject({ ok: false, status: 'LOST_CONNECTION', jobId: JOB }); + expect(res.message).toMatch(/Lost connection/); + expect(res.message).toMatch(/reopen Sitesmith/); + }); + + test('overall 6 minute cap -> TIMEOUT (job may still finish)', async () => { + script(OKSTART, [running()]); + const res = await sendFor(7 * 60 * 1000, 'hi', 'c'); + expect(res).toMatchObject({ ok: false, status: 'TIMEOUT', jobId: JOB }); + expect(res.message).toMatch(/background/); + expect(res.message).toMatch(/history/); + }); + + test('abort via signal stops polling and resolves ABORTED', async () => { + script(OKSTART, [running()]); + const ac = new AbortController(); + vi.useFakeTimers(); + let p!: Promise; + await act(async () => { p = api.send('hi', 'c', undefined, { signal: ac.signal }); await vi.advanceTimersByTimeAsync(3000); }); + const seen = polls; + expect(seen).toBeGreaterThan(0); + ac.abort(); + await act(async () => { await vi.advanceTimersByTimeAsync(60000); }); + const res = await p; + expect(res).toMatchObject({ ok: false, status: 'ABORTED' }); + expect(polls).toBe(seen); + }); + + test('409 job_in_progress resolves JOB_IN_PROGRESS with jobId and exposes activeJob; resumeJob finishes it', async () => { + script({ status: 409, body: { ok: false, error: 'job_in_progress', status: 'JOB_IN_PROGRESS', message: 'Already working.', job_id: JOB } }, [DONE_OK]); + const res = await sendFor(10, 'hi', 'c'); + expect(res).toMatchObject({ ok: false, status: 'JOB_IN_PROGRESS', message: 'Already working.', jobId: JOB }); + expect(api.activeJob?.job_id).toBe(JOB); + const h0 = historyFetches; + vi.useFakeTimers(); + let p!: Promise; + await act(async () => { p = api.resumeJob(); await vi.advanceTimersByTimeAsync(2000); }); + expect((await p).ok).toBe(true); + expect(historyFetches).toBeGreaterThan(h0); + expect(api.activeJob).toBeNull(); + }); + + test('history.active_job populates activeJob on mount', async () => { + override = (url) => url.includes('action=history') + ? resp({ body: { ok: true, messages: [], active_job: { job_id: JOB, status: 'running', phase: 'generating', elapsed_s: 3 } } }) + : undefined; + act(() => root.unmount()); + root = createRoot(container); + await act(async () => { + root.render( + React.createElement(EditorConfigProvider as any, { config: CFG }, + React.createElement(SaveGuardProvider as any, null, + React.createElement(ProjectExtrasProvider as any, null, React.createElement(Probe)))), + ); + }); + expect(api.activeJob).toMatchObject({ job_id: JOB, phase: 'generating' }); + }); + + test('start unknown_action falls back to legacy send once and is remembered', async () => { + let legacy = 0; + starts = []; polls = 0; + override = (url, init) => { + if (url.includes('action=start')) { starts.push(1); return resp({ status: 400, body: { ok: false, error: 'unknown_action' } }); } + if (url.includes('action=send')) { legacy++; return resp({ body: { ok: true, response: { type: 'ask', question: 'old' } } }); } + return undefined; + }; + const r1 = await sendFor(10, 'hi', 'c'); + expect(r1).toEqual({ ok: true, response: { type: 'ask', question: 'old' } }); + const r2 = await sendFor(10, 'again', 'c'); + expect(r2.ok).toBe(true); + expect(legacy).toBe(2); + expect(starts).toHaveLength(1); + }); + + test('legacy send fetch rejection becomes a friendly result', async () => { + override = (url) => { + if (url.includes('action=start')) return resp({ status: 400, body: { ok: false, error: 'unknown_action' } }); + if (url.includes('action=send')) return resp({ raw: 'throw' }); + return undefined; + }; + const res = await sendFor(10, 'hi', 'c'); + expect(res).toMatchObject({ ok: false, status: 'NETWORK', message: 'Could not reach the server. Check your connection and try again.' }); + }); + + test.each([ + [403, 'nonjson', /HTTP 403/, 'FORBIDDEN'], + [413, 'nonjson', /HTTP 413/, 'TOO_LARGE'], + [429, 'nonjson', /HTTP 429/, 'RATE_LIMITED'], + [503, 'nonjson', /HTTP 503/, 'SERVER_ERROR'], + [200, 'nonjson', /could not read/, 'BAD_RESPONSE'], + ])('start HTTP %s with non-JSON body maps to a distinct message', async (status, raw, re, code) => { + script({ status: status as number, raw: raw as any }, [DONE_OK]); + const res = await sendFor(10, 'hi', 'c'); + expect(res.ok).toBe(false); + expect(res.status).toBe(code); + expect(res.message).toMatch(re as RegExp); + }); + + test('session expiry (401 / csrf) tells the user to refresh and sign in', async () => { + script({ status: 403, body: { ok: false, error: 'csrf', message: 'Bad token' } }, [DONE_OK]); + let res = await sendFor(10, 'hi', 'c'); + expect(res.status).toBe('AUTH_REQUIRED'); + expect(res.message).toMatch(/Refresh the page and sign in/); + script({ status: 401, raw: 'nonjson' }, [DONE_OK]); + res = await sendFor(10, 'hi', 'c'); + expect(res.status).toBe('AUTH_REQUIRED'); + }); + + test('a sensible server message wins over the generic one', async () => { + script({ status: 400, body: { ok: false, error: 'message_too_long', message: 'Message is too long (max 4000 characters).' } }, [DONE_OK]); + const res = await sendFor(10, 'hi', 'c'); + expect(res.message).toBe('Message is too long (max 4000 characters).'); + }); + + test('unmount stops polling and does not throw', async () => { + script(OKSTART, [running()]); + vi.useFakeTimers(); + let p!: Promise; + await act(async () => { p = api.send('hi', 'c'); await vi.advanceTimersByTimeAsync(3000); }); + const seen = polls; + expect(seen).toBeGreaterThan(0); + act(() => root.unmount()); + root = createRoot(container); // so afterEach can unmount cleanly + await act(async () => { await vi.advanceTimersByTimeAsync(60000); }); + expect((await p).status).toBe('ABORTED'); + expect(polls).toBe(seen); + }); + + test('cancel posts job_id with CSRF header', async () => { + script(OKSTART, [DONE_OK]); + override = (url, init) => url.includes('action=cancel') ? resp({ body: { ok: true, status: 'cancelled' } }) : undefined; + const r = await api.cancel(JOB); + expect(r.ok).toBe(true); + const c = calls.find((x) => x.url.includes('action=cancel'))!; + expect(c.init.headers['X-CSRF-Token']).toBe('t'); + expect(JSON.parse(c.init.body)).toEqual({ job_id: JOB }); + }); +}); + +describe('useSitesmith capabilities and wrappers', () => { + test('capabilities tolerated missing (old backend 400)', async () => { + override = (url) => url.includes('action=capabilities') ? resp({ status: 400, body: { ok: false, error: 'unknown_action' } }) : undefined; + await act(async () => { await api.refreshEntitlement(); }); + expect(api.capabilities).toBeNull(); + expect(api.summary).not.toBeNull(); + }); + + test('capabilities populated when the backend provides them', async () => { + const caps = { ok: true, vision: { available: true, source: 'primary', message: '' }, features: { mockups: true, reference_screenshots: true, captions: true, self_review: false }, limits: { max_ref_images: 12, max_site_refs: 20, max_upload_bytes: 8388608, max_attach_images: 4 } }; + override = (url) => url.includes('action=capabilities') ? resp({ body: caps }) : undefined; + // capabilities are fetched once; remount to fetch with the override in place + act(() => root.unmount()); + root = createRoot(container); + await act(async () => { + root.render( + React.createElement(EditorConfigProvider as any, { config: CFG }, + React.createElement(SaveGuardProvider as any, null, + React.createElement(ProjectExtrasProvider as any, null, React.createElement(Probe)))), + ); + }); + expect(api.capabilities?.features.mockups).toBe(true); + }); + + test('wrappers send CSRF on POSTs and never throw on network failure', async () => { + override = (url) => resp({ body: { ok: true, references: [], images: [], reference: { id: 1 }, assets: [] } }); + expect((await api.listRefs()).ok).toBe(true); + expect((await api.listAssets()).ok).toBe(true); + await api.addReference({ url_label: 'x', what_i_like: 'y' }); + await api.updateReference(1, { what_i_like: 'z' }); + await api.deleteReference(1); + await api.deleteRefImage('img1'); + const posts = calls.filter((c) => c.init?.method === 'POST' && /refs_|ref_image_delete/.test(c.url)); + expect(posts).toHaveLength(4); + for (const p of posts) { expect(p.init.headers['X-CSRF-Token']).toBe('t'); expect(JSON.parse(p.init.body).site_id).toBe(7); } + override = () => resp({ raw: 'throw' }); + expect(await api.listRefs()).toMatchObject({ ok: false, message: expect.stringMatching(/Could not reach/) }); + expect(await api.deleteReference(1)).toMatchObject({ ok: false }); + expect(api.refImageUrl('a b')).toBe('/api/sitesmith.php?action=ref_image&site_id=7&id=a%20b'); + }); + + test('multipart upload sends CSRF but no manual Content-Type', async () => { + override = (url) => url.includes('action=ref_image_upload') ? resp({ body: { ok: true, image: { id: 'i1', kind: 'mockup', name: 'a.png', width: 1, height: 1, size: 3 } } }) : undefined; + const file = new File([new Uint8Array([1, 2, 3])], 'a.png', { type: 'image/png' }); + const r = await api.uploadRefImage(file, 'mockup'); + expect(r.ok).toBe(true); + const c = calls.find((x) => x.url.includes('ref_image_upload'))!; + expect(c.init.method).toBe('POST'); + expect(c.init.body).toBeInstanceOf(FormData); + expect(c.init.body.get('kind')).toBe('mockup'); + expect(c.init.body.get('site_id')).toBe('7'); + expect(c.init.headers['X-CSRF-Token']).toBe('t'); + expect(Object.keys(c.init.headers).map((k) => k.toLowerCase())).not.toContain('content-type'); + override = () => resp({ status: 413, raw: 'nonjson' }); + expect(await api.uploadRefImage(file, 'mockup')).toMatchObject({ ok: false, message: expect.stringMatching(/413/) }); + }); +}); diff --git a/craft/src/hooks/useSitesmith.ts b/craft/src/hooks/useSitesmith.ts index 26c648e..5542b94 100644 --- a/craft/src/hooks/useSitesmith.ts +++ b/craft/src/hooks/useSitesmith.ts @@ -8,67 +8,400 @@ function apiBase(apiUrl: string): string { return apiUrl.replace(/site-builder\.php$/, 'sitesmith.php'); } +// ---------------------------------------------------------------- types + +export interface SitesmithActiveJob { + job_id: string; + status: string; + phase: string; + elapsed_s?: number; + created_at?: number; +} + +export interface SitesmithCapabilities { + vision: { available: boolean; source: 'primary' | 'vision_slot' | 'none'; message: string }; + features: { mockups: boolean; reference_screenshots: boolean; captions: boolean; self_review: boolean }; + limits: { max_ref_images: number; max_site_refs: number; max_upload_bytes: number; max_attach_images: number }; +} + +export interface SitesmithReference { + id: number; + url_label: string; + what_i_like: string; + screenshot_ids: string[]; + created_at?: string; + updated_at?: string; +} + +export interface SitesmithRefImage { + id: string; + kind: 'mockup' | 'screenshot'; + name: string; + width: number | null; + height: number | null; + size: number; + created_at?: string; +} + +export interface SitesmithAsset { + filename: string; + relUrl: string; + url: string; + width: number | null; + height: number | null; + caption: string | null; + alt: string | null; +} + +export type SitesmithFailure = { ok: false; message: string; status?: string; error?: string }; +export type SitesmithApiResult = ({ ok: true } & T) | SitesmithFailure; + +export interface SitesmithSendOpts { + intent?: 'regenerate_section'; + mockupIds?: string[]; + referenceIds?: number[]; + useAssets?: boolean; + 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'; + +// ------------------------------------------------------------ constants + +const POLL_START_MS = 1000; +const POLL_GROWTH = 1.4; +const POLL_CAP_MS = 5000; +const OVERALL_CAP_MS = 6 * 60 * 1000; +const MAX_POLL_FAILURES = 5; + +export const MSG_NETWORK = 'Could not reach the server. Check your connection and try again.'; +export const MSG_LOST = 'Lost connection while waiting; Sitesmith may still finish -- reopen Sitesmith in a minute.'; +export const MSG_TIMEOUT = 'Sitesmith is taking longer than expected. It may still finish in the background -- reopen Sitesmith or check the history in a minute.'; +const MSG_AUTH = 'Your session has expired. Refresh the page and sign in again, then retry.'; + +const AUTH_ERRORS = new Set(['auth_required', 'csrf', 'csrf_invalid', 'csrf_mismatch', 'not_authenticated', 'unauthorized']); + +// -------------------------------------------------------------- helpers + +type Parsed = { net: true } | { net: false; http: number; body: any | null }; + +async function request(url: string, init?: RequestInit): Promise { + let r: Response; + try { + r = await fetch(url, init); + } catch { + return { net: true }; + } + const http = typeof r.status === 'number' ? r.status : 200; + let body: any | null = null; + try { + const j = await r.json(); + body = j && typeof j === 'object' ? j : null; + } catch { body = null; } + return { net: false, http, body }; +} + +function sensibleMessage(body: any): string | null { + const m = body?.message; + if (typeof m !== 'string') return null; + const t = m.trim(); + if (!t || t.length > 600 || /<\/?[a-z][^>]*>/i.test(t)) return null; + return t; +} + +/** 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 } { + 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'; + if (server) { + const st = typeof body?.status === 'string' && body.status ? body.status : byHttp(); + return { status: st, 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.' }; + 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: 'BAD_RESPONSE', message: `The server sent a reply Sitesmith could not read (HTTP ${http}). Try again, and refresh the page if it persists.` }; + } + // Parsed JSON refusal without a usable message. + 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}` : ''}).` }; +} + +const failure = (status: string, message: string, extra?: Record): SendResult => + ({ ok: false, status, message, ...(extra ?? {}) } as unknown as SendResult); + +const isTransientPoll = (p: Parsed): boolean => + p.net || p.body === null || p.http === 429 || p.http >= 500 || p.http === 408; + +// ----------------------------------------------------------------- hook + export function useSitesmith(siteId: number) { const { whpConfig } = useEditorConfig(); const extras = useProjectExtras(); // Set from the entitlement response once the backend says it understands `profile`. const profileSupported = useRef(false); + // Becomes false once `start` answered unknown_action (old backend): use blocking `send`. + const startSupported = useRef(true); + const capsTried = useRef(false); + const alive = useRef(true); + const wakers = useRef void>>(new Set()); + const resuming = useRef | null>(null); const [summary, setSummary] = useState(null); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [activeJob, setActiveJobState] = useState(null); + const [capabilities, setCapabilities] = useState(null); + + useEffect(() => { + alive.current = true; + const w = wakers.current; + return () => { + alive.current = false; + w.forEach((f) => f()); + w.clear(); + }; + }, []); + + const setActiveJob = useCallback((j: SitesmithActiveJob | null) => { if (alive.current) setActiveJobState(j); }, []); + + const fetchCapabilities = useCallback(async () => { + if (!whpConfig || capsTried.current) return; + capsTried.current = true; + const p = await request(`${apiBase(whpConfig.apiUrl)}?action=capabilities`, { credentials: 'include' }); + if (!alive.current) return; + if (!p.net && p.http < 400 && p.body?.ok && (p.body.features || p.body.limits || p.body.vision)) { + setCapabilities({ + vision: p.body.vision, features: p.body.features, limits: p.body.limits, + } as SitesmithCapabilities); + } + }, [whpConfig]); const refreshEntitlement = useCallback(async () => { if (!whpConfig) return; try { const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=entitlement`, { credentials: 'include' }); const j = await r.json(); + if (!alive.current) return; if (j.ok) { setSummary(j.summary); profileSupported.current = j.profile_supported === true; } else setError(j.error || 'Sitesmith entitlement unavailable'); - } catch (e: any) { setError(String(e?.message ?? e)); } - }, [whpConfig]); + } catch (e: any) { if (alive.current) setError(String(e?.message ?? e)); } + void fetchCapabilities(); + }, [whpConfig, fetchCapabilities]); const fetchHistory = useCallback(async () => { if (!whpConfig) { setLoading(false); return; } try { const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=history&site_id=${siteId}`, { credentials: 'include' }); const j = await r.json(); - if (j.ok) setMessages(j.messages); - } catch (e: any) { setError(String(e?.message ?? e)); } - finally { setLoading(false); } - }, [whpConfig, siteId]); + if (!alive.current) return; + if (j.ok) { + setMessages(j.messages); + if (j.active_job !== undefined) setActiveJob(j.active_job && j.active_job.job_id ? j.active_job : null); + } + } catch (e: any) { if (alive.current) setError(String(e?.message ?? e)); } + finally { if (alive.current) setLoading(false); } + }, [whpConfig, siteId, setActiveJob]); useEffect(() => { void refreshEntitlement(); void fetchHistory(); }, [refreshEntitlement, fetchHistory]); - const send = useCallback(async ( - userText: string, - canvasSummary: string, - target?: { node_id: string; display_name: string; tree_json: string }, - opts?: { intent?: 'regenerate_section' }, + const stopped = (signal?: AbortSignal) => !alive.current || !!signal?.aborted; + + /** Timer that vitest fake timers can drive; resolves false when aborted/unmounted. */ + const sleep = useCallback((ms: number, signal?: AbortSignal) => new Promise((resolve) => { + if (!alive.current || signal?.aborted) { resolve(false); return; } + let done = false; + const finish = (v: boolean) => { + if (done) return; + done = true; + clearTimeout(t); + wakers.current.delete(wake); + signal?.removeEventListener('abort', wake); + resolve(v); + }; + const wake = () => finish(false); + const t = setTimeout(() => finish(true), ms); + wakers.current.add(wake); + signal?.addEventListener('abort', wake); + }), []); + + const finalize = useCallback(() => { + if (!alive.current) return; + setActiveJob(null); + void fetchHistory(); + void refreshEntitlement(); + }, [fetchHistory, refreshEntitlement, setActiveJob]); + + /** Poll an existing job to completion. Never throws. */ + const pollJob = useCallback(async ( + jobId: string, + opts: { onProgress?: (phase: string) => void; signal?: AbortSignal } = {}, ): Promise => { - if (!whpConfig) return { ok: false, status: 'BLOCKED', message: 'No WHP config' }; - setMessages((m) => [...m, { role: 'user', content: userText, response_type: null, created_at: new Date().toISOString() }]); - // Read at send time (extras live in a ref), so a profile saved a moment ago is included. - const stored = normalizeProfile(extras.get(PROFILE_EXTRAS_KEY, null)); - const profile = stored && isProfileComplete(stored) ? stored : null; - // A backend that predates `profile` ignores the key, so until it says otherwise - // the business line also rides in the message. - const message = profile && !profileSupported.current ? `${profileToPrompt(profile)} ${userText}` : userText; - const body: Record = { site_id: siteId, message, canvas_summary: canvasSummary }; - if (target) body.target = target; - if (profile) body.profile = profile; - if (opts?.intent) body.intent = opts.intent; - const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=send`, { + if (!whpConfig) return failure('BLOCKED', 'No WHP config'); + const { signal } = opts; + const aborted = () => failure('ABORTED', 'Stopped waiting for Sitesmith. It may still finish in the background.', { jobId }); + const t0 = Date.now(); + let delay = POLL_START_MS; + let failures = 0; + let lastPhase = ''; + const url = `${apiBase(whpConfig.apiUrl)}?action=poll&job_id=${encodeURIComponent(jobId)}`; + for (;;) { + if (!(await sleep(delay, signal))) return aborted(); + const p = await request(url, { credentials: 'include', signal }); + if (stopped(signal)) return aborted(); + if (isTransientPoll(p)) { + failures++; + if (failures >= MAX_POLL_FAILURES) return failure('LOST_CONNECTION', MSG_LOST, { jobId }); + const hint = !p.net && typeof p.body?.retry_after_ms === 'number' ? p.body.retry_after_ms : 0; + delay = Math.max(hint, Math.min(POLL_CAP_MS, delay * POLL_GROWTH)); + } else { + const { http, body } = p as Extract; + failures = 0; + if (!body || body.ok !== true) { + if (http === 404 || body?.error === 'job_not_found') { + return failure('JOB_NOT_FOUND', sensibleMessage(body) ?? 'That Sitesmith job could not be found; it may have expired. Check the history.', { jobId }); + } + const f = describeFailure(http, body); + return failure(f.status, f.message, { jobId }); + } + const phase = typeof body.phase === 'string' ? body.phase : ''; + if (phase && phase !== lastPhase) { + lastPhase = phase; + try { opts.onProgress?.(phase); } catch { /* UI callback must not break polling */ } + if (alive.current) setActiveJobState((j) => (j && j.job_id === jobId ? { ...j, phase, status: body.status ?? j.status, elapsed_s: body.elapsed_s } : j)); + } + if (body.status && body.status !== 'running') { + const res = body.result; + if (res && typeof res === 'object') return res as SendResult; + if (body.status === 'cancelled') return failure('CANCELLED', 'The request was cancelled.', { jobId }); + return failure('ERROR', 'Sitesmith finished without a result. Check the history.', { jobId }); + } + delay = typeof body.retry_after_ms === 'number' && body.retry_after_ms > 0 + ? body.retry_after_ms + : Math.min(POLL_CAP_MS, delay * POLL_GROWTH); + } + if (Date.now() - t0 + delay > OVERALL_CAP_MS) { + // One last wait is pointless once the next poll would land past the cap. + return failure('TIMEOUT', MSG_TIMEOUT, { jobId }); + } + } + }, [whpConfig, sleep]); + + /** Old blocking `send` action (backends without `start`). Never throws. */ + const legacySend = useCallback(async (body: Record): Promise => { + if (!whpConfig) return failure('BLOCKED', 'No WHP config'); + const p = await request(`${apiBase(whpConfig.apiUrl)}?action=send`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken }, body: JSON.stringify(body), }); - const j: SendResult = await r.json(); - void fetchHistory(); - void refreshEntitlement(); - return j; - }, [whpConfig, siteId, extras, fetchHistory, refreshEntitlement]); + 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; + const f = describeFailure(p.http, p.body); + return failure(f.status, f.message); + }, [whpConfig]); + + const send = useCallback(async ( + userText: string, + canvasSummary: string, + target?: { node_id: string; display_name: string; tree_json: string }, + opts?: SitesmithSendOpts, + ): Promise => { + if (!whpConfig) return failure('BLOCKED', 'No WHP config'); + try { + setMessages((m) => [...m, { role: 'user', content: userText, response_type: null, created_at: new Date().toISOString() }]); + // Read at send time (extras live in a ref), so a profile saved a moment ago is included. + const stored = normalizeProfile(extras.get(PROFILE_EXTRAS_KEY, null)); + const profile = stored && isProfileComplete(stored) ? stored : null; + // A backend that predates `profile` ignores the key, so until it says otherwise + // the business line also rides in the message. + const message = profile && !profileSupported.current ? `${profileToPrompt(profile)} ${userText}` : userText; + const body: Record = { site_id: siteId, message, canvas_summary: canvasSummary }; + if (target) body.target = target; + if (profile) body.profile = profile; + if (opts?.intent) body.intent = opts.intent; + + let result: SendResult; + let usedStart = false; + if (startSupported.current) { + const startBody: Record = { ...body }; + 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; + const p = await request(`${apiBase(whpConfig.apiUrl)}?action=start`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken }, + body: JSON.stringify(startBody), + signal: opts?.signal, + }); + if (!alive.current) return failure('ABORTED', 'Stopped waiting for Sitesmith.'); + if (opts?.signal?.aborted) return failure('ABORTED', 'Stopped waiting for Sitesmith.'); + if (p.net) { + result = failure('NETWORK', MSG_NETWORK); + } else if (p.http === 400 && p.body?.error === 'unknown_action') { + startSupported.current = false; + result = await legacySend(body); + } else if (p.http === 409 || p.body?.error === 'job_in_progress') { + const jobId = typeof p.body?.job_id === 'string' ? p.body.job_id : undefined; + if (jobId) setActiveJob({ job_id: jobId, status: 'running', phase: 'generating' }); + result = failure('JOB_IN_PROGRESS', sensibleMessage(p.body) ?? 'Sitesmith is already working on a request for this site. You can wait for it to finish.', { jobId }); + } else if (p.body && p.body.ok === true && typeof p.body.job_id === 'string') { + usedStart = true; + const jobId = p.body.job_id as string; + setActiveJob({ job_id: jobId, status: 'running', phase: p.body.phase ?? 'queued', created_at: p.body.created_at }); + if (typeof p.body.phase === 'string') { try { opts?.onProgress?.(p.body.phase); } catch { /* ignore */ } } + 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.'); + } else { + const f = describeFailure(p.http, p.body); + result = failure(f.status, f.message); + } + } else { + result = await legacySend(body); + } + void usedStart; + const st = (result as any).status; + // 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(); } + return result; + } catch (e: any) { + return failure('ERROR', `Something went wrong while contacting Sitesmith (${String(e?.message ?? e)}). Please try again.`); + } + }, [whpConfig, siteId, extras, pollJob, legacySend, finalize, fetchHistory, setActiveJob]); + + const resumeJob = useCallback((jobId?: string, opts?: { onProgress?: (phase: string) => void; signal?: AbortSignal }): Promise => { + const id = jobId ?? activeJob?.job_id; + if (!id) return Promise.resolve(failure('JOB_NOT_FOUND', 'There is no Sitesmith job to resume.')); + if (resuming.current) return resuming.current; + const pr = (async () => { + try { + const res = await pollJob(id, opts); + const st = (res as any).status; + if (st !== 'ABORTED' && st !== 'TIMEOUT' && st !== 'LOST_CONNECTION') finalize(); + return res; + } catch (e: any) { + return failure('ERROR', `Something went wrong while contacting Sitesmith (${String(e?.message ?? e)}).`); + } finally { resuming.current = null; } + })(); + resuming.current = pr; + return pr; + }, [activeJob, pollJob, finalize]); const clearHistory = useCallback(async (): Promise<{ ok: boolean; cleared?: number; error?: string }> => { if (!whpConfig) return { ok: false, error: 'No WHP config' }; @@ -80,12 +413,72 @@ export function useSitesmith(siteId: number) { body: JSON.stringify({ site_id: siteId }), }); const j = await r.json(); - if (j.ok) setMessages([]); + if (j.ok && alive.current) setMessages([]); return j; } catch (e: any) { return { ok: false, error: String(e?.message ?? e) }; } }, [whpConfig, siteId]); - return { summary, messages, loading, error, send, refreshEntitlement, clearHistory }; + // ------------------------------------------------ typed API wrappers + + const call = useCallback(async ( + action: string, + o: { post?: boolean; query?: string; json?: Record; form?: FormData } = {}, + ): Promise> => { + if (!whpConfig) return { ok: false, message: 'No WHP config' }; + const url = `${apiBase(whpConfig.apiUrl)}?action=${action}${o.query ? `&${o.query}` : ''}`; + const init: RequestInit = { credentials: 'include' }; + if (o.post) { + init.method = 'POST'; + if (o.form) { + // Never set Content-Type: the browser must add the multipart boundary. + init.headers = { 'X-CSRF-Token': whpConfig.csrfToken }; + init.body = o.form; + } else { + init.headers = { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken }; + init.body = JSON.stringify(o.json ?? {}); + } + } + const p = await request(url, init); + if (p.net) return { ok: false, message: MSG_NETWORK, status: 'NETWORK' }; + if (p.body && p.body.ok === true && p.http < 400) return p.body as SitesmithApiResult; + const f = describeFailure(p.http, p.body); + return { ok: false, message: f.message, status: String(f.status), error: typeof p.body?.error === 'string' ? p.body.error : undefined }; + }, [whpConfig]); + + const cancel = useCallback(async (jobId?: string): Promise> => { + const id = jobId ?? activeJob?.job_id; + if (!id) return { ok: false, message: 'There is no Sitesmith job to cancel.' }; + return call<{ status?: string }>('cancel', { post: true, json: { job_id: id } }); + }, [call, activeJob]); + + const listRefs = useCallback(() => + call<{ references: SitesmithReference[]; images: SitesmithRefImage[] }>('refs_list', { query: `site_id=${siteId}` }), [call, siteId]); + const addReference = useCallback((r: { url_label: string; what_i_like: string; screenshot_ids?: string[] }) => + call<{ reference: SitesmithReference }>('refs_add', { post: true, json: { site_id: siteId, ...r } }), [call, siteId]); + const updateReference = useCallback((id: number, patch: { url_label?: string; what_i_like?: string; screenshot_ids?: string[] }) => + call<{ reference: SitesmithReference }>('refs_update', { post: true, json: { site_id: siteId, id, ...patch } }), [call, siteId]); + const deleteReference = useCallback((id: number) => + call<{}>('refs_delete', { post: true, json: { site_id: siteId, id } }), [call, siteId]); + const uploadRefImage = useCallback((file: File | Blob, kind: 'mockup' | 'screenshot') => { + const form = new FormData(); + form.append('site_id', String(siteId)); + form.append('kind', kind); + form.append('file', file); + return call<{ image: SitesmithRefImage }>('ref_image_upload', { post: true, form }); + }, [call, siteId]); + const deleteRefImage = useCallback((id: string) => + call<{}>('ref_image_delete', { post: true, json: { site_id: siteId, id } }), [call, siteId]); + const refImageUrl = useCallback((id: string): string => + whpConfig ? `${apiBase(whpConfig.apiUrl)}?action=ref_image&site_id=${siteId}&id=${encodeURIComponent(id)}` : '', [whpConfig, siteId]); + const listAssets = useCallback(() => + call<{ assets: SitesmithAsset[] }>('assets', { query: `site_id=${siteId}` }), [call, siteId]); + + return { + summary, messages, loading, error, send, refreshEntitlement, clearHistory, + activeJob, resumeJob, cancel, capabilities, + listRefs, addReference, updateReference, deleteReference, + uploadRefImage, deleteRefImage, refImageUrl, listAssets, + }; }