@alexkroman1/aai-ui 1.9.2 → 1.10.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/{_colors-DJUordGv.js → _colors-DYX7XRTr.js} +8 -0
- package/dist/{_utils-5cs73OrA.js → _utils-CN1yVgYS.js} +2 -5
- package/dist/audio.d.ts +6 -0
- package/dist/audio.js +62 -9
- package/dist/{chat-view-C1XbqDk0.js → chat-view-u3yBAlig.js} +16 -14
- package/dist/components/_colors.d.ts +0 -4
- package/dist/components/chat-view.js +1 -1
- package/dist/components/controls.d.ts +2 -2
- package/dist/components/controls.js +1 -1
- package/dist/components/message-list.d.ts +2 -2
- package/dist/components/message-list.js +70 -64
- package/dist/components/start-screen.js +1 -1
- package/dist/components/text-controls.d.ts +2 -2
- package/dist/components/tool-call-block.d.ts +6 -2
- package/dist/components/tool-call-block.js +1 -1
- package/dist/context.d.ts +3 -11
- package/dist/context.js +9 -22
- package/dist/{controls-BngrPbOC.js → controls-B2EPUJDU.js} +12 -9
- package/dist/default-client/assets/audio-COt0_Zvp.js +1 -0
- package/dist/default-client/assets/{capture-processor-C19oBn4L.js → capture-processor-UlKEKyIW.js} +4 -1
- package/dist/default-client/assets/index-B2bISVbm.css +2 -0
- package/dist/default-client/assets/index-DtsWxxc_.js +94 -0
- package/dist/default-client/assets/{playback-processor-BtlzAH78.js → playback-processor-C5HVRVbu.js} +11 -4
- package/dist/default-client/index.html +2 -2
- package/dist/define-client.js +3 -3
- package/dist/hooks.js +5 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -5
- package/dist/{session-core-D3NDaySY.js → session-core-Cfz7PPhN.js} +248 -111
- package/dist/session-core-audio-setup.d.ts +36 -0
- package/dist/session-core-messages.d.ts +12 -0
- package/dist/session-core-reconnect.d.ts +19 -0
- package/dist/session-core-url.d.ts +2 -0
- package/dist/session-core.js +1 -1
- package/dist/{tool-call-block-7f1GTG-P.js → tool-call-block-DIxpG8GM.js} +11 -6
- package/dist/types.d.ts +11 -3
- package/dist/worklets/capture-processor.d.ts +1 -1
- package/dist/worklets/capture-processor.js +4 -1
- package/dist/worklets/playback-processor.d.ts +1 -1
- package/dist/worklets/playback-processor.js +11 -4
- package/package.json +8 -5
- package/dist/default-client/assets/audio-Cs-6t_Wd.js +0 -1
- package/dist/default-client/assets/index-Bf4ZTNcx.js +0 -73
- package/dist/default-client/assets/index-Bzlh9i7w.css +0 -2
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
//#region components/_colors.ts
|
|
2
|
+
/**
|
|
3
|
+
* Shared tints used by the default components (AssemblyAI design system,
|
|
4
|
+
* "website refresh": warm neutrals over the cream/white surfaces).
|
|
5
|
+
*
|
|
6
|
+
* These sit on top of the {@link ClientTheme} colors (which own the opaque
|
|
7
|
+
* palette) and are intentionally not themeable: they are the warm-gray text
|
|
8
|
+
* steps and ink alpha layers the refresh uses over any light surface.
|
|
9
|
+
*/
|
|
2
10
|
/** Muted text — subtitles, secondary labels, thinking dots (fg-muted). */
|
|
3
11
|
const TEXT_MUTED = "#57534B";
|
|
4
12
|
/** Faint text — live transcripts, state indicator, start-screen subtitle (warm-500). */
|
|
@@ -1,12 +1,9 @@
|
|
|
1
|
+
import { safeJsonParse } from "@alexkroman1/aai";
|
|
1
2
|
//#region _utils.ts
|
|
2
3
|
/** Parse a JSON string, returning the input unchanged when it isn't valid JSON. */
|
|
3
4
|
function tryParseJSON(str) {
|
|
4
5
|
if (!str) return str;
|
|
5
|
-
|
|
6
|
-
return JSON.parse(str);
|
|
7
|
-
} catch {
|
|
8
|
-
return str;
|
|
9
|
-
}
|
|
6
|
+
return safeJsonParse(str) ?? str;
|
|
10
7
|
}
|
|
11
8
|
/** Truncate a string to `max` characters, appending an ellipsis when cut. */
|
|
12
9
|
function truncate(s, max = 80) {
|
package/dist/audio.d.ts
CHANGED
|
@@ -19,6 +19,12 @@ export type VoiceIOOptions = {
|
|
|
19
19
|
playbackWorkletSrc: string;
|
|
20
20
|
/** Callback invoked with buffered PCM16 microphone data to send to the server. */
|
|
21
21
|
onMicData: (pcm16: ArrayBuffer) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Called when an AudioWorklet processor throws and is killed by the browser
|
|
24
|
+
* (the node produces no further audio or messages), so the session can
|
|
25
|
+
* transition out of listening/speaking instead of looking healthy forever.
|
|
26
|
+
*/
|
|
27
|
+
onError?: ((err: Error) => void) | undefined;
|
|
22
28
|
};
|
|
23
29
|
/**
|
|
24
30
|
* Audio I/O interface for voice capture and playback.
|
package/dist/audio.js
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { MIC_BUFFER_SECONDS } from "./types.js";
|
|
2
2
|
//#region audio.ts
|
|
3
|
+
/** How often {@link VoiceIO.done} checks that the AudioContext is still rendering. */
|
|
4
|
+
const DONE_POLL_INTERVAL_MS = 1e3;
|
|
5
|
+
/**
|
|
6
|
+
* Hard cap on waiting for playback to drain. The playback worklet buffers up
|
|
7
|
+
* to 60s of audio, so the longest legitimate drain is just under that — a
|
|
8
|
+
* wait past this means the processor died without reporting 'stop'.
|
|
9
|
+
*/
|
|
10
|
+
const DONE_MAX_WAIT_MS = 65e3;
|
|
11
|
+
/**
|
|
12
|
+
* Bounded wait for the capture worklet's 'stopped' ack during close(). The
|
|
13
|
+
* ack follows the final flush, so waiting for it keeps the tail of speech
|
|
14
|
+
* from being dropped; the timeout covers a dead worklet.
|
|
15
|
+
*/
|
|
16
|
+
const CAPTURE_STOP_ACK_TIMEOUT_MS = 250;
|
|
3
17
|
/**
|
|
4
18
|
* Decode an audio file (any container/codec the browser can decode) and
|
|
5
19
|
* resample it to mono PCM16 at `targetRate` — the format the server's STT
|
|
@@ -18,9 +32,10 @@ async function decodeAudioToPcm16(data, targetRate) {
|
|
|
18
32
|
source.start();
|
|
19
33
|
const f32 = (await offline.startRendering()).getChannelData(0);
|
|
20
34
|
const pcm = new Int16Array(f32.length);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
35
|
+
let i = 0;
|
|
36
|
+
for (const sample of f32) {
|
|
37
|
+
const s = Math.max(-1, Math.min(1, sample));
|
|
38
|
+
pcm[i++] = s < 0 ? s * 32768 : s * 32767;
|
|
24
39
|
}
|
|
25
40
|
return pcm;
|
|
26
41
|
}
|
|
@@ -36,7 +51,7 @@ async function decodeAudioToPcm16(data, targetRate) {
|
|
|
36
51
|
* @throws If microphone access is denied or AudioWorklet registration fails.
|
|
37
52
|
*/
|
|
38
53
|
async function createVoiceIO(opts) {
|
|
39
|
-
const { sttSampleRate, ttsSampleRate, captureWorkletSrc, playbackWorkletSrc, onMicData } = opts;
|
|
54
|
+
const { sttSampleRate, ttsSampleRate, captureWorkletSrc, playbackWorkletSrc, onMicData, onError } = opts;
|
|
40
55
|
const contextRate = ttsSampleRate;
|
|
41
56
|
const ctx = new AudioContext({
|
|
42
57
|
sampleRate: contextRate,
|
|
@@ -77,10 +92,19 @@ async function createVoiceIO(opts) {
|
|
|
77
92
|
}
|
|
78
93
|
});
|
|
79
94
|
mic.connect(capNode);
|
|
95
|
+
capNode.onprocessorerror = () => {
|
|
96
|
+
const err = /* @__PURE__ */ new Error("Audio capture worklet crashed");
|
|
97
|
+
console.error("[aai-ui]", err.message);
|
|
98
|
+
onError?.(err);
|
|
99
|
+
};
|
|
80
100
|
capNode.port.postMessage({ event: "start" });
|
|
101
|
+
let onCaptureStopped = null;
|
|
81
102
|
capNode.port.onmessage = (e) => {
|
|
82
|
-
if (e.data.event
|
|
83
|
-
|
|
103
|
+
if (e.data.event === "chunk") onMicData(e.data.buffer);
|
|
104
|
+
else if (e.data.event === "stopped") {
|
|
105
|
+
onCaptureStopped?.();
|
|
106
|
+
onCaptureStopped = null;
|
|
107
|
+
}
|
|
84
108
|
};
|
|
85
109
|
let playNode = null;
|
|
86
110
|
let onPlaybackStop = null;
|
|
@@ -91,10 +115,18 @@ async function createVoiceIO(opts) {
|
|
|
91
115
|
node.connect(ctx.destination);
|
|
92
116
|
node.port.onmessage = (e) => {
|
|
93
117
|
if (e.data.event === "stop") {
|
|
118
|
+
if (e.data.reason === "interrupt") return;
|
|
94
119
|
onPlaybackStop?.();
|
|
95
120
|
onPlaybackStop = null;
|
|
96
121
|
}
|
|
97
122
|
};
|
|
123
|
+
node.onprocessorerror = () => {
|
|
124
|
+
const err = /* @__PURE__ */ new Error("Audio playback worklet crashed");
|
|
125
|
+
console.error("[aai-ui]", err.message);
|
|
126
|
+
onPlaybackStop?.();
|
|
127
|
+
onPlaybackStop = null;
|
|
128
|
+
onError?.(err);
|
|
129
|
+
};
|
|
98
130
|
playNode = node;
|
|
99
131
|
return node;
|
|
100
132
|
}
|
|
@@ -111,17 +143,38 @@ async function createVoiceIO(opts) {
|
|
|
111
143
|
if (!playNode) return Promise.resolve();
|
|
112
144
|
if (ctx.state !== "running") return Promise.resolve();
|
|
113
145
|
return new Promise((resolve) => {
|
|
114
|
-
onPlaybackStop
|
|
146
|
+
onPlaybackStop?.();
|
|
147
|
+
const settle = () => {
|
|
148
|
+
clearInterval(poll);
|
|
149
|
+
clearTimeout(cap);
|
|
150
|
+
if (onPlaybackStop === settle) onPlaybackStop = null;
|
|
151
|
+
resolve();
|
|
152
|
+
};
|
|
153
|
+
const poll = setInterval(() => {
|
|
154
|
+
if (ctx.state !== "running") settle();
|
|
155
|
+
}, DONE_POLL_INTERVAL_MS);
|
|
156
|
+
const cap = setTimeout(settle, DONE_MAX_WAIT_MS);
|
|
157
|
+
onPlaybackStop = settle;
|
|
115
158
|
playNode?.port.postMessage({ event: "done" });
|
|
116
159
|
});
|
|
117
160
|
},
|
|
118
161
|
flush() {
|
|
119
|
-
if (playNode)
|
|
162
|
+
if (!playNode) return;
|
|
163
|
+
onPlaybackStop?.();
|
|
164
|
+
onPlaybackStop = null;
|
|
165
|
+
playNode.port.postMessage({ event: "interrupt" });
|
|
120
166
|
},
|
|
121
167
|
async close() {
|
|
122
168
|
if (lifecycle.signal.aborted) return;
|
|
123
169
|
lifecycle.abort();
|
|
124
|
-
|
|
170
|
+
await new Promise((resolve) => {
|
|
171
|
+
const cap = setTimeout(resolve, CAPTURE_STOP_ACK_TIMEOUT_MS);
|
|
172
|
+
onCaptureStopped = () => {
|
|
173
|
+
clearTimeout(cap);
|
|
174
|
+
resolve();
|
|
175
|
+
};
|
|
176
|
+
capNode.port.postMessage({ event: "stop" });
|
|
177
|
+
});
|
|
125
178
|
mic.disconnect();
|
|
126
179
|
capNode.disconnect();
|
|
127
180
|
if (playNode) playNode.disconnect();
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useSessionCore, useSessionSelector, useTheme } from "./context.js";
|
|
2
2
|
import { Button } from "./components/button.js";
|
|
3
|
-
import { r as TEXT_FAINT, t as ERROR_COLOR } from "./_colors-
|
|
3
|
+
import { r as TEXT_FAINT, t as ERROR_COLOR } from "./_colors-DYX7XRTr.js";
|
|
4
4
|
import { t as AaiLogo } from "./aai-logo-B8lDmsut.js";
|
|
5
|
-
import { r as SessionUrlChips, t as Controls } from "./controls-
|
|
5
|
+
import { r as SessionUrlChips, t as Controls } from "./controls-B2EPUJDU.js";
|
|
6
6
|
import { t as Eyebrow } from "./eyebrow-C6ZFuiz6.js";
|
|
7
7
|
import { MessageList } from "./components/message-list.js";
|
|
8
8
|
import clsx from "clsx";
|
|
9
|
-
import { useRef, useState } from "react";
|
|
9
|
+
import { memo, useRef, useState } from "react";
|
|
10
10
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
11
11
|
import { errorMessage } from "@alexkroman1/aai";
|
|
12
12
|
//#region components/text-controls.tsx
|
|
@@ -22,7 +22,7 @@ import { errorMessage } from "@alexkroman1/aai";
|
|
|
22
22
|
*
|
|
23
23
|
* @public
|
|
24
24
|
*/
|
|
25
|
-
function TextControls({ className }) {
|
|
25
|
+
const TextControls = memo(function TextControls({ className }) {
|
|
26
26
|
const recording = useSessionSelector((s) => s.recording);
|
|
27
27
|
const state = useSessionSelector((s) => s.state);
|
|
28
28
|
const core = useSessionCore();
|
|
@@ -91,7 +91,7 @@ function TextControls({ className }) {
|
|
|
91
91
|
children: uploadError
|
|
92
92
|
})]
|
|
93
93
|
});
|
|
94
|
-
}
|
|
94
|
+
});
|
|
95
95
|
//#endregion
|
|
96
96
|
//#region components/chat-view.tsx
|
|
97
97
|
/** @jsxImportSource react */
|
|
@@ -129,9 +129,11 @@ function stateColor(state, primary) {
|
|
|
129
129
|
* @public
|
|
130
130
|
*/
|
|
131
131
|
function ChatView({ icon, title, className }) {
|
|
132
|
-
const
|
|
132
|
+
const state = useSessionSelector((s) => s.state);
|
|
133
|
+
const error = useSessionSelector((s) => s.error);
|
|
134
|
+
const audioOut = useSessionSelector((s) => s.audioOut);
|
|
133
135
|
const theme = useTheme();
|
|
134
|
-
const pulsing = PULSING_STATES.has(
|
|
136
|
+
const pulsing = PULSING_STATES.has(state);
|
|
135
137
|
return /* @__PURE__ */ jsxs("div", {
|
|
136
138
|
className: clsx("flex flex-col h-screen w-full max-w-190 mx-auto box-border px-6 py-8 gap-5 font-aai text-sm", className),
|
|
137
139
|
style: {
|
|
@@ -150,24 +152,24 @@ function ChatView({ icon, title, className }) {
|
|
|
150
152
|
})]
|
|
151
153
|
}), /* @__PURE__ */ jsxs(Eyebrow, {
|
|
152
154
|
className: "shrink-0",
|
|
153
|
-
"data-state":
|
|
155
|
+
"data-state": state,
|
|
154
156
|
children: [/* @__PURE__ */ jsx("span", {
|
|
155
157
|
className: "w-[7px] h-[7px] rounded-full",
|
|
156
158
|
style: {
|
|
157
|
-
background: stateColor(
|
|
159
|
+
background: stateColor(state, theme.primary),
|
|
158
160
|
animation: pulsing ? "aai-pulse 1.6s ease-in-out infinite" : "none"
|
|
159
161
|
}
|
|
160
|
-
}),
|
|
162
|
+
}), state]
|
|
161
163
|
})]
|
|
162
164
|
}),
|
|
163
|
-
|
|
165
|
+
error && /* @__PURE__ */ jsx("div", {
|
|
164
166
|
className: "px-3.5 py-2.5 rounded-aai border text-[13px] leading-[130%] shrink-0",
|
|
165
167
|
style: {
|
|
166
168
|
borderColor: "rgba(179,38,30,0.35)",
|
|
167
169
|
background: "rgba(179,38,30,0.06)",
|
|
168
170
|
color: "#B3261E"
|
|
169
171
|
},
|
|
170
|
-
children:
|
|
172
|
+
children: error.message
|
|
171
173
|
}),
|
|
172
174
|
/* @__PURE__ */ jsx("div", {
|
|
173
175
|
className: "flex flex-col flex-1 min-h-0 border rounded-lg overflow-hidden",
|
|
@@ -178,7 +180,7 @@ function ChatView({ icon, title, className }) {
|
|
|
178
180
|
},
|
|
179
181
|
children: /* @__PURE__ */ jsx(MessageList, {})
|
|
180
182
|
}),
|
|
181
|
-
|
|
183
|
+
audioOut ? /* @__PURE__ */ jsx(Controls, {}) : /* @__PURE__ */ jsx(TextControls, {})
|
|
182
184
|
]
|
|
183
185
|
});
|
|
184
186
|
}
|
|
@@ -6,14 +6,10 @@
|
|
|
6
6
|
* palette) and are intentionally not themeable: they are the warm-gray text
|
|
7
7
|
* steps and ink alpha layers the refresh uses over any light surface.
|
|
8
8
|
*/
|
|
9
|
-
/** Soft text — button labels on muted surfaces (warm-700). */
|
|
10
|
-
export declare const TEXT_SOFT = "#3D3A35";
|
|
11
9
|
/** Muted text — subtitles, secondary labels, thinking dots (fg-muted). */
|
|
12
10
|
export declare const TEXT_MUTED = "#57534B";
|
|
13
11
|
/** Faint text — live transcripts, state indicator, start-screen subtitle (warm-500). */
|
|
14
12
|
export declare const TEXT_FAINT = "#6F6A60";
|
|
15
|
-
/** Raised surface tint — secondary button background. */
|
|
16
|
-
export declare const SURFACE_RAISED = "rgba(20,18,12,0.05)";
|
|
17
13
|
/** Subtle surface tint — message bubbles, tool-call blocks. */
|
|
18
14
|
export declare const SURFACE_TINT = "rgba(20,18,12,0.03)";
|
|
19
15
|
/** Error red tuned for warm light surfaces. */
|
|
@@ -13,6 +13,6 @@
|
|
|
13
13
|
*
|
|
14
14
|
* @public
|
|
15
15
|
*/
|
|
16
|
-
export declare
|
|
16
|
+
export declare const Controls: import("react").MemoExoticComponent<({ className }: {
|
|
17
17
|
className?: string;
|
|
18
|
-
})
|
|
18
|
+
}) => import("react").JSX.Element>;
|
|
@@ -16,6 +16,6 @@
|
|
|
16
16
|
*
|
|
17
17
|
* @public
|
|
18
18
|
*/
|
|
19
|
-
export declare
|
|
19
|
+
export declare const MessageList: import("react").MemoExoticComponent<({ className }: {
|
|
20
20
|
className?: string;
|
|
21
|
-
})
|
|
21
|
+
}) => import("react").JSX.Element>;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { a as primaryTint, i as TEXT_MUTED, r as TEXT_FAINT } from "../_colors-
|
|
3
|
-
import { t as ToolCallBlock } from "../tool-call-block-
|
|
1
|
+
import { useSessionSelector, useTheme } from "../context.js";
|
|
2
|
+
import { a as primaryTint, i as TEXT_MUTED, r as TEXT_FAINT } from "../_colors-DYX7XRTr.js";
|
|
3
|
+
import { t as ToolCallBlock } from "../tool-call-block-DIxpG8GM.js";
|
|
4
4
|
import clsx from "clsx";
|
|
5
|
-
import { useEffect, useMemo, useRef } from "react";
|
|
5
|
+
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
|
6
6
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
7
|
//#region components/message-list.tsx
|
|
8
8
|
/** @jsxImportSource react */
|
|
@@ -48,8 +48,15 @@ function UserBubble({ theme, color, children }) {
|
|
|
48
48
|
})
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
|
-
/**
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Renders a single chat message: labeled agent prose, or a user bubble.
|
|
53
|
+
*
|
|
54
|
+
* Memoized so appending one message re-renders one row, not the whole capped
|
|
55
|
+
* list: message objects and the theme are referentially stable across
|
|
56
|
+
* snapshots, and rows are keyed on stable ids (`ChatMessage.id`) that survive
|
|
57
|
+
* the sliding 200-message window.
|
|
58
|
+
*/
|
|
59
|
+
const MessageBubble = memo(function MessageBubble({ message, theme }) {
|
|
53
60
|
if (message.role === "user") return /* @__PURE__ */ jsx(UserBubble, {
|
|
54
61
|
theme,
|
|
55
62
|
color: theme.text,
|
|
@@ -67,29 +74,53 @@ function MessageBubble({ message, theme }) {
|
|
|
67
74
|
children: message.content
|
|
68
75
|
})]
|
|
69
76
|
});
|
|
70
|
-
}
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* How close to the bottom (px) the container must be for auto-scroll to stay
|
|
80
|
+
* engaged. Generous enough that an in-flight smooth scroll doesn't unpin.
|
|
81
|
+
*/
|
|
82
|
+
const NEAR_BOTTOM_PX = 96;
|
|
71
83
|
/**
|
|
72
|
-
* Smooth-scroll to the anchor whenever the content version advances
|
|
84
|
+
* Smooth-scroll to the anchor whenever the content version advances — but
|
|
85
|
+
* only while the container is pinned near the bottom. A user who scrolled up
|
|
86
|
+
* to read history isn't yanked back down by streaming updates; scrolling back
|
|
87
|
+
* to the bottom re-engages the auto-scroll.
|
|
73
88
|
*
|
|
74
89
|
* The scroll runs inside `requestAnimationFrame` and is deduped per frame:
|
|
75
90
|
* several snapshot updates in one frame (transcript + message + tool call)
|
|
76
91
|
* trigger a single scroll after layout instead of one forced layout each.
|
|
92
|
+
*
|
|
93
|
+
* While a partial transcript is streaming, updates arrive faster than a
|
|
94
|
+
* smooth scroll finishes — each restart leaves the animation perpetually
|
|
95
|
+
* mid-flight — so `streaming` switches to an instant jump; committed-content
|
|
96
|
+
* updates keep the smooth animation.
|
|
77
97
|
*/
|
|
78
|
-
function useAutoScroll(contentVersion) {
|
|
79
|
-
const
|
|
98
|
+
function useAutoScroll(contentVersion, streaming) {
|
|
99
|
+
const anchorRef = useRef(null);
|
|
100
|
+
const pinnedRef = useRef(true);
|
|
80
101
|
const scheduledRef = useRef(false);
|
|
102
|
+
const streamingRef = useRef(streaming);
|
|
103
|
+
streamingRef.current = streaming;
|
|
104
|
+
const onScroll = useCallback((event) => {
|
|
105
|
+
const el = event.currentTarget;
|
|
106
|
+
pinnedRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - NEAR_BOTTOM_PX;
|
|
107
|
+
}, []);
|
|
81
108
|
useEffect(() => {
|
|
82
|
-
if (contentVersion === 0 || scheduledRef.current) return;
|
|
109
|
+
if (contentVersion === 0 || scheduledRef.current || !pinnedRef.current) return;
|
|
83
110
|
scheduledRef.current = true;
|
|
84
111
|
requestAnimationFrame(() => {
|
|
85
112
|
scheduledRef.current = false;
|
|
86
|
-
|
|
87
|
-
|
|
113
|
+
if (!pinnedRef.current) return;
|
|
114
|
+
anchorRef.current?.scrollIntoView({
|
|
115
|
+
behavior: streamingRef.current ? "instant" : "smooth",
|
|
88
116
|
block: "end"
|
|
89
117
|
});
|
|
90
118
|
});
|
|
91
119
|
}, [contentVersion]);
|
|
92
|
-
return
|
|
120
|
+
return {
|
|
121
|
+
anchorRef,
|
|
122
|
+
onScroll
|
|
123
|
+
};
|
|
93
124
|
}
|
|
94
125
|
/**
|
|
95
126
|
* Interleave messages and tool calls into render items, ordered by insertion
|
|
@@ -118,43 +149,6 @@ function interleave(messages, toolCalls, renderMessage, renderToolCall) {
|
|
|
118
149
|
return items;
|
|
119
150
|
}
|
|
120
151
|
/**
|
|
121
|
-
* Build the interleaved row elements, reusing each row's element object across
|
|
122
|
-
* renders while its inputs are unchanged. Returning the identical element
|
|
123
|
-
* reference lets React bail out of re-rendering that row entirely — the same
|
|
124
|
-
* effect as wrapping the row components in `memo()` — so appending one message
|
|
125
|
-
* re-renders one row, not the whole capped list. Rows are keyed on stable ids
|
|
126
|
-
* (`ChatMessage.id`, `ToolCallInfo.callId`), which survive the sliding
|
|
127
|
-
* 200-message window; message and tool-call objects are referentially stable
|
|
128
|
-
* across snapshots, making the identity checks below sufficient.
|
|
129
|
-
*/
|
|
130
|
-
function useChatItems(messages, toolCalls, theme) {
|
|
131
|
-
const cacheRef = useRef(/* @__PURE__ */ new Map());
|
|
132
|
-
return useMemo(() => {
|
|
133
|
-
const prev = cacheRef.current;
|
|
134
|
-
const next = /* @__PURE__ */ new Map();
|
|
135
|
-
const rowFor = (key, data, make) => {
|
|
136
|
-
const hit = prev.get(key);
|
|
137
|
-
const element = hit && hit.data === data && hit.theme === theme ? hit.element : make();
|
|
138
|
-
next.set(key, {
|
|
139
|
-
data,
|
|
140
|
-
theme,
|
|
141
|
-
element
|
|
142
|
-
});
|
|
143
|
-
return element;
|
|
144
|
-
};
|
|
145
|
-
const items = interleave(messages, toolCalls, (msg) => rowFor(`m${msg.id}`, msg, () => /* @__PURE__ */ jsx(MessageBubble, {
|
|
146
|
-
message: msg,
|
|
147
|
-
theme
|
|
148
|
-
}, msg.id)), (tc) => rowFor(`t${tc.callId}`, tc, () => /* @__PURE__ */ jsx(ToolCallBlock, { toolCall: tc }, tc.callId)));
|
|
149
|
-
cacheRef.current = next;
|
|
150
|
-
return items;
|
|
151
|
-
}, [
|
|
152
|
-
messages,
|
|
153
|
-
toolCalls,
|
|
154
|
-
theme
|
|
155
|
-
]);
|
|
156
|
-
}
|
|
157
|
-
/**
|
|
158
152
|
* Scrollable list of all chat messages, tool-call blocks, live transcript,
|
|
159
153
|
* streaming agent utterance, and a thinking indicator.
|
|
160
154
|
*
|
|
@@ -172,27 +166,39 @@ function useChatItems(messages, toolCalls, theme) {
|
|
|
172
166
|
*
|
|
173
167
|
* @public
|
|
174
168
|
*/
|
|
175
|
-
function MessageList({ className }) {
|
|
176
|
-
const
|
|
169
|
+
const MessageList = memo(function MessageList({ className }) {
|
|
170
|
+
const state = useSessionSelector((s) => s.state);
|
|
171
|
+
const messages = useSessionSelector((s) => s.messages);
|
|
172
|
+
const toolCalls = useSessionSelector((s) => s.toolCalls);
|
|
173
|
+
const userTranscript = useSessionSelector((s) => s.userTranscript);
|
|
174
|
+
const agentTranscript = useSessionSelector((s) => s.agentTranscript);
|
|
175
|
+
const contentVersion = useSessionSelector((s) => s.contentVersion);
|
|
177
176
|
const theme = useTheme();
|
|
178
177
|
const showThinking = useMemo(() => {
|
|
179
|
-
if (
|
|
180
|
-
const last =
|
|
178
|
+
if (state !== "thinking") return false;
|
|
179
|
+
const last = toolCalls.at(-1);
|
|
181
180
|
if (last?.status === "pending") return false;
|
|
182
|
-
const lastMsg =
|
|
181
|
+
const lastMsg = messages.at(-1);
|
|
183
182
|
return !lastMsg || lastMsg.role === "user" || Boolean(last);
|
|
184
183
|
}, [
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
184
|
+
state,
|
|
185
|
+
toolCalls,
|
|
186
|
+
messages
|
|
187
|
+
]);
|
|
188
|
+
const { anchorRef, onScroll } = useAutoScroll(contentVersion, userTranscript !== null);
|
|
189
|
+
const items = useMemo(() => interleave(messages, toolCalls, (msg) => /* @__PURE__ */ jsx(MessageBubble, {
|
|
190
|
+
message: msg,
|
|
191
|
+
theme
|
|
192
|
+
}, msg.id), (tc) => /* @__PURE__ */ jsx(ToolCallBlock, { toolCall: tc }, tc.callId)), [
|
|
193
|
+
messages,
|
|
194
|
+
toolCalls,
|
|
195
|
+
theme
|
|
188
196
|
]);
|
|
189
|
-
const { messages, toolCalls, userTranscript, agentTranscript } = session;
|
|
190
|
-
const scrollRef = useAutoScroll(session.contentVersion);
|
|
191
|
-
const items = useChatItems(messages, toolCalls, theme);
|
|
192
197
|
return /* @__PURE__ */ jsx("div", {
|
|
193
198
|
role: "log",
|
|
194
199
|
className: clsx("flex-1 overflow-y-auto [scrollbar-width:none]", className),
|
|
195
200
|
style: { background: theme.surface },
|
|
201
|
+
onScroll,
|
|
196
202
|
children: /* @__PURE__ */ jsxs("div", {
|
|
197
203
|
className: "flex flex-col gap-4 p-7",
|
|
198
204
|
children: [
|
|
@@ -210,10 +216,10 @@ function MessageList({ className }) {
|
|
|
210
216
|
children: userTranscript ? userTranscript : /* @__PURE__ */ jsx(ThinkingDots, {})
|
|
211
217
|
}),
|
|
212
218
|
showThinking && /* @__PURE__ */ jsx(ThinkingDots, {}),
|
|
213
|
-
/* @__PURE__ */ jsx("div", { ref:
|
|
219
|
+
/* @__PURE__ */ jsx("div", { ref: anchorRef })
|
|
214
220
|
]
|
|
215
221
|
})
|
|
216
222
|
});
|
|
217
|
-
}
|
|
223
|
+
});
|
|
218
224
|
//#endregion
|
|
219
225
|
export { MessageList };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useSessionCore, useSessionSelector, useTheme } from "../context.js";
|
|
2
2
|
import { Button } from "./button.js";
|
|
3
|
-
import "../_colors-
|
|
3
|
+
import "../_colors-DYX7XRTr.js";
|
|
4
4
|
import { t as AaiLogo } from "../aai-logo-B8lDmsut.js";
|
|
5
5
|
import { t as Eyebrow } from "../eyebrow-C6ZFuiz6.js";
|
|
6
6
|
import clsx from "clsx";
|
|
@@ -9,6 +9,6 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @public
|
|
11
11
|
*/
|
|
12
|
-
export declare
|
|
12
|
+
export declare const TextControls: import("react").MemoExoticComponent<({ className, }: {
|
|
13
13
|
className?: string | undefined;
|
|
14
|
-
})
|
|
14
|
+
}) => import("react").JSX.Element>;
|
|
@@ -12,6 +12,10 @@ import type { ToolCallInfo } from "../types.ts";
|
|
|
12
12
|
* While the tool call is pending a shimmer animation is shown. Once
|
|
13
13
|
* complete, clicking the row expands the formatted JSON result.
|
|
14
14
|
*
|
|
15
|
+
* Memoized: tool-call objects are referentially stable across session
|
|
16
|
+
* snapshots and rows are keyed on the stable `callId`, so a list update only
|
|
17
|
+
* re-renders the rows whose tool call actually changed.
|
|
18
|
+
*
|
|
15
19
|
* @example
|
|
16
20
|
* ```tsx
|
|
17
21
|
* <ToolCallBlock toolCall={toolCall} />
|
|
@@ -22,7 +26,7 @@ import type { ToolCallInfo } from "../types.ts";
|
|
|
22
26
|
*
|
|
23
27
|
* @public
|
|
24
28
|
*/
|
|
25
|
-
export declare
|
|
29
|
+
export declare const ToolCallBlock: import("react").MemoExoticComponent<({ toolCall, className, }: {
|
|
26
30
|
toolCall: ToolCallInfo;
|
|
27
31
|
className?: string;
|
|
28
|
-
})
|
|
32
|
+
}) => ReactNode>;
|
package/dist/context.d.ts
CHANGED
|
@@ -5,17 +5,9 @@ export declare function SessionProvider({ value, children }: {
|
|
|
5
5
|
value: SessionCore;
|
|
6
6
|
children?: ReactNode;
|
|
7
7
|
}): import("react").FunctionComponentElement<import("react").ProviderProps<SessionCore | null>>;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
resetState(): void;
|
|
12
|
-
reset(): void;
|
|
13
|
-
disconnect(): void;
|
|
14
|
-
toggle(): void;
|
|
15
|
-
startRecording(): void;
|
|
16
|
-
stopRecording(): void;
|
|
17
|
-
sendAudioFile(file: Blob): Promise<void>;
|
|
18
|
-
};
|
|
8
|
+
/** The session snapshot merged with the core's control methods. Method
|
|
9
|
+
* signatures come from {@link SessionCore} — one source of truth. */
|
|
10
|
+
export type Session = SessionSnapshot & Pick<SessionCore, "start" | "cancel" | "resetState" | "reset" | "disconnect" | "toggle" | "startRecording" | "stopRecording" | "sendAudioFile">;
|
|
19
11
|
/**
|
|
20
12
|
* Return the raw {@link SessionCore} from context without subscribing to
|
|
21
13
|
* snapshot changes. Useful for accessing stable methods (`start`, `toggle`,
|
package/dist/context.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { createContext, createElement,
|
|
1
|
+
import { createContext, createElement, useContext, useEffect, useMemo, useSyncExternalStore } from "react";
|
|
2
|
+
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector";
|
|
2
3
|
//#region context.ts
|
|
3
4
|
const DEFAULT_THEME = {
|
|
4
5
|
bg: "#FBF8F2",
|
|
@@ -27,8 +28,9 @@ function useSessionCore() {
|
|
|
27
28
|
}
|
|
28
29
|
function useSession() {
|
|
29
30
|
const core = useSessionCore();
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
const snapshot = useSyncExternalStore(core.subscribe, core.getSnapshot);
|
|
32
|
+
return useMemo(() => ({
|
|
33
|
+
...snapshot,
|
|
32
34
|
start: core.start,
|
|
33
35
|
cancel: core.cancel,
|
|
34
36
|
resetState: core.resetState,
|
|
@@ -38,7 +40,7 @@ function useSession() {
|
|
|
38
40
|
startRecording: core.startRecording,
|
|
39
41
|
stopRecording: core.stopRecording,
|
|
40
42
|
sendAudioFile: core.sendAudioFile
|
|
41
|
-
};
|
|
43
|
+
}), [snapshot, core]);
|
|
42
44
|
}
|
|
43
45
|
/**
|
|
44
46
|
* Subscribe to a narrow slice of the session snapshot.
|
|
@@ -56,29 +58,14 @@ function useSession() {
|
|
|
56
58
|
*/
|
|
57
59
|
function useSessionSelector(selector, isEqual = Object.is) {
|
|
58
60
|
const core = useSessionCore();
|
|
59
|
-
|
|
60
|
-
selectorRef.current = selector;
|
|
61
|
-
const isEqualRef = useRef(isEqual);
|
|
62
|
-
isEqualRef.current = isEqual;
|
|
63
|
-
const cacheRef = useRef({ hasValue: false });
|
|
64
|
-
const getSelection = useCallback(() => {
|
|
65
|
-
const next = selectorRef.current(core.getSnapshot());
|
|
66
|
-
const cache = cacheRef.current;
|
|
67
|
-
if (cache.hasValue && isEqualRef.current(cache.value, next)) return cache.value;
|
|
68
|
-
cacheRef.current = {
|
|
69
|
-
hasValue: true,
|
|
70
|
-
value: next
|
|
71
|
-
};
|
|
72
|
-
return next;
|
|
73
|
-
}, [core]);
|
|
74
|
-
return useSyncExternalStore(core.subscribe, getSelection);
|
|
61
|
+
return useSyncExternalStoreWithSelector(core.subscribe, core.getSnapshot, core.getSnapshot, selector, isEqual);
|
|
75
62
|
}
|
|
76
63
|
const ThemeCtx = createContext(DEFAULT_THEME);
|
|
77
64
|
function ThemeProvider({ value, children }) {
|
|
78
|
-
const merged = value ? {
|
|
65
|
+
const merged = useMemo(() => value ? {
|
|
79
66
|
...DEFAULT_THEME,
|
|
80
67
|
...value
|
|
81
|
-
} : DEFAULT_THEME;
|
|
68
|
+
} : DEFAULT_THEME, [value]);
|
|
82
69
|
usePageBackground(merged.bg);
|
|
83
70
|
return createElement(ThemeCtx.Provider, { value: merged }, children);
|
|
84
71
|
}
|