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/README.md +65 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/stream.d.ts +177 -0
- package/dist/stream.js +430 -0
- package/dist/voice.d.ts +292 -0
- package/dist/voice.js +577 -0
- package/package.json +1 -1
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
|
+
}
|