@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
|
@@ -1,24 +1,53 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { ArrowUp, Square } from "lucide-react";
|
|
3
|
+
import { ArrowUp, Paperclip, Square, X } from "lucide-react";
|
|
4
4
|
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react";
|
|
5
|
+
import type { ChatAttachment } from "@frankieai/core";
|
|
5
6
|
import { Button } from "../ui/button.js";
|
|
6
7
|
import { Textarea } from "../ui/textarea.js";
|
|
7
8
|
|
|
9
|
+
export interface ComposerAttachmentsConfig {
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
/** Accepted MIME patterns, e.g. "image/*". */
|
|
12
|
+
accept: string[];
|
|
13
|
+
maxCount: number;
|
|
14
|
+
maxMb: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ComposerModelPicker {
|
|
18
|
+
models: { id: string; label: string }[];
|
|
19
|
+
value?: string;
|
|
20
|
+
onChange: (id: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
8
23
|
export interface ComposerProps {
|
|
9
|
-
onSend: (text: string) => void;
|
|
24
|
+
onSend: (text: string, opts?: { attachments?: ChatAttachment[]; model?: string }) => void;
|
|
10
25
|
onStop: () => void;
|
|
11
26
|
busy: boolean;
|
|
12
27
|
placeholder?: string;
|
|
13
28
|
disabled?: boolean;
|
|
14
29
|
autoFocus?: boolean;
|
|
30
|
+
/** Enables the attach button when provided and `enabled`. */
|
|
31
|
+
attachments?: ComposerAttachmentsConfig;
|
|
32
|
+
/** Enables a model dropdown when provided with at least one model. */
|
|
33
|
+
modelPicker?: ComposerModelPicker;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface PickedFile extends ChatAttachment {
|
|
37
|
+
id: string;
|
|
15
38
|
}
|
|
16
39
|
|
|
17
40
|
const MAX_ROWS_PX = 140;
|
|
18
41
|
|
|
19
|
-
export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus }: ComposerProps) {
|
|
42
|
+
export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, attachments, modelPicker }: ComposerProps) {
|
|
20
43
|
const [value, setValue] = useState("");
|
|
44
|
+
const [files, setFiles] = useState<PickedFile[]>([]);
|
|
45
|
+
const [fileError, setFileError] = useState<string | undefined>(undefined);
|
|
21
46
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
47
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
48
|
+
|
|
49
|
+
const canAttach = Boolean(attachments?.enabled);
|
|
50
|
+
const models = modelPicker?.models ?? [];
|
|
22
51
|
|
|
23
52
|
// Grow with the content up to a cap, then scroll.
|
|
24
53
|
useEffect(() => {
|
|
@@ -28,12 +57,54 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
|
|
|
28
57
|
node.style.height = `${Math.min(node.scrollHeight, MAX_ROWS_PX)}px`;
|
|
29
58
|
}, [value]);
|
|
30
59
|
|
|
60
|
+
const addFiles = useCallback(
|
|
61
|
+
async (list: FileList | null) => {
|
|
62
|
+
if (!list || !attachments) return;
|
|
63
|
+
setFileError(undefined);
|
|
64
|
+
const room = attachments.maxCount - files.length;
|
|
65
|
+
const chosen = Array.from(list).slice(0, Math.max(0, room));
|
|
66
|
+
if (Array.from(list).length > room) setFileError(`You can attach up to ${attachments.maxCount} file(s).`);
|
|
67
|
+
|
|
68
|
+
const next: PickedFile[] = [];
|
|
69
|
+
for (const file of chosen) {
|
|
70
|
+
if (!mimeAccepted(file.type || "application/octet-stream", attachments.accept)) {
|
|
71
|
+
setFileError(`Files of type ${file.type || "unknown"} aren't allowed.`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (file.size > attachments.maxMb * 1024 * 1024) {
|
|
75
|
+
setFileError(`Each file must be under ${attachments.maxMb} MB.`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
next.push({
|
|
80
|
+
id: `${file.name}-${file.size}-${Math.random().toString(36).slice(2, 7)}`,
|
|
81
|
+
name: file.name,
|
|
82
|
+
mimeType: file.type || "application/octet-stream",
|
|
83
|
+
data: await readAsBase64(file),
|
|
84
|
+
});
|
|
85
|
+
} catch {
|
|
86
|
+
setFileError("Couldn't read that file.");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (next.length) setFiles((current) => [...current, ...next]);
|
|
90
|
+
},
|
|
91
|
+
[attachments, files.length],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const removeFile = (id: string) => setFiles((current) => current.filter((file) => file.id !== id));
|
|
95
|
+
|
|
31
96
|
const submit = useCallback(() => {
|
|
32
97
|
const text = value.trim();
|
|
33
|
-
if (!text || busy || disabled) return;
|
|
98
|
+
if ((!text && files.length === 0) || busy || disabled) return;
|
|
34
99
|
setValue("");
|
|
35
|
-
|
|
36
|
-
|
|
100
|
+
const attached = files.map(({ name, mimeType, data }) => ({ name, mimeType, data }));
|
|
101
|
+
setFiles([]);
|
|
102
|
+
setFileError(undefined);
|
|
103
|
+
onSend(text, {
|
|
104
|
+
...(attached.length ? { attachments: attached } : {}),
|
|
105
|
+
...(modelPicker?.value ? { model: modelPicker.value } : {}),
|
|
106
|
+
});
|
|
107
|
+
}, [busy, disabled, files, modelPicker?.value, onSend, value]);
|
|
37
108
|
|
|
38
109
|
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
39
110
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
@@ -42,34 +113,126 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
|
|
|
42
113
|
}
|
|
43
114
|
};
|
|
44
115
|
|
|
116
|
+
const nothingToSend = value.trim().length === 0 && files.length === 0;
|
|
117
|
+
|
|
45
118
|
return (
|
|
46
119
|
<form
|
|
47
|
-
className="flex
|
|
120
|
+
className="flex flex-col gap-2"
|
|
48
121
|
onSubmit={(event) => {
|
|
49
122
|
event.preventDefault();
|
|
50
123
|
submit();
|
|
51
124
|
}}
|
|
52
125
|
>
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
126
|
+
{files.length > 0 ? (
|
|
127
|
+
<div className="flex flex-wrap gap-2">
|
|
128
|
+
{files.map((file) => (
|
|
129
|
+
<span
|
|
130
|
+
key={file.id}
|
|
131
|
+
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"
|
|
132
|
+
>
|
|
133
|
+
<Paperclip className="size-3 shrink-0 opacity-70" />
|
|
134
|
+
<span className="truncate">{file.name}</span>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
onClick={() => removeFile(file.id)}
|
|
138
|
+
aria-label={`Remove ${file.name}`}
|
|
139
|
+
className="frankie-focus shrink-0 opacity-60 hover:opacity-100"
|
|
140
|
+
>
|
|
141
|
+
<X className="size-3" />
|
|
142
|
+
</button>
|
|
143
|
+
</span>
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
) : null}
|
|
147
|
+
|
|
148
|
+
{fileError ? <p className="text-xs text-red-600 dark:text-red-400">{fileError}</p> : null}
|
|
149
|
+
|
|
150
|
+
<div className="flex items-end gap-2">
|
|
151
|
+
{canAttach ? (
|
|
152
|
+
<>
|
|
153
|
+
<input
|
|
154
|
+
ref={fileInputRef}
|
|
155
|
+
type="file"
|
|
156
|
+
multiple
|
|
157
|
+
hidden
|
|
158
|
+
accept={attachments?.accept.join(",")}
|
|
159
|
+
onChange={(event) => {
|
|
160
|
+
void addFiles(event.target.files);
|
|
161
|
+
event.target.value = "";
|
|
162
|
+
}}
|
|
163
|
+
/>
|
|
164
|
+
<Button
|
|
165
|
+
type="button"
|
|
166
|
+
size="icon"
|
|
167
|
+
variant="outline"
|
|
168
|
+
disabled={disabled || files.length >= (attachments?.maxCount ?? 0)}
|
|
169
|
+
onClick={() => fileInputRef.current?.click()}
|
|
170
|
+
aria-label="Attach files"
|
|
171
|
+
title="Attach files"
|
|
172
|
+
>
|
|
173
|
+
<Paperclip />
|
|
174
|
+
</Button>
|
|
175
|
+
</>
|
|
176
|
+
) : null}
|
|
177
|
+
|
|
178
|
+
<Textarea
|
|
179
|
+
ref={textareaRef}
|
|
180
|
+
rows={1}
|
|
181
|
+
value={value}
|
|
182
|
+
disabled={disabled}
|
|
183
|
+
autoFocus={autoFocus}
|
|
184
|
+
onChange={(event) => setValue(event.target.value)}
|
|
185
|
+
onKeyDown={onKeyDown}
|
|
186
|
+
placeholder={placeholder ?? "Ask anything…"}
|
|
187
|
+
aria-label="Message"
|
|
188
|
+
/>
|
|
189
|
+
|
|
190
|
+
{models.length > 0 ? (
|
|
191
|
+
<select
|
|
192
|
+
value={modelPicker?.value ?? models[0]?.id}
|
|
193
|
+
onChange={(event) => modelPicker?.onChange(event.target.value)}
|
|
194
|
+
disabled={disabled}
|
|
195
|
+
aria-label="Model"
|
|
196
|
+
className="frankie-focus h-9 max-w-[8rem] rounded-[var(--frankie-radius)] border border-border bg-background px-2 text-sm"
|
|
197
|
+
>
|
|
198
|
+
{models.map((model) => (
|
|
199
|
+
<option key={model.id} value={model.id}>
|
|
200
|
+
{model.label}
|
|
201
|
+
</option>
|
|
202
|
+
))}
|
|
203
|
+
</select>
|
|
204
|
+
) : null}
|
|
205
|
+
|
|
206
|
+
{busy ? (
|
|
207
|
+
<Button type="button" size="icon" variant="outline" onClick={onStop} aria-label="Stop">
|
|
208
|
+
<Square />
|
|
209
|
+
</Button>
|
|
210
|
+
) : (
|
|
211
|
+
<Button type="submit" size="icon" disabled={disabled || nothingToSend} aria-label="Send">
|
|
212
|
+
<ArrowUp />
|
|
213
|
+
</Button>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
73
216
|
</form>
|
|
74
217
|
);
|
|
75
218
|
}
|
|
219
|
+
|
|
220
|
+
function mimeAccepted(mimeType: string, accept: string[]): boolean {
|
|
221
|
+
return accept.some((pattern) =>
|
|
222
|
+
pattern.endsWith("/*") ? mimeType.startsWith(pattern.slice(0, -1)) : pattern === mimeType,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Read a File to a base64 string (no data-URL prefix), for the wire protocol. */
|
|
227
|
+
function readAsBase64(file: File): Promise<string> {
|
|
228
|
+
return new Promise((resolve, reject) => {
|
|
229
|
+
const reader = new FileReader();
|
|
230
|
+
reader.onload = () => {
|
|
231
|
+
const result = String(reader.result);
|
|
232
|
+
const comma = result.indexOf(",");
|
|
233
|
+
resolve(comma >= 0 ? result.slice(comma + 1) : result);
|
|
234
|
+
};
|
|
235
|
+
reader.onerror = () => reject(reader.error);
|
|
236
|
+
reader.readAsDataURL(file);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties } from "react";
|
|
4
|
+
import type { FrankieProviderProps } from "../../hooks/FrankieProvider.jsx";
|
|
5
|
+
import { FrankieProvider } from "../../hooks/FrankieProvider.jsx";
|
|
6
|
+
import { FrankieInline } from "./FrankieInline.js";
|
|
7
|
+
import type { ChatPanelProps } from "./ChatPanel.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A complete chat route in one component. This is the simplest way to add a
|
|
11
|
+
* dedicated /chat page to an app while keeping the existing floating widget.
|
|
12
|
+
*/
|
|
13
|
+
export interface FrankieChatPageProps
|
|
14
|
+
extends Omit<FrankieProviderProps, "children">,
|
|
15
|
+
Pick<
|
|
16
|
+
ChatPanelProps,
|
|
17
|
+
"header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus"
|
|
18
|
+
> {
|
|
19
|
+
className?: string;
|
|
20
|
+
style?: CSSProperties;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function FrankieChatPage({
|
|
24
|
+
className,
|
|
25
|
+
style,
|
|
26
|
+
header,
|
|
27
|
+
footer,
|
|
28
|
+
emptyState,
|
|
29
|
+
renderMessage,
|
|
30
|
+
renderTool,
|
|
31
|
+
placeholder,
|
|
32
|
+
autoFocus,
|
|
33
|
+
...providerProps
|
|
34
|
+
}: FrankieChatPageProps) {
|
|
35
|
+
return (
|
|
36
|
+
<main
|
|
37
|
+
className={className}
|
|
38
|
+
style={{ height: "100dvh", minHeight: "100dvh", width: "100%", ...style }}
|
|
39
|
+
>
|
|
40
|
+
<FrankieProvider {...providerProps}>
|
|
41
|
+
<FrankieInline
|
|
42
|
+
header={header}
|
|
43
|
+
footer={footer}
|
|
44
|
+
emptyState={emptyState}
|
|
45
|
+
renderMessage={renderMessage}
|
|
46
|
+
renderTool={renderTool}
|
|
47
|
+
placeholder={placeholder}
|
|
48
|
+
autoFocus={autoFocus}
|
|
49
|
+
/>
|
|
50
|
+
</FrankieProvider>
|
|
51
|
+
</main>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import { Check, Copy, Paperclip } from "lucide-react";
|
|
4
|
+
import "./message-actions.css";
|
|
5
|
+
import { useState, type ReactNode } from "react";
|
|
4
6
|
import { cn } from "../../lib/utils.js";
|
|
5
7
|
import type { ChatMessage, ToolActivity } from "../../lib/messages.js";
|
|
6
8
|
import { Avatar } from "../ui/avatar.js";
|
|
@@ -24,10 +26,26 @@ export function MessageBubble({
|
|
|
24
26
|
}: MessageBubbleProps) {
|
|
25
27
|
if (message.role === "user") {
|
|
26
28
|
return (
|
|
27
|
-
<div className="flex
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
<div className="flex flex-col items-end gap-1">
|
|
30
|
+
{message.attachments?.length ? (
|
|
31
|
+
<div className="flex max-w-[85%] flex-wrap justify-end gap-1">
|
|
32
|
+
{message.attachments.map((file, index) => (
|
|
33
|
+
<span
|
|
34
|
+
key={`${file.name}-${index}`}
|
|
35
|
+
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"
|
|
36
|
+
>
|
|
37
|
+
<Paperclip className="size-3 shrink-0 opacity-70" />
|
|
38
|
+
<span className="truncate">{file.name}</span>
|
|
39
|
+
</span>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
) : null}
|
|
43
|
+
{message.text ? (
|
|
44
|
+
<div className="flex max-w-[85%] items-end gap-1">
|
|
45
|
+
<div className="rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground">{message.text}</div>
|
|
46
|
+
<MessageCopyButton text={message.text} />
|
|
47
|
+
</div>
|
|
48
|
+
) : null}
|
|
31
49
|
</div>
|
|
32
50
|
);
|
|
33
51
|
}
|
|
@@ -36,7 +54,10 @@ export function MessageBubble({
|
|
|
36
54
|
|
|
37
55
|
return (
|
|
38
56
|
<div className="flex gap-2">
|
|
39
|
-
<
|
|
57
|
+
<div className="frankie-assistant-actions mt-0.5 flex shrink-0 flex-col items-center gap-1">
|
|
58
|
+
<Avatar {...(avatarUrl ? { src: avatarUrl } : {})} name={assistantName} />
|
|
59
|
+
{message.text ? <MessageCopyButton text={message.text} /> : null}
|
|
60
|
+
</div>
|
|
40
61
|
<div className="min-w-0 max-w-[85%]">
|
|
41
62
|
{message.status === "error" ? (
|
|
42
63
|
<p className="rounded-[var(--frankie-radius)] bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
|
|
@@ -55,6 +76,18 @@ export function MessageBubble({
|
|
|
55
76
|
);
|
|
56
77
|
}
|
|
57
78
|
|
|
79
|
+
function MessageCopyButton({ text }: { text: string }) {
|
|
80
|
+
const [copied, setCopied] = useState(false);
|
|
81
|
+
const copy = async () => {
|
|
82
|
+
try {
|
|
83
|
+
await navigator.clipboard.writeText(text);
|
|
84
|
+
setCopied(true);
|
|
85
|
+
window.setTimeout(() => setCopied(false), 1500);
|
|
86
|
+
} catch { /* Clipboard access can be disabled by an embedding host. */ }
|
|
87
|
+
};
|
|
88
|
+
return <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"}>{copied ? <Check className="size-3.5" aria-hidden="true" /> : <Copy className="size-3.5" aria-hidden="true" />}</button>;
|
|
89
|
+
}
|
|
90
|
+
|
|
58
91
|
export function TypingDots() {
|
|
59
92
|
return (
|
|
60
93
|
<span className="inline-flex items-center gap-1 py-2" role="status" aria-label="Thinking">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Check, Loader2, ShieldAlert, X } from "lucide-react";
|
|
3
|
+
import { Check, ChevronDown, Loader2, ShieldAlert, X } from "lucide-react";
|
|
4
4
|
import { Badge } from "../ui/badge.js";
|
|
5
5
|
import type { ToolActivity } from "../../lib/messages.js";
|
|
6
6
|
|
|
@@ -13,12 +13,16 @@ export interface ToolActivityListProps {
|
|
|
13
13
|
/** The "what the assistant is doing" strip under a streaming answer. */
|
|
14
14
|
export function ToolActivityList({ tools, renderTool }: ToolActivityListProps) {
|
|
15
15
|
if (tools.length === 0) return null;
|
|
16
|
+
const running = tools.some((tool) => tool.status === "running");
|
|
16
17
|
return (
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
<details className="frankie-tool-activity mt-2" open={running}>
|
|
19
|
+
<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">
|
|
20
|
+
<ChevronDown className="frankie-tool-chevron size-3.5 transition-transform" aria-hidden="true" />
|
|
21
|
+
<span>{running ? "Working" : "Activity"}</span>
|
|
22
|
+
<span className="text-[11px]">· {tools.length} tool call{tools.length === 1 ? "" : "s"}</span>
|
|
23
|
+
</summary>
|
|
24
|
+
<ul className="mt-1 flex flex-col gap-1 border-l border-border pl-2">{tools.map((tool) => <li key={tool.id}>{renderTool ? renderTool(tool) : <ToolActivityRow tool={tool} />}</li>)}</ul>
|
|
25
|
+
</details>
|
|
22
26
|
);
|
|
23
27
|
}
|
|
24
28
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
.frankie-root .frankie-message-copy { opacity: .72; }
|
|
2
|
+
.frankie-root .frankie-message-copy:hover { opacity: 1; }
|
|
3
|
+
.frankie-root .frankie-tool-activity[open] .frankie-tool-chevron { transform: rotate(180deg); }
|
|
4
|
+
|
|
5
|
+
.frankie-root .frankie-assistant-actions .frankie-message-copy {
|
|
6
|
+
width: 28px; height: 28px; min-width: 28px; min-height: 28px;
|
|
7
|
+
border: 0; background: transparent; opacity: .46;
|
|
8
|
+
}
|
|
9
|
+
.frankie-root .frankie-assistant-actions .frankie-message-copy:hover,
|
|
10
|
+
.frankie-root .frankie-assistant-actions .frankie-message-copy:focus-visible { opacity: 1; }
|
|
11
|
+
.frankie-root .frankie-tool-activity { max-width: 100%; }
|
|
12
|
+
.frankie-root .frankie-tool-activity[open] > summary { color: var(--frankie-foreground); }
|
|
13
|
+
.frankie-root .frankie-tool-activity li .frankie-badge { max-width: 100%; }
|
|
@@ -6,14 +6,8 @@ import { FrankieClient, type FrankieClientOptions, type PageContext } from "../l
|
|
|
6
6
|
|
|
7
7
|
export interface FrankieProviderProps extends FrankieClientOptions {
|
|
8
8
|
children: ReactNode;
|
|
9
|
-
/**
|
|
10
|
-
* Page context sent with every turn — current route, plan tier, whatever the
|
|
11
|
-
* agent should know. Treated as data by the server, never as instructions.
|
|
12
|
-
*/
|
|
13
9
|
context?: PageContext;
|
|
14
|
-
/** Overrides for the server-side theme, so a host can restyle without a redeploy. */
|
|
15
10
|
theme?: WidgetTheme;
|
|
16
|
-
/** Skip the `/v1/config` fetch by supplying the config yourself. */
|
|
17
11
|
config?: PublicWidgetConfig;
|
|
18
12
|
}
|
|
19
13
|
|
|
@@ -37,9 +31,8 @@ export function FrankieProvider({
|
|
|
37
31
|
}: FrankieProviderProps) {
|
|
38
32
|
const client = useMemo(
|
|
39
33
|
() => new FrankieClient(clientOptions),
|
|
40
|
-
// The client holds session state; rebuild it only when the endpoint changes.
|
|
41
34
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
42
|
-
[clientOptions.baseUrl, clientOptions.publishableKey],
|
|
35
|
+
[clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId],
|
|
43
36
|
);
|
|
44
37
|
|
|
45
38
|
const [config, setConfig] = useState<PublicWidgetConfig | undefined>(providedConfig);
|
|
@@ -87,7 +80,6 @@ export function useFrankie(): FrankieContextValue {
|
|
|
87
80
|
return value;
|
|
88
81
|
}
|
|
89
82
|
|
|
90
|
-
/** The theme actually in effect: provider override wins over server config. */
|
|
91
83
|
export function useFrankieTheme(): WidgetTheme | undefined {
|
|
92
84
|
const { config, theme } = useFrankie();
|
|
93
85
|
return useMemo(() => ({ ...config?.theme, ...theme }), [config?.theme, theme]);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { ChatAttachment } from "@frankieai/core";
|
|
4
5
|
import { FrankieApiError } from "../lib/client.js";
|
|
5
6
|
import {
|
|
6
7
|
applyEvent,
|
|
@@ -11,13 +12,19 @@ import {
|
|
|
11
12
|
} from "../lib/messages.js";
|
|
12
13
|
import { useFrankie } from "./FrankieProvider.jsx";
|
|
13
14
|
|
|
15
|
+
/** Extra inputs a turn can carry: attached files and a chosen model. */
|
|
16
|
+
export interface SendOptions {
|
|
17
|
+
attachments?: ChatAttachment[];
|
|
18
|
+
model?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
export interface UseFrankieChatResult {
|
|
15
22
|
messages: ChatMessage[];
|
|
16
23
|
/** True from the moment a message is sent until the turn ends. */
|
|
17
24
|
busy: boolean;
|
|
18
25
|
/** Set when the turn failed outright (network, auth, rate limit). */
|
|
19
26
|
error?: string;
|
|
20
|
-
send: (text: string) => Promise<void>;
|
|
27
|
+
send: (text: string, opts?: SendOptions) => Promise<void>;
|
|
21
28
|
/** Abort the in-flight turn. The partial answer stays in the transcript. */
|
|
22
29
|
stop: () => void;
|
|
23
30
|
/** Clear the transcript and start a brand-new server session. */
|
|
@@ -32,6 +39,7 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
32
39
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
33
40
|
const [busy, setBusy] = useState(false);
|
|
34
41
|
const [error, setError] = useState<string | undefined>(undefined);
|
|
42
|
+
const [restored, setRestored] = useState(false);
|
|
35
43
|
const abortRef = useRef<AbortController | null>(null);
|
|
36
44
|
const mounted = useRef(true);
|
|
37
45
|
|
|
@@ -45,30 +53,47 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
45
53
|
|
|
46
54
|
const storageKey = `${client.namespace}.messages`;
|
|
47
55
|
|
|
48
|
-
// Restore
|
|
49
|
-
// The
|
|
56
|
+
// Restore local storage immediately, then hydrate the authoritative server
|
|
57
|
+
// transcript. The server path makes the same signed-in user's chats available
|
|
58
|
+
// on another browser or device.
|
|
50
59
|
useEffect(() => {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
let active = true;
|
|
61
|
+
const restore = async () => {
|
|
62
|
+
if (client.persist) {
|
|
63
|
+
try {
|
|
64
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
65
|
+
const stored = raw ? (JSON.parse(raw) as ChatMessage[]) : [];
|
|
66
|
+
if (Array.isArray(stored) && stored.length && active) setMessages(stored);
|
|
67
|
+
} catch {
|
|
68
|
+
/* storage unavailable; server history can still restore */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const remote = await client.resumeConversation();
|
|
73
|
+
if (active && remote?.messages.length) setMessages(remote.messages as ChatMessage[]);
|
|
74
|
+
} catch {
|
|
75
|
+
/* A new conversation does not exist remotely until its first message. */
|
|
76
|
+
} finally {
|
|
77
|
+
if (active) setRestored(true);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
void restore();
|
|
81
|
+
return () => { active = false; };
|
|
82
|
+
}, [client, storageKey]);
|
|
61
83
|
|
|
62
84
|
// Persist once a turn settles — never mid-stream — so the saved copy is clean.
|
|
85
|
+
// Wait until the initial restore has completed so an empty initial state never
|
|
86
|
+
// deletes an existing transcript.
|
|
63
87
|
useEffect(() => {
|
|
64
|
-
if (!client.persist || busy) return;
|
|
88
|
+
if (!client.persist || !restored || busy) return;
|
|
65
89
|
try {
|
|
66
90
|
if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
|
|
67
91
|
else window.localStorage.removeItem(storageKey);
|
|
68
92
|
} catch {
|
|
69
93
|
/* ignore */
|
|
70
94
|
}
|
|
71
|
-
|
|
95
|
+
if (messages.length) void client.syncConversation(messages, conversationTitle(messages)).catch(() => undefined);
|
|
96
|
+
}, [messages, busy, client, storageKey, restored]);
|
|
72
97
|
|
|
73
98
|
const patchAssistant = useCallback((id: string, update: (m: AssistantChatMessage) => AssistantChatMessage) => {
|
|
74
99
|
setMessages((current) =>
|
|
@@ -79,16 +104,23 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
79
104
|
}, []);
|
|
80
105
|
|
|
81
106
|
const send = useCallback(
|
|
82
|
-
async (text: string) => {
|
|
107
|
+
async (text: string, opts?: SendOptions) => {
|
|
83
108
|
const trimmed = text.trim();
|
|
84
|
-
|
|
109
|
+
const attachments = opts?.attachments ?? [];
|
|
110
|
+
if ((!trimmed && attachments.length === 0) || busy) return;
|
|
111
|
+
// The server needs a message; an attachment-only turn gets a stand-in.
|
|
112
|
+
const outgoing = trimmed || "Please take a look at the attached file(s).";
|
|
85
113
|
|
|
86
114
|
const assistantId = nextId("a");
|
|
87
115
|
setError(undefined);
|
|
88
116
|
setBusy(true);
|
|
89
117
|
setMessages((current) => [
|
|
90
118
|
...current,
|
|
91
|
-
newUserMessage(
|
|
119
|
+
newUserMessage(
|
|
120
|
+
nextId("u"),
|
|
121
|
+
trimmed,
|
|
122
|
+
attachments.map((file) => ({ name: file.name, mimeType: file.mimeType })),
|
|
123
|
+
),
|
|
92
124
|
newAssistantMessage(assistantId),
|
|
93
125
|
]);
|
|
94
126
|
|
|
@@ -96,10 +128,35 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
96
128
|
abortRef.current = abort;
|
|
97
129
|
|
|
98
130
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
131
|
+
let receivedText = false;
|
|
132
|
+
let receivedToolActivity = false;
|
|
133
|
+
await client.send(
|
|
134
|
+
{
|
|
135
|
+
message: outgoing,
|
|
136
|
+
...(context ? { context } : {}),
|
|
137
|
+
...(opts?.model ? { model: opts.model } : {}),
|
|
138
|
+
...(attachments.length ? { attachments } : {}),
|
|
139
|
+
signal: abort.signal,
|
|
140
|
+
},
|
|
141
|
+
(event) => {
|
|
142
|
+
if (event.type === "ping" || event.type === "session") return;
|
|
143
|
+
if (event.type === "text_delta") receivedText = true;
|
|
144
|
+
if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
|
|
145
|
+
patchAssistant(assistantId, (message) => applyEvent(message, event));
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
// A terminated stream with no text previously looked like Frankie did
|
|
149
|
+
// nothing. Preserve visible tool activity, but never leave an empty
|
|
150
|
+
// assistant bubble for the user to interpret.
|
|
151
|
+
if (!receivedText) {
|
|
152
|
+
patchAssistant(assistantId, (message) => ({
|
|
153
|
+
...message,
|
|
154
|
+
status: "done",
|
|
155
|
+
text: receivedToolActivity
|
|
156
|
+
? "Completed. Open Agent activity for the details."
|
|
157
|
+
: "Frankie didn’t return an answer. Please try again.",
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
103
160
|
} catch (caught) {
|
|
104
161
|
if (abort.signal.aborted) {
|
|
105
162
|
patchAssistant(assistantId, (message) => ({
|
|
@@ -111,7 +168,9 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
111
168
|
const message =
|
|
112
169
|
caught instanceof FrankieApiError
|
|
113
170
|
? caught.message
|
|
114
|
-
:
|
|
171
|
+
: caught instanceof Error && caught.message.startsWith("Frankie")
|
|
172
|
+
? caught.message
|
|
173
|
+
: "Could not reach the assistant. Check your connection and try again.";
|
|
115
174
|
if (mounted.current) setError(message);
|
|
116
175
|
patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
|
|
117
176
|
}
|
|
@@ -137,6 +196,7 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
137
196
|
setMessages([]);
|
|
138
197
|
setError(undefined);
|
|
139
198
|
setBusy(false);
|
|
199
|
+
setRestored(true);
|
|
140
200
|
try {
|
|
141
201
|
if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
|
|
142
202
|
} catch {
|
|
@@ -146,3 +206,11 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
146
206
|
|
|
147
207
|
return { messages, busy, ...(error ? { error } : {}), send, stop, reset };
|
|
148
208
|
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
function conversationTitle(messages: ChatMessage[]): string | undefined {
|
|
212
|
+
const first = messages.find((message) => message.role === "user" && message.text.trim());
|
|
213
|
+
if (!first) return undefined;
|
|
214
|
+
const title = first.text.trim().replace(/\s+/g, " ");
|
|
215
|
+
return title.length > 42 ? `${title.slice(0, 42)}…` : title;
|
|
216
|
+
}
|