@frankieai/react 0.1.4 → 0.2.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/frankie.css +1 -1
- package/dist/index.cjs +290 -128
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -10
- package/dist/index.d.ts +16 -10
- package/dist/index.js +290 -128
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/ChatPanel.tsx +3 -2
- package/src/components/chat/FrankieChatPage.tsx +82 -42
- package/src/components/chat/FrankieWidget.tsx +7 -3
- package/src/hooks/FrankieProvider.tsx +12 -4
- package/src/hooks/useFrankieChat.ts +52 -21
- package/src/lib/client.ts +16 -6
- package/src/styles/frankie.css +182 -0
- package/src/widget.test.tsx +35 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
// src/hooks/FrankieProvider.tsx
|
|
4
|
-
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { createContext as createContext2, useContext as useContext2, useEffect as useEffect2, useMemo, useRef as useRef2, useState as useState2 } from "react";
|
|
5
5
|
|
|
6
6
|
// src/lib/client.ts
|
|
7
7
|
var FRANKIE_CLOUD_URL = "https://frankie-data-gq5o5syq2q-uc.a.run.app";
|
|
@@ -20,11 +20,18 @@ var FrankieClient = class {
|
|
|
20
20
|
this.baseUrl = (options.baseUrl ?? FRANKIE_CLOUD_URL).replace(/\/+$/, "");
|
|
21
21
|
this.doFetch = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
22
22
|
this.session = this.readStored();
|
|
23
|
+
if (options.getUserToken && typeof window !== "undefined") {
|
|
24
|
+
try {
|
|
25
|
+
for (const suffix of ["session", "messages"]) window.localStorage.removeItem(`${this.namespace}.${suffix}`);
|
|
26
|
+
} catch {
|
|
27
|
+
}
|
|
28
|
+
}
|
|
23
29
|
}
|
|
24
30
|
options;
|
|
25
31
|
baseUrl;
|
|
26
32
|
doFetch;
|
|
27
33
|
session;
|
|
34
|
+
sessionUserToken;
|
|
28
35
|
get sessionId() {
|
|
29
36
|
return this.session?.sessionId;
|
|
30
37
|
}
|
|
@@ -33,7 +40,7 @@ var FrankieClient = class {
|
|
|
33
40
|
}
|
|
34
41
|
/** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
|
|
35
42
|
get persist() {
|
|
36
|
-
return this.options.persist !== false && typeof window !== "undefined";
|
|
43
|
+
return !this.options.getUserToken && this.options.persist !== false && typeof window !== "undefined";
|
|
37
44
|
}
|
|
38
45
|
/** localStorage namespace for this tenant/conversation, shared with the chat hook. */
|
|
39
46
|
get namespace() {
|
|
@@ -66,15 +73,17 @@ var FrankieClient = class {
|
|
|
66
73
|
return await this.unwrap(response);
|
|
67
74
|
}
|
|
68
75
|
async ensureSession(context) {
|
|
69
|
-
if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 3e4) return this.session;
|
|
70
76
|
const userToken = await this.options.getUserToken?.();
|
|
71
77
|
if (this.options.getUserToken && !userToken) {
|
|
78
|
+
this.resetSession();
|
|
72
79
|
throw new FrankieApiError(
|
|
73
80
|
"Your signed-in Frankie session could not be verified. Refresh the page and sign in again.",
|
|
74
81
|
401,
|
|
75
82
|
"user_token_unavailable"
|
|
76
83
|
);
|
|
77
84
|
}
|
|
85
|
+
if (userToken !== this.sessionUserToken) this.resetSession();
|
|
86
|
+
if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 3e4) return this.session;
|
|
78
87
|
const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
|
|
79
88
|
method: "POST",
|
|
80
89
|
headers: { "content-type": "application/json", "x-frankie-key": this.options.publishableKey },
|
|
@@ -85,6 +94,7 @@ var FrankieClient = class {
|
|
|
85
94
|
})
|
|
86
95
|
});
|
|
87
96
|
this.session = await this.unwrap(response);
|
|
97
|
+
this.sessionUserToken = userToken;
|
|
88
98
|
this.writeStored();
|
|
89
99
|
return this.session;
|
|
90
100
|
}
|
|
@@ -97,7 +107,7 @@ var FrankieClient = class {
|
|
|
97
107
|
return Array.isArray(body.conversations) ? body.conversations : [];
|
|
98
108
|
}
|
|
99
109
|
async resumeConversation(id = this.options.conversationId) {
|
|
100
|
-
if (!id) return void 0;
|
|
110
|
+
if (!id || !this.options.getUserToken) return void 0;
|
|
101
111
|
const userToken = await this.requireUserToken();
|
|
102
112
|
const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}/resume`, {
|
|
103
113
|
method: "POST",
|
|
@@ -106,6 +116,7 @@ var FrankieClient = class {
|
|
|
106
116
|
if (response.status === 404) return void 0;
|
|
107
117
|
const body = await this.unwrap(response);
|
|
108
118
|
this.session = body.session;
|
|
119
|
+
this.sessionUserToken = userToken;
|
|
109
120
|
this.writeStored();
|
|
110
121
|
return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat" };
|
|
111
122
|
}
|
|
@@ -212,64 +223,8 @@ function parseFrame(frame) {
|
|
|
212
223
|
}
|
|
213
224
|
}
|
|
214
225
|
|
|
215
|
-
// src/hooks/FrankieProvider.tsx
|
|
216
|
-
import { jsx } from "react/jsx-runtime";
|
|
217
|
-
var FrankieContext = createContext(null);
|
|
218
|
-
function FrankieProvider({
|
|
219
|
-
children,
|
|
220
|
-
context,
|
|
221
|
-
theme,
|
|
222
|
-
config: providedConfig,
|
|
223
|
-
...clientOptions
|
|
224
|
-
}) {
|
|
225
|
-
const client = useMemo(
|
|
226
|
-
() => new FrankieClient(clientOptions),
|
|
227
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
228
|
-
[clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId]
|
|
229
|
-
);
|
|
230
|
-
const [config, setConfig] = useState(providedConfig);
|
|
231
|
-
const [configError, setConfigError] = useState(void 0);
|
|
232
|
-
const [loading, setLoading] = useState(!providedConfig);
|
|
233
|
-
useEffect(() => {
|
|
234
|
-
if (providedConfig) return;
|
|
235
|
-
const abort = new AbortController();
|
|
236
|
-
setLoading(true);
|
|
237
|
-
client.getConfig(abort.signal).then((next) => {
|
|
238
|
-
setConfig(next);
|
|
239
|
-
setConfigError(void 0);
|
|
240
|
-
}).catch((error) => {
|
|
241
|
-
if (abort.signal.aborted) return;
|
|
242
|
-
setConfigError(error instanceof Error ? error.message : "Could not load the assistant.");
|
|
243
|
-
}).finally(() => {
|
|
244
|
-
if (!abort.signal.aborted) setLoading(false);
|
|
245
|
-
});
|
|
246
|
-
return () => abort.abort();
|
|
247
|
-
}, [client, providedConfig]);
|
|
248
|
-
const value = useMemo(
|
|
249
|
-
() => ({
|
|
250
|
-
client,
|
|
251
|
-
...config ? { config } : {},
|
|
252
|
-
...configError ? { configError } : {},
|
|
253
|
-
loading,
|
|
254
|
-
...context ? { context } : {},
|
|
255
|
-
...theme ? { theme } : {}
|
|
256
|
-
}),
|
|
257
|
-
[client, config, configError, loading, context, theme]
|
|
258
|
-
);
|
|
259
|
-
return /* @__PURE__ */ jsx(FrankieContext.Provider, { value, children });
|
|
260
|
-
}
|
|
261
|
-
function useFrankie() {
|
|
262
|
-
const value = useContext(FrankieContext);
|
|
263
|
-
if (!value) throw new Error("Frankie components must be rendered inside <FrankieProvider>.");
|
|
264
|
-
return value;
|
|
265
|
-
}
|
|
266
|
-
function useFrankieTheme() {
|
|
267
|
-
const { config, theme } = useFrankie();
|
|
268
|
-
return useMemo(() => ({ ...config?.theme, ...theme }), [config?.theme, theme]);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
226
|
// src/hooks/useFrankieChat.ts
|
|
272
|
-
import { useCallback, useEffect
|
|
227
|
+
import { createContext, useContext, useCallback, useEffect, useRef, useState } from "react";
|
|
273
228
|
|
|
274
229
|
// src/lib/messages.ts
|
|
275
230
|
function applyEvent(message, event) {
|
|
@@ -346,15 +301,23 @@ function newUserMessage(id, text, attachments) {
|
|
|
346
301
|
// src/hooks/useFrankieChat.ts
|
|
347
302
|
var counter = 0;
|
|
348
303
|
var nextId = (prefix) => `${prefix}_${Date.now().toString(36)}_${counter += 1}`;
|
|
304
|
+
var FrankieChatContext = createContext(null);
|
|
349
305
|
function useFrankieChat() {
|
|
306
|
+
const value = useContext(FrankieChatContext);
|
|
307
|
+
if (!value) throw new Error("useFrankieChat requires FrankieProvider.");
|
|
308
|
+
return value;
|
|
309
|
+
}
|
|
310
|
+
function useFrankieChatState() {
|
|
350
311
|
const { client, context } = useFrankie();
|
|
351
|
-
const [messages, setMessages] =
|
|
352
|
-
const [busy, setBusy] =
|
|
353
|
-
const [error, setError] =
|
|
354
|
-
const [restored, setRestored] =
|
|
312
|
+
const [messages, setMessages] = useState([]);
|
|
313
|
+
const [busy, setBusy] = useState(false);
|
|
314
|
+
const [error, setError] = useState(void 0);
|
|
315
|
+
const [restored, setRestored] = useState(false);
|
|
316
|
+
const [loadedSkills, setLoadedSkills] = useState(null);
|
|
355
317
|
const abortRef = useRef(null);
|
|
356
318
|
const mounted = useRef(true);
|
|
357
|
-
|
|
319
|
+
const messagesRef = useRef([]);
|
|
320
|
+
useEffect(() => {
|
|
358
321
|
mounted.current = true;
|
|
359
322
|
return () => {
|
|
360
323
|
mounted.current = false;
|
|
@@ -362,21 +325,29 @@ function useFrankieChat() {
|
|
|
362
325
|
};
|
|
363
326
|
}, []);
|
|
364
327
|
const storageKey = `${client.namespace}.messages`;
|
|
365
|
-
|
|
328
|
+
useEffect(() => {
|
|
366
329
|
let active = true;
|
|
367
330
|
const restore = async () => {
|
|
368
331
|
if (client.persist) {
|
|
369
332
|
try {
|
|
370
333
|
const raw = window.localStorage.getItem(storageKey);
|
|
371
334
|
const stored = raw ? JSON.parse(raw) : [];
|
|
372
|
-
if (Array.isArray(stored) && stored.length && active)
|
|
335
|
+
if (Array.isArray(stored) && stored.length && active) {
|
|
336
|
+
messagesRef.current = stored;
|
|
337
|
+
setMessages(stored);
|
|
338
|
+
}
|
|
373
339
|
} catch {
|
|
374
340
|
}
|
|
375
341
|
}
|
|
376
342
|
try {
|
|
377
343
|
const remote = await client.resumeConversation();
|
|
378
|
-
if (active && remote?.messages.length)
|
|
344
|
+
if (active && remote?.messages.length) {
|
|
345
|
+
const restoredMessages = remote.messages;
|
|
346
|
+
messagesRef.current = restoredMessages;
|
|
347
|
+
setMessages(restoredMessages);
|
|
348
|
+
}
|
|
379
349
|
} catch {
|
|
350
|
+
if (active) setError("Could not restore this conversation. Check your sign-in and connection, then try opening it again.");
|
|
380
351
|
} finally {
|
|
381
352
|
if (active) setRestored(true);
|
|
382
353
|
}
|
|
@@ -386,7 +357,7 @@ function useFrankieChat() {
|
|
|
386
357
|
active = false;
|
|
387
358
|
};
|
|
388
359
|
}, [client, storageKey]);
|
|
389
|
-
|
|
360
|
+
useEffect(() => {
|
|
390
361
|
if (!client.persist || !restored || busy) return;
|
|
391
362
|
try {
|
|
392
363
|
if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
|
|
@@ -396,30 +367,33 @@ function useFrankieChat() {
|
|
|
396
367
|
if (messages.length) void client.syncConversation(messages, conversationTitle(messages)).catch(() => void 0);
|
|
397
368
|
}, [messages, busy, client, storageKey, restored]);
|
|
398
369
|
const patchAssistant = useCallback((id, update) => {
|
|
399
|
-
|
|
400
|
-
(
|
|
401
|
-
(message) => message.id === id && message.role === "assistant" ? update(message) : message
|
|
402
|
-
)
|
|
370
|
+
const next = messagesRef.current.map(
|
|
371
|
+
(message) => message.id === id && message.role === "assistant" ? update(message) : message
|
|
403
372
|
);
|
|
373
|
+
messagesRef.current = next;
|
|
374
|
+
setMessages(next);
|
|
404
375
|
}, []);
|
|
405
376
|
const send = useCallback(
|
|
406
377
|
async (text, opts) => {
|
|
407
378
|
const trimmed = text.trim();
|
|
408
379
|
const attachments = opts?.attachments ?? [];
|
|
409
|
-
if (!trimmed && attachments.length === 0 ||
|
|
380
|
+
if (!trimmed && attachments.length === 0 || abortRef.current || !restored) return;
|
|
410
381
|
const outgoing = trimmed || "Please take a look at the attached file(s).";
|
|
411
382
|
const assistantId = nextId("a");
|
|
412
383
|
setError(void 0);
|
|
384
|
+
setLoadedSkills(null);
|
|
413
385
|
setBusy(true);
|
|
414
|
-
|
|
415
|
-
...current,
|
|
386
|
+
const next = [
|
|
387
|
+
...messagesRef.current,
|
|
416
388
|
newUserMessage(
|
|
417
389
|
nextId("u"),
|
|
418
390
|
trimmed,
|
|
419
391
|
attachments.map((file) => ({ name: file.name, mimeType: file.mimeType }))
|
|
420
392
|
),
|
|
421
393
|
newAssistantMessage(assistantId)
|
|
422
|
-
]
|
|
394
|
+
];
|
|
395
|
+
messagesRef.current = next;
|
|
396
|
+
setMessages(next);
|
|
423
397
|
const abort = new AbortController();
|
|
424
398
|
abortRef.current = abort;
|
|
425
399
|
try {
|
|
@@ -434,6 +408,10 @@ function useFrankieChat() {
|
|
|
434
408
|
signal: abort.signal
|
|
435
409
|
},
|
|
436
410
|
(event) => {
|
|
411
|
+
if (event.type === "skills_loaded") {
|
|
412
|
+
setLoadedSkills(event.skills);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
437
415
|
if (event.type === "ping" || event.type === "session") return;
|
|
438
416
|
if (event.type === "text_delta") receivedText = true;
|
|
439
417
|
if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
|
|
@@ -460,32 +438,36 @@ function useFrankieChat() {
|
|
|
460
438
|
patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
|
|
461
439
|
}
|
|
462
440
|
} finally {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
441
|
+
patchAssistant(
|
|
442
|
+
assistantId,
|
|
443
|
+
(message) => message.status === "streaming" ? { ...message, status: "done" } : message
|
|
444
|
+
);
|
|
445
|
+
const transcript = messagesRef.current;
|
|
446
|
+
if (transcript.length) {
|
|
447
|
+
await client.syncConversation(transcript, conversationTitle(transcript)).catch(() => void 0);
|
|
469
448
|
}
|
|
449
|
+
if (mounted.current) setBusy(false);
|
|
470
450
|
abortRef.current = null;
|
|
471
451
|
}
|
|
472
452
|
},
|
|
473
|
-
[busy, client, context, patchAssistant]
|
|
453
|
+
[busy, client, context, patchAssistant, restored]
|
|
474
454
|
);
|
|
475
455
|
const stop = useCallback(() => abortRef.current?.abort(), []);
|
|
476
456
|
const reset = useCallback(() => {
|
|
477
457
|
abortRef.current?.abort();
|
|
478
458
|
client.resetSession();
|
|
459
|
+
messagesRef.current = [];
|
|
479
460
|
setMessages([]);
|
|
480
461
|
setError(void 0);
|
|
481
462
|
setBusy(false);
|
|
482
463
|
setRestored(true);
|
|
464
|
+
setLoadedSkills(null);
|
|
483
465
|
try {
|
|
484
466
|
if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
|
|
485
467
|
} catch {
|
|
486
468
|
}
|
|
487
469
|
}, [client]);
|
|
488
|
-
return { messages, busy, ...error ? { error } : {}, send, stop, reset };
|
|
470
|
+
return { messages, busy, loadedSkills, restoring: !restored, ...error ? { error } : {}, send, stop, reset };
|
|
489
471
|
}
|
|
490
472
|
function conversationTitle(messages) {
|
|
491
473
|
const first = messages.find((message) => message.role === "user" && message.text.trim());
|
|
@@ -494,6 +476,68 @@ function conversationTitle(messages) {
|
|
|
494
476
|
return title.length > 42 ? `${title.slice(0, 42)}\u2026` : title;
|
|
495
477
|
}
|
|
496
478
|
|
|
479
|
+
// src/hooks/FrankieProvider.tsx
|
|
480
|
+
import { jsx } from "react/jsx-runtime";
|
|
481
|
+
var FrankieContext = createContext2(null);
|
|
482
|
+
function FrankieProvider({
|
|
483
|
+
children,
|
|
484
|
+
context,
|
|
485
|
+
theme,
|
|
486
|
+
config: providedConfig,
|
|
487
|
+
...clientOptions
|
|
488
|
+
}) {
|
|
489
|
+
const tokenCallback = useRef2(clientOptions.getUserToken);
|
|
490
|
+
tokenCallback.current = clientOptions.getUserToken;
|
|
491
|
+
const client = useMemo(
|
|
492
|
+
() => new FrankieClient({ ...clientOptions, ...clientOptions.getUserToken ? { getUserToken: () => tokenCallback.current?.() } : {} }),
|
|
493
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
494
|
+
[clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId, clientOptions.persist, clientOptions.fetchImpl, Boolean(clientOptions.getUserToken)]
|
|
495
|
+
);
|
|
496
|
+
const [config, setConfig] = useState2(providedConfig);
|
|
497
|
+
const [configError, setConfigError] = useState2(void 0);
|
|
498
|
+
const [loading, setLoading] = useState2(!providedConfig);
|
|
499
|
+
useEffect2(() => {
|
|
500
|
+
if (providedConfig) return;
|
|
501
|
+
const abort = new AbortController();
|
|
502
|
+
setLoading(true);
|
|
503
|
+
client.getConfig(abort.signal).then((next) => {
|
|
504
|
+
setConfig(next);
|
|
505
|
+
setConfigError(void 0);
|
|
506
|
+
}).catch((error) => {
|
|
507
|
+
if (abort.signal.aborted) return;
|
|
508
|
+
setConfigError(error instanceof Error ? error.message : "Could not load the assistant.");
|
|
509
|
+
}).finally(() => {
|
|
510
|
+
if (!abort.signal.aborted) setLoading(false);
|
|
511
|
+
});
|
|
512
|
+
return () => abort.abort();
|
|
513
|
+
}, [client, providedConfig]);
|
|
514
|
+
const value = useMemo(
|
|
515
|
+
() => ({
|
|
516
|
+
client,
|
|
517
|
+
...config ? { config } : {},
|
|
518
|
+
...configError ? { configError } : {},
|
|
519
|
+
loading,
|
|
520
|
+
...context ? { context } : {},
|
|
521
|
+
...theme ? { theme } : {}
|
|
522
|
+
}),
|
|
523
|
+
[client, config, configError, loading, context, theme]
|
|
524
|
+
);
|
|
525
|
+
return /* @__PURE__ */ jsx(FrankieContext.Provider, { value, children: /* @__PURE__ */ jsx(ConversationState, { children }, client.namespace + ":" + (client.conversationId ?? "")) });
|
|
526
|
+
}
|
|
527
|
+
function ConversationState({ children }) {
|
|
528
|
+
const chat = useFrankieChatState();
|
|
529
|
+
return /* @__PURE__ */ jsx(FrankieChatContext.Provider, { value: chat, children });
|
|
530
|
+
}
|
|
531
|
+
function useFrankie() {
|
|
532
|
+
const value = useContext2(FrankieContext);
|
|
533
|
+
if (!value) throw new Error("Frankie components must be rendered inside <FrankieProvider>.");
|
|
534
|
+
return value;
|
|
535
|
+
}
|
|
536
|
+
function useFrankieTheme() {
|
|
537
|
+
const { config, theme } = useFrankie();
|
|
538
|
+
return useMemo(() => ({ ...config?.theme, ...theme }), [config?.theme, theme]);
|
|
539
|
+
}
|
|
540
|
+
|
|
497
541
|
// src/components/chat/FrankieWidget.tsx
|
|
498
542
|
import { MessageCircle, X as X4 } from "lucide-react";
|
|
499
543
|
import { useEffect as useEffect5, useState as useState6 } from "react";
|
|
@@ -557,7 +601,7 @@ function Button({ className, variant, size, asChild = false, ...props }) {
|
|
|
557
601
|
|
|
558
602
|
// src/components/chat/ChatPanel.tsx
|
|
559
603
|
import { RotateCcw, X as X3 } from "lucide-react";
|
|
560
|
-
import { useEffect as useEffect4, useRef as
|
|
604
|
+
import { useEffect as useEffect4, useRef as useRef4, useState as useState5 } from "react";
|
|
561
605
|
|
|
562
606
|
// src/components/ui/avatar.tsx
|
|
563
607
|
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
@@ -617,7 +661,7 @@ var ScrollArea = forwardRef(
|
|
|
617
661
|
|
|
618
662
|
// src/components/chat/Composer.tsx
|
|
619
663
|
import { ArrowUp, Paperclip, Square, X } from "lucide-react";
|
|
620
|
-
import { useCallback as useCallback2, useEffect as useEffect3, useRef as
|
|
664
|
+
import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
|
|
621
665
|
|
|
622
666
|
// src/components/ui/textarea.tsx
|
|
623
667
|
import { forwardRef as forwardRef2 } from "react";
|
|
@@ -645,8 +689,8 @@ function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, atta
|
|
|
645
689
|
const [value, setValue] = useState3("");
|
|
646
690
|
const [files, setFiles] = useState3([]);
|
|
647
691
|
const [fileError, setFileError] = useState3(void 0);
|
|
648
|
-
const textareaRef =
|
|
649
|
-
const fileInputRef =
|
|
692
|
+
const textareaRef = useRef3(null);
|
|
693
|
+
const fileInputRef = useRef3(null);
|
|
650
694
|
const canAttach = Boolean(attachments?.enabled);
|
|
651
695
|
const models = modelPicker?.models ?? [];
|
|
652
696
|
useEffect3(() => {
|
|
@@ -1096,8 +1140,8 @@ function ChatPanel({
|
|
|
1096
1140
|
}) {
|
|
1097
1141
|
const { config, configError, loading } = useFrankie();
|
|
1098
1142
|
const theme = useFrankieTheme();
|
|
1099
|
-
const { messages, busy, error, send, stop, reset } = useFrankieChat();
|
|
1100
|
-
const scrollRef =
|
|
1143
|
+
const { messages, busy, restoring, loadedSkills, error, send, stop, reset } = useFrankieChat();
|
|
1144
|
+
const scrollRef = useRef4(null);
|
|
1101
1145
|
const features = config?.features;
|
|
1102
1146
|
const pickerModels = features?.modelPicker.enabled ? features.modelPicker.models : [];
|
|
1103
1147
|
const [model, setModel] = useState5(void 0);
|
|
@@ -1151,6 +1195,16 @@ function ChatPanel({
|
|
|
1151
1195
|
)
|
|
1152
1196
|
) }) }) }),
|
|
1153
1197
|
/* @__PURE__ */ jsxs4(CardFooter, { className: "flex flex-col gap-2", children: [
|
|
1198
|
+
loadedSkills !== null ? /* @__PURE__ */ jsxs4("details", { className: "frankie-loaded-skills", children: [
|
|
1199
|
+
/* @__PURE__ */ jsx13("summary", { role: "status", children: loadedSkills.length ? `${loadedSkills.length} ${loadedSkills.length === 1 ? "skill" : "skills"} loaded` : "No skills loaded for this turn" }),
|
|
1200
|
+
loadedSkills.length ? /* @__PURE__ */ jsx13("ul", { children: loadedSkills.map((skill) => /* @__PURE__ */ jsxs4("li", { children: [
|
|
1201
|
+
skill.name,
|
|
1202
|
+
/* @__PURE__ */ jsxs4("small", { children: [
|
|
1203
|
+
"v",
|
|
1204
|
+
skill.revision
|
|
1205
|
+
] })
|
|
1206
|
+
] }, skill.id)) }) : /* @__PURE__ */ jsx13("p", { children: "Your assistant can still answer using its available tools." })
|
|
1207
|
+
] }) : null,
|
|
1154
1208
|
error ? /* @__PURE__ */ jsx13("p", { className: "w-full text-xs text-red-600 dark:text-red-400", children: error }) : null,
|
|
1155
1209
|
/* @__PURE__ */ jsx13("div", { className: "w-full", children: /* @__PURE__ */ jsx13(
|
|
1156
1210
|
Composer,
|
|
@@ -1158,7 +1212,7 @@ function ChatPanel({
|
|
|
1158
1212
|
onSend: send,
|
|
1159
1213
|
onStop: stop,
|
|
1160
1214
|
busy,
|
|
1161
|
-
disabled: Boolean(configError),
|
|
1215
|
+
disabled: loading || restoring || Boolean(configError),
|
|
1162
1216
|
...placeholder ? { placeholder } : {},
|
|
1163
1217
|
...autoFocus ? { autoFocus } : {},
|
|
1164
1218
|
...features?.attachments.enabled ? { attachments: features.attachments } : {},
|
|
@@ -1178,6 +1232,7 @@ function FrankieWidget({
|
|
|
1178
1232
|
position = "bottom-right",
|
|
1179
1233
|
offset = 24,
|
|
1180
1234
|
defaultOpen = false,
|
|
1235
|
+
zIndex = 20,
|
|
1181
1236
|
open: controlledOpen,
|
|
1182
1237
|
onOpenChange,
|
|
1183
1238
|
renderLauncher,
|
|
@@ -1206,11 +1261,11 @@ function FrankieWidget({
|
|
|
1206
1261
|
"div",
|
|
1207
1262
|
{
|
|
1208
1263
|
className: cn("frankie-root frankie-widget", mode === "dark" && "dark"),
|
|
1209
|
-
style: { ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex:
|
|
1264
|
+
style: { ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex, pointerEvents: "none" },
|
|
1210
1265
|
"data-frankie-mode": mode,
|
|
1211
1266
|
children: /* @__PURE__ */ jsxs5("div", { className: cn("flex flex-col gap-3", position === "bottom-left" ? "items-start" : "items-end"), children: [
|
|
1212
|
-
open ? /* @__PURE__ */ jsx14(ChatPanel, { ...panelProps, onClose: () => setOpen(false), autoFocus: true }) : null,
|
|
1213
|
-
renderLauncher ? renderLauncher({ open, toggle: () => setOpen(!open), label }) : /* @__PURE__ */ jsx14(
|
|
1267
|
+
open ? /* @__PURE__ */ jsx14("div", { style: { pointerEvents: "auto", maxHeight: "calc(100dvh - 100px)", overflow: "hidden" }, children: /* @__PURE__ */ jsx14(ChatPanel, { ...panelProps, className: cn("frankie-widget-panel", panelProps.className), onClose: () => setOpen(false), autoFocus: true }) }) : null,
|
|
1268
|
+
/* @__PURE__ */ jsx14("div", { style: { pointerEvents: "auto" }, children: renderLauncher ? renderLauncher({ open, toggle: () => setOpen(!open), label }) : /* @__PURE__ */ jsx14(
|
|
1214
1269
|
Button,
|
|
1215
1270
|
{
|
|
1216
1271
|
size: "icon",
|
|
@@ -1221,7 +1276,7 @@ function FrankieWidget({
|
|
|
1221
1276
|
title: label,
|
|
1222
1277
|
children: open ? /* @__PURE__ */ jsx14(X4, { className: "size-5" }) : /* @__PURE__ */ jsx14(MessageCircle, { className: "size-5" })
|
|
1223
1278
|
}
|
|
1224
|
-
)
|
|
1279
|
+
) })
|
|
1225
1280
|
] })
|
|
1226
1281
|
}
|
|
1227
1282
|
);
|
|
@@ -1244,38 +1299,145 @@ function FrankieInline({ className, style, ...panelProps }) {
|
|
|
1244
1299
|
}
|
|
1245
1300
|
|
|
1246
1301
|
// src/components/chat/FrankieChatPage.tsx
|
|
1247
|
-
import {
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
})
|
|
1260
|
-
|
|
1261
|
-
"
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1302
|
+
import { useCallback as useCallback3, useEffect as useEffect6, useRef as useRef5, useState as useState7 } from "react";
|
|
1303
|
+
import { History, Menu, Plus, Settings, X as X5 } from "lucide-react";
|
|
1304
|
+
import { Fragment as Fragment3, jsx as jsx16, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
1305
|
+
function PageEmptyState() {
|
|
1306
|
+
const { config } = useFrankie();
|
|
1307
|
+
const { send, busy, restoring } = useFrankieChat();
|
|
1308
|
+
const name = config?.branding.name ?? "Frankie";
|
|
1309
|
+
return /* @__PURE__ */ jsxs6("section", { className: "frankie-page-empty", "aria-label": "Start a conversation", children: [
|
|
1310
|
+
/* @__PURE__ */ jsx16("span", { className: "frankie-page-avatar", "aria-hidden": true, children: name.trim().slice(0, 1).toUpperCase() }),
|
|
1311
|
+
/* @__PURE__ */ jsxs6("div", { children: [
|
|
1312
|
+
/* @__PURE__ */ jsx16("p", { className: "frankie-page-kicker", children: "Your product assistant" }),
|
|
1313
|
+
/* @__PURE__ */ jsx16("h1", { children: "How can I help?" }),
|
|
1314
|
+
/* @__PURE__ */ jsx16("p", { children: config?.greeting ?? "Ask a question or choose a task to get started." })
|
|
1315
|
+
] }),
|
|
1316
|
+
/* @__PURE__ */ jsx16("div", { className: "frankie-page-prompts", children: config?.suggestedPrompts?.slice(0, 3).map((prompt) => /* @__PURE__ */ jsxs6("button", { type: "button", disabled: busy || restoring, onClick: () => void send(prompt), children: [
|
|
1317
|
+
/* @__PURE__ */ jsx16("strong", { children: prompt }),
|
|
1318
|
+
/* @__PURE__ */ jsx16("b", { "aria-hidden": true, children: "\u2197" })
|
|
1319
|
+
] }, prompt)) })
|
|
1320
|
+
] });
|
|
1321
|
+
}
|
|
1322
|
+
function PageChrome({ showHistory, navigation, currentId, onSelect, onNew, ...panel }) {
|
|
1323
|
+
const { client, config } = useFrankie();
|
|
1324
|
+
const { busy } = useFrankieChat();
|
|
1325
|
+
const theme = useFrankieTheme();
|
|
1326
|
+
const [conversations, setConversations] = useState7([]);
|
|
1327
|
+
const [historyError, setHistoryError] = useState7("");
|
|
1328
|
+
const [historyLoading, setHistoryLoading] = useState7(false);
|
|
1329
|
+
const [drawerOpen, setDrawerOpen] = useState7(false);
|
|
1330
|
+
const [query, setQuery] = useState7("");
|
|
1331
|
+
const dialog = useRef5(null);
|
|
1332
|
+
const menu = useRef5(null);
|
|
1333
|
+
const requestVersion = useRef5(0);
|
|
1334
|
+
const name = config?.branding.name ?? "Frankie";
|
|
1335
|
+
const refresh = useCallback3(async () => {
|
|
1336
|
+
if (!showHistory || !config || config.allowAnonymous) {
|
|
1337
|
+
setHistoryError("");
|
|
1338
|
+
return;
|
|
1277
1339
|
}
|
|
1278
|
-
|
|
1340
|
+
const version = ++requestVersion.current;
|
|
1341
|
+
setHistoryLoading(true);
|
|
1342
|
+
setHistoryError("");
|
|
1343
|
+
try {
|
|
1344
|
+
const items = await client.listConversations();
|
|
1345
|
+
if (version === requestVersion.current) setConversations(items);
|
|
1346
|
+
} catch {
|
|
1347
|
+
if (version === requestVersion.current) setHistoryError("History couldn\u2019t load. Check your sign-in and try again.");
|
|
1348
|
+
} finally {
|
|
1349
|
+
if (version === requestVersion.current) setHistoryLoading(false);
|
|
1350
|
+
}
|
|
1351
|
+
}, [client, showHistory, config?.allowAnonymous]);
|
|
1352
|
+
useEffect6(() => {
|
|
1353
|
+
if (!busy) void refresh();
|
|
1354
|
+
return () => {
|
|
1355
|
+
requestVersion.current++;
|
|
1356
|
+
};
|
|
1357
|
+
}, [refresh, busy]);
|
|
1358
|
+
useEffect6(() => {
|
|
1359
|
+
const node = dialog.current;
|
|
1360
|
+
if (drawerOpen) {
|
|
1361
|
+
node?.showModal?.();
|
|
1362
|
+
} else {
|
|
1363
|
+
node?.close?.();
|
|
1364
|
+
}
|
|
1365
|
+
}, [drawerOpen]);
|
|
1366
|
+
const close = () => {
|
|
1367
|
+
setDrawerOpen(false);
|
|
1368
|
+
menu.current?.focus();
|
|
1369
|
+
};
|
|
1370
|
+
const select = (id) => {
|
|
1371
|
+
if (!busy) {
|
|
1372
|
+
onSelect(id);
|
|
1373
|
+
close();
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
const history = /* @__PURE__ */ jsxs6(Fragment3, { children: [
|
|
1377
|
+
/* @__PURE__ */ jsxs6("div", { className: "frankie-history-heading", children: [
|
|
1378
|
+
/* @__PURE__ */ jsx16("strong", { children: "Conversations" }),
|
|
1379
|
+
/* @__PURE__ */ jsx16("button", { type: "button", className: "frankie-mobile-only", "aria-label": "Close conversations", onClick: close, children: /* @__PURE__ */ jsx16(X5, { size: 18 }) })
|
|
1380
|
+
] }),
|
|
1381
|
+
/* @__PURE__ */ jsxs6("button", { type: "button", className: "frankie-new-chat", disabled: busy, onClick: () => {
|
|
1382
|
+
onNew();
|
|
1383
|
+
close();
|
|
1384
|
+
}, children: [
|
|
1385
|
+
/* @__PURE__ */ jsx16(Plus, { size: 17 }),
|
|
1386
|
+
"New chat"
|
|
1387
|
+
] }),
|
|
1388
|
+
/* @__PURE__ */ jsxs6("label", { className: "frankie-history-search", children: [
|
|
1389
|
+
/* @__PURE__ */ jsx16("span", { children: "Search conversations" }),
|
|
1390
|
+
/* @__PURE__ */ jsx16("input", { type: "search", placeholder: "Search chats\u2026", value: query, onChange: (event) => setQuery(event.target.value) })
|
|
1391
|
+
] }),
|
|
1392
|
+
/* @__PURE__ */ jsx16("nav", { className: "frankie-history-list", "aria-label": "Conversations", children: conversations.filter((item) => item.title.toLowerCase().includes(query.toLowerCase())).map((item) => /* @__PURE__ */ jsxs6("button", { type: "button", "aria-current": item.id === currentId ? "page" : void 0, disabled: busy, onClick: () => select(item.id), children: [
|
|
1393
|
+
/* @__PURE__ */ jsx16("span", { children: item.title }),
|
|
1394
|
+
/* @__PURE__ */ jsx16("small", { children: new Date(item.updatedAt).toLocaleDateString() })
|
|
1395
|
+
] }, item.id)) }),
|
|
1396
|
+
historyLoading ? /* @__PURE__ */ jsx16("p", { role: "status", children: "Loading history\u2026" }) : historyError ? /* @__PURE__ */ jsxs6("p", { role: "status", children: [
|
|
1397
|
+
historyError,
|
|
1398
|
+
/* @__PURE__ */ jsx16("button", { type: "button", onClick: () => void refresh(), children: "Try again" })
|
|
1399
|
+
] }) : !conversations.length ? /* @__PURE__ */ jsx16("p", { children: config?.allowAnonymous ? "Sign in to sync conversations across devices." : "Your conversations will appear here." }) : null,
|
|
1400
|
+
navigation ? /* @__PURE__ */ jsx16("div", { className: "frankie-host-navigation", children: navigation }) : null
|
|
1401
|
+
] });
|
|
1402
|
+
return /* @__PURE__ */ jsxs6("div", { className: "frankie-page-shell" + (resolveMode(theme?.mode) === "dark" ? " dark" : ""), style: themeToCssVars(theme), children: [
|
|
1403
|
+
showHistory ? /* @__PURE__ */ jsx16("aside", { className: "frankie-history-desktop", children: history }) : null,
|
|
1404
|
+
/* @__PURE__ */ jsxs6("div", { className: "frankie-page-main", children: [
|
|
1405
|
+
/* @__PURE__ */ jsxs6("header", { className: "frankie-page-header", children: [
|
|
1406
|
+
/* @__PURE__ */ jsxs6("div", { children: [
|
|
1407
|
+
showHistory ? /* @__PURE__ */ jsx16("button", { type: "button", className: "frankie-mobile-only", ref: menu, "aria-label": "Open conversations", "aria-haspopup": "dialog", "aria-expanded": drawerOpen, onClick: () => setDrawerOpen(true), children: /* @__PURE__ */ jsx16(Menu, { size: 20 }) }) : null,
|
|
1408
|
+
/* @__PURE__ */ jsx16("strong", { children: name }),
|
|
1409
|
+
/* @__PURE__ */ jsx16("span", { className: "frankie-header-caption", children: "Product assistant" })
|
|
1410
|
+
] }),
|
|
1411
|
+
/* @__PURE__ */ jsxs6("div", { children: [
|
|
1412
|
+
/* @__PURE__ */ jsxs6("button", { type: "button", disabled: busy, onClick: onNew, "aria-label": "Start a new conversation", children: [
|
|
1413
|
+
/* @__PURE__ */ jsx16(Plus, { size: 16 }),
|
|
1414
|
+
/* @__PURE__ */ jsx16("span", { children: "New chat" })
|
|
1415
|
+
] }),
|
|
1416
|
+
/* @__PURE__ */ jsxs6("details", { className: "frankie-chat-settings", children: [
|
|
1417
|
+
/* @__PURE__ */ jsx16("summary", { "aria-label": "Chat settings", children: /* @__PURE__ */ jsx16(Settings, { size: 19 }) }),
|
|
1418
|
+
/* @__PURE__ */ jsxs6("div", { children: [
|
|
1419
|
+
/* @__PURE__ */ jsx16("strong", { children: "Chat settings" }),
|
|
1420
|
+
/* @__PURE__ */ jsx16("p", { children: "History syncs automatically for signed-in users." }),
|
|
1421
|
+
/* @__PURE__ */ jsxs6("button", { type: "button", onClick: () => void refresh(), disabled: historyLoading || !showHistory, children: [
|
|
1422
|
+
/* @__PURE__ */ jsx16(History, { size: 16 }),
|
|
1423
|
+
"Refresh history"
|
|
1424
|
+
] }),
|
|
1425
|
+
/* @__PURE__ */ jsx16("small", { children: "Skills are checked at the start of each message." })
|
|
1426
|
+
] })
|
|
1427
|
+
] })
|
|
1428
|
+
] })
|
|
1429
|
+
] }),
|
|
1430
|
+
/* @__PURE__ */ jsx16("div", { className: "frankie-page-canvas", children: /* @__PURE__ */ jsx16(FrankieInline, { ...panel, header: panel.header ?? false, emptyState: panel.emptyState ?? /* @__PURE__ */ jsx16(PageEmptyState, {}) }) })
|
|
1431
|
+
] }),
|
|
1432
|
+
showHistory ? /* @__PURE__ */ jsx16("dialog", { ref: dialog, className: "frankie-history-dialog frankie-root", "aria-label": "Chat conversations", style: themeToCssVars(theme), onCancel: close, onClick: (event) => {
|
|
1433
|
+
if (event.target === event.currentTarget) close();
|
|
1434
|
+
}, children: /* @__PURE__ */ jsx16("aside", { children: history }) }) : null
|
|
1435
|
+
] });
|
|
1436
|
+
}
|
|
1437
|
+
function FrankieChatPage({ className, style, header, footer, emptyState, renderMessage, renderTool, placeholder, autoFocus, showHistory = true, navigation, ...provider }) {
|
|
1438
|
+
const [id, setId] = useState7(() => provider.conversationId ?? crypto.randomUUID());
|
|
1439
|
+
const onNew = () => setId(crypto.randomUUID());
|
|
1440
|
+
return /* @__PURE__ */ jsx16("div", { className: ["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" "), style: { height: "100%", minHeight: "min(36rem, 100dvh)", width: "100%", ...style }, children: /* @__PURE__ */ jsx16(FrankieProvider, { ...provider, conversationId: id, storageNamespace: (provider.storageNamespace ?? "frankie." + provider.publishableKey) + "." + id, children: /* @__PURE__ */ jsx16(PageChrome, { showHistory, navigation, currentId: id, onSelect: setId, onNew, header, footer, emptyState, renderMessage, renderTool, placeholder, autoFocus }) }) });
|
|
1279
1441
|
}
|
|
1280
1442
|
|
|
1281
1443
|
// src/components/ui/separator.tsx
|