@frankieai/react 0.1.0 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/frankie.css +1 -1
- package/dist/index.cjs +396 -72
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +33 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.cts +71 -21
- package/dist/index.d.ts +71 -21
- package/dist/index.js +397 -74
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/ChatPanel.tsx +15 -3
- package/src/components/chat/Composer.tsx +190 -27
- package/src/components/chat/FrankieChatPage.tsx +53 -0
- package/src/components/chat/MessageBubble.tsx +39 -6
- package/src/components/chat/ToolActivityList.tsx +10 -6
- package/src/components/chat/message-actions.css +13 -0
- package/src/hooks/FrankieProvider.tsx +1 -9
- package/src/hooks/useFrankieChat.ts +91 -23
- package/src/index.ts +4 -2
- package/src/lib/client.ts +92 -16
- package/src/lib/messages.ts +9 -2
- package/src/styles/frankie.css +12 -4
- package/src/widget.test.tsx +4 -0
package/dist/index.js
CHANGED
|
@@ -28,13 +28,16 @@ var FrankieClient = class {
|
|
|
28
28
|
get sessionId() {
|
|
29
29
|
return this.session?.sessionId;
|
|
30
30
|
}
|
|
31
|
+
get conversationId() {
|
|
32
|
+
return this.options.conversationId;
|
|
33
|
+
}
|
|
31
34
|
/** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
|
|
32
35
|
get persist() {
|
|
33
36
|
return this.options.persist !== false && typeof window !== "undefined";
|
|
34
37
|
}
|
|
35
|
-
/** localStorage namespace for this tenant, shared with the chat hook. */
|
|
38
|
+
/** localStorage namespace for this tenant/conversation, shared with the chat hook. */
|
|
36
39
|
get namespace() {
|
|
37
|
-
return `frankie.${this.options.publishableKey}`;
|
|
40
|
+
return this.options.storageNamespace ?? `frankie.${this.options.publishableKey}`;
|
|
38
41
|
}
|
|
39
42
|
readStored() {
|
|
40
43
|
if (!this.persist) return void 0;
|
|
@@ -62,36 +65,88 @@ var FrankieClient = class {
|
|
|
62
65
|
});
|
|
63
66
|
return await this.unwrap(response);
|
|
64
67
|
}
|
|
65
|
-
/** Opens (or reuses) a chat session. */
|
|
66
68
|
async ensureSession(context) {
|
|
67
|
-
if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 3e4)
|
|
68
|
-
return this.session;
|
|
69
|
-
}
|
|
69
|
+
if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 3e4) return this.session;
|
|
70
70
|
const userToken = await this.options.getUserToken?.();
|
|
71
|
+
if (this.options.getUserToken && !userToken) {
|
|
72
|
+
throw new FrankieApiError(
|
|
73
|
+
"Your signed-in Frankie session could not be verified. Refresh the page and sign in again.",
|
|
74
|
+
401,
|
|
75
|
+
"user_token_unavailable"
|
|
76
|
+
);
|
|
77
|
+
}
|
|
71
78
|
const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
|
|
72
79
|
method: "POST",
|
|
73
80
|
headers: { "content-type": "application/json", "x-frankie-key": this.options.publishableKey },
|
|
74
|
-
body: JSON.stringify({
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
...userToken ? { userToken } : {},
|
|
83
|
+
...context ? { context } : {},
|
|
84
|
+
...this.options.conversationId ? { conversationId: this.options.conversationId } : {}
|
|
85
|
+
})
|
|
75
86
|
});
|
|
76
87
|
this.session = await this.unwrap(response);
|
|
77
88
|
this.writeStored();
|
|
78
89
|
return this.session;
|
|
79
90
|
}
|
|
80
|
-
|
|
91
|
+
async listConversations() {
|
|
92
|
+
const userToken = await this.requireUserToken();
|
|
93
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/conversations`, {
|
|
94
|
+
headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` }
|
|
95
|
+
});
|
|
96
|
+
const body = await this.unwrap(response);
|
|
97
|
+
return Array.isArray(body.conversations) ? body.conversations : [];
|
|
98
|
+
}
|
|
99
|
+
async resumeConversation(id = this.options.conversationId) {
|
|
100
|
+
if (!id) return void 0;
|
|
101
|
+
const userToken = await this.requireUserToken();
|
|
102
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}/resume`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` }
|
|
105
|
+
});
|
|
106
|
+
if (response.status === 404) return void 0;
|
|
107
|
+
const body = await this.unwrap(response);
|
|
108
|
+
this.session = body.session;
|
|
109
|
+
this.writeStored();
|
|
110
|
+
return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat" };
|
|
111
|
+
}
|
|
112
|
+
async syncConversation(messages, title) {
|
|
113
|
+
if (!this.session || !this.options.conversationId) return;
|
|
114
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(this.options.conversationId)}`, {
|
|
115
|
+
method: "PUT",
|
|
116
|
+
headers: {
|
|
117
|
+
"content-type": "application/json",
|
|
118
|
+
"x-frankie-key": this.options.publishableKey,
|
|
119
|
+
authorization: `Bearer ${this.session.sessionToken}`
|
|
120
|
+
},
|
|
121
|
+
body: JSON.stringify({ messages, ...title ? { title } : {} })
|
|
122
|
+
});
|
|
123
|
+
await this.unwrap(response);
|
|
124
|
+
}
|
|
125
|
+
async deleteConversation(id) {
|
|
126
|
+
const userToken = await this.requireUserToken();
|
|
127
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}`, {
|
|
128
|
+
method: "DELETE",
|
|
129
|
+
headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` }
|
|
130
|
+
});
|
|
131
|
+
if (response.status !== 404) await this.unwrap(response);
|
|
132
|
+
}
|
|
133
|
+
async requireUserToken() {
|
|
134
|
+
const token = await this.options.getUserToken?.();
|
|
135
|
+
if (!token) throw new FrankieApiError("Sign in to access your chat history.", 401, "unauthorized");
|
|
136
|
+
return token;
|
|
137
|
+
}
|
|
81
138
|
resetSession() {
|
|
82
139
|
this.session = void 0;
|
|
83
140
|
this.writeStored();
|
|
84
141
|
}
|
|
85
|
-
/**
|
|
86
|
-
* Sends a message and invokes `onEvent` for each server event as it arrives.
|
|
87
|
-
* Resolves when the turn ends.
|
|
88
|
-
*/
|
|
89
142
|
async send(input, onEvent) {
|
|
90
143
|
const session = await this.ensureSession(input.context);
|
|
91
144
|
const body = {
|
|
92
145
|
sessionId: session.sessionId,
|
|
93
146
|
message: input.message,
|
|
94
|
-
...input.context ? { context: input.context } : {}
|
|
147
|
+
...input.context ? { context: input.context } : {},
|
|
148
|
+
...input.model ? { model: input.model } : {},
|
|
149
|
+
...input.attachments?.length ? { attachments: input.attachments } : {}
|
|
95
150
|
};
|
|
96
151
|
const response = await this.doFetch(`${this.baseUrl}/v1/chat`, {
|
|
97
152
|
method: "POST",
|
|
@@ -169,9 +224,8 @@ function FrankieProvider({
|
|
|
169
224
|
}) {
|
|
170
225
|
const client = useMemo(
|
|
171
226
|
() => new FrankieClient(clientOptions),
|
|
172
|
-
// The client holds session state; rebuild it only when the endpoint changes.
|
|
173
227
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
174
|
-
[clientOptions.baseUrl, clientOptions.publishableKey]
|
|
228
|
+
[clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId]
|
|
175
229
|
);
|
|
176
230
|
const [config, setConfig] = useState(providedConfig);
|
|
177
231
|
const [configError, setConfigError] = useState(void 0);
|
|
@@ -285,8 +339,8 @@ function fallbackText(stopReason) {
|
|
|
285
339
|
function newAssistantMessage(id) {
|
|
286
340
|
return { id, role: "assistant", text: "", thinking: "", tools: [], status: "streaming", createdAt: Date.now() };
|
|
287
341
|
}
|
|
288
|
-
function newUserMessage(id, text) {
|
|
289
|
-
return { id, role: "user", text, createdAt: Date.now() };
|
|
342
|
+
function newUserMessage(id, text, attachments) {
|
|
343
|
+
return { id, role: "user", text, ...attachments?.length ? { attachments } : {}, createdAt: Date.now() };
|
|
290
344
|
}
|
|
291
345
|
|
|
292
346
|
// src/hooks/useFrankieChat.ts
|
|
@@ -297,6 +351,7 @@ function useFrankieChat() {
|
|
|
297
351
|
const [messages, setMessages] = useState2([]);
|
|
298
352
|
const [busy, setBusy] = useState2(false);
|
|
299
353
|
const [error, setError] = useState2(void 0);
|
|
354
|
+
const [restored, setRestored] = useState2(false);
|
|
300
355
|
const abortRef = useRef(null);
|
|
301
356
|
const mounted = useRef(true);
|
|
302
357
|
useEffect2(() => {
|
|
@@ -308,22 +363,38 @@ function useFrankieChat() {
|
|
|
308
363
|
}, []);
|
|
309
364
|
const storageKey = `${client.namespace}.messages`;
|
|
310
365
|
useEffect2(() => {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
366
|
+
let active = true;
|
|
367
|
+
const restore = async () => {
|
|
368
|
+
if (client.persist) {
|
|
369
|
+
try {
|
|
370
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
371
|
+
const stored = raw ? JSON.parse(raw) : [];
|
|
372
|
+
if (Array.isArray(stored) && stored.length && active) setMessages(stored);
|
|
373
|
+
} catch {
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
const remote = await client.resumeConversation();
|
|
378
|
+
if (active && remote?.messages.length) setMessages(remote.messages);
|
|
379
|
+
} catch {
|
|
380
|
+
} finally {
|
|
381
|
+
if (active) setRestored(true);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
void restore();
|
|
385
|
+
return () => {
|
|
386
|
+
active = false;
|
|
387
|
+
};
|
|
388
|
+
}, [client, storageKey]);
|
|
319
389
|
useEffect2(() => {
|
|
320
|
-
if (!client.persist || busy) return;
|
|
390
|
+
if (!client.persist || !restored || busy) return;
|
|
321
391
|
try {
|
|
322
392
|
if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
|
|
323
393
|
else window.localStorage.removeItem(storageKey);
|
|
324
394
|
} catch {
|
|
325
395
|
}
|
|
326
|
-
|
|
396
|
+
if (messages.length) void client.syncConversation(messages, conversationTitle(messages)).catch(() => void 0);
|
|
397
|
+
}, [messages, busy, client, storageKey, restored]);
|
|
327
398
|
const patchAssistant = useCallback((id, update) => {
|
|
328
399
|
setMessages(
|
|
329
400
|
(current) => current.map(
|
|
@@ -332,24 +403,50 @@ function useFrankieChat() {
|
|
|
332
403
|
);
|
|
333
404
|
}, []);
|
|
334
405
|
const send = useCallback(
|
|
335
|
-
async (text) => {
|
|
406
|
+
async (text, opts) => {
|
|
336
407
|
const trimmed = text.trim();
|
|
337
|
-
|
|
408
|
+
const attachments = opts?.attachments ?? [];
|
|
409
|
+
if (!trimmed && attachments.length === 0 || busy) return;
|
|
410
|
+
const outgoing = trimmed || "Please take a look at the attached file(s).";
|
|
338
411
|
const assistantId = nextId("a");
|
|
339
412
|
setError(void 0);
|
|
340
413
|
setBusy(true);
|
|
341
414
|
setMessages((current) => [
|
|
342
415
|
...current,
|
|
343
|
-
newUserMessage(
|
|
416
|
+
newUserMessage(
|
|
417
|
+
nextId("u"),
|
|
418
|
+
trimmed,
|
|
419
|
+
attachments.map((file) => ({ name: file.name, mimeType: file.mimeType }))
|
|
420
|
+
),
|
|
344
421
|
newAssistantMessage(assistantId)
|
|
345
422
|
]);
|
|
346
423
|
const abort = new AbortController();
|
|
347
424
|
abortRef.current = abort;
|
|
348
425
|
try {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
426
|
+
let receivedText = false;
|
|
427
|
+
let receivedToolActivity = false;
|
|
428
|
+
await client.send(
|
|
429
|
+
{
|
|
430
|
+
message: outgoing,
|
|
431
|
+
...context ? { context } : {},
|
|
432
|
+
...opts?.model ? { model: opts.model } : {},
|
|
433
|
+
...attachments.length ? { attachments } : {},
|
|
434
|
+
signal: abort.signal
|
|
435
|
+
},
|
|
436
|
+
(event) => {
|
|
437
|
+
if (event.type === "ping" || event.type === "session") return;
|
|
438
|
+
if (event.type === "text_delta") receivedText = true;
|
|
439
|
+
if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
|
|
440
|
+
patchAssistant(assistantId, (message) => applyEvent(message, event));
|
|
441
|
+
}
|
|
442
|
+
);
|
|
443
|
+
if (!receivedText) {
|
|
444
|
+
patchAssistant(assistantId, (message) => ({
|
|
445
|
+
...message,
|
|
446
|
+
status: "done",
|
|
447
|
+
text: receivedToolActivity ? "Completed. Open Agent activity for the details." : "Frankie didn\u2019t return an answer. Please try again."
|
|
448
|
+
}));
|
|
449
|
+
}
|
|
353
450
|
} catch (caught) {
|
|
354
451
|
if (abort.signal.aborted) {
|
|
355
452
|
patchAssistant(assistantId, (message) => ({
|
|
@@ -358,7 +455,7 @@ function useFrankieChat() {
|
|
|
358
455
|
text: message.text || "Stopped."
|
|
359
456
|
}));
|
|
360
457
|
} else {
|
|
361
|
-
const message = caught instanceof FrankieApiError ? caught.message : "Could not reach the assistant. Check your connection and try again.";
|
|
458
|
+
const message = caught instanceof FrankieApiError ? caught.message : caught instanceof Error && caught.message.startsWith("Frankie") ? caught.message : "Could not reach the assistant. Check your connection and try again.";
|
|
362
459
|
if (mounted.current) setError(message);
|
|
363
460
|
patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
|
|
364
461
|
}
|
|
@@ -382,6 +479,7 @@ function useFrankieChat() {
|
|
|
382
479
|
setMessages([]);
|
|
383
480
|
setError(void 0);
|
|
384
481
|
setBusy(false);
|
|
482
|
+
setRestored(true);
|
|
385
483
|
try {
|
|
386
484
|
if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
|
|
387
485
|
} catch {
|
|
@@ -389,10 +487,16 @@ function useFrankieChat() {
|
|
|
389
487
|
}, [client]);
|
|
390
488
|
return { messages, busy, ...error ? { error } : {}, send, stop, reset };
|
|
391
489
|
}
|
|
490
|
+
function conversationTitle(messages) {
|
|
491
|
+
const first = messages.find((message) => message.role === "user" && message.text.trim());
|
|
492
|
+
if (!first) return void 0;
|
|
493
|
+
const title = first.text.trim().replace(/\s+/g, " ");
|
|
494
|
+
return title.length > 42 ? `${title.slice(0, 42)}\u2026` : title;
|
|
495
|
+
}
|
|
392
496
|
|
|
393
497
|
// src/components/chat/FrankieWidget.tsx
|
|
394
|
-
import { MessageCircle, X as
|
|
395
|
-
import { useEffect as useEffect5, useState as
|
|
498
|
+
import { MessageCircle, X as X4 } from "lucide-react";
|
|
499
|
+
import { useEffect as useEffect5, useState as useState6 } from "react";
|
|
396
500
|
|
|
397
501
|
// src/lib/theme.ts
|
|
398
502
|
function themeToCssVars(theme) {
|
|
@@ -452,8 +556,8 @@ function Button({ className, variant, size, asChild = false, ...props }) {
|
|
|
452
556
|
}
|
|
453
557
|
|
|
454
558
|
// src/components/chat/ChatPanel.tsx
|
|
455
|
-
import { RotateCcw, X as
|
|
456
|
-
import { useEffect as useEffect4, useRef as useRef3 } from "react";
|
|
559
|
+
import { RotateCcw, X as X3 } from "lucide-react";
|
|
560
|
+
import { useEffect as useEffect4, useRef as useRef3, useState as useState5 } from "react";
|
|
457
561
|
|
|
458
562
|
// src/components/ui/avatar.tsx
|
|
459
563
|
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
@@ -512,7 +616,7 @@ var ScrollArea = forwardRef(
|
|
|
512
616
|
);
|
|
513
617
|
|
|
514
618
|
// src/components/chat/Composer.tsx
|
|
515
|
-
import { ArrowUp, Square } from "lucide-react";
|
|
619
|
+
import { ArrowUp, Paperclip, Square, X } from "lucide-react";
|
|
516
620
|
import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
|
|
517
621
|
|
|
518
622
|
// src/components/ui/textarea.tsx
|
|
@@ -535,60 +639,190 @@ var Textarea = forwardRef2(
|
|
|
535
639
|
);
|
|
536
640
|
|
|
537
641
|
// src/components/chat/Composer.tsx
|
|
538
|
-
import { jsx as jsx7, jsxs } from "react/jsx-runtime";
|
|
642
|
+
import { Fragment, jsx as jsx7, jsxs } from "react/jsx-runtime";
|
|
539
643
|
var MAX_ROWS_PX = 140;
|
|
540
|
-
function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus }) {
|
|
644
|
+
function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, attachments, modelPicker }) {
|
|
541
645
|
const [value, setValue] = useState3("");
|
|
646
|
+
const [files, setFiles] = useState3([]);
|
|
647
|
+
const [fileError, setFileError] = useState3(void 0);
|
|
542
648
|
const textareaRef = useRef2(null);
|
|
649
|
+
const fileInputRef = useRef2(null);
|
|
650
|
+
const canAttach = Boolean(attachments?.enabled);
|
|
651
|
+
const models = modelPicker?.models ?? [];
|
|
543
652
|
useEffect3(() => {
|
|
544
653
|
const node = textareaRef.current;
|
|
545
654
|
if (!node) return;
|
|
546
655
|
node.style.height = "auto";
|
|
547
656
|
node.style.height = `${Math.min(node.scrollHeight, MAX_ROWS_PX)}px`;
|
|
548
657
|
}, [value]);
|
|
658
|
+
const addFiles = useCallback2(
|
|
659
|
+
async (list) => {
|
|
660
|
+
if (!list || !attachments) return;
|
|
661
|
+
setFileError(void 0);
|
|
662
|
+
const room = attachments.maxCount - files.length;
|
|
663
|
+
const chosen = Array.from(list).slice(0, Math.max(0, room));
|
|
664
|
+
if (Array.from(list).length > room) setFileError(`You can attach up to ${attachments.maxCount} file(s).`);
|
|
665
|
+
const next = [];
|
|
666
|
+
for (const file of chosen) {
|
|
667
|
+
if (!mimeAccepted(file.type || "application/octet-stream", attachments.accept)) {
|
|
668
|
+
setFileError(`Files of type ${file.type || "unknown"} aren't allowed.`);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (file.size > attachments.maxMb * 1024 * 1024) {
|
|
672
|
+
setFileError(`Each file must be under ${attachments.maxMb} MB.`);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
next.push({
|
|
677
|
+
id: `${file.name}-${file.size}-${Math.random().toString(36).slice(2, 7)}`,
|
|
678
|
+
name: file.name,
|
|
679
|
+
mimeType: file.type || "application/octet-stream",
|
|
680
|
+
data: await readAsBase64(file)
|
|
681
|
+
});
|
|
682
|
+
} catch {
|
|
683
|
+
setFileError("Couldn't read that file.");
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (next.length) setFiles((current) => [...current, ...next]);
|
|
687
|
+
},
|
|
688
|
+
[attachments, files.length]
|
|
689
|
+
);
|
|
690
|
+
const removeFile = (id) => setFiles((current) => current.filter((file) => file.id !== id));
|
|
549
691
|
const submit = useCallback2(() => {
|
|
550
692
|
const text = value.trim();
|
|
551
|
-
if (!text || busy || disabled) return;
|
|
693
|
+
if (!text && files.length === 0 || busy || disabled) return;
|
|
552
694
|
setValue("");
|
|
553
|
-
|
|
554
|
-
|
|
695
|
+
const attached = files.map(({ name, mimeType, data }) => ({ name, mimeType, data }));
|
|
696
|
+
setFiles([]);
|
|
697
|
+
setFileError(void 0);
|
|
698
|
+
onSend(text, {
|
|
699
|
+
...attached.length ? { attachments: attached } : {},
|
|
700
|
+
...modelPicker?.value ? { model: modelPicker.value } : {}
|
|
701
|
+
});
|
|
702
|
+
}, [busy, disabled, files, modelPicker?.value, onSend, value]);
|
|
555
703
|
const onKeyDown = (event) => {
|
|
556
704
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
557
705
|
event.preventDefault();
|
|
558
706
|
submit();
|
|
559
707
|
}
|
|
560
708
|
};
|
|
709
|
+
const nothingToSend = value.trim().length === 0 && files.length === 0;
|
|
561
710
|
return /* @__PURE__ */ jsxs(
|
|
562
711
|
"form",
|
|
563
712
|
{
|
|
564
|
-
className: "flex
|
|
713
|
+
className: "flex flex-col gap-2",
|
|
565
714
|
onSubmit: (event) => {
|
|
566
715
|
event.preventDefault();
|
|
567
716
|
submit();
|
|
568
717
|
},
|
|
569
718
|
children: [
|
|
570
|
-
/* @__PURE__ */ jsx7(
|
|
571
|
-
|
|
719
|
+
files.length > 0 ? /* @__PURE__ */ jsx7("div", { className: "flex flex-wrap gap-2", children: files.map((file) => /* @__PURE__ */ jsxs(
|
|
720
|
+
"span",
|
|
572
721
|
{
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
722
|
+
className: "inline-flex max-w-[14rem] items-center gap-1.5 rounded-[var(--frankie-radius)] border border-border bg-muted px-2 py-1 text-xs",
|
|
723
|
+
children: [
|
|
724
|
+
/* @__PURE__ */ jsx7(Paperclip, { className: "size-3 shrink-0 opacity-70" }),
|
|
725
|
+
/* @__PURE__ */ jsx7("span", { className: "truncate", children: file.name }),
|
|
726
|
+
/* @__PURE__ */ jsx7(
|
|
727
|
+
"button",
|
|
728
|
+
{
|
|
729
|
+
type: "button",
|
|
730
|
+
onClick: () => removeFile(file.id),
|
|
731
|
+
"aria-label": `Remove ${file.name}`,
|
|
732
|
+
className: "frankie-focus shrink-0 opacity-60 hover:opacity-100",
|
|
733
|
+
children: /* @__PURE__ */ jsx7(X, { className: "size-3" })
|
|
734
|
+
}
|
|
735
|
+
)
|
|
736
|
+
]
|
|
737
|
+
},
|
|
738
|
+
file.id
|
|
739
|
+
)) }) : null,
|
|
740
|
+
fileError ? /* @__PURE__ */ jsx7("p", { className: "text-xs text-red-600 dark:text-red-400", children: fileError }) : null,
|
|
741
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-end gap-2", children: [
|
|
742
|
+
canAttach ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
743
|
+
/* @__PURE__ */ jsx7(
|
|
744
|
+
"input",
|
|
745
|
+
{
|
|
746
|
+
ref: fileInputRef,
|
|
747
|
+
type: "file",
|
|
748
|
+
multiple: true,
|
|
749
|
+
hidden: true,
|
|
750
|
+
accept: attachments?.accept.join(","),
|
|
751
|
+
onChange: (event) => {
|
|
752
|
+
void addFiles(event.target.files);
|
|
753
|
+
event.target.value = "";
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
),
|
|
757
|
+
/* @__PURE__ */ jsx7(
|
|
758
|
+
Button,
|
|
759
|
+
{
|
|
760
|
+
type: "button",
|
|
761
|
+
size: "icon",
|
|
762
|
+
variant: "outline",
|
|
763
|
+
disabled: disabled || files.length >= (attachments?.maxCount ?? 0),
|
|
764
|
+
onClick: () => fileInputRef.current?.click(),
|
|
765
|
+
"aria-label": "Attach files",
|
|
766
|
+
title: "Attach files",
|
|
767
|
+
children: /* @__PURE__ */ jsx7(Paperclip, {})
|
|
768
|
+
}
|
|
769
|
+
)
|
|
770
|
+
] }) : null,
|
|
771
|
+
/* @__PURE__ */ jsx7(
|
|
772
|
+
Textarea,
|
|
773
|
+
{
|
|
774
|
+
ref: textareaRef,
|
|
775
|
+
rows: 1,
|
|
776
|
+
value,
|
|
777
|
+
disabled,
|
|
778
|
+
autoFocus,
|
|
779
|
+
onChange: (event) => setValue(event.target.value),
|
|
780
|
+
onKeyDown,
|
|
781
|
+
placeholder: placeholder ?? "Ask anything\u2026",
|
|
782
|
+
"aria-label": "Message"
|
|
783
|
+
}
|
|
784
|
+
),
|
|
785
|
+
models.length > 0 ? /* @__PURE__ */ jsx7(
|
|
786
|
+
"select",
|
|
787
|
+
{
|
|
788
|
+
value: modelPicker?.value ?? models[0]?.id,
|
|
789
|
+
onChange: (event) => modelPicker?.onChange(event.target.value),
|
|
790
|
+
disabled,
|
|
791
|
+
"aria-label": "Model",
|
|
792
|
+
className: "frankie-focus h-9 max-w-[8rem] rounded-[var(--frankie-radius)] border border-border bg-background px-2 text-sm",
|
|
793
|
+
children: models.map((model) => /* @__PURE__ */ jsx7("option", { value: model.id, children: model.label }, model.id))
|
|
794
|
+
}
|
|
795
|
+
) : null,
|
|
796
|
+
busy ? /* @__PURE__ */ jsx7(Button, { type: "button", size: "icon", variant: "outline", onClick: onStop, "aria-label": "Stop", children: /* @__PURE__ */ jsx7(Square, {}) }) : /* @__PURE__ */ jsx7(Button, { type: "submit", size: "icon", disabled: disabled || nothingToSend, "aria-label": "Send", children: /* @__PURE__ */ jsx7(ArrowUp, {}) })
|
|
797
|
+
] })
|
|
585
798
|
]
|
|
586
799
|
}
|
|
587
800
|
);
|
|
588
801
|
}
|
|
802
|
+
function mimeAccepted(mimeType, accept) {
|
|
803
|
+
return accept.some(
|
|
804
|
+
(pattern) => pattern.endsWith("/*") ? mimeType.startsWith(pattern.slice(0, -1)) : pattern === mimeType
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
function readAsBase64(file) {
|
|
808
|
+
return new Promise((resolve, reject) => {
|
|
809
|
+
const reader = new FileReader();
|
|
810
|
+
reader.onload = () => {
|
|
811
|
+
const result = String(reader.result);
|
|
812
|
+
const comma = result.indexOf(",");
|
|
813
|
+
resolve(comma >= 0 ? result.slice(comma + 1) : result);
|
|
814
|
+
};
|
|
815
|
+
reader.onerror = () => reject(reader.error);
|
|
816
|
+
reader.readAsDataURL(file);
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// src/components/chat/MessageBubble.tsx
|
|
821
|
+
import { Check as Check2, Copy, Paperclip as Paperclip2 } from "lucide-react";
|
|
822
|
+
import { useState as useState4 } from "react";
|
|
589
823
|
|
|
590
824
|
// src/components/chat/Markdown.tsx
|
|
591
|
-
import { Fragment } from "react";
|
|
825
|
+
import { Fragment as Fragment2 } from "react";
|
|
592
826
|
import { jsx as jsx8 } from "react/jsx-runtime";
|
|
593
827
|
function Markdown({ text, className }) {
|
|
594
828
|
return /* @__PURE__ */ jsx8("div", { className: cn("frankie-prose", className), children: renderBlocks(text) });
|
|
@@ -673,7 +907,7 @@ function renderInline(text) {
|
|
|
673
907
|
const token = match[0];
|
|
674
908
|
const start = match.index;
|
|
675
909
|
if (start > lastIndex) nodes.push(text.slice(lastIndex, start));
|
|
676
|
-
nodes.push(/* @__PURE__ */ jsx8(
|
|
910
|
+
nodes.push(/* @__PURE__ */ jsx8(Fragment2, { children: renderToken(token) }, start));
|
|
677
911
|
lastIndex = start + token.length;
|
|
678
912
|
}
|
|
679
913
|
if (lastIndex < text.length) nodes.push(text.slice(lastIndex));
|
|
@@ -690,7 +924,7 @@ function renderToken(token) {
|
|
|
690
924
|
}
|
|
691
925
|
|
|
692
926
|
// src/components/chat/ToolActivityList.tsx
|
|
693
|
-
import { Check, Loader2, ShieldAlert, X } from "lucide-react";
|
|
927
|
+
import { Check, ChevronDown, Loader2, ShieldAlert, X as X2 } from "lucide-react";
|
|
694
928
|
|
|
695
929
|
// src/components/ui/badge.tsx
|
|
696
930
|
import { cva as cva2 } from "class-variance-authority";
|
|
@@ -721,7 +955,20 @@ function Badge({
|
|
|
721
955
|
import { jsx as jsx10, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
722
956
|
function ToolActivityList({ tools, renderTool }) {
|
|
723
957
|
if (tools.length === 0) return null;
|
|
724
|
-
|
|
958
|
+
const running = tools.some((tool) => tool.status === "running");
|
|
959
|
+
return /* @__PURE__ */ jsxs2("details", { className: "frankie-tool-activity mt-2", open: running, children: [
|
|
960
|
+
/* @__PURE__ */ jsxs2("summary", { className: "inline-flex min-h-7 cursor-pointer list-none items-center gap-1.5 rounded-md px-1 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground [&::-webkit-details-marker]:hidden", children: [
|
|
961
|
+
/* @__PURE__ */ jsx10(ChevronDown, { className: "frankie-tool-chevron size-3.5 transition-transform", "aria-hidden": "true" }),
|
|
962
|
+
/* @__PURE__ */ jsx10("span", { children: running ? "Working" : "Activity" }),
|
|
963
|
+
/* @__PURE__ */ jsxs2("span", { className: "text-[11px]", children: [
|
|
964
|
+
"\xB7 ",
|
|
965
|
+
tools.length,
|
|
966
|
+
" tool call",
|
|
967
|
+
tools.length === 1 ? "" : "s"
|
|
968
|
+
] })
|
|
969
|
+
] }),
|
|
970
|
+
/* @__PURE__ */ jsx10("ul", { className: "mt-1 flex flex-col gap-1 border-l border-border pl-2", children: tools.map((tool) => /* @__PURE__ */ jsx10("li", { children: renderTool ? renderTool(tool) : /* @__PURE__ */ jsx10(ToolActivityRow, { tool }) }, tool.id)) })
|
|
971
|
+
] });
|
|
725
972
|
}
|
|
726
973
|
function ToolActivityRow({ tool }) {
|
|
727
974
|
return /* @__PURE__ */ jsxs2(Badge, { variant: badgeVariant(tool.status), title: tool.detail ?? tool.name, children: [
|
|
@@ -738,7 +985,7 @@ function ToolIcon({ status }) {
|
|
|
738
985
|
case "denied":
|
|
739
986
|
return /* @__PURE__ */ jsx10(ShieldAlert, { className: "size-3", "aria-hidden": "true" });
|
|
740
987
|
default:
|
|
741
|
-
return /* @__PURE__ */ jsx10(
|
|
988
|
+
return /* @__PURE__ */ jsx10(X2, { className: "size-3", "aria-hidden": "true" });
|
|
742
989
|
}
|
|
743
990
|
}
|
|
744
991
|
function badgeVariant(status) {
|
|
@@ -758,17 +1005,48 @@ function MessageBubble({
|
|
|
758
1005
|
renderTool
|
|
759
1006
|
}) {
|
|
760
1007
|
if (message.role === "user") {
|
|
761
|
-
return /* @__PURE__ */
|
|
1008
|
+
return /* @__PURE__ */ jsxs3("div", { className: "flex flex-col items-end gap-1", children: [
|
|
1009
|
+
message.attachments?.length ? /* @__PURE__ */ jsx11("div", { className: "flex max-w-[85%] flex-wrap justify-end gap-1", children: message.attachments.map((file, index) => /* @__PURE__ */ jsxs3(
|
|
1010
|
+
"span",
|
|
1011
|
+
{
|
|
1012
|
+
className: "inline-flex max-w-[12rem] items-center gap-1 rounded-[var(--frankie-radius)] border border-border bg-muted px-2 py-1 text-xs",
|
|
1013
|
+
children: [
|
|
1014
|
+
/* @__PURE__ */ jsx11(Paperclip2, { className: "size-3 shrink-0 opacity-70" }),
|
|
1015
|
+
/* @__PURE__ */ jsx11("span", { className: "truncate", children: file.name })
|
|
1016
|
+
]
|
|
1017
|
+
},
|
|
1018
|
+
`${file.name}-${index}`
|
|
1019
|
+
)) }) : null,
|
|
1020
|
+
message.text ? /* @__PURE__ */ jsxs3("div", { className: "flex max-w-[85%] items-end gap-1", children: [
|
|
1021
|
+
/* @__PURE__ */ jsx11("div", { className: "rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground", children: message.text }),
|
|
1022
|
+
/* @__PURE__ */ jsx11(MessageCopyButton, { text: message.text })
|
|
1023
|
+
] }) : null
|
|
1024
|
+
] });
|
|
762
1025
|
}
|
|
763
1026
|
const empty = message.text.length === 0 && message.tools.length === 0;
|
|
764
1027
|
return /* @__PURE__ */ jsxs3("div", { className: "flex gap-2", children: [
|
|
765
|
-
/* @__PURE__ */
|
|
1028
|
+
/* @__PURE__ */ jsxs3("div", { className: "frankie-assistant-actions mt-0.5 flex shrink-0 flex-col items-center gap-1", children: [
|
|
1029
|
+
/* @__PURE__ */ jsx11(Avatar, { ...avatarUrl ? { src: avatarUrl } : {}, name: assistantName }),
|
|
1030
|
+
message.text ? /* @__PURE__ */ jsx11(MessageCopyButton, { text: message.text }) : null
|
|
1031
|
+
] }),
|
|
766
1032
|
/* @__PURE__ */ jsxs3("div", { className: "min-w-0 max-w-[85%]", children: [
|
|
767
1033
|
message.status === "error" ? /* @__PURE__ */ jsx11("p", { className: "rounded-[var(--frankie-radius)] bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400", children: message.error }) : empty && message.status === "streaming" ? /* @__PURE__ */ jsx11(TypingDots, {}) : /* @__PURE__ */ jsx11(Markdown, { text: message.text, className: "text-sm" }),
|
|
768
1034
|
showToolActivity ? /* @__PURE__ */ jsx11(ToolActivityList, { tools: message.tools, ...renderTool ? { renderTool } : {} }) : null
|
|
769
1035
|
] })
|
|
770
1036
|
] });
|
|
771
1037
|
}
|
|
1038
|
+
function MessageCopyButton({ text }) {
|
|
1039
|
+
const [copied, setCopied] = useState4(false);
|
|
1040
|
+
const copy = async () => {
|
|
1041
|
+
try {
|
|
1042
|
+
await navigator.clipboard.writeText(text);
|
|
1043
|
+
setCopied(true);
|
|
1044
|
+
window.setTimeout(() => setCopied(false), 1500);
|
|
1045
|
+
} catch {
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
return /* @__PURE__ */ jsx11("button", { type: "button", onClick: () => void copy(), className: "frankie-message-copy inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-transparent text-muted-foreground transition hover:border-border hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary", "aria-label": copied ? "Copied" : "Copy message", title: copied ? "Copied" : "Copy message", children: copied ? /* @__PURE__ */ jsx11(Check2, { className: "size-3.5", "aria-hidden": "true" }) : /* @__PURE__ */ jsx11(Copy, { className: "size-3.5", "aria-hidden": "true" }) });
|
|
1049
|
+
}
|
|
772
1050
|
function TypingDots() {
|
|
773
1051
|
return /* @__PURE__ */ jsx11("span", { className: "inline-flex items-center gap-1 py-2", role: "status", "aria-label": "Thinking", children: [0, 1, 2].map((index) => /* @__PURE__ */ jsx11(
|
|
774
1052
|
"span",
|
|
@@ -820,6 +1098,9 @@ function ChatPanel({
|
|
|
820
1098
|
const theme = useFrankieTheme();
|
|
821
1099
|
const { messages, busy, error, send, stop, reset } = useFrankieChat();
|
|
822
1100
|
const scrollRef = useRef3(null);
|
|
1101
|
+
const features = config?.features;
|
|
1102
|
+
const pickerModels = features?.modelPicker.enabled ? features.modelPicker.models : [];
|
|
1103
|
+
const [model, setModel] = useState5(void 0);
|
|
823
1104
|
useEffect4(() => {
|
|
824
1105
|
const node = scrollRef.current;
|
|
825
1106
|
if (!node) return;
|
|
@@ -847,9 +1128,13 @@ function ChatPanel({
|
|
|
847
1128
|
config?.branding.tagline ? /* @__PURE__ */ jsx13(CardDescription, { children: config.branding.tagline }) : null
|
|
848
1129
|
] }),
|
|
849
1130
|
/* @__PURE__ */ jsx13(Button, { size: "icon", variant: "ghost", onClick: reset, "aria-label": "Start a new conversation", title: "New conversation", children: /* @__PURE__ */ jsx13(RotateCcw, {}) }),
|
|
850
|
-
onClose ? /* @__PURE__ */ jsx13(Button, { size: "icon", variant: "ghost", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ jsx13(
|
|
1131
|
+
onClose ? /* @__PURE__ */ jsx13(Button, { size: "icon", variant: "ghost", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ jsx13(X3, {}) }) : null
|
|
851
1132
|
] }),
|
|
852
|
-
/* @__PURE__ */ jsx13(CardContent, { children: /* @__PURE__ */ jsx13(ScrollArea, { ref: scrollRef, className: "flex h-full flex-col gap-4 p-4", "aria-live": "polite", children: configError ? /* @__PURE__ */
|
|
1133
|
+
/* @__PURE__ */ jsx13(CardContent, { children: /* @__PURE__ */ jsx13(ScrollArea, { ref: scrollRef, className: "flex h-full flex-col gap-4 p-4", "aria-live": "polite", children: configError ? /* @__PURE__ */ jsxs4("div", { className: "frankie-config-error", role: "alert", children: [
|
|
1134
|
+
/* @__PURE__ */ jsx13("strong", { children: "Frankie couldn\u2019t start" }),
|
|
1135
|
+
/* @__PURE__ */ jsx13("p", { children: configError }),
|
|
1136
|
+
/* @__PURE__ */ jsx13("p", { children: "Refresh this page after checking the workspace\u2019s allowed origin and sign-in settings." })
|
|
1137
|
+
] }) : loading ? /* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: "Starting Frankie\u2026" }) : messages.length === 0 ? emptyState ?? /* @__PURE__ */ jsxs4("div", { className: "flex flex-col gap-4", children: [
|
|
853
1138
|
/* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: config?.greeting }),
|
|
854
1139
|
/* @__PURE__ */ jsx13(SuggestedPrompts, { prompts: config?.suggestedPrompts ?? [], onPick: send, disabled: busy })
|
|
855
1140
|
] }) : /* @__PURE__ */ jsx13("div", { className: "flex flex-col gap-4", children: messages.map(
|
|
@@ -875,7 +1160,9 @@ function ChatPanel({
|
|
|
875
1160
|
busy,
|
|
876
1161
|
disabled: Boolean(configError),
|
|
877
1162
|
...placeholder ? { placeholder } : {},
|
|
878
|
-
...autoFocus ? { autoFocus } : {}
|
|
1163
|
+
...autoFocus ? { autoFocus } : {},
|
|
1164
|
+
...features?.attachments.enabled ? { attachments: features.attachments } : {},
|
|
1165
|
+
...pickerModels.length ? { modelPicker: { models: pickerModels, value: model ?? pickerModels[0]?.id, onChange: setModel } } : {}
|
|
879
1166
|
}
|
|
880
1167
|
) }),
|
|
881
1168
|
footer
|
|
@@ -898,7 +1185,7 @@ function FrankieWidget({
|
|
|
898
1185
|
}) {
|
|
899
1186
|
const { config } = useFrankie();
|
|
900
1187
|
const theme = useFrankieTheme();
|
|
901
|
-
const [uncontrolledOpen, setUncontrolledOpen] =
|
|
1188
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState6(defaultOpen);
|
|
902
1189
|
const open = controlledOpen ?? uncontrolledOpen;
|
|
903
1190
|
const setOpen = (next) => {
|
|
904
1191
|
if (controlledOpen === void 0) setUncontrolledOpen(next);
|
|
@@ -932,7 +1219,7 @@ function FrankieWidget({
|
|
|
932
1219
|
"aria-expanded": open,
|
|
933
1220
|
"aria-label": open ? "Close the assistant" : label,
|
|
934
1221
|
title: label,
|
|
935
|
-
children: open ? /* @__PURE__ */ jsx14(
|
|
1222
|
+
children: open ? /* @__PURE__ */ jsx14(X4, { className: "size-5" }) : /* @__PURE__ */ jsx14(MessageCircle, { className: "size-5" })
|
|
936
1223
|
}
|
|
937
1224
|
)
|
|
938
1225
|
] })
|
|
@@ -956,10 +1243,45 @@ function FrankieInline({ className, style, ...panelProps }) {
|
|
|
956
1243
|
);
|
|
957
1244
|
}
|
|
958
1245
|
|
|
959
|
-
// src/components/
|
|
1246
|
+
// src/components/chat/FrankieChatPage.tsx
|
|
960
1247
|
import { jsx as jsx16 } from "react/jsx-runtime";
|
|
1248
|
+
function FrankieChatPage({
|
|
1249
|
+
className,
|
|
1250
|
+
style,
|
|
1251
|
+
header,
|
|
1252
|
+
footer,
|
|
1253
|
+
emptyState,
|
|
1254
|
+
renderMessage,
|
|
1255
|
+
renderTool,
|
|
1256
|
+
placeholder,
|
|
1257
|
+
autoFocus,
|
|
1258
|
+
...providerProps
|
|
1259
|
+
}) {
|
|
1260
|
+
return /* @__PURE__ */ jsx16(
|
|
1261
|
+
"main",
|
|
1262
|
+
{
|
|
1263
|
+
className,
|
|
1264
|
+
style: { height: "100dvh", minHeight: "100dvh", width: "100%", ...style },
|
|
1265
|
+
children: /* @__PURE__ */ jsx16(FrankieProvider, { ...providerProps, children: /* @__PURE__ */ jsx16(
|
|
1266
|
+
FrankieInline,
|
|
1267
|
+
{
|
|
1268
|
+
header,
|
|
1269
|
+
footer,
|
|
1270
|
+
emptyState,
|
|
1271
|
+
renderMessage,
|
|
1272
|
+
renderTool,
|
|
1273
|
+
placeholder,
|
|
1274
|
+
autoFocus
|
|
1275
|
+
}
|
|
1276
|
+
) })
|
|
1277
|
+
}
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// src/components/ui/separator.tsx
|
|
1282
|
+
import { jsx as jsx17 } from "react/jsx-runtime";
|
|
961
1283
|
function Separator({ className }) {
|
|
962
|
-
return /* @__PURE__ */
|
|
1284
|
+
return /* @__PURE__ */ jsx17("div", { role: "separator", className: cn("h-px w-full bg-border", className) });
|
|
963
1285
|
}
|
|
964
1286
|
export {
|
|
965
1287
|
Avatar,
|
|
@@ -975,6 +1297,7 @@ export {
|
|
|
975
1297
|
Composer,
|
|
976
1298
|
FRANKIE_CLOUD_URL,
|
|
977
1299
|
FrankieApiError,
|
|
1300
|
+
FrankieChatPage,
|
|
978
1301
|
FrankieClient,
|
|
979
1302
|
FrankieInline,
|
|
980
1303
|
FrankieProvider,
|