@convex-dev/agent 0.1.4-alpha.0 → 0.1.4
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/commonjs/client/streaming.d.ts +2 -2
- package/dist/commonjs/client/streaming.d.ts.map +1 -1
- package/dist/commonjs/client/streaming.js +2 -1
- package/dist/commonjs/client/streaming.js.map +1 -1
- package/dist/commonjs/component/streams.d.ts.map +1 -1
- package/dist/commonjs/component/streams.js +1 -4
- package/dist/commonjs/component/streams.js.map +1 -1
- package/dist/commonjs/react/deltas.d.ts +29 -0
- package/dist/commonjs/react/deltas.d.ts.map +1 -0
- package/dist/commonjs/react/deltas.js +267 -0
- package/dist/commonjs/react/deltas.js.map +1 -0
- package/dist/commonjs/react/index.d.ts +4 -49
- package/dist/commonjs/react/index.d.ts.map +1 -1
- package/dist/commonjs/react/index.js +15 -446
- package/dist/commonjs/react/index.js.map +1 -1
- package/dist/commonjs/react/optimisticallySendMessage.d.ts +8 -0
- package/dist/commonjs/react/optimisticallySendMessage.d.ts.map +1 -0
- package/dist/commonjs/react/optimisticallySendMessage.js +40 -0
- package/dist/commonjs/react/optimisticallySendMessage.js.map +1 -0
- package/dist/commonjs/react/toUIMessages.d.ts.map +1 -1
- package/dist/commonjs/react/toUIMessages.js +4 -2
- package/dist/commonjs/react/toUIMessages.js.map +1 -1
- package/dist/commonjs/react/types.d.ts +26 -0
- package/dist/commonjs/react/types.d.ts.map +1 -0
- package/dist/commonjs/react/types.js +2 -0
- package/dist/commonjs/react/types.js.map +1 -0
- package/dist/commonjs/react/useSmoothText.d.ts +20 -0
- package/dist/commonjs/react/useSmoothText.d.ts.map +1 -0
- package/dist/commonjs/react/useSmoothText.js +50 -0
- package/dist/commonjs/react/useSmoothText.js.map +1 -0
- package/dist/esm/client/streaming.d.ts +2 -2
- package/dist/esm/client/streaming.d.ts.map +1 -1
- package/dist/esm/client/streaming.js +2 -1
- package/dist/esm/client/streaming.js.map +1 -1
- package/dist/esm/component/streams.d.ts.map +1 -1
- package/dist/esm/component/streams.js +1 -4
- package/dist/esm/component/streams.js.map +1 -1
- package/dist/esm/react/deltas.d.ts +29 -0
- package/dist/esm/react/deltas.d.ts.map +1 -0
- package/dist/esm/react/deltas.js +267 -0
- package/dist/esm/react/deltas.js.map +1 -0
- package/dist/esm/react/index.d.ts +4 -49
- package/dist/esm/react/index.d.ts.map +1 -1
- package/dist/esm/react/index.js +15 -446
- package/dist/esm/react/index.js.map +1 -1
- package/dist/esm/react/optimisticallySendMessage.d.ts +8 -0
- package/dist/esm/react/optimisticallySendMessage.d.ts.map +1 -0
- package/dist/esm/react/optimisticallySendMessage.js +40 -0
- package/dist/esm/react/optimisticallySendMessage.js.map +1 -0
- package/dist/esm/react/toUIMessages.d.ts.map +1 -1
- package/dist/esm/react/toUIMessages.js +4 -2
- package/dist/esm/react/toUIMessages.js.map +1 -1
- package/dist/esm/react/types.d.ts +26 -0
- package/dist/esm/react/types.d.ts.map +1 -0
- package/dist/esm/react/types.js +2 -0
- package/dist/esm/react/types.js.map +1 -0
- package/dist/esm/react/useSmoothText.d.ts +20 -0
- package/dist/esm/react/useSmoothText.d.ts.map +1 -0
- package/dist/esm/react/useSmoothText.js +50 -0
- package/dist/esm/react/useSmoothText.js.map +1 -0
- package/package.json +4 -1
- package/src/client/streaming.ts +3 -2
- package/src/component/streams.ts +3 -4
- package/src/react/deltas.test.ts +298 -0
- package/src/react/deltas.ts +340 -0
- package/src/react/index.ts +26 -589
- package/src/react/optimisticallySendMessage.ts +46 -0
- package/src/react/toUIMessages.test.ts +301 -0
- package/src/react/toUIMessages.ts +6 -2
- package/src/react/types.ts +52 -0
- package/src/react/useSmoothText.ts +76 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { insertAtTop } from "convex/react";
|
|
2
|
+
import type { MessageDoc } from "../client";
|
|
3
|
+
import type { OptimisticLocalStore } from "convex/browser";
|
|
4
|
+
import type { ThreadQuery } from "./types";
|
|
5
|
+
|
|
6
|
+
export function optimisticallySendMessage(
|
|
7
|
+
query: ThreadQuery<unknown, MessageDoc>
|
|
8
|
+
): (
|
|
9
|
+
store: OptimisticLocalStore,
|
|
10
|
+
args: { threadId: string; prompt: string }
|
|
11
|
+
) => void {
|
|
12
|
+
return (store, args) => {
|
|
13
|
+
const queries = store.getAllQueries(query);
|
|
14
|
+
let maxOrder = 0;
|
|
15
|
+
let maxStepOrder = 0;
|
|
16
|
+
for (const q of queries) {
|
|
17
|
+
if (q.args?.threadId !== args.threadId) continue;
|
|
18
|
+
if (q.args.streamArgs) continue;
|
|
19
|
+
for (const m of q.value?.page ?? []) {
|
|
20
|
+
maxOrder = Math.max(maxOrder, m.order);
|
|
21
|
+
maxStepOrder = Math.max(maxStepOrder, m.stepOrder);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const order = maxOrder + 1;
|
|
25
|
+
const stepOrder = 0;
|
|
26
|
+
insertAtTop({
|
|
27
|
+
paginatedQuery: query,
|
|
28
|
+
argsToMatch: { threadId: args.threadId, streamArgs: undefined },
|
|
29
|
+
item: {
|
|
30
|
+
_creationTime: Date.now(),
|
|
31
|
+
_id: crypto.randomUUID(),
|
|
32
|
+
order,
|
|
33
|
+
stepOrder,
|
|
34
|
+
status: "pending",
|
|
35
|
+
threadId: args.threadId,
|
|
36
|
+
tool: false,
|
|
37
|
+
message: {
|
|
38
|
+
role: "user",
|
|
39
|
+
content: args.prompt,
|
|
40
|
+
},
|
|
41
|
+
text: args.prompt,
|
|
42
|
+
},
|
|
43
|
+
localQueryStore: store,
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { toUIMessages } from "./toUIMessages";
|
|
3
|
+
import type { MessageDoc } from "../client";
|
|
4
|
+
|
|
5
|
+
// Helper to create a base message doc
|
|
6
|
+
function baseMessageDoc(overrides: Partial<MessageDoc> = {}): MessageDoc {
|
|
7
|
+
return {
|
|
8
|
+
_id: "msg1",
|
|
9
|
+
_creationTime: Date.now(),
|
|
10
|
+
order: 1,
|
|
11
|
+
stepOrder: 0,
|
|
12
|
+
status: "success",
|
|
13
|
+
threadId: "thread1",
|
|
14
|
+
tool: false,
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("toUIMessages", () => {
|
|
20
|
+
it("handles user message", () => {
|
|
21
|
+
const messages = [
|
|
22
|
+
baseMessageDoc({
|
|
23
|
+
message: {
|
|
24
|
+
role: "user",
|
|
25
|
+
content: "Hello!",
|
|
26
|
+
},
|
|
27
|
+
text: "Hello!",
|
|
28
|
+
}),
|
|
29
|
+
];
|
|
30
|
+
const uiMessages = toUIMessages(messages);
|
|
31
|
+
expect(uiMessages).toHaveLength(1);
|
|
32
|
+
expect(uiMessages[0].role).toBe("user");
|
|
33
|
+
expect(uiMessages[0].content).toBe("Hello!");
|
|
34
|
+
expect(uiMessages[0].parts[0]).toEqual({ type: "text", text: "Hello!" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("handles assistant message", () => {
|
|
38
|
+
const messages = [
|
|
39
|
+
baseMessageDoc({
|
|
40
|
+
message: {
|
|
41
|
+
role: "assistant",
|
|
42
|
+
content: "Hi, how can I help?",
|
|
43
|
+
},
|
|
44
|
+
text: "Hi, how can I help?",
|
|
45
|
+
}),
|
|
46
|
+
];
|
|
47
|
+
const uiMessages = toUIMessages(messages);
|
|
48
|
+
expect(uiMessages).toHaveLength(1);
|
|
49
|
+
expect(uiMessages[0].role).toBe("assistant");
|
|
50
|
+
expect(uiMessages[0].content).toBe("Hi, how can I help?");
|
|
51
|
+
expect(uiMessages[0].parts[0]).toEqual({
|
|
52
|
+
type: "text",
|
|
53
|
+
text: "Hi, how can I help?",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("handles multiple messages", () => {
|
|
58
|
+
const messages = [
|
|
59
|
+
baseMessageDoc({
|
|
60
|
+
message: {
|
|
61
|
+
role: "user",
|
|
62
|
+
content: "Hello!",
|
|
63
|
+
},
|
|
64
|
+
text: "Hello!",
|
|
65
|
+
}),
|
|
66
|
+
baseMessageDoc({
|
|
67
|
+
message: {
|
|
68
|
+
role: "assistant",
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
type: "reasoning",
|
|
72
|
+
text: "I'm thinking...",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
type: "redacted-reasoning",
|
|
76
|
+
data: "asdfasdfasdf",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
type: "text",
|
|
80
|
+
text: "I'm thinking...",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
type: "file",
|
|
84
|
+
mimeType: "text/plain",
|
|
85
|
+
data: "asdfasdfasdf",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
type: "tool-call",
|
|
89
|
+
toolName: "myTool",
|
|
90
|
+
toolCallId: "call1",
|
|
91
|
+
args: "",
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
tool: true,
|
|
96
|
+
files: [
|
|
97
|
+
{
|
|
98
|
+
mimeType: "text/plain",
|
|
99
|
+
data: "asdfasdfasdf",
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
reasoning: "I'm thinking...",
|
|
103
|
+
text: "I'm thinking...",
|
|
104
|
+
}),
|
|
105
|
+
baseMessageDoc({
|
|
106
|
+
message: {
|
|
107
|
+
role: "tool",
|
|
108
|
+
content: [
|
|
109
|
+
{
|
|
110
|
+
type: "tool-result",
|
|
111
|
+
toolCallId: "call1",
|
|
112
|
+
toolName: "myTool",
|
|
113
|
+
result: "42",
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
text: "42",
|
|
118
|
+
tool: true,
|
|
119
|
+
}),
|
|
120
|
+
];
|
|
121
|
+
const uiMessages = toUIMessages(messages);
|
|
122
|
+
expect(uiMessages).toHaveLength(2);
|
|
123
|
+
expect(uiMessages[0].role).toBe("user");
|
|
124
|
+
expect(uiMessages[0].parts.filter((p) => p.type === "text")).toHaveLength(
|
|
125
|
+
1
|
|
126
|
+
);
|
|
127
|
+
expect(uiMessages[1].role).toBe("assistant");
|
|
128
|
+
expect(
|
|
129
|
+
uiMessages[1].parts.filter((p) => p.type === "tool-invocation")
|
|
130
|
+
).toHaveLength(1);
|
|
131
|
+
expect(
|
|
132
|
+
uiMessages[1].parts.filter((p) => p.type === "tool-invocation")[0]
|
|
133
|
+
.toolInvocation
|
|
134
|
+
).toEqual({
|
|
135
|
+
toolName: "myTool",
|
|
136
|
+
toolCallId: "call1",
|
|
137
|
+
args: "",
|
|
138
|
+
state: "result",
|
|
139
|
+
result: "42",
|
|
140
|
+
step: 0,
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("handles multiple text and reasoning parts", () => {
|
|
145
|
+
const messages = [
|
|
146
|
+
baseMessageDoc({
|
|
147
|
+
message: {
|
|
148
|
+
role: "assistant",
|
|
149
|
+
content: [
|
|
150
|
+
{
|
|
151
|
+
type: "reasoning",
|
|
152
|
+
text: "I'm thinking...",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
type: "text",
|
|
156
|
+
text: "Here's one idea.",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
type: "reasoning",
|
|
160
|
+
text: "I'm thinking...",
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
type: "text",
|
|
164
|
+
text: "Here's another idea.",
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
reasoning: "I'm thinking...I'm thinking...",
|
|
169
|
+
text: "Here's one idea. Here's another idea.",
|
|
170
|
+
}),
|
|
171
|
+
];
|
|
172
|
+
const uiMessages = toUIMessages(messages);
|
|
173
|
+
expect(uiMessages).toHaveLength(1);
|
|
174
|
+
expect(uiMessages[0].role).toBe("assistant");
|
|
175
|
+
expect(uiMessages[0].content).toBe("Here's one idea. Here's another idea.");
|
|
176
|
+
expect(
|
|
177
|
+
uiMessages[0].parts.filter((p) => p.type === "reasoning")
|
|
178
|
+
).toHaveLength(1);
|
|
179
|
+
expect(uiMessages[0].parts.filter((p) => p.type === "text")).toHaveLength(
|
|
180
|
+
1
|
|
181
|
+
);
|
|
182
|
+
expect(uiMessages[0].parts.filter((p) => p.type === "text")[0].text).toBe(
|
|
183
|
+
"Here's one idea. Here's another idea."
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("handles system message", () => {
|
|
188
|
+
const messages = [
|
|
189
|
+
baseMessageDoc({
|
|
190
|
+
message: {
|
|
191
|
+
role: "system",
|
|
192
|
+
content: "System message here",
|
|
193
|
+
},
|
|
194
|
+
text: "System message here",
|
|
195
|
+
}),
|
|
196
|
+
];
|
|
197
|
+
const uiMessages = toUIMessages(messages);
|
|
198
|
+
expect(uiMessages).toHaveLength(1);
|
|
199
|
+
expect(uiMessages[0].role).toBe("system");
|
|
200
|
+
expect(uiMessages[0].content).toBe("System message here");
|
|
201
|
+
expect(uiMessages[0].parts[0]).toEqual({
|
|
202
|
+
type: "text",
|
|
203
|
+
text: "System message here",
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("handles tool call", () => {
|
|
208
|
+
const messages = [
|
|
209
|
+
baseMessageDoc({
|
|
210
|
+
message: {
|
|
211
|
+
role: "assistant",
|
|
212
|
+
content: [
|
|
213
|
+
{
|
|
214
|
+
type: "tool-call",
|
|
215
|
+
toolName: "myTool",
|
|
216
|
+
toolCallId: "call1",
|
|
217
|
+
args: "",
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
text: "",
|
|
222
|
+
}),
|
|
223
|
+
];
|
|
224
|
+
const uiMessages = toUIMessages(messages);
|
|
225
|
+
expect(uiMessages).toHaveLength(1);
|
|
226
|
+
expect(uiMessages[0].role).toBe("assistant");
|
|
227
|
+
expect(
|
|
228
|
+
uiMessages[0].parts.filter((p) => p.type === "tool-invocation")
|
|
229
|
+
).toHaveLength(1);
|
|
230
|
+
expect(
|
|
231
|
+
uiMessages[0].parts.filter((p) => p.type === "tool-invocation")[0]
|
|
232
|
+
.toolInvocation
|
|
233
|
+
).toEqual({
|
|
234
|
+
toolName: "myTool",
|
|
235
|
+
toolCallId: "call1",
|
|
236
|
+
args: "",
|
|
237
|
+
state: "call",
|
|
238
|
+
step: 0,
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("handles tool result", () => {
|
|
243
|
+
const messages = [
|
|
244
|
+
baseMessageDoc({
|
|
245
|
+
tool: true,
|
|
246
|
+
message: {
|
|
247
|
+
role: "assistant",
|
|
248
|
+
content: [
|
|
249
|
+
{
|
|
250
|
+
type: "tool-call",
|
|
251
|
+
toolName: "myTool",
|
|
252
|
+
toolCallId: "call1",
|
|
253
|
+
args: "",
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
text: "",
|
|
258
|
+
}),
|
|
259
|
+
baseMessageDoc({
|
|
260
|
+
message: {
|
|
261
|
+
role: "tool",
|
|
262
|
+
content: [
|
|
263
|
+
{
|
|
264
|
+
type: "tool-result",
|
|
265
|
+
toolCallId: "call1",
|
|
266
|
+
toolName: "myTool",
|
|
267
|
+
result: "42",
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
text: "",
|
|
272
|
+
}),
|
|
273
|
+
];
|
|
274
|
+
const uiMessages = toUIMessages(messages);
|
|
275
|
+
expect(uiMessages).toHaveLength(1);
|
|
276
|
+
expect(uiMessages[0].role).toBe("assistant");
|
|
277
|
+
// Should have a tool-invocation part
|
|
278
|
+
expect(uiMessages[0].parts.some((p) => p.type === "tool-invocation")).toBe(
|
|
279
|
+
true
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("does not duplicate text content", () => {
|
|
284
|
+
const messages = [
|
|
285
|
+
baseMessageDoc({
|
|
286
|
+
message: {
|
|
287
|
+
role: "assistant",
|
|
288
|
+
content: "Hello!",
|
|
289
|
+
},
|
|
290
|
+
text: "Hello!",
|
|
291
|
+
}),
|
|
292
|
+
];
|
|
293
|
+
const uiMessages = toUIMessages(messages);
|
|
294
|
+
// There should only be one text part
|
|
295
|
+
const textParts = uiMessages[0].parts.filter((p) => p.type === "text");
|
|
296
|
+
expect(textParts).toHaveLength(1);
|
|
297
|
+
expect(textParts[0].text).toBe("Hello!");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Add more tests for array content, tool calls, etc. as needed
|
|
301
|
+
});
|
|
@@ -64,7 +64,7 @@ export function toUIMessages(
|
|
|
64
64
|
assistantMessage = {
|
|
65
65
|
...common,
|
|
66
66
|
role: "assistant",
|
|
67
|
-
content:
|
|
67
|
+
content: "",
|
|
68
68
|
parts: [],
|
|
69
69
|
};
|
|
70
70
|
uiMessages.push(assistantMessage);
|
|
@@ -152,7 +152,11 @@ export function toUIMessages(
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
-
if (
|
|
155
|
+
if (
|
|
156
|
+
!message.tool &&
|
|
157
|
+
assistantMessage &&
|
|
158
|
+
assistantMessage.parts.length > 0
|
|
159
|
+
) {
|
|
156
160
|
// Reset it so the next set of tool calls will create a new assistant message
|
|
157
161
|
assistantMessage = undefined;
|
|
158
162
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { BetterOmit, Expand } from "convex-helpers";
|
|
2
|
+
import type {
|
|
3
|
+
FunctionArgs,
|
|
4
|
+
FunctionReference,
|
|
5
|
+
PaginationOptions,
|
|
6
|
+
PaginationResult,
|
|
7
|
+
} from "convex/server";
|
|
8
|
+
import type { MessageDoc } from "../client";
|
|
9
|
+
import type { SyncStreamsReturnValue } from "../client/types";
|
|
10
|
+
import type { StreamArgs } from "../validators";
|
|
11
|
+
|
|
12
|
+
export type ThreadQuery<
|
|
13
|
+
Args = unknown,
|
|
14
|
+
M extends MessageDoc = MessageDoc,
|
|
15
|
+
> = FunctionReference<
|
|
16
|
+
"query",
|
|
17
|
+
"public",
|
|
18
|
+
{
|
|
19
|
+
threadId: string;
|
|
20
|
+
paginationOpts: PaginationOptions;
|
|
21
|
+
// TODO: will this allow passing a function that doesn't have this param?
|
|
22
|
+
/**
|
|
23
|
+
* If { stream: true } is passed, it will also query for stream deltas.
|
|
24
|
+
* In order for this to work, the query must take as an argument streamArgs.
|
|
25
|
+
*/
|
|
26
|
+
streamArgs?: StreamArgs;
|
|
27
|
+
} & Args,
|
|
28
|
+
PaginationResult<M> & { streams?: SyncStreamsReturnValue }
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
export type ThreadStreamQuery<
|
|
32
|
+
Args = Record<string, unknown>,
|
|
33
|
+
M extends MessageDoc = MessageDoc,
|
|
34
|
+
> = FunctionReference<
|
|
35
|
+
"query",
|
|
36
|
+
"public",
|
|
37
|
+
{
|
|
38
|
+
threadId: string;
|
|
39
|
+
paginationOpts: PaginationOptions;
|
|
40
|
+
streamArgs?: StreamArgs; // required for stream query
|
|
41
|
+
} & Args,
|
|
42
|
+
PaginationResult<M> & { streams: SyncStreamsReturnValue }
|
|
43
|
+
>;
|
|
44
|
+
|
|
45
|
+
export type ThreadMessagesArgs<Query extends ThreadQuery<unknown, MessageDoc>> =
|
|
46
|
+
Query extends ThreadQuery<unknown, MessageDoc>
|
|
47
|
+
? Expand<BetterOmit<FunctionArgs<Query>, "paginationOpts" | "streamArgs">>
|
|
48
|
+
: never;
|
|
49
|
+
|
|
50
|
+
export type ThreadMessagesResult<
|
|
51
|
+
Query extends ThreadQuery<unknown, MessageDoc>,
|
|
52
|
+
> = Query extends ThreadQuery<unknown, infer M> ? M : never;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
const FPS = 20;
|
|
4
|
+
const MS_PER_FRAME = 1000 / FPS;
|
|
5
|
+
const MAX_TIME_JUMP_MS = 250;
|
|
6
|
+
/**
|
|
7
|
+
* A hook that smoothly displays text as it is streamed.
|
|
8
|
+
*
|
|
9
|
+
* @param text The text to display. Pass in the full text each time.
|
|
10
|
+
* @param charsPerSec The number of characters to display per second.
|
|
11
|
+
* @returns A tuple of the visible text and the state of the smooth text,
|
|
12
|
+
* including the current cursor position and whether it's still streaming.
|
|
13
|
+
* This allows you to decide if it's too far behind and you want to adjust
|
|
14
|
+
* the charsPerSec or just prefer the full text.
|
|
15
|
+
*/
|
|
16
|
+
export function useSmoothText(
|
|
17
|
+
text: string,
|
|
18
|
+
{
|
|
19
|
+
charsPerSec = 256,
|
|
20
|
+
}: {
|
|
21
|
+
/**
|
|
22
|
+
* The number of characters to display per second.
|
|
23
|
+
*/
|
|
24
|
+
charsPerSec?: number;
|
|
25
|
+
} = {}
|
|
26
|
+
): [string, { cursor: number; isStreaming: boolean }] {
|
|
27
|
+
const [visibleText, setVisibleText] = useState(text);
|
|
28
|
+
const smoothState = useRef({
|
|
29
|
+
tick: Date.now() + (text.length * 1000) / charsPerSec,
|
|
30
|
+
cursor: text.length,
|
|
31
|
+
start: Date.now(),
|
|
32
|
+
initialLength: text.length,
|
|
33
|
+
charsPerMs: charsPerSec / 1000,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const isStreaming = smoothState.current.cursor < text.length;
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!isStreaming) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const latestCharsPerMs =
|
|
43
|
+
(text.length - smoothState.current.initialLength) /
|
|
44
|
+
(Date.now() - smoothState.current.start);
|
|
45
|
+
// Smooth out the charsPerSec by averaging it with the previous value.
|
|
46
|
+
smoothState.current.charsPerMs = Math.min(
|
|
47
|
+
(2 * latestCharsPerMs + smoothState.current.charsPerMs) / 3,
|
|
48
|
+
smoothState.current.charsPerMs * 2
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
function update() {
|
|
52
|
+
if (smoothState.current.cursor >= text.length) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const timeSinceLastUpdate = Math.min(
|
|
57
|
+
MAX_TIME_JUMP_MS,
|
|
58
|
+
now - smoothState.current.tick
|
|
59
|
+
);
|
|
60
|
+
const chars = Math.floor(
|
|
61
|
+
timeSinceLastUpdate * smoothState.current.charsPerMs
|
|
62
|
+
);
|
|
63
|
+
smoothState.current.cursor = Math.min(
|
|
64
|
+
smoothState.current.cursor + chars,
|
|
65
|
+
text.length
|
|
66
|
+
);
|
|
67
|
+
smoothState.current.tick = now;
|
|
68
|
+
setVisibleText(text.slice(0, smoothState.current.cursor));
|
|
69
|
+
}
|
|
70
|
+
update();
|
|
71
|
+
const interval = setInterval(update, MS_PER_FRAME);
|
|
72
|
+
return () => clearInterval(interval);
|
|
73
|
+
}, [text, isStreaming, charsPerSec]);
|
|
74
|
+
|
|
75
|
+
return [visibleText, { cursor: smoothState.current.cursor, isStreaming }];
|
|
76
|
+
}
|