@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.
Files changed (73) hide show
  1. package/dist/assistant-ui/index.d.ts +13 -2
  2. package/dist/assistant-ui/index.d.ts.map +1 -1
  3. package/dist/assistant-ui/index.js +15 -12
  4. package/dist/assistant-ui/index.js.map +1 -1
  5. package/dist/auth.d.ts +3 -0
  6. package/dist/auth.d.ts.map +1 -1
  7. package/dist/auth.js +3 -0
  8. package/dist/auth.js.map +1 -1
  9. package/dist/chat/chat.d.ts.map +1 -1
  10. package/dist/chat/chat.js +8 -3
  11. package/dist/chat/chat.js.map +1 -1
  12. package/dist/chat/lib/conversation-store.d.ts +105 -0
  13. package/dist/chat/lib/conversation-store.d.ts.map +1 -0
  14. package/dist/chat/lib/conversation-store.js +278 -0
  15. package/dist/chat/lib/conversation-store.js.map +1 -0
  16. package/dist/chat/lib/use-conversations.d.ts +13 -0
  17. package/dist/chat/lib/use-conversations.d.ts.map +1 -0
  18. package/dist/chat/lib/use-conversations.js +365 -0
  19. package/dist/chat/lib/use-conversations.js.map +1 -0
  20. package/dist/chat/provider.d.ts.map +1 -1
  21. package/dist/chat/provider.js +6 -0
  22. package/dist/chat/provider.js.map +1 -1
  23. package/dist/chat/ui/avatar.d.ts +7 -8
  24. package/dist/chat/ui/avatar.d.ts.map +1 -1
  25. package/dist/chat/ui/avatar.js +30 -18
  26. package/dist/chat/ui/avatar.js.map +1 -1
  27. package/dist/chat/ui/button.d.ts +5 -2
  28. package/dist/chat/ui/button.d.ts.map +1 -1
  29. package/dist/chat/ui/button.js +10 -3
  30. package/dist/chat/ui/button.js.map +1 -1
  31. package/dist/chat/ui/collapsible.d.ts +4 -4
  32. package/dist/chat/ui/collapsible.d.ts.map +1 -1
  33. package/dist/chat/ui/collapsible.js +18 -9
  34. package/dist/chat/ui/collapsible.js.map +1 -1
  35. package/dist/chat/ui/dialog.d.ts +11 -11
  36. package/dist/chat/ui/dialog.d.ts.map +1 -1
  37. package/dist/chat/ui/dialog.js +49 -28
  38. package/dist/chat/ui/dialog.js.map +1 -1
  39. package/dist/chat/ui/input.d.ts +1 -1
  40. package/dist/chat/ui/input.d.ts.map +1 -1
  41. package/dist/chat/ui/input.js +10 -3
  42. package/dist/chat/ui/input.js.map +1 -1
  43. package/dist/chat/ui/skeleton.d.ts +2 -1
  44. package/dist/chat/ui/skeleton.d.ts.map +1 -1
  45. package/dist/chat/ui/skeleton.js +10 -3
  46. package/dist/chat/ui/skeleton.js.map +1 -1
  47. package/dist/chat/ui/textarea.d.ts +1 -1
  48. package/dist/chat/ui/textarea.d.ts.map +1 -1
  49. package/dist/chat/ui/textarea.js +10 -3
  50. package/dist/chat/ui/textarea.js.map +1 -1
  51. package/dist/chat/ui/tooltip.d.ts +3 -2
  52. package/dist/chat/ui/tooltip.d.ts.map +1 -1
  53. package/dist/chat/ui/tooltip.js +25 -8
  54. package/dist/chat/ui/tooltip.js.map +1 -1
  55. package/dist/chat/vendor/thread-list.aui.d.ts +3 -3
  56. package/dist/chat/vendor/thread-list.aui.d.ts.map +1 -1
  57. package/dist/chat/vendor/thread-list.aui.js +7 -7
  58. package/dist/chat/vendor/thread-list.aui.js.map +1 -1
  59. package/dist/chat-css.js +1 -1
  60. package/dist/chat.css +1 -1
  61. package/dist/chat.isolated.css +3 -0
  62. package/dist/chat.raw.css +3 -0
  63. package/dist/client.d.ts +4 -0
  64. package/dist/client.d.ts.map +1 -1
  65. package/dist/client.js +18 -2
  66. package/dist/client.js.map +1 -1
  67. package/dist/index.d.ts +56 -2
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +104 -6
  70. package/dist/index.js.map +1 -1
  71. package/dist/types.d.ts +20 -0
  72. package/dist/types.d.ts.map +1 -1
  73. 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