@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.
@@ -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
- onSend(text);
36
- }, [busy, disabled, onSend, value]);
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 items-end gap-2"
120
+ className="flex flex-col gap-2"
48
121
  onSubmit={(event) => {
49
122
  event.preventDefault();
50
123
  submit();
51
124
  }}
52
125
  >
53
- <Textarea
54
- ref={textareaRef}
55
- rows={1}
56
- value={value}
57
- disabled={disabled}
58
- autoFocus={autoFocus}
59
- onChange={(event) => setValue(event.target.value)}
60
- onKeyDown={onKeyDown}
61
- placeholder={placeholder ?? "Ask anything…"}
62
- aria-label="Message"
63
- />
64
- {busy ? (
65
- <Button type="button" size="icon" variant="outline" onClick={onStop} aria-label="Stop">
66
- <Square />
67
- </Button>
68
- ) : (
69
- <Button type="submit" size="icon" disabled={disabled || value.trim().length === 0} aria-label="Send">
70
- <ArrowUp />
71
- </Button>
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 justify-end">
28
- <div className="max-w-[85%] rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground">
29
- {message.text}
30
- </div>
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
- if (!trimmed || busy) return;
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(nextId("u"), trimmed),
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({ message: trimmed, ...(context ? { context } : {}), signal: abort.signal }, (event) => {
100
- if (event.type === "ping" || event.type === "session") return;
101
- patchAssistant(assistantId, (message) => applyEvent(message, event));
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: { message: string; context?: PageContext; signal?: AbortSignal },
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`, {
@@ -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
  }
@@ -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> {