solid-drift 0.23.0 → 0.25.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 +61 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/sheet.d.ts +99 -0
- package/dist/sheet.js +219 -0
- package/dist/stream.d.ts +177 -0
- package/dist/stream.js +430 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1473,6 +1473,67 @@ const beat = createHapticBeat(haptic, {
|
|
|
1473
1473
|
- `createHapticBeat(haptic, options?)` returns `{ playing, bpm, step, start, stop, toggle, setBpm }`. The 16-step pattern uses `"x"` for a hit, `"X"` for an accent, anything else for a rest; steps run as 16th notes at `bpm` (live-changeable via `setBpm`), the downbeat fires immediately on `start()`, and `onStep(i)` reports each step index. `hapticBeatPresets` ships `heartbeat`, `metronome`, `ticks`, and `pulse`.
|
|
1474
1474
|
- Haptics are tactile, not visual, so they fire under reduced motion too; the `enabled` switch is the way to offer quiet. Everything is a no-op where vibration is unsupported, and SSR-safe.
|
|
1475
1475
|
|
|
1476
|
+
### Bottom sheet
|
|
1477
|
+
|
|
1478
|
+
A draggable bottom sheet built on `createDrag`: the user pulls it up by a handle (or the sheet itself) and on release it springs to the nearest snap point, projected forward by the release velocity like a native sheet. Dragging below the lowest snap (or a fast downward flick) dismisses it when `dismissible`.
|
|
1479
|
+
|
|
1480
|
+
```tsx
|
|
1481
|
+
import { createBottomSheet } from "solid-drift"
|
|
1482
|
+
|
|
1483
|
+
let sheet!: HTMLDivElement
|
|
1484
|
+
let handle!: HTMLDivElement
|
|
1485
|
+
const bs = createBottomSheet(() => handle, {
|
|
1486
|
+
snapPoints: [0.4, 1], // fractions of the sheet's own height
|
|
1487
|
+
measureRef: () => sheet, // measure the sheet, not the handle
|
|
1488
|
+
onOpenChange: (open) => setScrimVisible(open),
|
|
1489
|
+
})
|
|
1490
|
+
|
|
1491
|
+
<div
|
|
1492
|
+
ref={sheet}
|
|
1493
|
+
style={{
|
|
1494
|
+
position: "fixed", left: "0", right: "0", bottom: "0",
|
|
1495
|
+
transform: `translateY(${bs.y()}px)`,
|
|
1496
|
+
}}
|
|
1497
|
+
>
|
|
1498
|
+
<div ref={handle} style={{ "touch-action": "none" }}>Handle</div>
|
|
1499
|
+
<div>Sheet content</div>
|
|
1500
|
+
</div>
|
|
1501
|
+
<button onClick={() => bs.openSheet()}>Open</button>
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
- `createBottomSheet(ref, options?)` returns `{ open, snapIndex, y, status, openSheet, close, snapTo }`. `y()` is the current translateY in pixels; `status()` is `idle`, `dragging`, or `settling`; `snapIndex()` is the snap-point index or -1 when dismissed.
|
|
1505
|
+
- `snapPoints` are visible height fractions (`1` fully open); values are clamped to [0, 1] and sorted ascending, an empty array falls back to `[1]`. Default `[0.5, 1]`; `initialSnap` (default the fullest) picks the point `openSheet()` opens at.
|
|
1506
|
+
- While the pointer is down the sheet tracks 1:1 with light rubber-banding past the fully-open top and the dismissed bottom. On release, the target is the nearest snap to `y + velocity * 0.18`; dismissal happens past the midpoint between the lowest snap and closed, or on a downward flick over 700 px/s. Snap travel uses a spring (`options.spring`, default stiffness 400 / damping 40).
|
|
1507
|
+
- Bind `ref` to the drag handle when the sheet body scrolls (keeps drag and scroll from fighting), to the sheet root otherwise. The moving element gets `translateY(y())`; the drag target needs `touch-action: none`. Starts dismissed on the server (SSR-safe); under reduced motion it jumps straight to snap targets.
|
|
1508
|
+
|
|
1509
|
+
### Streaming
|
|
1510
|
+
|
|
1511
|
+
Token-by-token chat over OpenAI, Anthropic, Meta's Llama API, or your own provider, plus a low-level fetch-based SSE client for any event stream.
|
|
1512
|
+
|
|
1513
|
+
```tsx
|
|
1514
|
+
import { createChatModel } from "solid-drift"
|
|
1515
|
+
|
|
1516
|
+
const chat = createChatModel({
|
|
1517
|
+
provider: "openai",
|
|
1518
|
+
apiKey: () => localStorage.getItem("openai_key") ?? "",
|
|
1519
|
+
model: "gpt-4o-mini",
|
|
1520
|
+
system: "You are a concise assistant.",
|
|
1521
|
+
})
|
|
1522
|
+
|
|
1523
|
+
// In your component:
|
|
1524
|
+
<For each={chat.messages()}>
|
|
1525
|
+
{(m) => <div class={m.role}>{m.content}</div>}
|
|
1526
|
+
</For>
|
|
1527
|
+
<button onClick={() => chat.send(input())} disabled={chat.status() === "streaming"}>
|
|
1528
|
+
Send
|
|
1529
|
+
</button>
|
|
1530
|
+
```
|
|
1531
|
+
|
|
1532
|
+
- `createChatModel(options)` returns `{ messages, streamingText, status, error, send, stop, reset }`. `send(content)` appends the user message and streams the reply into a live assistant message, so UI bound to `messages()` renders token by token; `status()` is `idle`, `streaming`, or `error`. `stop()` aborts and keeps the partial reply; `send()` while streaming is ignored.
|
|
1533
|
+
- `provider` is `"openai"`, `"anthropic"`, `"meta"`, or a custom `{ kind: "custom", stream, parseDelta }`. OpenAI and Meta (Llama API via `/compat/v1`, OpenAI-compatible) use Bearer auth and `data:` chunks terminated by `[DONE]`; Anthropic uses `x-api-key` plus `anthropic-version: 2023-06-01`, a top-level `system` prompt, and `content_block_delta` text deltas (required `maxTokens` defaults to 1024).
|
|
1534
|
+
- Honest limitation: api.anthropic.com does not send CORS headers for browser origins, so from a browser Anthropic must go through your own server route or proxy; point `baseUrl` at it. For production with any provider, prefer a server route that holds the key and set `baseUrl` to it so keys never ship to the browser.
|
|
1535
|
+
- `createSSE(url, options?)` is a fetch-based event-stream client (`{ status, events, lastEvent, error, connect, disconnect }`): unlike `EventSource` it supports any method and custom headers, parses the full SSE framing (named events, multi-line data, comments, chunk splits), and leaves reconnection manual via `connect()`. SSR-safe: nothing connects until `connect()` (or `autoConnect`) runs on the client.
|
|
1536
|
+
|
|
1476
1537
|
### Easings
|
|
1477
1538
|
|
|
1478
1539
|
Named easings: `linear`, `easeInQuad`, `easeOutQuad`, `easeInOutQuad`, `easeInCubic`, `easeOutCubic`, `easeInOutCubic`, `easeInQuart`, `easeOutQuart`, `easeInOutQuart`, `easeOutExpo`, `easeOutBack`, plus the cartoon set: `easeInBack` (anticipation dip before movement), `easeInOutBack` (wind-up, overshoot, settle), `easeOutElastic` (decaying rubber-band oscillation), `easeOutBounce` (shrinking cartoon bounces). Also `cubicBezier(x1, y1, x2, y2)` for CSS-style curves. Pass a name or a custom `(t) => number` function anywhere an easing is accepted.
|
package/dist/index.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export { createSlotMachine, type SlotMachineControls, type SlotMachineOptions, t
|
|
|
16
16
|
export { createStagger } from "./stagger.js";
|
|
17
17
|
export { createDebounced, createThrottled, createLocalStorage, type LocalStorageOptions, type LocalStorageControls, createMediaQuery, createClickOutside, type ClickOutsideOptions, createScrollLock, type ScrollLockControls, createInfiniteScroll, type InfiniteScrollOptions, } from "./dom.js";
|
|
18
18
|
export { createHaptic, hapticPatterns, type HapticOptions, type HapticControls, type HapticPattern, createHapticBeat, hapticBeatPresets, type HapticBeatOptions, type HapticBeatControls, } from "./haptic.js";
|
|
19
|
+
export { createBottomSheet, type BottomSheetOptions, type BottomSheetControls, } from "./sheet.js";
|
|
19
20
|
export { createHorizontalScroll, type HorizontalScrollOptions, type HorizontalScrollResult, } from "./horizontal.js";
|
|
20
21
|
export { createScrub, type ScrubKeyframe, type ScrubOptions } from "./scrub.js";
|
|
21
22
|
export { createScrollColor, type ScrollColorStop, type ScrollColorOptions, type ScrollColorFormat, createScrollTracking, type ScrollTrackingOptions, createScrollLine, type ScrollLineOptions, type ScrollLineStyle, type ScrollLineAxis, type ScrollLineOrigin, } from "./scrollfx.js";
|
|
@@ -39,3 +40,5 @@ export { createTxLifecycle, createTicker, createMintReveal, createConnectButton,
|
|
|
39
40
|
export type { TxState, TxStatusInput, TxLifecycleOptions, TxLifecycleControls, TickerOptions, TickerControls, MintRevealStatus, MintRevealOptions, MintRevealControls, ConnectButtonOptions, ConnectButtonStatus, ConnectButtonControls, AgentTxState, AgentTxProposal, AgentTxOptions, AgentTxControls, } from "./web3.js";
|
|
40
41
|
export { createPoll, shortenAddress, isAddress, formatUnits, parseUnits, CHAINS, createChain, createTokenPrice, createPriceChange, createPriceCompare, createGasPrice, createBalance, createTxReceipt, createBlockNumber, createChainlinkPrice, createNFTMetadata, createENS, createIdenticon, } from "./web3data.js";
|
|
41
42
|
export type { PollStatus, PollOptions, PollControls, ChainInfo, TokenPrice, TokenPriceOptions, PriceChangeOptions, GasPriceOptions, GasPriceData, BalanceOptions, BalanceData, TxReceiptData, TxReceiptOptions, BlockNumberOptions, ChainlinkPriceOptions, NFTMetadata, NFTMetadataOptions, ENSOptions, IdenticonOptions, } from "./web3data.js";
|
|
43
|
+
export { createSSEParser, createSSE, createChatModel, } from "./stream.js";
|
|
44
|
+
export type { SSEEvent, StreamStatus, SSEOptions, SSEControls, ChatMessage, ChatProviderKind, CustomChatProvider, ChatModelOptions, ChatModelControls, } from "./stream.js";
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@ export { createSlotMachine, createRedPacket, createConfetti, createEmojiBurst, c
|
|
|
16
16
|
export { createStagger } from "./stagger.js";
|
|
17
17
|
export { createDebounced, createThrottled, createLocalStorage, createMediaQuery, createClickOutside, createScrollLock, createInfiniteScroll, } from "./dom.js";
|
|
18
18
|
export { createHaptic, hapticPatterns, createHapticBeat, hapticBeatPresets, } from "./haptic.js";
|
|
19
|
+
export { createBottomSheet, } from "./sheet.js";
|
|
19
20
|
export { createHorizontalScroll, } from "./horizontal.js";
|
|
20
21
|
export { createScrub } from "./scrub.js";
|
|
21
22
|
export { createScrollColor, createScrollTracking, createScrollLine, } from "./scrollfx.js";
|
|
@@ -35,3 +36,4 @@ export { createDrag, createSwipe, } from "./gesture.js";
|
|
|
35
36
|
export { createStreamReveal, createAgentState, parseDriftSpec, createSpecPlayer, DriftSpecError, } from "./ai.js";
|
|
36
37
|
export { createTxLifecycle, createTicker, createMintReveal, createConnectButton, createAgentTx, } from "./web3.js";
|
|
37
38
|
export { createPoll, shortenAddress, isAddress, formatUnits, parseUnits, CHAINS, createChain, createTokenPrice, createPriceChange, createPriceCompare, createGasPrice, createBalance, createTxReceipt, createBlockNumber, createChainlinkPrice, createNFTMetadata, createENS, createIdenticon, } from "./web3data.js";
|
|
39
|
+
export { createSSEParser, createSSE, createChatModel, } from "./stream.js";
|
package/dist/sheet.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type Accessor } from "solid-js";
|
|
2
|
+
import { type DragStatus } from "./gesture.js";
|
|
3
|
+
import type { SpringOptions } from "./spring.js";
|
|
4
|
+
type MaybeElement = () => Element | null | undefined;
|
|
5
|
+
export interface BottomSheetOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Snap points as fractions of the sheet's own height. `1` is fully
|
|
8
|
+
* open, `0.4` shows 40% of the sheet. Values are clamped to
|
|
9
|
+
* [0, 1] and sorted ascending; an empty array falls back to [1].
|
|
10
|
+
* Default `[0.5, 1]`.
|
|
11
|
+
*/
|
|
12
|
+
snapPoints?: number[];
|
|
13
|
+
/**
|
|
14
|
+
* Index into `snapPoints` opened by `openSheet()` with no argument.
|
|
15
|
+
* Default is the last point (fully open).
|
|
16
|
+
*/
|
|
17
|
+
initialSnap?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Dragging below the lowest snap dismisses the sheet on release.
|
|
20
|
+
* Default true.
|
|
21
|
+
*/
|
|
22
|
+
dismissible?: boolean;
|
|
23
|
+
/** Spring physics for snap animations. Default `{ stiffness: 400, damping: 40 }`. */
|
|
24
|
+
spring?: SpringOptions;
|
|
25
|
+
/**
|
|
26
|
+
* Element used to measure the sheet height. Defaults to the drag
|
|
27
|
+
* ref. Pass the sheet root here when the drag ref is a handle, so
|
|
28
|
+
* snap fractions apply to the sheet and not the handle.
|
|
29
|
+
*/
|
|
30
|
+
measureRef?: MaybeElement;
|
|
31
|
+
/** Called when the sheet opens or closes. */
|
|
32
|
+
onOpenChange?: (open: boolean) => void;
|
|
33
|
+
}
|
|
34
|
+
export interface BottomSheetControls {
|
|
35
|
+
/** Whether the sheet is open (not dismissed). */
|
|
36
|
+
open: Accessor<boolean>;
|
|
37
|
+
/** Index into `snapPoints`, or -1 when dismissed. */
|
|
38
|
+
snapIndex: Accessor<number>;
|
|
39
|
+
/**
|
|
40
|
+
* Current translateY in pixels. Drive the sheet's transform with
|
|
41
|
+
* this: `transform: translateY(${y()}px)`.
|
|
42
|
+
*/
|
|
43
|
+
y: Accessor<number>;
|
|
44
|
+
/** `idle`, `dragging` while the pointer is down, `settling` while snapping. */
|
|
45
|
+
status: Accessor<DragStatus>;
|
|
46
|
+
/** Open the sheet at a snap point (default the fullest). */
|
|
47
|
+
openSheet: (index?: number) => void;
|
|
48
|
+
/** Dismiss the sheet below the screen. */
|
|
49
|
+
close: () => void;
|
|
50
|
+
/** Snap to a point by index (opens the sheet if dismissed). */
|
|
51
|
+
snapTo: (index: number) => void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A draggable bottom sheet built on `createDrag`.
|
|
55
|
+
*
|
|
56
|
+
* The sheet is a fixed, bottom-anchored panel the user drags by a
|
|
57
|
+
* handle (or the sheet itself). On release it snaps to the nearest
|
|
58
|
+
* snap point, projected forward by the release velocity like a
|
|
59
|
+
* native sheet; a downward drag past the lowest point (or a fast
|
|
60
|
+
* downward flick) dismisses it when `dismissible`.
|
|
61
|
+
*
|
|
62
|
+
* The drag gesture itself, pointer tracking, release velocity, and
|
|
63
|
+
* reduced-motion behavior come from `createDrag`; this primitive
|
|
64
|
+
* adds the snap-point semantics on top. While the pointer is down
|
|
65
|
+
* the sheet tracks 1:1 with light rubber-banding past the top and
|
|
66
|
+
* bottom edges; on release it springs to the chosen point.
|
|
67
|
+
*
|
|
68
|
+
* Bind the `ref` to the drag handle when the sheet body scrolls
|
|
69
|
+
* (a handle keeps drag and scroll from fighting); bind it to the
|
|
70
|
+
* sheet root for non-scrolling sheets. Either way the moving
|
|
71
|
+
* element gets `transform: translateY(${y()}px)` and the drag
|
|
72
|
+
* target gets `touch-action: none`. When the drag ref is a handle,
|
|
73
|
+
* pass the sheet root as `options.measureRef` so snap fractions
|
|
74
|
+
* are measured against the sheet.
|
|
75
|
+
*
|
|
76
|
+
* SSR-safe: starts dismissed with `y()` at 0 on the server.
|
|
77
|
+
*
|
|
78
|
+
* ```tsx
|
|
79
|
+
* let sheet!: HTMLDivElement
|
|
80
|
+
* let handle!: HTMLDivElement
|
|
81
|
+
* const bs = createBottomSheet(() => handle, {
|
|
82
|
+
* snapPoints: [0.4, 1],
|
|
83
|
+
* measureRef: () => sheet,
|
|
84
|
+
* })
|
|
85
|
+
* <div
|
|
86
|
+
* ref={sheet}
|
|
87
|
+
* style={{
|
|
88
|
+
* position: "fixed", left: "0", right: "0", bottom: "0",
|
|
89
|
+
* transform: `translateY(${bs.y()}px)`,
|
|
90
|
+
* }}
|
|
91
|
+
* >
|
|
92
|
+
* <div ref={handle} style={{ "touch-action": "none" }}>Handle</div>
|
|
93
|
+
* <div>Sheet content</div>
|
|
94
|
+
* </div>
|
|
95
|
+
* <button onClick={() => bs.openSheet()}>Open</button>
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export declare function createBottomSheet(ref: MaybeElement, options?: BottomSheetOptions): BottomSheetControls;
|
|
99
|
+
export {};
|
package/dist/sheet.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { createEffect, createSignal, onCleanup, untrack, } from "solid-js";
|
|
2
|
+
import { now, schedule } from "./engine.js";
|
|
3
|
+
import { createDrag, } from "./gesture.js";
|
|
4
|
+
import { prefersReducedMotion } from "./reduced-motion.js";
|
|
5
|
+
/**
|
|
6
|
+
* A draggable bottom sheet built on `createDrag`.
|
|
7
|
+
*
|
|
8
|
+
* The sheet is a fixed, bottom-anchored panel the user drags by a
|
|
9
|
+
* handle (or the sheet itself). On release it snaps to the nearest
|
|
10
|
+
* snap point, projected forward by the release velocity like a
|
|
11
|
+
* native sheet; a downward drag past the lowest point (or a fast
|
|
12
|
+
* downward flick) dismisses it when `dismissible`.
|
|
13
|
+
*
|
|
14
|
+
* The drag gesture itself, pointer tracking, release velocity, and
|
|
15
|
+
* reduced-motion behavior come from `createDrag`; this primitive
|
|
16
|
+
* adds the snap-point semantics on top. While the pointer is down
|
|
17
|
+
* the sheet tracks 1:1 with light rubber-banding past the top and
|
|
18
|
+
* bottom edges; on release it springs to the chosen point.
|
|
19
|
+
*
|
|
20
|
+
* Bind the `ref` to the drag handle when the sheet body scrolls
|
|
21
|
+
* (a handle keeps drag and scroll from fighting); bind it to the
|
|
22
|
+
* sheet root for non-scrolling sheets. Either way the moving
|
|
23
|
+
* element gets `transform: translateY(${y()}px)` and the drag
|
|
24
|
+
* target gets `touch-action: none`. When the drag ref is a handle,
|
|
25
|
+
* pass the sheet root as `options.measureRef` so snap fractions
|
|
26
|
+
* are measured against the sheet.
|
|
27
|
+
*
|
|
28
|
+
* SSR-safe: starts dismissed with `y()` at 0 on the server.
|
|
29
|
+
*
|
|
30
|
+
* ```tsx
|
|
31
|
+
* let sheet!: HTMLDivElement
|
|
32
|
+
* let handle!: HTMLDivElement
|
|
33
|
+
* const bs = createBottomSheet(() => handle, {
|
|
34
|
+
* snapPoints: [0.4, 1],
|
|
35
|
+
* measureRef: () => sheet,
|
|
36
|
+
* })
|
|
37
|
+
* <div
|
|
38
|
+
* ref={sheet}
|
|
39
|
+
* style={{
|
|
40
|
+
* position: "fixed", left: "0", right: "0", bottom: "0",
|
|
41
|
+
* transform: `translateY(${bs.y()}px)`,
|
|
42
|
+
* }}
|
|
43
|
+
* >
|
|
44
|
+
* <div ref={handle} style={{ "touch-action": "none" }}>Handle</div>
|
|
45
|
+
* <div>Sheet content</div>
|
|
46
|
+
* </div>
|
|
47
|
+
* <button onClick={() => bs.openSheet()}>Open</button>
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function createBottomSheet(ref, options = {}) {
|
|
51
|
+
const { snapPoints: snapPointsOption, initialSnap: initialSnapOption, dismissible = true, spring = {}, measureRef, onOpenChange, } = options;
|
|
52
|
+
// Snap points are visible height fractions, ascending. Values are
|
|
53
|
+
// clamped to [0, 1] and sorted; an empty list falls back to [1]
|
|
54
|
+
// (a single fully-open snap).
|
|
55
|
+
const snapPoints = (snapPointsOption?.length ? [...snapPointsOption] : [1])
|
|
56
|
+
.map((p) => Math.min(1, Math.max(0, p)))
|
|
57
|
+
.sort((a, b) => a - b);
|
|
58
|
+
const initialSnap = initialSnapOption ?? snapPoints.length - 1;
|
|
59
|
+
const [open, setOpen] = createSignal(false);
|
|
60
|
+
const [snapIndex, setSnapIndex] = createSignal(-1);
|
|
61
|
+
const [y, setY] = createSignal(0);
|
|
62
|
+
const [snapping, setSnapping] = createSignal(false);
|
|
63
|
+
// Sheet height in px, measured lazily from the element.
|
|
64
|
+
let height = 0;
|
|
65
|
+
const measure = () => {
|
|
66
|
+
const el = (measureRef ?? ref)();
|
|
67
|
+
if (el && typeof el.offsetHeight === "number") {
|
|
68
|
+
const h = el.offsetHeight;
|
|
69
|
+
if (h > 0)
|
|
70
|
+
height = h;
|
|
71
|
+
}
|
|
72
|
+
return height;
|
|
73
|
+
};
|
|
74
|
+
const snapY = (index) => height * (1 - snapPoints[index]);
|
|
75
|
+
const closedY = () => height;
|
|
76
|
+
// Snap animation: the same semi-implicit Euler spring the gesture
|
|
77
|
+
// primitives use.
|
|
78
|
+
let cancelSnap = null;
|
|
79
|
+
const animateTo = (target) => {
|
|
80
|
+
cancelSnap?.();
|
|
81
|
+
cancelSnap = null;
|
|
82
|
+
if (prefersReducedMotion()) {
|
|
83
|
+
setY(target);
|
|
84
|
+
setSnapping(false);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const { stiffness = 400, damping = 40 } = spring;
|
|
88
|
+
let current = untrack(y);
|
|
89
|
+
let v = 0;
|
|
90
|
+
let last = now();
|
|
91
|
+
setSnapping(true);
|
|
92
|
+
const task = (t) => {
|
|
93
|
+
const dt = Math.min(Math.max((t - last) / 1000, 0), 0.064);
|
|
94
|
+
last = t;
|
|
95
|
+
v += (-stiffness * (current - target) - damping * v) * dt;
|
|
96
|
+
current += v * dt;
|
|
97
|
+
setY(current);
|
|
98
|
+
if (Math.abs(current - target) < 0.5 && Math.abs(v) < 20) {
|
|
99
|
+
setY(target);
|
|
100
|
+
setSnapping(false);
|
|
101
|
+
cancelSnap = null;
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
cancelSnap = schedule(task);
|
|
107
|
+
};
|
|
108
|
+
onCleanup(() => cancelSnap?.());
|
|
109
|
+
const openAt = (index) => {
|
|
110
|
+
measure();
|
|
111
|
+
const i = Math.max(0, Math.min(snapPoints.length - 1, index));
|
|
112
|
+
if (!untrack(open)) {
|
|
113
|
+
setOpen(true);
|
|
114
|
+
onOpenChange?.(true);
|
|
115
|
+
}
|
|
116
|
+
setSnapIndex(i);
|
|
117
|
+
animateTo(snapY(i));
|
|
118
|
+
};
|
|
119
|
+
const close = () => {
|
|
120
|
+
measure();
|
|
121
|
+
if (untrack(open)) {
|
|
122
|
+
setOpen(false);
|
|
123
|
+
onOpenChange?.(false);
|
|
124
|
+
}
|
|
125
|
+
setSnapIndex(-1);
|
|
126
|
+
animateTo(closedY());
|
|
127
|
+
};
|
|
128
|
+
// The underlying gesture: pointer tracking and release velocity.
|
|
129
|
+
// Its own y is only ever used as a per-grab delta source; the
|
|
130
|
+
// sheet position lives in `y` above.
|
|
131
|
+
let dragBase = 0;
|
|
132
|
+
let sheetBase = 0;
|
|
133
|
+
const settleFromRelease = (info) => {
|
|
134
|
+
measure();
|
|
135
|
+
const releaseY = untrack(y);
|
|
136
|
+
const projected = releaseY + info.velocityY * 0.18;
|
|
137
|
+
const lowest = snapY(0);
|
|
138
|
+
const dismissLine = (lowest + height) / 2;
|
|
139
|
+
if (dismissible &&
|
|
140
|
+
(projected >= dismissLine ||
|
|
141
|
+
(info.velocityY > 700 && projected > lowest))) {
|
|
142
|
+
close();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
let best = 0;
|
|
146
|
+
let bestDist = Math.abs(snapY(0) - projected);
|
|
147
|
+
for (let i = 1; i < snapPoints.length; i++) {
|
|
148
|
+
const d = Math.abs(snapY(i) - projected);
|
|
149
|
+
if (d < bestDist) {
|
|
150
|
+
bestDist = d;
|
|
151
|
+
best = i;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
openAt(best);
|
|
155
|
+
};
|
|
156
|
+
const drag = createDrag(ref, {
|
|
157
|
+
axis: "y",
|
|
158
|
+
momentum: false, // the sheet chooses its own snap target
|
|
159
|
+
onDragStart: () => {
|
|
160
|
+
cancelSnap?.();
|
|
161
|
+
cancelSnap = null;
|
|
162
|
+
setSnapping(false);
|
|
163
|
+
measure();
|
|
164
|
+
},
|
|
165
|
+
onDragEnd: settleFromRelease,
|
|
166
|
+
});
|
|
167
|
+
// 1:1 tracking while the pointer is down, with rubber-banding past
|
|
168
|
+
// the fully-open top and the dismissed bottom. The grab bases are
|
|
169
|
+
// captured on the transition into "dragging" inside this effect:
|
|
170
|
+
// writes from event handlers flush effects synchronously, so the
|
|
171
|
+
// effect runs during setStatus("dragging"), before onDragStart.
|
|
172
|
+
// Capturing here keeps the bases correct under any scheduling.
|
|
173
|
+
let prevDragStatus = "idle";
|
|
174
|
+
createEffect(() => {
|
|
175
|
+
const s = drag.status();
|
|
176
|
+
if (s === "dragging" && prevDragStatus !== "dragging") {
|
|
177
|
+
dragBase = drag.y();
|
|
178
|
+
sheetBase = untrack(y);
|
|
179
|
+
}
|
|
180
|
+
prevDragStatus = s;
|
|
181
|
+
if (s !== "dragging")
|
|
182
|
+
return;
|
|
183
|
+
const raw = sheetBase + (drag.y() - dragBase);
|
|
184
|
+
const h = height;
|
|
185
|
+
let next = raw;
|
|
186
|
+
if (next < 0)
|
|
187
|
+
next = next * 0.3;
|
|
188
|
+
else if (next > h)
|
|
189
|
+
next = h + (next - h) * 0.3;
|
|
190
|
+
setY(next);
|
|
191
|
+
});
|
|
192
|
+
const status = () => {
|
|
193
|
+
if (drag.status() === "dragging")
|
|
194
|
+
return "dragging";
|
|
195
|
+
return snapping() ? "settling" : "idle";
|
|
196
|
+
};
|
|
197
|
+
// Client-only: park the dismissed sheet below the screen once the
|
|
198
|
+
// element (and its height) exists.
|
|
199
|
+
if (typeof window !== "undefined") {
|
|
200
|
+
createEffect(() => {
|
|
201
|
+
const el = ref();
|
|
202
|
+
const mel = (measureRef ?? ref)();
|
|
203
|
+
if (!el || !mel)
|
|
204
|
+
return;
|
|
205
|
+
const h = measure();
|
|
206
|
+
if (!untrack(open) && untrack(snapIndex) === -1)
|
|
207
|
+
setY(h);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
open,
|
|
212
|
+
snapIndex,
|
|
213
|
+
y,
|
|
214
|
+
status,
|
|
215
|
+
openSheet: (index = initialSnap) => openAt(index),
|
|
216
|
+
close,
|
|
217
|
+
snapTo: openAt,
|
|
218
|
+
};
|
|
219
|
+
}
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { type Accessor } from "solid-js";
|
|
2
|
+
/** One parsed Server-Sent Event. */
|
|
3
|
+
export interface SSEEvent {
|
|
4
|
+
/** Event name; defaults to "message" when the stream sets none. */
|
|
5
|
+
event: string;
|
|
6
|
+
/** Payload: data lines joined with newlines. */
|
|
7
|
+
data: string;
|
|
8
|
+
/** Last `id:` field seen, if any. */
|
|
9
|
+
id?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Feed raw text chunks through the SSE framing rules and call
|
|
13
|
+
* `onEvent` for each dispatched event. Handles events split across
|
|
14
|
+
* chunk boundaries, multi-line `data:` payloads, and `:` comments.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createSSEParser(onEvent: (event: SSEEvent) => void): (chunk: string) => void;
|
|
17
|
+
type FetchFn = typeof fetch;
|
|
18
|
+
/** Connection state of a stream. */
|
|
19
|
+
export type StreamStatus = "idle" | "connecting" | "open" | "closed" | "error";
|
|
20
|
+
export interface SSEOptions {
|
|
21
|
+
/** HTTP method. Default "GET". */
|
|
22
|
+
method?: string;
|
|
23
|
+
/** Extra headers, or a function returning them per connection. */
|
|
24
|
+
headers?: Record<string, string> | (() => Record<string, string>);
|
|
25
|
+
/** JSON-encoded request body (for POST-style SSE endpoints). */
|
|
26
|
+
body?: unknown;
|
|
27
|
+
/** Only surface events with this name. */
|
|
28
|
+
event?: string;
|
|
29
|
+
/** Called for each parsed event (after the name filter). */
|
|
30
|
+
onEvent?: (event: SSEEvent) => void;
|
|
31
|
+
/** Called when the stream opens (first byte accepted). */
|
|
32
|
+
onOpen?: () => void;
|
|
33
|
+
/** Called when the stream ends cleanly. */
|
|
34
|
+
onDone?: () => void;
|
|
35
|
+
/** Called on connection or HTTP errors. */
|
|
36
|
+
onError?: (error: Error) => void;
|
|
37
|
+
/** Connect immediately on creation. Default true. */
|
|
38
|
+
autoConnect?: boolean;
|
|
39
|
+
/** Fetch implementation (for tests or custom transports). */
|
|
40
|
+
fetchFn?: FetchFn;
|
|
41
|
+
}
|
|
42
|
+
export interface SSEControls {
|
|
43
|
+
/** Connection state. */
|
|
44
|
+
status: Accessor<StreamStatus>;
|
|
45
|
+
/** All parsed events received on this connection. */
|
|
46
|
+
events: Accessor<SSEEvent[]>;
|
|
47
|
+
/** The most recent event, if any. */
|
|
48
|
+
lastEvent: Accessor<SSEEvent | null>;
|
|
49
|
+
/** The last error, if any. */
|
|
50
|
+
error: Accessor<Error | null>;
|
|
51
|
+
/** Open (or re-open) the stream. */
|
|
52
|
+
connect: () => void;
|
|
53
|
+
/** Close the stream and abort the request. */
|
|
54
|
+
disconnect: () => void;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A fetch-based Server-Sent Events client.
|
|
58
|
+
*
|
|
59
|
+
* Unlike `EventSource`, this works with any HTTP method and custom
|
|
60
|
+
* headers, so it can reach authenticated or POST-style SSE endpoints.
|
|
61
|
+
* There is no automatic reconnection: a dropped stream moves to
|
|
62
|
+
* "closed" (or "error") and `connect()` re-opens it manually.
|
|
63
|
+
*
|
|
64
|
+
* SSR-safe: nothing connects until `connect()` runs (or
|
|
65
|
+
* `autoConnect` fires on the client).
|
|
66
|
+
*
|
|
67
|
+
* ```ts
|
|
68
|
+
* const sse = createSSE("https://api.example.com/events", {
|
|
69
|
+
* headers: { Authorization: `Bearer ${token}` },
|
|
70
|
+
* onEvent: (ev) => console.log(ev.event, ev.data),
|
|
71
|
+
* });
|
|
72
|
+
* sse.disconnect();
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export declare function createSSE(url: string | (() => string), options?: SSEOptions): SSEControls;
|
|
76
|
+
/** A single chat turn. */
|
|
77
|
+
export interface ChatMessage {
|
|
78
|
+
id: string;
|
|
79
|
+
role: "system" | "user" | "assistant";
|
|
80
|
+
content: string;
|
|
81
|
+
}
|
|
82
|
+
/** Built-in provider kinds. */
|
|
83
|
+
export type ChatProviderKind = "openai" | "anthropic" | "meta";
|
|
84
|
+
/**
|
|
85
|
+
* Custom streaming provider. `stream` opens the request and returns
|
|
86
|
+
* the raw Response; `parseDelta` maps each SSE data payload to text
|
|
87
|
+
* (appended to the reply) or a done flag.
|
|
88
|
+
*/
|
|
89
|
+
export interface CustomChatProvider {
|
|
90
|
+
kind: "custom";
|
|
91
|
+
stream: (messages: ChatMessage[], context: {
|
|
92
|
+
signal: AbortSignal;
|
|
93
|
+
fetchFn: FetchFn;
|
|
94
|
+
}) => Promise<Response>;
|
|
95
|
+
parseDelta: (data: string, event?: string) => {
|
|
96
|
+
text?: string;
|
|
97
|
+
done?: boolean;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export interface ChatModelOptions {
|
|
101
|
+
/**
|
|
102
|
+
* Provider. Built-in kinds:
|
|
103
|
+
* - `"openai"`: chat completions, `data:` chunks, `[DONE]` terminator.
|
|
104
|
+
* - `"anthropic"`: messages API, `content_block_delta` text deltas.
|
|
105
|
+
* Note: api.anthropic.com does not send CORS headers for browser
|
|
106
|
+
* origins, so from a browser you must call it through your own
|
|
107
|
+
* server route or proxy (set `baseUrl` to that proxy).
|
|
108
|
+
* - `"meta"`: Llama API, OpenAI-compatible via /compat/v1.
|
|
109
|
+
* - custom `{ kind: "custom", stream, parseDelta }`.
|
|
110
|
+
*/
|
|
111
|
+
provider: ChatProviderKind | CustomChatProvider;
|
|
112
|
+
/** API key, or a function returning it. Sent as Bearer (openai/meta) or x-api-key (anthropic). */
|
|
113
|
+
apiKey?: string | (() => string | undefined);
|
|
114
|
+
/** Model name, e.g. "gpt-4o-mini", "claude-sonnet-4-20250514". */
|
|
115
|
+
model: string;
|
|
116
|
+
/** Override the API root. Defaults per provider kind. */
|
|
117
|
+
baseUrl?: string;
|
|
118
|
+
/** System prompt, sent as a system message (top-level `system` for anthropic). */
|
|
119
|
+
system?: string;
|
|
120
|
+
/** Sampling temperature, passed through when set. */
|
|
121
|
+
temperature?: number;
|
|
122
|
+
/** Max output tokens. Default 1024. Required by the anthropic API. */
|
|
123
|
+
maxTokens?: number;
|
|
124
|
+
/** Extra headers merged into the request. */
|
|
125
|
+
headers?: Record<string, string>;
|
|
126
|
+
/** Fetch implementation (for tests or custom transports). */
|
|
127
|
+
fetchFn?: FetchFn;
|
|
128
|
+
/** Called with the finished assistant message. */
|
|
129
|
+
onFinish?: (message: ChatMessage) => void;
|
|
130
|
+
/** Called on request or stream errors. */
|
|
131
|
+
onError?: (error: Error) => void;
|
|
132
|
+
}
|
|
133
|
+
export interface ChatModelControls {
|
|
134
|
+
/** Full conversation, including the in-progress reply. */
|
|
135
|
+
messages: Accessor<ChatMessage[]>;
|
|
136
|
+
/** The reply text streamed so far (empty when idle). */
|
|
137
|
+
streamingText: Accessor<string>;
|
|
138
|
+
/** `idle`, `streaming`, or `error`. */
|
|
139
|
+
status: Accessor<"idle" | "streaming" | "error">;
|
|
140
|
+
/** The last error, if any. */
|
|
141
|
+
error: Accessor<Error | null>;
|
|
142
|
+
/** Send a user message and stream the reply. Ignored while streaming. */
|
|
143
|
+
send: (content: string) => Promise<void>;
|
|
144
|
+
/** Abort the in-flight reply, keeping the partial text. */
|
|
145
|
+
stop: () => void;
|
|
146
|
+
/** Clear the conversation and abort any in-flight reply. */
|
|
147
|
+
reset: () => void;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Streaming chat over OpenAI, Anthropic, Meta (Llama API), or a
|
|
151
|
+
* custom provider.
|
|
152
|
+
*
|
|
153
|
+
* `send()` appends the user message, opens the provider stream, and
|
|
154
|
+
* appends text deltas to a live assistant message as they arrive, so
|
|
155
|
+
* UI bound to `messages()` renders the reply token by token. Pairs
|
|
156
|
+
* well with `createTyping` for a typewriter reveal.
|
|
157
|
+
*
|
|
158
|
+
* Keys stay in your hands: pass `apiKey` directly, or a function
|
|
159
|
+
* reading it from your own store. For production, prefer calling
|
|
160
|
+
* through your own server route and pointing `baseUrl` at it so
|
|
161
|
+
* keys never ship to the browser.
|
|
162
|
+
*
|
|
163
|
+
* SSR-safe: nothing connects until `send()` is called.
|
|
164
|
+
*
|
|
165
|
+
* ```ts
|
|
166
|
+
* const chat = createChatModel({
|
|
167
|
+
* provider: "openai",
|
|
168
|
+
* apiKey: () => localStorage.getItem("openai_key") ?? "",
|
|
169
|
+
* model: "gpt-4o-mini",
|
|
170
|
+
* system: "You are a concise assistant.",
|
|
171
|
+
* onFinish: (msg) => console.log("done:", msg.content.length),
|
|
172
|
+
* });
|
|
173
|
+
* await chat.send("What is a signal?");
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
export declare function createChatModel(options: ChatModelOptions): ChatModelControls;
|
|
177
|
+
export {};
|
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
|
+
}
|
package/package.json
CHANGED