@frankieai/react 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/frankie.css +1 -1
- package/dist/index.cjs +200 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +43 -6
- package/dist/index.d.ts +43 -6
- package/dist/index.js +208 -44
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/ChatPanel.tsx +9 -1
- package/src/components/chat/Composer.tsx +190 -27
- package/src/components/chat/MessageBubble.tsx +20 -4
- package/src/hooks/useFrankieChat.ts +31 -8
- package/src/index.ts +2 -2
- package/src/lib/client.ts +12 -1
- package/src/lib/messages.ts +9 -2
- 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
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Paperclip } from "lucide-react";
|
|
3
4
|
import type { ReactNode } from "react";
|
|
4
5
|
import { cn } from "../../lib/utils.js";
|
|
5
6
|
import type { ChatMessage, ToolActivity } from "../../lib/messages.js";
|
|
@@ -24,10 +25,25 @@ export function MessageBubble({
|
|
|
24
25
|
}: MessageBubbleProps) {
|
|
25
26
|
if (message.role === "user") {
|
|
26
27
|
return (
|
|
27
|
-
<div className="flex
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
<div className="flex flex-col items-end gap-1">
|
|
29
|
+
{message.attachments?.length ? (
|
|
30
|
+
<div className="flex max-w-[85%] flex-wrap justify-end gap-1">
|
|
31
|
+
{message.attachments.map((file, index) => (
|
|
32
|
+
<span
|
|
33
|
+
key={`${file.name}-${index}`}
|
|
34
|
+
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"
|
|
35
|
+
>
|
|
36
|
+
<Paperclip className="size-3 shrink-0 opacity-70" />
|
|
37
|
+
<span className="truncate">{file.name}</span>
|
|
38
|
+
</span>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
) : null}
|
|
42
|
+
{message.text ? (
|
|
43
|
+
<div className="max-w-[85%] rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground">
|
|
44
|
+
{message.text}
|
|
45
|
+
</div>
|
|
46
|
+
) : null}
|
|
31
47
|
</div>
|
|
32
48
|
);
|
|
33
49
|
}
|
|
@@ -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. */
|
|
@@ -79,16 +86,23 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
79
86
|
}, []);
|
|
80
87
|
|
|
81
88
|
const send = useCallback(
|
|
82
|
-
async (text: string) => {
|
|
89
|
+
async (text: string, opts?: SendOptions) => {
|
|
83
90
|
const trimmed = text.trim();
|
|
84
|
-
|
|
91
|
+
const attachments = opts?.attachments ?? [];
|
|
92
|
+
if ((!trimmed && attachments.length === 0) || busy) return;
|
|
93
|
+
// The server needs a message; an attachment-only turn gets a stand-in.
|
|
94
|
+
const outgoing = trimmed || "Please take a look at the attached file(s).";
|
|
85
95
|
|
|
86
96
|
const assistantId = nextId("a");
|
|
87
97
|
setError(undefined);
|
|
88
98
|
setBusy(true);
|
|
89
99
|
setMessages((current) => [
|
|
90
100
|
...current,
|
|
91
|
-
newUserMessage(
|
|
101
|
+
newUserMessage(
|
|
102
|
+
nextId("u"),
|
|
103
|
+
trimmed,
|
|
104
|
+
attachments.map((file) => ({ name: file.name, mimeType: file.mimeType })),
|
|
105
|
+
),
|
|
92
106
|
newAssistantMessage(assistantId),
|
|
93
107
|
]);
|
|
94
108
|
|
|
@@ -96,10 +110,19 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
96
110
|
abortRef.current = abort;
|
|
97
111
|
|
|
98
112
|
try {
|
|
99
|
-
await client.send(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
113
|
+
await client.send(
|
|
114
|
+
{
|
|
115
|
+
message: outgoing,
|
|
116
|
+
...(context ? { context } : {}),
|
|
117
|
+
...(opts?.model ? { model: opts.model } : {}),
|
|
118
|
+
...(attachments.length ? { attachments } : {}),
|
|
119
|
+
signal: abort.signal,
|
|
120
|
+
},
|
|
121
|
+
(event) => {
|
|
122
|
+
if (event.type === "ping" || event.type === "session") return;
|
|
123
|
+
patchAssistant(assistantId, (message) => applyEvent(message, event));
|
|
124
|
+
},
|
|
125
|
+
);
|
|
103
126
|
} catch (caught) {
|
|
104
127
|
if (abort.signal.aborted) {
|
|
105
128
|
patchAssistant(assistantId, (message) => ({
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { FrankieProvider, useFrankie, useFrankieTheme } from "./hooks/FrankieProvider.jsx";
|
|
2
2
|
export type { FrankieProviderProps } from "./hooks/FrankieProvider.jsx";
|
|
3
3
|
export { useFrankieChat } from "./hooks/useFrankieChat.js";
|
|
4
|
-
export type { UseFrankieChatResult } from "./hooks/useFrankieChat.js";
|
|
4
|
+
export type { UseFrankieChatResult, SendOptions } from "./hooks/useFrankieChat.js";
|
|
5
5
|
|
|
6
6
|
export { FrankieWidget } from "./components/chat/FrankieWidget.js";
|
|
7
7
|
export type { FrankieWidgetProps } from "./components/chat/FrankieWidget.js";
|
|
@@ -27,7 +27,7 @@ export { Textarea } from "./components/ui/textarea.js";
|
|
|
27
27
|
export { FrankieClient, FrankieApiError, readEventStream, FRANKIE_CLOUD_URL } from "./lib/client.js";
|
|
28
28
|
export type { FrankieClientOptions, PageContext } from "./lib/client.js";
|
|
29
29
|
export { applyEvent, newAssistantMessage, newUserMessage } from "./lib/messages.js";
|
|
30
|
-
export type { AssistantChatMessage, ChatMessage, ToolActivity, UserChatMessage } from "./lib/messages.js";
|
|
30
|
+
export type { AssistantChatMessage, ChatMessage, MessageAttachment, ToolActivity, UserChatMessage } from "./lib/messages.js";
|
|
31
31
|
export { resolveMode, themeToCssVars } from "./lib/theme.js";
|
|
32
32
|
export { cn } from "./lib/utils.js";
|
|
33
33
|
|
package/src/lib/client.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
ChatAttachment,
|
|
2
3
|
ChatRequestBody,
|
|
3
4
|
CreateSessionResponse,
|
|
4
5
|
FrankieServerEvent,
|
|
@@ -126,7 +127,15 @@ export class FrankieClient {
|
|
|
126
127
|
* Resolves when the turn ends.
|
|
127
128
|
*/
|
|
128
129
|
async send(
|
|
129
|
-
input: {
|
|
130
|
+
input: {
|
|
131
|
+
message: string;
|
|
132
|
+
context?: PageContext;
|
|
133
|
+
/** Model id chosen by the user, honoured only if the workspace allows it. */
|
|
134
|
+
model?: string;
|
|
135
|
+
/** Files attached to this turn. */
|
|
136
|
+
attachments?: ChatAttachment[];
|
|
137
|
+
signal?: AbortSignal;
|
|
138
|
+
},
|
|
130
139
|
onEvent: (event: FrankieServerEvent) => void,
|
|
131
140
|
): Promise<void> {
|
|
132
141
|
const session = await this.ensureSession(input.context);
|
|
@@ -134,6 +143,8 @@ export class FrankieClient {
|
|
|
134
143
|
sessionId: session.sessionId,
|
|
135
144
|
message: input.message,
|
|
136
145
|
...(input.context ? { context: input.context } : {}),
|
|
146
|
+
...(input.model ? { model: input.model } : {}),
|
|
147
|
+
...(input.attachments?.length ? { attachments: input.attachments } : {}),
|
|
137
148
|
};
|
|
138
149
|
|
|
139
150
|
const response = await this.doFetch(`${this.baseUrl}/v1/chat`, {
|
package/src/lib/messages.ts
CHANGED
|
@@ -8,10 +8,17 @@ export interface ToolActivity {
|
|
|
8
8
|
detail?: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/** A file shown on a user message (name/type only — the bytes aren't kept in the transcript). */
|
|
12
|
+
export interface MessageAttachment {
|
|
13
|
+
name: string;
|
|
14
|
+
mimeType: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
export interface UserChatMessage {
|
|
12
18
|
id: string;
|
|
13
19
|
role: "user";
|
|
14
20
|
text: string;
|
|
21
|
+
attachments?: MessageAttachment[];
|
|
15
22
|
createdAt: number;
|
|
16
23
|
}
|
|
17
24
|
|
|
@@ -116,6 +123,6 @@ export function newAssistantMessage(id: string): AssistantChatMessage {
|
|
|
116
123
|
return { id, role: "assistant", text: "", thinking: "", tools: [], status: "streaming", createdAt: Date.now() };
|
|
117
124
|
}
|
|
118
125
|
|
|
119
|
-
export function newUserMessage(id: string, text: string): UserChatMessage {
|
|
120
|
-
return { id, role: "user", text, createdAt: Date.now() };
|
|
126
|
+
export function newUserMessage(id: string, text: string, attachments?: MessageAttachment[]): UserChatMessage {
|
|
127
|
+
return { id, role: "user", text, ...(attachments?.length ? { attachments } : {}), createdAt: Date.now() };
|
|
121
128
|
}
|
package/src/widget.test.tsx
CHANGED
|
@@ -16,6 +16,10 @@ const config: PublicWidgetConfig = {
|
|
|
16
16
|
theme: { mode: "light" },
|
|
17
17
|
allowAnonymous: true,
|
|
18
18
|
showToolActivity: true,
|
|
19
|
+
features: {
|
|
20
|
+
attachments: { enabled: false, accept: ["image/*"], maxMb: 10, maxCount: 4 },
|
|
21
|
+
modelPicker: { enabled: false, models: [] },
|
|
22
|
+
},
|
|
19
23
|
};
|
|
20
24
|
|
|
21
25
|
function sseStream(events: FrankieServerEvent[]): ReadableStream<Uint8Array> {
|