@talk2view/sdk 0.16.2 → 0.17.1
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/assistant-ui/index.d.ts +13 -2
- package/dist/assistant-ui/index.d.ts.map +1 -1
- package/dist/assistant-ui/index.js +15 -12
- package/dist/assistant-ui/index.js.map +1 -1
- package/dist/auth.d.ts +3 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +3 -0
- package/dist/auth.js.map +1 -1
- package/dist/chat/chat.d.ts.map +1 -1
- package/dist/chat/chat.js +8 -3
- package/dist/chat/chat.js.map +1 -1
- package/dist/chat/lib/conversation-store.d.ts +105 -0
- package/dist/chat/lib/conversation-store.d.ts.map +1 -0
- package/dist/chat/lib/conversation-store.js +278 -0
- package/dist/chat/lib/conversation-store.js.map +1 -0
- package/dist/chat/lib/use-conversations.d.ts +13 -0
- package/dist/chat/lib/use-conversations.d.ts.map +1 -0
- package/dist/chat/lib/use-conversations.js +365 -0
- package/dist/chat/lib/use-conversations.js.map +1 -0
- package/dist/chat/provider.d.ts.map +1 -1
- package/dist/chat/provider.js +6 -0
- package/dist/chat/provider.js.map +1 -1
- package/dist/chat/ui/avatar.d.ts +7 -8
- package/dist/chat/ui/avatar.d.ts.map +1 -1
- package/dist/chat/ui/avatar.js +30 -18
- package/dist/chat/ui/avatar.js.map +1 -1
- package/dist/chat/ui/button.d.ts +5 -2
- package/dist/chat/ui/button.d.ts.map +1 -1
- package/dist/chat/ui/button.js +10 -3
- package/dist/chat/ui/button.js.map +1 -1
- package/dist/chat/ui/collapsible.d.ts +4 -4
- package/dist/chat/ui/collapsible.d.ts.map +1 -1
- package/dist/chat/ui/collapsible.js +18 -9
- package/dist/chat/ui/collapsible.js.map +1 -1
- package/dist/chat/ui/dialog.d.ts +11 -11
- package/dist/chat/ui/dialog.d.ts.map +1 -1
- package/dist/chat/ui/dialog.js +49 -28
- package/dist/chat/ui/dialog.js.map +1 -1
- package/dist/chat/ui/input.d.ts +1 -1
- package/dist/chat/ui/input.d.ts.map +1 -1
- package/dist/chat/ui/input.js +10 -3
- package/dist/chat/ui/input.js.map +1 -1
- package/dist/chat/ui/skeleton.d.ts +2 -1
- package/dist/chat/ui/skeleton.d.ts.map +1 -1
- package/dist/chat/ui/skeleton.js +10 -3
- package/dist/chat/ui/skeleton.js.map +1 -1
- package/dist/chat/ui/textarea.d.ts +1 -1
- package/dist/chat/ui/textarea.d.ts.map +1 -1
- package/dist/chat/ui/textarea.js +10 -3
- package/dist/chat/ui/textarea.js.map +1 -1
- package/dist/chat/ui/tooltip.d.ts +3 -2
- package/dist/chat/ui/tooltip.d.ts.map +1 -1
- package/dist/chat/ui/tooltip.js +25 -8
- package/dist/chat/ui/tooltip.js.map +1 -1
- package/dist/chat/vendor/thread-list.aui.d.ts +3 -3
- package/dist/chat/vendor/thread-list.aui.d.ts.map +1 -1
- package/dist/chat/vendor/thread-list.aui.js +7 -7
- package/dist/chat/vendor/thread-list.aui.js.map +1 -1
- package/dist/chat-css.js +1 -1
- package/dist/chat.css +1 -1
- package/dist/chat.isolated.css +3 -0
- package/dist/chat.raw.css +3 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +18 -2
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +56 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +104 -6
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/** What the thread list shows for a conversation with no first message yet. */
|
|
2
|
+
export const UNTITLED = 'New chat';
|
|
3
|
+
/**
|
|
4
|
+
* Per identity. Fifty is far more than anyone accumulates in a side panel, and
|
|
5
|
+
* small enough to render in one list and to keep the stored blob modest. The
|
|
6
|
+
* size ceiling below usually binds first.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_CONVERSATIONS = 50;
|
|
9
|
+
/**
|
|
10
|
+
* Identity buckets kept per partner. Signing out and back in as a guest mints a
|
|
11
|
+
* new anonymous id each time, so without this the entry would grow one dead
|
|
12
|
+
* bucket per visit. Eight keeps a shared machine's recent users and drops the
|
|
13
|
+
* rest, least recently used first.
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_IDENTITIES = 8;
|
|
16
|
+
/**
|
|
17
|
+
* Characters in the stored entry, across every identity. `localStorage` gives
|
|
18
|
+
* an origin about 5 MB in every current browser, and the chat is a guest in the
|
|
19
|
+
* partner's page — taking a tenth of their budget is the most it can justify.
|
|
20
|
+
* A conversation of twenty substantial turns is about 20 KB, so this is roughly
|
|
21
|
+
* twenty-five of them; the count cap above catches the short ones.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_CHARS = 512 * 1024;
|
|
24
|
+
/** Longest title kept; longer first messages are cut at a word boundary. */
|
|
25
|
+
const MAX_TITLE = 60;
|
|
26
|
+
const KEY_PREFIX = 't2v_chat_conversations_v1:';
|
|
27
|
+
/** The `localStorage` entry for one partner key. */
|
|
28
|
+
export function storageKey(partnerKey) {
|
|
29
|
+
return KEY_PREFIX + partnerKey;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The bucket for an end-user. `null` — nobody signed in and no guest session
|
|
33
|
+
* yet — gets its own, which only ever holds the conversation a visitor started
|
|
34
|
+
* before anything authenticated; {@link useConversations} moves that one across
|
|
35
|
+
* when an id appears.
|
|
36
|
+
*/
|
|
37
|
+
export function identityKey(userId) {
|
|
38
|
+
return userId ? `u:${userId}` : 'none';
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A short title from the first user message: one line, cut at a word boundary.
|
|
42
|
+
* Null when there is nothing to make one from — an attachment with no text, or
|
|
43
|
+
* whitespace — and the caller shows {@link UNTITLED} instead.
|
|
44
|
+
*/
|
|
45
|
+
export function titleFrom(message) {
|
|
46
|
+
const text = (message?.content ?? '').replace(/\s+/g, ' ').trim();
|
|
47
|
+
if (text)
|
|
48
|
+
return text.length <= MAX_TITLE ? text : cut(text);
|
|
49
|
+
const file = message?.attachments?.[0]?.filename?.trim();
|
|
50
|
+
return file ? (file.length <= MAX_TITLE ? file : cut(file)) : null;
|
|
51
|
+
}
|
|
52
|
+
function cut(text) {
|
|
53
|
+
const head = text.slice(0, MAX_TITLE);
|
|
54
|
+
const space = head.lastIndexOf(' ');
|
|
55
|
+
// Only break on a space if one is far enough in to leave a real title behind;
|
|
56
|
+
// a first word longer than that is cut mid-word rather than thrown away.
|
|
57
|
+
return (space > MAX_TITLE / 2 ? head.slice(0, space) : head).trimEnd() + '…';
|
|
58
|
+
}
|
|
59
|
+
// ── Reading ────────────────────────────────────────────────────────────────
|
|
60
|
+
/**
|
|
61
|
+
* This identity's conversations, newest first.
|
|
62
|
+
*
|
|
63
|
+
* Never throws and never trusts what it finds: a private window refuses
|
|
64
|
+
* `localStorage` entirely, and the value itself is whatever anything else with
|
|
65
|
+
* access to this origin last wrote there. Anything that does not validate is
|
|
66
|
+
* dropped — one bad conversation, or the whole entry — and the chat carries on
|
|
67
|
+
* with no history rather than not at all.
|
|
68
|
+
*/
|
|
69
|
+
export function readConversations(partnerKey, identity) {
|
|
70
|
+
const file = readFile(partnerKey);
|
|
71
|
+
const bucket = file?.identities?.[identity];
|
|
72
|
+
if (!bucket)
|
|
73
|
+
return [];
|
|
74
|
+
return sortNewestFirst(asArray(bucket.conversations)
|
|
75
|
+
.map(parseConversation)
|
|
76
|
+
.filter((c) => c !== null));
|
|
77
|
+
}
|
|
78
|
+
// ── Writing ────────────────────────────────────────────────────────────────
|
|
79
|
+
/**
|
|
80
|
+
* Replace this identity's conversations, keeping every other identity's as they
|
|
81
|
+
* are on disk. Returns false when nothing could be stored (no `localStorage`,
|
|
82
|
+
* or a quota that even the caps below could not get under), which the caller
|
|
83
|
+
* treats as "this device does not keep history" rather than as a failure.
|
|
84
|
+
*
|
|
85
|
+
* `current` is the conversation on screen: it is the one the caps must never
|
|
86
|
+
* drop, because dropping it would lose what the end-user is looking at.
|
|
87
|
+
*/
|
|
88
|
+
export function writeConversations(partnerKey, identity, conversations, current) {
|
|
89
|
+
const file = readFile(partnerKey) ?? { v: 1, identities: {} };
|
|
90
|
+
const identities = { ...file.identities };
|
|
91
|
+
const mine = sortNewestFirst([...conversations]).slice(0, MAX_CONVERSATIONS);
|
|
92
|
+
if (mine.length === 0) {
|
|
93
|
+
delete identities[identity];
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
identities[identity] = { updatedAt: Date.now(), conversations: mine.map(serialise) };
|
|
97
|
+
}
|
|
98
|
+
// Least recently used identities go first: they are somebody else's old
|
|
99
|
+
// visit, where this one is on screen.
|
|
100
|
+
const ordered = Object.keys(identities)
|
|
101
|
+
.filter((key) => key !== identity)
|
|
102
|
+
.sort((a, b) => (identities[b]?.updatedAt ?? 0) - (identities[a]?.updatedAt ?? 0));
|
|
103
|
+
for (const key of ordered.slice(MAX_IDENTITIES - 1))
|
|
104
|
+
delete identities[key];
|
|
105
|
+
let json = JSON.stringify({ v: 1, identities });
|
|
106
|
+
while (json.length > MAX_CHARS) {
|
|
107
|
+
if (!dropOldest(identities, identity, current))
|
|
108
|
+
return remove(partnerKey);
|
|
109
|
+
json = JSON.stringify({ v: 1, identities });
|
|
110
|
+
}
|
|
111
|
+
return write(partnerKey, json);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Drop the single oldest conversation we are allowed to drop: another
|
|
115
|
+
* identity's first, then this one's, and never the one on screen. False when
|
|
116
|
+
* there is nothing left to give up.
|
|
117
|
+
*/
|
|
118
|
+
function dropOldest(identities, identity, current) {
|
|
119
|
+
const candidates = [];
|
|
120
|
+
for (const [key, bucket] of Object.entries(identities)) {
|
|
121
|
+
bucket.conversations.forEach((conversation, index) => {
|
|
122
|
+
if (key === identity && conversation.id === current)
|
|
123
|
+
return;
|
|
124
|
+
candidates.push({
|
|
125
|
+
key,
|
|
126
|
+
index,
|
|
127
|
+
mine: key === identity,
|
|
128
|
+
updatedAt: typeof conversation.updatedAt === 'number' ? conversation.updatedAt : 0,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// Another identity's conversations go before any of this one's, however old:
|
|
133
|
+
// what this end-user can still open is worth more than what they cannot.
|
|
134
|
+
candidates.sort((a, b) => Number(a.mine) - Number(b.mine) || a.updatedAt - b.updatedAt);
|
|
135
|
+
const victim = candidates[0];
|
|
136
|
+
if (!victim)
|
|
137
|
+
return false;
|
|
138
|
+
const bucket = identities[victim.key];
|
|
139
|
+
bucket.conversations = bucket.conversations.filter((_, i) => i !== victim.index);
|
|
140
|
+
if (bucket.conversations.length === 0)
|
|
141
|
+
delete identities[victim.key];
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
/** Forget everything stored for this partner key. Always returns false: nothing is stored now. */
|
|
145
|
+
function remove(partnerKey) {
|
|
146
|
+
try {
|
|
147
|
+
localStorage.removeItem(storageKey(partnerKey));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// A private window, or storage the page is not allowed to touch at all.
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
function write(partnerKey, json) {
|
|
155
|
+
try {
|
|
156
|
+
localStorage.setItem(storageKey(partnerKey), json);
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// Out of quota even under the caps, or storage refused outright. The list
|
|
161
|
+
// still works for the rest of this page's life; it just does not survive.
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function serialise(conversation) {
|
|
166
|
+
return {
|
|
167
|
+
id: conversation.id,
|
|
168
|
+
title: conversation.title,
|
|
169
|
+
createdAt: conversation.createdAt,
|
|
170
|
+
updatedAt: conversation.updatedAt,
|
|
171
|
+
threadId: conversation.snapshot.threadId,
|
|
172
|
+
messages: conversation.snapshot.messages.map((message) => {
|
|
173
|
+
// `isStreaming` is deliberately dropped. A conversation left mid-reply is
|
|
174
|
+
// stored with the text that had arrived; restoring it with the flag still
|
|
175
|
+
// set would put a cursor on a message nothing is writing to.
|
|
176
|
+
const { isStreaming: _isStreaming, timestamp, ...rest } = message;
|
|
177
|
+
return { ...rest, timestamp: asDate(timestamp).toISOString() };
|
|
178
|
+
}),
|
|
179
|
+
history: conversation.snapshot.history,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function readFile(partnerKey) {
|
|
183
|
+
let raw;
|
|
184
|
+
try {
|
|
185
|
+
raw = localStorage.getItem(storageKey(partnerKey));
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
if (!raw)
|
|
191
|
+
return null;
|
|
192
|
+
try {
|
|
193
|
+
const parsed = JSON.parse(raw);
|
|
194
|
+
if (!isRecord(parsed) || !isRecord(parsed.identities))
|
|
195
|
+
return null;
|
|
196
|
+
const identities = {};
|
|
197
|
+
for (const [key, value] of Object.entries(parsed.identities)) {
|
|
198
|
+
if (!isRecord(value))
|
|
199
|
+
continue;
|
|
200
|
+
identities[key] = {
|
|
201
|
+
updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : 0,
|
|
202
|
+
conversations: asArray(value.conversations),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return { v: 1, identities };
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* One stored conversation, checked field by field. Anything missing or of the
|
|
213
|
+
* wrong type means the record is not ours — a half-written value, an older
|
|
214
|
+
* shape, or something hostile — and it is dropped rather than rendered.
|
|
215
|
+
*/
|
|
216
|
+
function parseConversation(value) {
|
|
217
|
+
if (!isRecord(value) || typeof value.id !== 'string' || !value.id)
|
|
218
|
+
return null;
|
|
219
|
+
const messages = asArray(value.messages)
|
|
220
|
+
.map(parseMessage)
|
|
221
|
+
.filter((m) => m !== null);
|
|
222
|
+
const history = asArray(value.history).filter(isChatMessage);
|
|
223
|
+
const createdAt = typeof value.createdAt === 'number' ? value.createdAt : Date.now();
|
|
224
|
+
return {
|
|
225
|
+
id: value.id,
|
|
226
|
+
title: typeof value.title === 'string' && value.title ? value.title : null,
|
|
227
|
+
createdAt,
|
|
228
|
+
updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : createdAt,
|
|
229
|
+
snapshot: {
|
|
230
|
+
messages,
|
|
231
|
+
history,
|
|
232
|
+
threadId: typeof value.threadId === 'string' ? value.threadId : null,
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function parseMessage(value) {
|
|
237
|
+
if (!isRecord(value))
|
|
238
|
+
return null;
|
|
239
|
+
if (typeof value.id !== 'string' || typeof value.content !== 'string')
|
|
240
|
+
return null;
|
|
241
|
+
if (value.role !== 'user' && value.role !== 'assistant')
|
|
242
|
+
return null;
|
|
243
|
+
const message = {
|
|
244
|
+
id: value.id,
|
|
245
|
+
role: value.role,
|
|
246
|
+
content: value.content,
|
|
247
|
+
timestamp: asDate(value.timestamp),
|
|
248
|
+
};
|
|
249
|
+
if (Array.isArray(value.attachments))
|
|
250
|
+
message.attachments = value.attachments;
|
|
251
|
+
if (typeof value.plan === 'string')
|
|
252
|
+
message.plan = value.plan;
|
|
253
|
+
if (Array.isArray(value.steps))
|
|
254
|
+
message.steps = value.steps;
|
|
255
|
+
return message;
|
|
256
|
+
}
|
|
257
|
+
/** `role` and `content` are all the engine is sent; content may be parts, not a string. */
|
|
258
|
+
function isChatMessage(value) {
|
|
259
|
+
if (!isRecord(value))
|
|
260
|
+
return false;
|
|
261
|
+
if (value.role !== 'user' && value.role !== 'assistant' && value.role !== 'system')
|
|
262
|
+
return false;
|
|
263
|
+
return typeof value.content === 'string' || Array.isArray(value.content);
|
|
264
|
+
}
|
|
265
|
+
function asDate(value) {
|
|
266
|
+
const date = value instanceof Date ? value : new Date(typeof value === 'string' ? value : NaN);
|
|
267
|
+
return Number.isNaN(date.getTime()) ? new Date(0) : date;
|
|
268
|
+
}
|
|
269
|
+
function isRecord(value) {
|
|
270
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
271
|
+
}
|
|
272
|
+
function asArray(value) {
|
|
273
|
+
return Array.isArray(value) ? value : [];
|
|
274
|
+
}
|
|
275
|
+
function sortNewestFirst(conversations) {
|
|
276
|
+
return conversations.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
277
|
+
}
|
|
278
|
+
//# sourceMappingURL=conversation-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conversation-store.js","sourceRoot":"","sources":["../../../src/chat/lib/conversation-store.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ExternalStoreThreadListAdapter } from '@assistant-ui/react';
|
|
2
|
+
import type { Talk2View } from '../../index.js';
|
|
3
|
+
/**
|
|
4
|
+
* The thread-list adapter for `adapters.threadList`.
|
|
5
|
+
*
|
|
6
|
+
* With `enabled` false there is no list and nothing is stored, but New chat
|
|
7
|
+
* still works: it clears the conversation the way it always did, deleting the
|
|
8
|
+
* engine session too, because with no list there is no way back to it.
|
|
9
|
+
*/
|
|
10
|
+
export declare function useConversations(client: Talk2View, { enabled }: {
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
}): ExternalStoreThreadListAdapter;
|
|
13
|
+
//# sourceMappingURL=use-conversations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-conversations.d.ts","sourceRoot":"","sources":["../../../src/chat/lib/use-conversations.ts"],"names":[],"mappings":"AA4BA,OAAO,KAAK,EAA2B,8BAA8B,EAAE,MAAM,qBAAqB,CAAC;AACnG,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAyDhD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,SAAS,EACjB,EAAE,OAAO,EAAE,EAAE;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,GAChC,8BAA8B,CAwPhC"}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* The chat's list of conversations: what "New chat" starts, what the Chats view
|
|
4
|
+
* shows, and what survives a reload.
|
|
5
|
+
*
|
|
6
|
+
* It owns the list and hands assistant-ui a thread-list adapter for it. The
|
|
7
|
+
* runtime stays dumb: it passes the adapter through and knows nothing about
|
|
8
|
+
* storage. {@link conversation-store} is the storage half.
|
|
9
|
+
*
|
|
10
|
+
* ## What happens when
|
|
11
|
+
*
|
|
12
|
+
* - **New chat** — the conversation on screen is kept where it is and an empty
|
|
13
|
+
* one takes its place. Clicking it again on an empty chat does nothing, so
|
|
14
|
+
* the list does not fill up with blanks.
|
|
15
|
+
* - **Switching** — the client is handed the stored transcript and the engine
|
|
16
|
+
* session is detached, not deleted: the visitor may switch back, and the turn
|
|
17
|
+
* it is running may not be finished. The next message opens a new session and
|
|
18
|
+
* replays the transcript into it.
|
|
19
|
+
* - **Signing in or out** — the list follows the end-user. Whoever is there now
|
|
20
|
+
* sees their own conversations and an empty chat, never the last person's.
|
|
21
|
+
* The one exception is a visitor who started typing before anything
|
|
22
|
+
* authenticated: their conversation moves across with them when the guest
|
|
23
|
+
* session appears, because it was theirs all along.
|
|
24
|
+
* - **A guest becoming an account** — nothing moves. Converting keeps the same
|
|
25
|
+
* user id, so the conversations are already in the right bucket.
|
|
26
|
+
*/
|
|
27
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
28
|
+
import { UNTITLED, identityKey, readConversations, titleFrom, writeConversations, } from './conversation-store.js';
|
|
29
|
+
/**
|
|
30
|
+
* Written this long after the last change, so a reply is stored once when it
|
|
31
|
+
* lands and not once per token.
|
|
32
|
+
*/
|
|
33
|
+
const WRITE_DELAY_MS = 500;
|
|
34
|
+
/**
|
|
35
|
+
* …but never delayed longer than this. A reply that streams for a minute is
|
|
36
|
+
* checkpointed as it goes, so a reload in the middle of one keeps what arrived.
|
|
37
|
+
*/
|
|
38
|
+
const WRITE_MAX_DELAY_MS = 5_000;
|
|
39
|
+
const EMPTY = { messages: [], history: [], threadId: null };
|
|
40
|
+
/**
|
|
41
|
+
* The thread-list adapter for `adapters.threadList`.
|
|
42
|
+
*
|
|
43
|
+
* With `enabled` false there is no list and nothing is stored, but New chat
|
|
44
|
+
* still works: it clears the conversation the way it always did, deleting the
|
|
45
|
+
* engine session too, because with no list there is no way back to it.
|
|
46
|
+
*/
|
|
47
|
+
export function useConversations(client, { enabled }) {
|
|
48
|
+
const partnerKey = client.config.partnerKey;
|
|
49
|
+
// A mutable store plus a counter to re-render on: the snapshot of the
|
|
50
|
+
// conversation on screen changes on every streamed token, and the list only
|
|
51
|
+
// changes when a conversation is added, renamed, deleted or gains a message.
|
|
52
|
+
// Keeping them apart is what stops a reply repainting the list 60 times.
|
|
53
|
+
const store = useRef(null);
|
|
54
|
+
const [, setListVersion] = useState(0);
|
|
55
|
+
const publish = useCallback(() => setListVersion((n) => n + 1), []);
|
|
56
|
+
// Once, on the first render. Deliberately not in an effect: `start` may put a
|
|
57
|
+
// stored conversation back into the client, and the runtime reads the client's
|
|
58
|
+
// messages as its own initial state on this same render. Restoring later would
|
|
59
|
+
// paint the welcome screen first and replace it a frame afterwards. Nothing is
|
|
60
|
+
// subscribed to the client yet at this point — this hook is called before the
|
|
61
|
+
// runtime's — so the change this publishes reaches no other component.
|
|
62
|
+
if (store.current === null) {
|
|
63
|
+
store.current = start(client, partnerKey, enabled);
|
|
64
|
+
}
|
|
65
|
+
// ── Persistence ──────────────────────────────────────────────────────────
|
|
66
|
+
const timer = useRef(null);
|
|
67
|
+
const oldestPending = useRef(0);
|
|
68
|
+
const flush = useCallback(() => {
|
|
69
|
+
if (timer.current !== null) {
|
|
70
|
+
clearTimeout(timer.current);
|
|
71
|
+
timer.current = null;
|
|
72
|
+
}
|
|
73
|
+
oldestPending.current = 0;
|
|
74
|
+
const s = store.current;
|
|
75
|
+
if (!enabled)
|
|
76
|
+
return;
|
|
77
|
+
writeConversations(partnerKey, s.identity, s.conversations, s.currentId);
|
|
78
|
+
}, [enabled, partnerKey]);
|
|
79
|
+
const schedule = useCallback(() => {
|
|
80
|
+
if (!enabled)
|
|
81
|
+
return;
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
if (oldestPending.current === 0)
|
|
84
|
+
oldestPending.current = now;
|
|
85
|
+
if (timer.current !== null)
|
|
86
|
+
clearTimeout(timer.current);
|
|
87
|
+
const wait = Math.min(WRITE_DELAY_MS, Math.max(0, oldestPending.current + WRITE_MAX_DELAY_MS - now));
|
|
88
|
+
timer.current = setTimeout(flush, wait);
|
|
89
|
+
}, [enabled, flush]);
|
|
90
|
+
// Closing the tab, or a phone putting the page to sleep, does not wait for a
|
|
91
|
+
// debounce. `pagehide` is the one that fires on the bfcache path too.
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const onHide = () => {
|
|
94
|
+
if (timer.current !== null)
|
|
95
|
+
flush();
|
|
96
|
+
};
|
|
97
|
+
const onVisibility = () => {
|
|
98
|
+
if (document.visibilityState === 'hidden')
|
|
99
|
+
onHide();
|
|
100
|
+
};
|
|
101
|
+
window.addEventListener('pagehide', onHide);
|
|
102
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
103
|
+
return () => {
|
|
104
|
+
window.removeEventListener('pagehide', onHide);
|
|
105
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
106
|
+
// An unmount mid-debounce would otherwise drop the last change.
|
|
107
|
+
if (timer.current !== null)
|
|
108
|
+
flush();
|
|
109
|
+
};
|
|
110
|
+
}, [flush]);
|
|
111
|
+
// ── Keeping the conversation on screen up to date ────────────────────────
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!enabled)
|
|
114
|
+
return;
|
|
115
|
+
const sync = () => {
|
|
116
|
+
const s = store.current;
|
|
117
|
+
const current = s.conversations.find((c) => c.id === s.currentId);
|
|
118
|
+
if (!current)
|
|
119
|
+
return;
|
|
120
|
+
const snapshot = client.exportConversation();
|
|
121
|
+
current.snapshot = snapshot;
|
|
122
|
+
// Title and date move when the transcript gains a message, not when the
|
|
123
|
+
// reply in it grows by a token.
|
|
124
|
+
if (snapshot.messages.length !== s.seen) {
|
|
125
|
+
s.seen = snapshot.messages.length;
|
|
126
|
+
current.updatedAt = Date.now();
|
|
127
|
+
if (current.title === null)
|
|
128
|
+
current.title = titleFrom(firstUser(snapshot.messages));
|
|
129
|
+
reorder(s);
|
|
130
|
+
publish();
|
|
131
|
+
}
|
|
132
|
+
schedule();
|
|
133
|
+
};
|
|
134
|
+
const offs = [
|
|
135
|
+
client.on('messagesChange', sync),
|
|
136
|
+
// The last thing a turn does is record itself in the history the next
|
|
137
|
+
// message replays, and it does that after its final message change — so a
|
|
138
|
+
// snapshot taken on messages alone is one assistant turn short of being
|
|
139
|
+
// able to carry the conversation on. The loading flag drops after it.
|
|
140
|
+
client.on('loadingChange', (loading) => {
|
|
141
|
+
if (!loading)
|
|
142
|
+
sync();
|
|
143
|
+
}),
|
|
144
|
+
];
|
|
145
|
+
return () => offs.forEach((off) => off());
|
|
146
|
+
}, [client, enabled, publish, schedule]);
|
|
147
|
+
// ── Identity ─────────────────────────────────────────────────────────────
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (!enabled)
|
|
150
|
+
return;
|
|
151
|
+
return client.auth.onAuthStateChange((user) => {
|
|
152
|
+
const s = store.current;
|
|
153
|
+
const next = identityKey(user?.id);
|
|
154
|
+
if (next === s.identity)
|
|
155
|
+
return;
|
|
156
|
+
// The conversation on screen follows the end-user across exactly one
|
|
157
|
+
// step: out of a guest session, or out of no session at all, into the
|
|
158
|
+
// account they have just signed in to. That is the same person carrying
|
|
159
|
+
// on — the visitor who typed before anything authenticated, and the guest
|
|
160
|
+
// who signed up because the demo ran out.
|
|
161
|
+
//
|
|
162
|
+
// It has to be a conversation they started here, in this page's life: one
|
|
163
|
+
// read back out of a guest bucket may be the last visitor's, and it must
|
|
164
|
+
// not walk into somebody else's account. And it never crosses from one
|
|
165
|
+
// account to another, where the two are simply different people.
|
|
166
|
+
const carried = s.guest && s.fresh.has(s.currentId)
|
|
167
|
+
? s.conversations.find((c) => c.id === s.currentId)
|
|
168
|
+
: undefined;
|
|
169
|
+
// What the previous identity keeps, written before we let go of it. A
|
|
170
|
+
// conversation that moves is taken out of it rather than left in both.
|
|
171
|
+
const left = carried ? s.conversations.filter((c) => c.id !== carried.id) : s.conversations;
|
|
172
|
+
if (timer.current !== null)
|
|
173
|
+
clearTimeout(timer.current);
|
|
174
|
+
timer.current = null;
|
|
175
|
+
oldestPending.current = 0;
|
|
176
|
+
writeConversations(partnerKey, s.identity, left, null);
|
|
177
|
+
s.identity = next;
|
|
178
|
+
s.guest = isGuest(client, next);
|
|
179
|
+
s.conversations = readConversations(partnerKey, next);
|
|
180
|
+
if (carried) {
|
|
181
|
+
s.conversations.unshift(carried);
|
|
182
|
+
reorder(s);
|
|
183
|
+
s.seen = client.messages.length;
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
// Somebody else is in front of the chat now. They get their own list and
|
|
187
|
+
// an empty conversation; the last person's goes off the screen.
|
|
188
|
+
blank(s);
|
|
189
|
+
client.restoreConversation(EMPTY);
|
|
190
|
+
}
|
|
191
|
+
publish();
|
|
192
|
+
flush();
|
|
193
|
+
});
|
|
194
|
+
}, [client, enabled, flush, partnerKey, publish]);
|
|
195
|
+
// ── The adapter ──────────────────────────────────────────────────────────
|
|
196
|
+
const s = store.current;
|
|
197
|
+
const threads = useMemo(() => s.conversations
|
|
198
|
+
// An empty conversation is not one yet. Leaving it out is what keeps
|
|
199
|
+
// the Chats button off until there is something behind it, and stops
|
|
200
|
+
// the list opening on a single row saying "New chat".
|
|
201
|
+
.filter((c) => c.snapshot.messages.length > 0)
|
|
202
|
+
.map((c) => ({
|
|
203
|
+
id: c.id,
|
|
204
|
+
status: 'regular',
|
|
205
|
+
// Never left to assistant-ui's own fallback, so one word decides
|
|
206
|
+
// what an untitled conversation is called.
|
|
207
|
+
title: c.title ?? UNTITLED,
|
|
208
|
+
lastMessageAt: new Date(c.updatedAt),
|
|
209
|
+
})),
|
|
210
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
211
|
+
[s.conversations]);
|
|
212
|
+
const onSwitchToNewThread = useCallback(() => {
|
|
213
|
+
if (!enabled) {
|
|
214
|
+
// No list, so no way back: end the session rather than leave it behind.
|
|
215
|
+
client.clearMessages();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const s = store.current;
|
|
219
|
+
const current = s.conversations.find((c) => c.id === s.currentId);
|
|
220
|
+
// Already on an empty chat. Pressing New again should not stack up blanks.
|
|
221
|
+
if (current && current.snapshot.messages.length === 0)
|
|
222
|
+
return;
|
|
223
|
+
blank(s);
|
|
224
|
+
client.restoreConversation(EMPTY);
|
|
225
|
+
s.seen = 0;
|
|
226
|
+
publish();
|
|
227
|
+
flush();
|
|
228
|
+
}, [client, enabled, flush, publish]);
|
|
229
|
+
const onSwitchToThread = useCallback((id) => {
|
|
230
|
+
const s = store.current;
|
|
231
|
+
const target = s.conversations.find((c) => c.id === id);
|
|
232
|
+
if (!target || id === s.currentId)
|
|
233
|
+
return;
|
|
234
|
+
s.currentId = id;
|
|
235
|
+
s.seen = target.snapshot.messages.length;
|
|
236
|
+
client.restoreConversation(target.snapshot);
|
|
237
|
+
publish();
|
|
238
|
+
flush();
|
|
239
|
+
}, [client, flush, publish]);
|
|
240
|
+
const onRename = useCallback((id, title) => {
|
|
241
|
+
const s = store.current;
|
|
242
|
+
const target = s.conversations.find((c) => c.id === id);
|
|
243
|
+
if (!target)
|
|
244
|
+
return;
|
|
245
|
+
// Renaming does not count as activity: the conversation keeps its place
|
|
246
|
+
// in the list instead of jumping to the top of it.
|
|
247
|
+
target.title = title.trim() || null;
|
|
248
|
+
// A new array, or the adapter would hand assistant-ui the one it already
|
|
249
|
+
// has and the new name would never reach the screen.
|
|
250
|
+
reorder(s);
|
|
251
|
+
publish();
|
|
252
|
+
flush();
|
|
253
|
+
}, [flush, publish]);
|
|
254
|
+
const onDelete = useCallback((id) => {
|
|
255
|
+
const s = store.current;
|
|
256
|
+
s.conversations = s.conversations.filter((c) => c.id !== id);
|
|
257
|
+
if (id === s.currentId) {
|
|
258
|
+
blank(s);
|
|
259
|
+
client.restoreConversation(EMPTY);
|
|
260
|
+
s.seen = 0;
|
|
261
|
+
}
|
|
262
|
+
publish();
|
|
263
|
+
flush();
|
|
264
|
+
}, [client, flush, publish]);
|
|
265
|
+
return useMemo(() => enabled
|
|
266
|
+
? {
|
|
267
|
+
threadId: s.currentId,
|
|
268
|
+
threads,
|
|
269
|
+
onSwitchToNewThread,
|
|
270
|
+
onSwitchToThread,
|
|
271
|
+
onRename,
|
|
272
|
+
onDelete,
|
|
273
|
+
}
|
|
274
|
+
: { onSwitchToNewThread }, [enabled, s.currentId, threads, onSwitchToNewThread, onSwitchToThread, onRename, onDelete]);
|
|
275
|
+
}
|
|
276
|
+
// ── The store ──────────────────────────────────────────────────────────────
|
|
277
|
+
/**
|
|
278
|
+
* The list as it stands when the chat mounts.
|
|
279
|
+
*
|
|
280
|
+
* A client handed to the chat may already be holding a conversation — a partner
|
|
281
|
+
* sharing one client with the rest of their app, or a chat that unmounted and
|
|
282
|
+
* came back. That transcript is adopted rather than thrown away: into the
|
|
283
|
+
* stored conversation it came from where the last message identifies one, and
|
|
284
|
+
* into a new conversation where it does not.
|
|
285
|
+
*/
|
|
286
|
+
function start(client, partnerKey, enabled) {
|
|
287
|
+
const identity = identityKey(client.auth.getUser?.()?.id);
|
|
288
|
+
const conversations = enabled ? readConversations(partnerKey, identity) : [];
|
|
289
|
+
const messages = client.messages;
|
|
290
|
+
const store = {
|
|
291
|
+
identity,
|
|
292
|
+
conversations,
|
|
293
|
+
currentId: '',
|
|
294
|
+
seen: 0,
|
|
295
|
+
fresh: new Set(),
|
|
296
|
+
guest: isGuest(client, identity),
|
|
297
|
+
};
|
|
298
|
+
if (messages.length > 0) {
|
|
299
|
+
// A transcript already in the client is one somebody is in the middle of,
|
|
300
|
+
// so it counts as started here however it got there.
|
|
301
|
+
const lastId = messages[messages.length - 1].id;
|
|
302
|
+
const known = conversations.find((c) => c.snapshot.messages[c.snapshot.messages.length - 1]?.id === lastId);
|
|
303
|
+
const conversation = known ??
|
|
304
|
+
{
|
|
305
|
+
id: newId(),
|
|
306
|
+
title: titleFrom(firstUser(messages)),
|
|
307
|
+
createdAt: Date.now(),
|
|
308
|
+
updatedAt: Date.now(),
|
|
309
|
+
snapshot: client.exportConversation(),
|
|
310
|
+
};
|
|
311
|
+
if (known)
|
|
312
|
+
known.snapshot = client.exportConversation();
|
|
313
|
+
else
|
|
314
|
+
conversations.unshift(conversation);
|
|
315
|
+
store.currentId = conversation.id;
|
|
316
|
+
store.seen = messages.length;
|
|
317
|
+
store.fresh.add(conversation.id);
|
|
318
|
+
return store;
|
|
319
|
+
}
|
|
320
|
+
// Nothing on screen: pick up where this device left off. The transcript is
|
|
321
|
+
// put back locally — the engine is not asked for anything and does not need
|
|
322
|
+
// to remember it. Not marked as started here: it may be an earlier visitor's.
|
|
323
|
+
const newest = conversations[0];
|
|
324
|
+
if (newest) {
|
|
325
|
+
client.restoreConversation(newest.snapshot);
|
|
326
|
+
store.currentId = newest.id;
|
|
327
|
+
store.seen = newest.snapshot.messages.length;
|
|
328
|
+
return store;
|
|
329
|
+
}
|
|
330
|
+
blank(store);
|
|
331
|
+
return store;
|
|
332
|
+
}
|
|
333
|
+
/** Put a fresh, empty conversation at the top of the list and make it the current one. */
|
|
334
|
+
function blank(store) {
|
|
335
|
+
const conversation = {
|
|
336
|
+
id: newId(),
|
|
337
|
+
title: null,
|
|
338
|
+
createdAt: Date.now(),
|
|
339
|
+
updatedAt: Date.now(),
|
|
340
|
+
snapshot: { messages: [], history: [], threadId: null },
|
|
341
|
+
};
|
|
342
|
+
store.conversations = [conversation, ...store.conversations];
|
|
343
|
+
store.currentId = conversation.id;
|
|
344
|
+
store.seen = 0;
|
|
345
|
+
store.fresh.add(conversation.id);
|
|
346
|
+
}
|
|
347
|
+
/** Newest first, and a new array so the adapter's `threads` is seen to have changed. */
|
|
348
|
+
function reorder(store) {
|
|
349
|
+
store.conversations = [...store.conversations].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
350
|
+
}
|
|
351
|
+
function firstUser(messages) {
|
|
352
|
+
return messages.find((m) => m.role === 'user');
|
|
353
|
+
}
|
|
354
|
+
/** Whether there is no account behind this identity: a guest, or nobody yet. */
|
|
355
|
+
function isGuest(client, identity) {
|
|
356
|
+
return identity === identityKey(null) || client.auth.isAnonymous?.() === true;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* `crypto.randomUUID` needs a secure context, which a partner's staging page on
|
|
360
|
+
* plain http is not. The id only has to be unique inside one browser's storage.
|
|
361
|
+
*/
|
|
362
|
+
function newId() {
|
|
363
|
+
return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
364
|
+
}
|
|
365
|
+
//# sourceMappingURL=use-conversations.js.map
|