PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.4
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.4
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / src / services / request.js

request.js in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.4, at src/services/request.js

267 lines 8.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * request.js — Raw HTTP primitives for the ZipWP chat assistant.
3 *
4 * All methods read auth token and base URL from window.ZIP_AI_CONFIG at
5 * call-time so they work correctly after config is set by WordPress.
6 *
7 * Flow: request.js → api.js (endpoints) → actions/ (store updates) → components
8 */
9
10 const getBaseUrl = () =>
11 (window.ZIP_AI_CONFIG?.apiUrl || '/api').replace(/\/+$/, '');
12
13 // ─── Abort controller ─────────────────────────────────────────────────────────
14 // Tracks the active SSE stream so it can be cancelled on demand.
15
16 let _abortController = null;
17
18 export function abortCurrentStream() {
19 if (_abortController) {
20 _abortController.abort();
21 _abortController = null;
22 }
23 }
24
25 export function createSignal() {
26 _abortController = new AbortController();
27 return _abortController.signal;
28 }
29
30 const getToken = () => window.ZIP_AI_CONFIG?.token;
31
32 /**
33 * Build default request headers.
34 * @param {boolean} sse - If true, set Accept: text/event-stream.
35 * @returns {Record<string, string>}
36 */
37 export function buildHeaders(sse = false) {
38 const token = getToken();
39 return {
40 'Content-Type': 'application/json',
41 Accept: sse ? 'text/event-stream' : 'application/json',
42 ...(token && { Authorization: `Bearer ${token}` }),
43 };
44 }
45
46 /**
47 * Resolve a relative endpoint to an absolute URL.
48 * @param {string} endpoint - Path like '/agent/chat/stream' or full URL
49 * @returns {string}
50 */
51 export function resolveUrl(endpoint) {
52 return endpoint.startsWith('http') ? endpoint : `${getBaseUrl()}${endpoint}`;
53 }
54
55 /**
56 * GET request — returns parsed JSON.
57 * @param {string} endpoint
58 * @param {RequestInit} [init]
59 * @returns {Promise<unknown>}
60 */
61 export async function get(endpoint, init = {}) {
62 const response = await fetch(resolveUrl(endpoint), {
63 method: 'GET',
64 ...init,
65 headers: { ...buildHeaders(), ...init.headers },
66 });
67
68 if (!response.ok) {
69 const error = await response.json().catch(() => ({}));
70 throw new Error(error.message || `API Error: ${response.status}`);
71 }
72
73 return response.json();
74 }
75
76 /**
77 * POST request — returns parsed JSON.
78 * @param {string} endpoint
79 * @param {unknown} body - Will be JSON.stringify'd unless already a string.
80 * @param {RequestInit} [init]
81 * @returns {Promise<unknown>}
82 */
83 export async function post(endpoint, body = {}, init = {}) {
84 const response = await fetch(resolveUrl(endpoint), {
85 method: 'POST',
86 ...init,
87 headers: { ...buildHeaders(), ...init.headers },
88 body: typeof body === 'string' ? body : JSON.stringify(body),
89 });
90
91 if (!response.ok) {
92 const error = await response.json().catch(() => ({}));
93 throw new Error(error.message || `API Error: ${response.status}`);
94 }
95
96 return response.json();
97 }
98
99 /**
100 * DELETE request — returns parsed JSON.
101 * @param {string} endpoint
102 * @param {RequestInit} [init]
103 * @returns {Promise<unknown>}
104 */
105 export async function del(endpoint, init = {}) {
106 const response = await fetch(resolveUrl(endpoint), {
107 method: 'DELETE',
108 ...init,
109 headers: { ...buildHeaders(), ...init.headers },
110 });
111
112 if (!response.ok) {
113 const error = await response.json().catch(() => ({}));
114 throw new Error(error.message || `API Error: ${response.status}`);
115 }
116
117 return response.json();
118 }
119
120 /**
121 * Parse a single raw SSE event string into { type, data }.
122 * @param {string} raw - e.g. "event: text_delta\ndata: {...}"
123 * @returns {{ type: string, data: unknown } | null}
124 */
125 export function parseSSEEvent(raw) {
126 const lines = raw.split('\n');
127 let eventType = 'message';
128 let dataStr = '';
129
130 for (const line of lines) {
131 if (line.startsWith('event:')) {
132 eventType = line.slice(6).trim();
133 } else if (line.startsWith('data:')) {
134 dataStr += line.slice(5).trim();
135 }
136 }
137
138 if (!dataStr) return null;
139
140 try {
141 return { type: eventType, data: JSON.parse(dataStr) };
142 } catch {
143 return null;
144 }
145 }
146
147 /**
148 * POST a streaming (SSE) request and process the event stream.
149 *
150 * Calls onEvent(type, data) for every SSE event except 'complete' (handled
151 * internally). Returns the final result from the 'complete' event.
152 *
153 * @param {string} endpoint
154 * @param {unknown} body
155 * @param {Function|null} onEvent - (type: string, data: unknown) => void
156 * @param {AbortSignal} [signal] - Optional AbortSignal for cancellation
157 * @returns {Promise<unknown>} Final result from 'complete' event
158 */
159 export async function stream(endpoint, body, onEvent = null, signal = null) {
160 // Auto-create abort controller if caller didn't supply a signal
161 const activeSignal = signal ?? createSignal();
162
163 const response = await fetch(resolveUrl(endpoint), {
164 method: 'POST',
165 headers: buildHeaders(true),
166 body: typeof body === 'string' ? body : JSON.stringify(body),
167 signal: activeSignal,
168 });
169
170 if (!response.ok) {
171 const error = await response.json().catch(() => ({}));
172 throw new Error(error.message || `API Error: ${response.status}`);
173 }
174
175 return readSSEStream(response, onEvent);
176 }
177
178 /**
179 * Inactivity timeout for SSE streams (ms). Resets on every chunk received.
180 *
181 * Must exceed the server-side `turn_timeout_ms` (default 120_000 in
182 * config/agent-brain.php). The brain also emits a named `heartbeat` event
183 * every 10s while awaiting the LLM's first token, so this ceiling should
184 * effectively never be hit — it's defense-in-depth for cases where the
185 * heartbeat path is blocked (proxy / brain crash / network issue).
186 */
187 const SSE_INACTIVITY_TIMEOUT = 150_000;
188
189 /**
190 * Read and parse an SSE stream from a fetch Response.
191 * Throws if no data arrives for SSE_INACTIVITY_TIMEOUT ms.
192 *
193 * @param {Response} response
194 * @param {Function|null} onEvent
195 * @returns {Promise<unknown>}
196 */
197 export async function readSSEStream(response, onEvent = null) {
198 const reader = response.body.getReader();
199 const decoder = new TextDecoder();
200 let buffer = '';
201 let finalResult = null;
202 let inactivityTimer = null;
203
204 const resetTimer = () => {
205 if (inactivityTimer) clearTimeout(inactivityTimer);
206 inactivityTimer = setTimeout(() => {
207 reader.cancel();
208 }, SSE_INACTIVITY_TIMEOUT);
209 };
210
211 resetTimer();
212
213 try {
214 while (true) {
215 const { done, value } = await reader.read();
216 if (done) break;
217
218 resetTimer();
219
220 buffer += decoder.decode(value, { stream: true });
221
222 const events = buffer.split('\n\n');
223 buffer = events.pop() || '';
224
225 for (const raw of events) {
226 if (!raw.trim()) continue;
227
228 const parsed = parseSSEEvent(raw);
229 if (!parsed) continue;
230
231 switch (parsed.type) {
232 case 'complete': {
233 const streamedComponents = finalResult?.messageComponents;
234 finalResult = { ...finalResult, ...parsed.data };
235 if (streamedComponents?.length) {
236 finalResult.messageComponents = streamedComponents;
237 }
238 continue; // internal only — do not dispatch
239 }
240 case 'error':
241 throw new Error(parsed.data.message || 'Stream error');
242 case 'image_generation_complete':
243 if (!finalResult) finalResult = {};
244 finalResult.generatedImages = parsed.data.images;
245 break;
246 case 'message_component':
247 // Components are handled in real-time by the onEvent callback
248 // (chatActions.js addMessage). Don't accumulate here to avoid duplication.
249 break;
250 }
251
252 if (onEvent) onEvent(parsed.type, parsed.data);
253 }
254 }
255 } catch (err) {
256 if (err.message?.includes('cancelled') || err.message?.includes('canceled')) {
257 throw new Error('The response took too long. Please try again.');
258 }
259 throw err;
260 } finally {
261 if (inactivityTimer) clearTimeout(inactivityTimer);
262 reader.releaseLock();
263 }
264
265 return finalResult;
266 }
267