solid-drift 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/stream.js ADDED
@@ -0,0 +1,430 @@
1
+ import { createSignal, onCleanup } from "solid-js";
2
+ /**
3
+ * Feed raw text chunks through the SSE framing rules and call
4
+ * `onEvent` for each dispatched event. Handles events split across
5
+ * chunk boundaries, multi-line `data:` payloads, and `:` comments.
6
+ */
7
+ export function createSSEParser(onEvent) {
8
+ let buffer = "";
9
+ let event = "message";
10
+ let data = [];
11
+ let id;
12
+ const dispatch = () => {
13
+ if (data.length === 0 && event === "message") {
14
+ event = "message";
15
+ id = undefined;
16
+ return;
17
+ }
18
+ onEvent({ event, data: data.join("\n"), ...(id ? { id } : {}) });
19
+ event = "message";
20
+ data = [];
21
+ id = undefined;
22
+ };
23
+ return (chunk) => {
24
+ buffer += chunk;
25
+ let nl;
26
+ while ((nl = buffer.indexOf("\n")) >= 0) {
27
+ let line = buffer.slice(0, nl);
28
+ buffer = buffer.slice(nl + 1);
29
+ if (line.endsWith("\r"))
30
+ line = line.slice(0, -1);
31
+ if (line === "") {
32
+ dispatch();
33
+ continue;
34
+ }
35
+ if (line.startsWith(":"))
36
+ continue; // comment / keep-alive
37
+ const colon = line.indexOf(":");
38
+ let field = line;
39
+ let value = "";
40
+ if (colon >= 0) {
41
+ field = line.slice(0, colon);
42
+ value = line.slice(colon + 1);
43
+ if (value.startsWith(" "))
44
+ value = value.slice(1);
45
+ }
46
+ if (field === "event")
47
+ event = value;
48
+ else if (field === "data")
49
+ data.push(value);
50
+ else if (field === "id")
51
+ id = value;
52
+ // "retry:" is noted but reconnection stays manual: call connect().
53
+ }
54
+ };
55
+ }
56
+ /**
57
+ * Read a fetch Response body as SSE, dispatching parsed events.
58
+ * Resolves when the stream ends cleanly; rejects on HTTP errors.
59
+ */
60
+ async function pumpSSE(response, onEvent, signal) {
61
+ if (!response.ok) {
62
+ const body = await response.text().catch(() => "");
63
+ throw new Error(`Stream request failed: ${response.status} ${response.statusText}${body ? ` - ${body.slice(0, 200)}` : ""}`);
64
+ }
65
+ const feed = createSSEParser(onEvent);
66
+ const reader = response.body?.getReader();
67
+ if (!reader)
68
+ return;
69
+ const decoder = new TextDecoder();
70
+ try {
71
+ for (;;) {
72
+ if (signal.aborted)
73
+ return;
74
+ const { done, value } = await reader.read();
75
+ if (done)
76
+ return;
77
+ feed(decoder.decode(value, { stream: true }));
78
+ }
79
+ }
80
+ finally {
81
+ feed(decoder.decode());
82
+ reader.releaseLock();
83
+ }
84
+ }
85
+ /**
86
+ * A fetch-based Server-Sent Events client.
87
+ *
88
+ * Unlike `EventSource`, this works with any HTTP method and custom
89
+ * headers, so it can reach authenticated or POST-style SSE endpoints.
90
+ * There is no automatic reconnection: a dropped stream moves to
91
+ * "closed" (or "error") and `connect()` re-opens it manually.
92
+ *
93
+ * SSR-safe: nothing connects until `connect()` runs (or
94
+ * `autoConnect` fires on the client).
95
+ *
96
+ * ```ts
97
+ * const sse = createSSE("https://api.example.com/events", {
98
+ * headers: { Authorization: `Bearer ${token}` },
99
+ * onEvent: (ev) => console.log(ev.event, ev.data),
100
+ * });
101
+ * sse.disconnect();
102
+ * ```
103
+ */
104
+ export function createSSE(url, options = {}) {
105
+ const { method = "GET", headers, body, event: eventFilter, onEvent, onOpen, onDone, onError, autoConnect = true, fetchFn, } = options;
106
+ const [status, setStatus] = createSignal("idle");
107
+ const [events, setEvents] = createSignal([]);
108
+ const [lastEvent, setLastEvent] = createSignal(null);
109
+ const [error, setError] = createSignal(null);
110
+ let controller = null;
111
+ const disconnect = () => {
112
+ controller?.abort();
113
+ controller = null;
114
+ if (status() === "connecting" || status() === "open") {
115
+ setStatus("closed");
116
+ }
117
+ };
118
+ const connect = () => {
119
+ var _a;
120
+ disconnect();
121
+ const target = typeof url === "function" ? url() : url;
122
+ const fetchImpl = fetchFn ?? (typeof fetch !== "undefined" ? fetch : null);
123
+ if (!fetchImpl) {
124
+ const err = new Error("createSSE: no fetch implementation available");
125
+ setError(err);
126
+ setStatus("error");
127
+ onError?.(err);
128
+ return;
129
+ }
130
+ controller = new AbortController();
131
+ const signal = controller.signal;
132
+ setError(null);
133
+ setStatus("connecting");
134
+ const resolvedHeaders = typeof headers === "function" ? headers() : (headers ?? {});
135
+ const init = { method, headers: resolvedHeaders, signal };
136
+ if (body !== undefined) {
137
+ init.body = typeof body === "string" ? body : JSON.stringify(body);
138
+ (_a = init.headers)["Content-Type"] ?? (_a["Content-Type"] = "application/json");
139
+ }
140
+ void (async () => {
141
+ try {
142
+ const response = await fetchImpl(target, init);
143
+ if (signal.aborted)
144
+ return;
145
+ setStatus("open");
146
+ onOpen?.();
147
+ await pumpSSE(response, (ev) => {
148
+ if (eventFilter && ev.event !== eventFilter)
149
+ return;
150
+ setEvents((prev) => [...prev, ev]);
151
+ setLastEvent(ev);
152
+ onEvent?.(ev);
153
+ }, signal);
154
+ if (signal.aborted)
155
+ return;
156
+ setStatus("closed");
157
+ onDone?.();
158
+ }
159
+ catch (err) {
160
+ if (signal.aborted)
161
+ return;
162
+ const e = err instanceof Error ? err : new Error(String(err));
163
+ setError(e);
164
+ setStatus("error");
165
+ onError?.(e);
166
+ }
167
+ })();
168
+ };
169
+ onCleanup(disconnect);
170
+ if (autoConnect && typeof window !== "undefined")
171
+ connect();
172
+ return { status, events, lastEvent, error, connect, disconnect };
173
+ }
174
+ let chatMessageCounter = 0;
175
+ const nextMessageId = () => `msg_${++chatMessageCounter}_${Date.now()}`;
176
+ const defaultBaseUrl = (kind) => {
177
+ if (kind === "anthropic")
178
+ return "https://api.anthropic.com";
179
+ if (kind === "meta")
180
+ return "https://api.llama.com/compat/v1";
181
+ return "https://api.openai.com/v1";
182
+ };
183
+ function buildBuiltinRequest(kind, options, history, signal, fetchImpl) {
184
+ const base = (options.baseUrl ?? defaultBaseUrl(kind)).replace(/\/$/, "");
185
+ const key = typeof options.apiKey === "function" ? options.apiKey() : options.apiKey;
186
+ const extraHeaders = options.headers ?? {};
187
+ const temperature = options.temperature;
188
+ const maxTokens = options.maxTokens ?? 1024;
189
+ if (kind === "anthropic") {
190
+ const systemText = options.system ??
191
+ history.find((m) => m.role === "system")?.content;
192
+ const apiMessages = history
193
+ .filter((m) => m.role !== "system")
194
+ .map((m) => ({ role: m.role, content: m.content }));
195
+ const init = {
196
+ method: "POST",
197
+ headers: {
198
+ "Content-Type": "application/json",
199
+ ...(key ? { "x-api-key": key } : {}),
200
+ "anthropic-version": "2023-06-01",
201
+ ...extraHeaders,
202
+ },
203
+ body: JSON.stringify({
204
+ model: options.model,
205
+ max_tokens: maxTokens,
206
+ ...(systemText ? { system: systemText } : {}),
207
+ ...(temperature !== undefined ? { temperature } : {}),
208
+ messages: apiMessages,
209
+ stream: true,
210
+ }),
211
+ signal,
212
+ };
213
+ return {
214
+ url: `${base}/v1/messages`,
215
+ init,
216
+ parseDelta: (data, event) => {
217
+ if (event === "message_stop")
218
+ return { done: true };
219
+ if (event === "error") {
220
+ let message = "Anthropic stream error";
221
+ try {
222
+ const parsed = JSON.parse(data);
223
+ if (parsed.error?.message)
224
+ message = parsed.error.message;
225
+ }
226
+ catch {
227
+ if (data.trim())
228
+ message = `Anthropic stream error: ${data.slice(0, 200)}`;
229
+ }
230
+ throw new Error(message);
231
+ }
232
+ if (event !== "content_block_delta")
233
+ return {};
234
+ try {
235
+ const parsed = JSON.parse(data);
236
+ if (parsed.delta?.type === "text_delta" && parsed.delta.text) {
237
+ return { text: parsed.delta.text };
238
+ }
239
+ }
240
+ catch {
241
+ // Ignore malformed delta payloads.
242
+ }
243
+ return {};
244
+ },
245
+ };
246
+ }
247
+ // openai + meta: OpenAI-compatible chat completions.
248
+ const init = {
249
+ method: "POST",
250
+ headers: {
251
+ "Content-Type": "application/json",
252
+ ...(key ? { Authorization: `Bearer ${key}` } : {}),
253
+ ...extraHeaders,
254
+ },
255
+ body: JSON.stringify({
256
+ model: options.model,
257
+ messages: [
258
+ ...(options.system
259
+ ? [{ role: "system", content: options.system }]
260
+ : history
261
+ .filter((m) => m.role === "system")
262
+ .map((m) => ({ role: m.role, content: m.content }))),
263
+ ...history
264
+ .filter((m) => m.role !== "system")
265
+ .map((m) => ({ role: m.role, content: m.content })),
266
+ ],
267
+ ...(temperature !== undefined ? { temperature } : {}),
268
+ stream: true,
269
+ }),
270
+ signal,
271
+ };
272
+ return {
273
+ url: `${base}/chat/completions`,
274
+ init,
275
+ parseDelta: (data) => {
276
+ if (data.trim() === "[DONE]")
277
+ return { done: true };
278
+ let parsed;
279
+ try {
280
+ parsed = JSON.parse(data);
281
+ }
282
+ catch {
283
+ return {}; // ignore malformed chunks
284
+ }
285
+ if (parsed.error?.message)
286
+ throw new Error(parsed.error.message);
287
+ const text = parsed.choices?.[0]?.delta?.content;
288
+ return text ? { text } : {};
289
+ },
290
+ };
291
+ }
292
+ /**
293
+ * Streaming chat over OpenAI, Anthropic, Meta (Llama API), or a
294
+ * custom provider.
295
+ *
296
+ * `send()` appends the user message, opens the provider stream, and
297
+ * appends text deltas to a live assistant message as they arrive, so
298
+ * UI bound to `messages()` renders the reply token by token. Pairs
299
+ * well with `createTyping` for a typewriter reveal.
300
+ *
301
+ * Keys stay in your hands: pass `apiKey` directly, or a function
302
+ * reading it from your own store. For production, prefer calling
303
+ * through your own server route and pointing `baseUrl` at it so
304
+ * keys never ship to the browser.
305
+ *
306
+ * SSR-safe: nothing connects until `send()` is called.
307
+ *
308
+ * ```ts
309
+ * const chat = createChatModel({
310
+ * provider: "openai",
311
+ * apiKey: () => localStorage.getItem("openai_key") ?? "",
312
+ * model: "gpt-4o-mini",
313
+ * system: "You are a concise assistant.",
314
+ * onFinish: (msg) => console.log("done:", msg.content.length),
315
+ * });
316
+ * await chat.send("What is a signal?");
317
+ * ```
318
+ */
319
+ export function createChatModel(options) {
320
+ const [messages, setMessages] = createSignal(options.system
321
+ ? [{ id: nextMessageId(), role: "system", content: options.system }]
322
+ : []);
323
+ const [streamingText, setStreamingText] = createSignal("");
324
+ const [status, setStatus] = createSignal("idle");
325
+ const [error, setError] = createSignal(null);
326
+ let controller = null;
327
+ const stop = () => {
328
+ controller?.abort();
329
+ controller = null;
330
+ if (status() === "streaming")
331
+ setStatus("idle");
332
+ };
333
+ const reset = () => {
334
+ stop();
335
+ setError(null);
336
+ setStreamingText("");
337
+ setMessages(options.system
338
+ ? [{ id: nextMessageId(), role: "system", content: options.system }]
339
+ : []);
340
+ };
341
+ const send = async (content) => {
342
+ if (status() === "streaming")
343
+ return;
344
+ const fetchImpl = options.fetchFn ??
345
+ (typeof fetch !== "undefined" ? fetch : null);
346
+ if (!fetchImpl) {
347
+ const err = new Error("createChatModel: no fetch implementation available");
348
+ setError(err);
349
+ setStatus("error");
350
+ options.onError?.(err);
351
+ return;
352
+ }
353
+ const userMessage = {
354
+ id: nextMessageId(),
355
+ role: "user",
356
+ content,
357
+ };
358
+ const assistantMessage = {
359
+ id: nextMessageId(),
360
+ role: "assistant",
361
+ content: "",
362
+ };
363
+ const history = [...messages(), userMessage];
364
+ setMessages([...history, assistantMessage]);
365
+ setStreamingText("");
366
+ setError(null);
367
+ setStatus("streaming");
368
+ controller = new AbortController();
369
+ const signal = controller.signal;
370
+ const assistantId = assistantMessage.id;
371
+ const appendText = (text) => {
372
+ setStreamingText((prev) => prev + text);
373
+ setMessages((prev) => prev.map((m) => m.id === assistantId ? { ...m, content: m.content + text } : m));
374
+ };
375
+ try {
376
+ let response;
377
+ let parseDelta;
378
+ const provider = options.provider;
379
+ if (typeof provider === "object" && provider.kind === "custom") {
380
+ response = await provider.stream(history, { signal, fetchFn: fetchImpl });
381
+ parseDelta = provider.parseDelta;
382
+ }
383
+ else {
384
+ const kind = provider;
385
+ const built = buildBuiltinRequest(kind, options, history, signal, fetchImpl);
386
+ parseDelta = built.parseDelta;
387
+ response = await fetchImpl(built.url, built.init);
388
+ }
389
+ if (signal.aborted)
390
+ return;
391
+ let finished = false;
392
+ await pumpSSE(response, (ev) => {
393
+ if (finished)
394
+ return;
395
+ let parsed;
396
+ try {
397
+ parsed = parseDelta(ev.data, ev.event);
398
+ }
399
+ catch (e) {
400
+ throw e instanceof Error ? e : new Error(String(e));
401
+ }
402
+ if (parsed.text)
403
+ appendText(parsed.text);
404
+ if (parsed.done)
405
+ finished = true;
406
+ }, signal);
407
+ if (signal.aborted)
408
+ return;
409
+ setStatus("idle");
410
+ const final = messages().find((m) => m.id === assistantId);
411
+ if (final)
412
+ options.onFinish?.(final);
413
+ }
414
+ catch (err) {
415
+ if (signal.aborted) {
416
+ setStatus("idle");
417
+ return;
418
+ }
419
+ const e = err instanceof Error ? err : new Error(String(err));
420
+ setError(e);
421
+ setStatus("error");
422
+ options.onError?.(e);
423
+ }
424
+ finally {
425
+ controller = null;
426
+ }
427
+ };
428
+ onCleanup(stop);
429
+ return { messages, streamingText, status, error, send, stop, reset };
430
+ }