Merge feat/sitesmith-hook into release/sitesmith-colors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
57734fbe51
2 files changed
+748
-33
No files matched your search
@@ -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<any> {
|
||||
vi.useFakeTimers();
|
||||
let p!: Promise<any>;
|
||||
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<any>;
|
||||
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<any>;
|
||||
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<any>;
|
||||
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<any>;
|
||||
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<any>;
|
||||
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/) });
|
||||
});
|
||||
});
|
||||
+424
-31
@@ -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<T> = ({ 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<Parsed> {
|
||||
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<string, unknown>): 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<Set<() => void>>(new Set());
|
||||
const resuming = useRef<Promise<SendResult> | null>(null);
|
||||
const [summary, setSummary] = useState<SitesmithSummary | null>(null);
|
||||
const [messages, setMessages] = useState<SitesmithMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeJob, setActiveJobState] = useState<SitesmithActiveJob | null>(null);
|
||||
const [capabilities, setCapabilities] = useState<SitesmithCapabilities | null>(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<boolean>((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<SendResult> => {
|
||||
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<unknown>(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<string, unknown> = { 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<Parsed, { net: false }>;
|
||||
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<string, unknown>): Promise<SendResult> => {
|
||||
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<SendResult> => {
|
||||
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<unknown>(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<string, unknown> = { 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<string, unknown> = { ...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<SendResult> => {
|
||||
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 <T,>(
|
||||
action: string,
|
||||
o: { post?: boolean; query?: string; json?: Record<string, unknown>; form?: FormData } = {},
|
||||
): Promise<SitesmithApiResult<T>> => {
|
||||
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<T>;
|
||||
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<SitesmithApiResult<{ status?: string }>> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user