@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/index.cjs CHANGED
@@ -34,6 +34,7 @@ __export(index_exports, {
34
34
  Composer: () => Composer,
35
35
  FRANKIE_CLOUD_URL: () => FRANKIE_CLOUD_URL,
36
36
  FrankieApiError: () => FrankieApiError,
37
+ FrankieChatPage: () => FrankieChatPage,
37
38
  FrankieClient: () => FrankieClient,
38
39
  FrankieInline: () => FrankieInline,
39
40
  FrankieProvider: () => FrankieProvider,
@@ -89,13 +90,16 @@ var FrankieClient = class {
89
90
  get sessionId() {
90
91
  return this.session?.sessionId;
91
92
  }
93
+ get conversationId() {
94
+ return this.options.conversationId;
95
+ }
92
96
  /** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
93
97
  get persist() {
94
98
  return this.options.persist !== false && typeof window !== "undefined";
95
99
  }
96
- /** localStorage namespace for this tenant, shared with the chat hook. */
100
+ /** localStorage namespace for this tenant/conversation, shared with the chat hook. */
97
101
  get namespace() {
98
- return `frankie.${this.options.publishableKey}`;
102
+ return this.options.storageNamespace ?? `frankie.${this.options.publishableKey}`;
99
103
  }
100
104
  readStored() {
101
105
  if (!this.persist) return void 0;
@@ -123,36 +127,88 @@ var FrankieClient = class {
123
127
  });
124
128
  return await this.unwrap(response);
125
129
  }
126
- /** Opens (or reuses) a chat session. */
127
130
  async ensureSession(context) {
128
- if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 3e4) {
129
- return this.session;
130
- }
131
+ if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 3e4) return this.session;
131
132
  const userToken = await this.options.getUserToken?.();
133
+ if (this.options.getUserToken && !userToken) {
134
+ throw new FrankieApiError(
135
+ "Your signed-in Frankie session could not be verified. Refresh the page and sign in again.",
136
+ 401,
137
+ "user_token_unavailable"
138
+ );
139
+ }
132
140
  const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
133
141
  method: "POST",
134
142
  headers: { "content-type": "application/json", "x-frankie-key": this.options.publishableKey },
135
- body: JSON.stringify({ ...userToken ? { userToken } : {}, ...context ? { context } : {} })
143
+ body: JSON.stringify({
144
+ ...userToken ? { userToken } : {},
145
+ ...context ? { context } : {},
146
+ ...this.options.conversationId ? { conversationId: this.options.conversationId } : {}
147
+ })
136
148
  });
137
149
  this.session = await this.unwrap(response);
138
150
  this.writeStored();
139
151
  return this.session;
140
152
  }
141
- /** Forget the current session so the next send starts a fresh conversation. */
153
+ async listConversations() {
154
+ const userToken = await this.requireUserToken();
155
+ const response = await this.doFetch(`${this.baseUrl}/v1/conversations`, {
156
+ headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` }
157
+ });
158
+ const body = await this.unwrap(response);
159
+ return Array.isArray(body.conversations) ? body.conversations : [];
160
+ }
161
+ async resumeConversation(id = this.options.conversationId) {
162
+ if (!id) return void 0;
163
+ const userToken = await this.requireUserToken();
164
+ const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}/resume`, {
165
+ method: "POST",
166
+ headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` }
167
+ });
168
+ if (response.status === 404) return void 0;
169
+ const body = await this.unwrap(response);
170
+ this.session = body.session;
171
+ this.writeStored();
172
+ return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat" };
173
+ }
174
+ async syncConversation(messages, title) {
175
+ if (!this.session || !this.options.conversationId) return;
176
+ const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(this.options.conversationId)}`, {
177
+ method: "PUT",
178
+ headers: {
179
+ "content-type": "application/json",
180
+ "x-frankie-key": this.options.publishableKey,
181
+ authorization: `Bearer ${this.session.sessionToken}`
182
+ },
183
+ body: JSON.stringify({ messages, ...title ? { title } : {} })
184
+ });
185
+ await this.unwrap(response);
186
+ }
187
+ async deleteConversation(id) {
188
+ const userToken = await this.requireUserToken();
189
+ const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}`, {
190
+ method: "DELETE",
191
+ headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` }
192
+ });
193
+ if (response.status !== 404) await this.unwrap(response);
194
+ }
195
+ async requireUserToken() {
196
+ const token = await this.options.getUserToken?.();
197
+ if (!token) throw new FrankieApiError("Sign in to access your chat history.", 401, "unauthorized");
198
+ return token;
199
+ }
142
200
  resetSession() {
143
201
  this.session = void 0;
144
202
  this.writeStored();
145
203
  }
146
- /**
147
- * Sends a message and invokes `onEvent` for each server event as it arrives.
148
- * Resolves when the turn ends.
149
- */
150
204
  async send(input, onEvent) {
151
205
  const session = await this.ensureSession(input.context);
152
206
  const body = {
153
207
  sessionId: session.sessionId,
154
208
  message: input.message,
155
- ...input.context ? { context: input.context } : {}
209
+ ...input.context ? { context: input.context } : {},
210
+ ...input.model ? { model: input.model } : {},
211
+ ...input.attachments?.length ? { attachments: input.attachments } : {}
156
212
  };
157
213
  const response = await this.doFetch(`${this.baseUrl}/v1/chat`, {
158
214
  method: "POST",
@@ -230,9 +286,8 @@ function FrankieProvider({
230
286
  }) {
231
287
  const client = (0, import_react.useMemo)(
232
288
  () => new FrankieClient(clientOptions),
233
- // The client holds session state; rebuild it only when the endpoint changes.
234
289
  // eslint-disable-next-line react-hooks/exhaustive-deps
235
- [clientOptions.baseUrl, clientOptions.publishableKey]
290
+ [clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId]
236
291
  );
237
292
  const [config, setConfig] = (0, import_react.useState)(providedConfig);
238
293
  const [configError, setConfigError] = (0, import_react.useState)(void 0);
@@ -346,8 +401,8 @@ function fallbackText(stopReason) {
346
401
  function newAssistantMessage(id) {
347
402
  return { id, role: "assistant", text: "", thinking: "", tools: [], status: "streaming", createdAt: Date.now() };
348
403
  }
349
- function newUserMessage(id, text) {
350
- return { id, role: "user", text, createdAt: Date.now() };
404
+ function newUserMessage(id, text, attachments) {
405
+ return { id, role: "user", text, ...attachments?.length ? { attachments } : {}, createdAt: Date.now() };
351
406
  }
352
407
 
353
408
  // src/hooks/useFrankieChat.ts
@@ -358,6 +413,7 @@ function useFrankieChat() {
358
413
  const [messages, setMessages] = (0, import_react2.useState)([]);
359
414
  const [busy, setBusy] = (0, import_react2.useState)(false);
360
415
  const [error, setError] = (0, import_react2.useState)(void 0);
416
+ const [restored, setRestored] = (0, import_react2.useState)(false);
361
417
  const abortRef = (0, import_react2.useRef)(null);
362
418
  const mounted = (0, import_react2.useRef)(true);
363
419
  (0, import_react2.useEffect)(() => {
@@ -369,22 +425,38 @@ function useFrankieChat() {
369
425
  }, []);
370
426
  const storageKey = `${client.namespace}.messages`;
371
427
  (0, import_react2.useEffect)(() => {
372
- if (!client.persist) return;
373
- try {
374
- const raw = window.localStorage.getItem(storageKey);
375
- const stored = raw ? JSON.parse(raw) : [];
376
- if (Array.isArray(stored) && stored.length) setMessages(stored);
377
- } catch {
378
- }
379
- }, [storageKey]);
428
+ let active = true;
429
+ const restore = async () => {
430
+ if (client.persist) {
431
+ try {
432
+ const raw = window.localStorage.getItem(storageKey);
433
+ const stored = raw ? JSON.parse(raw) : [];
434
+ if (Array.isArray(stored) && stored.length && active) setMessages(stored);
435
+ } catch {
436
+ }
437
+ }
438
+ try {
439
+ const remote = await client.resumeConversation();
440
+ if (active && remote?.messages.length) setMessages(remote.messages);
441
+ } catch {
442
+ } finally {
443
+ if (active) setRestored(true);
444
+ }
445
+ };
446
+ void restore();
447
+ return () => {
448
+ active = false;
449
+ };
450
+ }, [client, storageKey]);
380
451
  (0, import_react2.useEffect)(() => {
381
- if (!client.persist || busy) return;
452
+ if (!client.persist || !restored || busy) return;
382
453
  try {
383
454
  if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
384
455
  else window.localStorage.removeItem(storageKey);
385
456
  } catch {
386
457
  }
387
- }, [messages, busy, client, storageKey]);
458
+ if (messages.length) void client.syncConversation(messages, conversationTitle(messages)).catch(() => void 0);
459
+ }, [messages, busy, client, storageKey, restored]);
388
460
  const patchAssistant = (0, import_react2.useCallback)((id, update) => {
389
461
  setMessages(
390
462
  (current) => current.map(
@@ -393,24 +465,50 @@ function useFrankieChat() {
393
465
  );
394
466
  }, []);
395
467
  const send = (0, import_react2.useCallback)(
396
- async (text) => {
468
+ async (text, opts) => {
397
469
  const trimmed = text.trim();
398
- if (!trimmed || busy) return;
470
+ const attachments = opts?.attachments ?? [];
471
+ if (!trimmed && attachments.length === 0 || busy) return;
472
+ const outgoing = trimmed || "Please take a look at the attached file(s).";
399
473
  const assistantId = nextId("a");
400
474
  setError(void 0);
401
475
  setBusy(true);
402
476
  setMessages((current) => [
403
477
  ...current,
404
- newUserMessage(nextId("u"), trimmed),
478
+ newUserMessage(
479
+ nextId("u"),
480
+ trimmed,
481
+ attachments.map((file) => ({ name: file.name, mimeType: file.mimeType }))
482
+ ),
405
483
  newAssistantMessage(assistantId)
406
484
  ]);
407
485
  const abort = new AbortController();
408
486
  abortRef.current = abort;
409
487
  try {
410
- await client.send({ message: trimmed, ...context ? { context } : {}, signal: abort.signal }, (event) => {
411
- if (event.type === "ping" || event.type === "session") return;
412
- patchAssistant(assistantId, (message) => applyEvent(message, event));
413
- });
488
+ let receivedText = false;
489
+ let receivedToolActivity = false;
490
+ await client.send(
491
+ {
492
+ message: outgoing,
493
+ ...context ? { context } : {},
494
+ ...opts?.model ? { model: opts.model } : {},
495
+ ...attachments.length ? { attachments } : {},
496
+ signal: abort.signal
497
+ },
498
+ (event) => {
499
+ if (event.type === "ping" || event.type === "session") return;
500
+ if (event.type === "text_delta") receivedText = true;
501
+ if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
502
+ patchAssistant(assistantId, (message) => applyEvent(message, event));
503
+ }
504
+ );
505
+ if (!receivedText) {
506
+ patchAssistant(assistantId, (message) => ({
507
+ ...message,
508
+ status: "done",
509
+ text: receivedToolActivity ? "Completed. Open Agent activity for the details." : "Frankie didn\u2019t return an answer. Please try again."
510
+ }));
511
+ }
414
512
  } catch (caught) {
415
513
  if (abort.signal.aborted) {
416
514
  patchAssistant(assistantId, (message) => ({
@@ -419,7 +517,7 @@ function useFrankieChat() {
419
517
  text: message.text || "Stopped."
420
518
  }));
421
519
  } else {
422
- const message = caught instanceof FrankieApiError ? caught.message : "Could not reach the assistant. Check your connection and try again.";
520
+ 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.";
423
521
  if (mounted.current) setError(message);
424
522
  patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
425
523
  }
@@ -443,6 +541,7 @@ function useFrankieChat() {
443
541
  setMessages([]);
444
542
  setError(void 0);
445
543
  setBusy(false);
544
+ setRestored(true);
446
545
  try {
447
546
  if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
448
547
  } catch {
@@ -450,10 +549,16 @@ function useFrankieChat() {
450
549
  }, [client]);
451
550
  return { messages, busy, ...error ? { error } : {}, send, stop, reset };
452
551
  }
552
+ function conversationTitle(messages) {
553
+ const first = messages.find((message) => message.role === "user" && message.text.trim());
554
+ if (!first) return void 0;
555
+ const title = first.text.trim().replace(/\s+/g, " ");
556
+ return title.length > 42 ? `${title.slice(0, 42)}\u2026` : title;
557
+ }
453
558
 
454
559
  // src/components/chat/FrankieWidget.tsx
455
- var import_lucide_react4 = require("lucide-react");
456
- var import_react8 = require("react");
560
+ var import_lucide_react5 = require("lucide-react");
561
+ var import_react9 = require("react");
457
562
 
458
563
  // src/lib/theme.ts
459
564
  function themeToCssVars(theme) {
@@ -513,8 +618,8 @@ function Button({ className, variant, size, asChild = false, ...props }) {
513
618
  }
514
619
 
515
620
  // src/components/chat/ChatPanel.tsx
516
- var import_lucide_react3 = require("lucide-react");
517
- var import_react7 = require("react");
621
+ var import_lucide_react4 = require("lucide-react");
622
+ var import_react8 = require("react");
518
623
 
519
624
  // src/components/ui/avatar.tsx
520
625
  var import_jsx_runtime3 = require("react/jsx-runtime");
@@ -598,55 +703,185 @@ var Textarea = (0, import_react4.forwardRef)(
598
703
  // src/components/chat/Composer.tsx
599
704
  var import_jsx_runtime7 = require("react/jsx-runtime");
600
705
  var MAX_ROWS_PX = 140;
601
- function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus }) {
706
+ function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, attachments, modelPicker }) {
602
707
  const [value, setValue] = (0, import_react5.useState)("");
708
+ const [files, setFiles] = (0, import_react5.useState)([]);
709
+ const [fileError, setFileError] = (0, import_react5.useState)(void 0);
603
710
  const textareaRef = (0, import_react5.useRef)(null);
711
+ const fileInputRef = (0, import_react5.useRef)(null);
712
+ const canAttach = Boolean(attachments?.enabled);
713
+ const models = modelPicker?.models ?? [];
604
714
  (0, import_react5.useEffect)(() => {
605
715
  const node = textareaRef.current;
606
716
  if (!node) return;
607
717
  node.style.height = "auto";
608
718
  node.style.height = `${Math.min(node.scrollHeight, MAX_ROWS_PX)}px`;
609
719
  }, [value]);
720
+ const addFiles = (0, import_react5.useCallback)(
721
+ async (list) => {
722
+ if (!list || !attachments) return;
723
+ setFileError(void 0);
724
+ const room = attachments.maxCount - files.length;
725
+ const chosen = Array.from(list).slice(0, Math.max(0, room));
726
+ if (Array.from(list).length > room) setFileError(`You can attach up to ${attachments.maxCount} file(s).`);
727
+ const next = [];
728
+ for (const file of chosen) {
729
+ if (!mimeAccepted(file.type || "application/octet-stream", attachments.accept)) {
730
+ setFileError(`Files of type ${file.type || "unknown"} aren't allowed.`);
731
+ continue;
732
+ }
733
+ if (file.size > attachments.maxMb * 1024 * 1024) {
734
+ setFileError(`Each file must be under ${attachments.maxMb} MB.`);
735
+ continue;
736
+ }
737
+ try {
738
+ next.push({
739
+ id: `${file.name}-${file.size}-${Math.random().toString(36).slice(2, 7)}`,
740
+ name: file.name,
741
+ mimeType: file.type || "application/octet-stream",
742
+ data: await readAsBase64(file)
743
+ });
744
+ } catch {
745
+ setFileError("Couldn't read that file.");
746
+ }
747
+ }
748
+ if (next.length) setFiles((current) => [...current, ...next]);
749
+ },
750
+ [attachments, files.length]
751
+ );
752
+ const removeFile = (id) => setFiles((current) => current.filter((file) => file.id !== id));
610
753
  const submit = (0, import_react5.useCallback)(() => {
611
754
  const text = value.trim();
612
- if (!text || busy || disabled) return;
755
+ if (!text && files.length === 0 || busy || disabled) return;
613
756
  setValue("");
614
- onSend(text);
615
- }, [busy, disabled, onSend, value]);
757
+ const attached = files.map(({ name, mimeType, data }) => ({ name, mimeType, data }));
758
+ setFiles([]);
759
+ setFileError(void 0);
760
+ onSend(text, {
761
+ ...attached.length ? { attachments: attached } : {},
762
+ ...modelPicker?.value ? { model: modelPicker.value } : {}
763
+ });
764
+ }, [busy, disabled, files, modelPicker?.value, onSend, value]);
616
765
  const onKeyDown = (event) => {
617
766
  if (event.key === "Enter" && !event.shiftKey) {
618
767
  event.preventDefault();
619
768
  submit();
620
769
  }
621
770
  };
771
+ const nothingToSend = value.trim().length === 0 && files.length === 0;
622
772
  return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
623
773
  "form",
624
774
  {
625
- className: "flex items-end gap-2",
775
+ className: "flex flex-col gap-2",
626
776
  onSubmit: (event) => {
627
777
  event.preventDefault();
628
778
  submit();
629
779
  },
630
780
  children: [
631
- /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
632
- Textarea,
781
+ files.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { className: "flex flex-wrap gap-2", children: files.map((file) => /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
782
+ "span",
633
783
  {
634
- ref: textareaRef,
635
- rows: 1,
636
- value,
637
- disabled,
638
- autoFocus,
639
- onChange: (event) => setValue(event.target.value),
640
- onKeyDown,
641
- placeholder: placeholder ?? "Ask anything\u2026",
642
- "aria-label": "Message"
643
- }
644
- ),
645
- busy ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(Button, { type: "button", size: "icon", variant: "outline", onClick: onStop, "aria-label": "Stop", children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_lucide_react.Square, {}) }) : /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(Button, { type: "submit", size: "icon", disabled: disabled || value.trim().length === 0, "aria-label": "Send", children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_lucide_react.ArrowUp, {}) })
784
+ 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",
785
+ children: [
786
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_lucide_react.Paperclip, { className: "size-3 shrink-0 opacity-70" }),
787
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: "truncate", children: file.name }),
788
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
789
+ "button",
790
+ {
791
+ type: "button",
792
+ onClick: () => removeFile(file.id),
793
+ "aria-label": `Remove ${file.name}`,
794
+ className: "frankie-focus shrink-0 opacity-60 hover:opacity-100",
795
+ children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_lucide_react.X, { className: "size-3" })
796
+ }
797
+ )
798
+ ]
799
+ },
800
+ file.id
801
+ )) }) : null,
802
+ fileError ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("p", { className: "text-xs text-red-600 dark:text-red-400", children: fileError }) : null,
803
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { className: "flex items-end gap-2", children: [
804
+ canAttach ? /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(import_jsx_runtime7.Fragment, { children: [
805
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
806
+ "input",
807
+ {
808
+ ref: fileInputRef,
809
+ type: "file",
810
+ multiple: true,
811
+ hidden: true,
812
+ accept: attachments?.accept.join(","),
813
+ onChange: (event) => {
814
+ void addFiles(event.target.files);
815
+ event.target.value = "";
816
+ }
817
+ }
818
+ ),
819
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
820
+ Button,
821
+ {
822
+ type: "button",
823
+ size: "icon",
824
+ variant: "outline",
825
+ disabled: disabled || files.length >= (attachments?.maxCount ?? 0),
826
+ onClick: () => fileInputRef.current?.click(),
827
+ "aria-label": "Attach files",
828
+ title: "Attach files",
829
+ children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_lucide_react.Paperclip, {})
830
+ }
831
+ )
832
+ ] }) : null,
833
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
834
+ Textarea,
835
+ {
836
+ ref: textareaRef,
837
+ rows: 1,
838
+ value,
839
+ disabled,
840
+ autoFocus,
841
+ onChange: (event) => setValue(event.target.value),
842
+ onKeyDown,
843
+ placeholder: placeholder ?? "Ask anything\u2026",
844
+ "aria-label": "Message"
845
+ }
846
+ ),
847
+ models.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
848
+ "select",
849
+ {
850
+ value: modelPicker?.value ?? models[0]?.id,
851
+ onChange: (event) => modelPicker?.onChange(event.target.value),
852
+ disabled,
853
+ "aria-label": "Model",
854
+ className: "frankie-focus h-9 max-w-[8rem] rounded-[var(--frankie-radius)] border border-border bg-background px-2 text-sm",
855
+ children: models.map((model) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("option", { value: model.id, children: model.label }, model.id))
856
+ }
857
+ ) : null,
858
+ busy ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(Button, { type: "button", size: "icon", variant: "outline", onClick: onStop, "aria-label": "Stop", children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_lucide_react.Square, {}) }) : /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(Button, { type: "submit", size: "icon", disabled: disabled || nothingToSend, "aria-label": "Send", children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_lucide_react.ArrowUp, {}) })
859
+ ] })
646
860
  ]
647
861
  }
648
862
  );
649
863
  }
864
+ function mimeAccepted(mimeType, accept) {
865
+ return accept.some(
866
+ (pattern) => pattern.endsWith("/*") ? mimeType.startsWith(pattern.slice(0, -1)) : pattern === mimeType
867
+ );
868
+ }
869
+ function readAsBase64(file) {
870
+ return new Promise((resolve, reject) => {
871
+ const reader = new FileReader();
872
+ reader.onload = () => {
873
+ const result = String(reader.result);
874
+ const comma = result.indexOf(",");
875
+ resolve(comma >= 0 ? result.slice(comma + 1) : result);
876
+ };
877
+ reader.onerror = () => reject(reader.error);
878
+ reader.readAsDataURL(file);
879
+ });
880
+ }
881
+
882
+ // src/components/chat/MessageBubble.tsx
883
+ var import_lucide_react3 = require("lucide-react");
884
+ var import_react7 = require("react");
650
885
 
651
886
  // src/components/chat/Markdown.tsx
652
887
  var import_react6 = require("react");
@@ -782,7 +1017,20 @@ function Badge({
782
1017
  var import_jsx_runtime10 = require("react/jsx-runtime");
783
1018
  function ToolActivityList({ tools, renderTool }) {
784
1019
  if (tools.length === 0) return null;
785
- return /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("ul", { className: "mt-2 flex flex-col gap-1", children: tools.map((tool) => /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("li", { children: renderTool ? renderTool(tool) : /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(ToolActivityRow, { tool }) }, tool.id)) });
1020
+ const running = tools.some((tool) => tool.status === "running");
1021
+ return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("details", { className: "frankie-tool-activity mt-2", open: running, children: [
1022
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("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: [
1023
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_lucide_react2.ChevronDown, { className: "frankie-tool-chevron size-3.5 transition-transform", "aria-hidden": "true" }),
1024
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("span", { children: running ? "Working" : "Activity" }),
1025
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { className: "text-[11px]", children: [
1026
+ "\xB7 ",
1027
+ tools.length,
1028
+ " tool call",
1029
+ tools.length === 1 ? "" : "s"
1030
+ ] })
1031
+ ] }),
1032
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("ul", { className: "mt-1 flex flex-col gap-1 border-l border-border pl-2", children: tools.map((tool) => /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("li", { children: renderTool ? renderTool(tool) : /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(ToolActivityRow, { tool }) }, tool.id)) })
1033
+ ] });
786
1034
  }
787
1035
  function ToolActivityRow({ tool }) {
788
1036
  return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Badge, { variant: badgeVariant(tool.status), title: tool.detail ?? tool.name, children: [
@@ -819,17 +1067,48 @@ function MessageBubble({
819
1067
  renderTool
820
1068
  }) {
821
1069
  if (message.role === "user") {
822
- return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "flex justify-end", children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "max-w-[85%] rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground", children: message.text }) });
1070
+ return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "flex flex-col items-end gap-1", children: [
1071
+ message.attachments?.length ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "flex max-w-[85%] flex-wrap justify-end gap-1", children: message.attachments.map((file, index) => /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(
1072
+ "span",
1073
+ {
1074
+ 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",
1075
+ children: [
1076
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_lucide_react3.Paperclip, { className: "size-3 shrink-0 opacity-70" }),
1077
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("span", { className: "truncate", children: file.name })
1078
+ ]
1079
+ },
1080
+ `${file.name}-${index}`
1081
+ )) }) : null,
1082
+ message.text ? /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "flex max-w-[85%] items-end gap-1", children: [
1083
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground", children: message.text }),
1084
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(MessageCopyButton, { text: message.text })
1085
+ ] }) : null
1086
+ ] });
823
1087
  }
824
1088
  const empty = message.text.length === 0 && message.tools.length === 0;
825
1089
  return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "flex gap-2", children: [
826
- /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(Avatar, { ...avatarUrl ? { src: avatarUrl } : {}, name: assistantName, className: "mt-0.5" }),
1090
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "frankie-assistant-actions mt-0.5 flex shrink-0 flex-col items-center gap-1", children: [
1091
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(Avatar, { ...avatarUrl ? { src: avatarUrl } : {}, name: assistantName }),
1092
+ message.text ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(MessageCopyButton, { text: message.text }) : null
1093
+ ] }),
827
1094
  /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "min-w-0 max-w-[85%]", children: [
828
1095
  message.status === "error" ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("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__ */ (0, import_jsx_runtime11.jsx)(TypingDots, {}) : /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(Markdown, { text: message.text, className: "text-sm" }),
829
1096
  showToolActivity ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(ToolActivityList, { tools: message.tools, ...renderTool ? { renderTool } : {} }) : null
830
1097
  ] })
831
1098
  ] });
832
1099
  }
1100
+ function MessageCopyButton({ text }) {
1101
+ const [copied, setCopied] = (0, import_react7.useState)(false);
1102
+ const copy = async () => {
1103
+ try {
1104
+ await navigator.clipboard.writeText(text);
1105
+ setCopied(true);
1106
+ window.setTimeout(() => setCopied(false), 1500);
1107
+ } catch {
1108
+ }
1109
+ };
1110
+ return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("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__ */ (0, import_jsx_runtime11.jsx)(import_lucide_react3.Check, { className: "size-3.5", "aria-hidden": "true" }) : /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_lucide_react3.Copy, { className: "size-3.5", "aria-hidden": "true" }) });
1111
+ }
833
1112
  function TypingDots() {
834
1113
  return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("span", { className: "inline-flex items-center gap-1 py-2", role: "status", "aria-label": "Thinking", children: [0, 1, 2].map((index) => /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
835
1114
  "span",
@@ -880,8 +1159,11 @@ function ChatPanel({
880
1159
  const { config, configError, loading } = useFrankie();
881
1160
  const theme = useFrankieTheme();
882
1161
  const { messages, busy, error, send, stop, reset } = useFrankieChat();
883
- const scrollRef = (0, import_react7.useRef)(null);
884
- (0, import_react7.useEffect)(() => {
1162
+ const scrollRef = (0, import_react8.useRef)(null);
1163
+ const features = config?.features;
1164
+ const pickerModels = features?.modelPicker.enabled ? features.modelPicker.models : [];
1165
+ const [model, setModel] = (0, import_react8.useState)(void 0);
1166
+ (0, import_react8.useEffect)(() => {
885
1167
  const node = scrollRef.current;
886
1168
  if (!node) return;
887
1169
  const distanceFromBottom = node.scrollHeight - node.scrollTop - node.clientHeight;
@@ -907,10 +1189,14 @@ function ChatPanel({
907
1189
  /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(CardTitle, { children: name }),
908
1190
  config?.branding.tagline ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(CardDescription, { children: config.branding.tagline }) : null
909
1191
  ] }),
910
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(Button, { size: "icon", variant: "ghost", onClick: reset, "aria-label": "Start a new conversation", title: "New conversation", children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(import_lucide_react3.RotateCcw, {}) }),
911
- onClose ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(Button, { size: "icon", variant: "ghost", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(import_lucide_react3.X, {}) }) : null
1192
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(Button, { size: "icon", variant: "ghost", onClick: reset, "aria-label": "Start a new conversation", title: "New conversation", children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(import_lucide_react4.RotateCcw, {}) }),
1193
+ onClose ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(Button, { size: "icon", variant: "ghost", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(import_lucide_react4.X, {}) }) : null
912
1194
  ] }),
913
- /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(CardContent, { children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(ScrollArea, { ref: scrollRef, className: "flex h-full flex-col gap-4 p-4", "aria-live": "polite", children: configError ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("p", { className: "text-sm text-muted-foreground", children: configError }) : loading ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("p", { className: "text-sm text-muted-foreground", children: "Loading\u2026" }) : messages.length === 0 ? emptyState ?? /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex flex-col gap-4", children: [
1195
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(CardContent, { children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(ScrollArea, { ref: scrollRef, className: "flex h-full flex-col gap-4 p-4", "aria-live": "polite", children: configError ? /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "frankie-config-error", role: "alert", children: [
1196
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("strong", { children: "Frankie couldn\u2019t start" }),
1197
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("p", { children: configError }),
1198
+ /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("p", { children: "Refresh this page after checking the workspace\u2019s allowed origin and sign-in settings." })
1199
+ ] }) : loading ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("p", { className: "text-sm text-muted-foreground", children: "Starting Frankie\u2026" }) : messages.length === 0 ? emptyState ?? /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex flex-col gap-4", children: [
914
1200
  /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("p", { className: "text-sm text-muted-foreground", children: config?.greeting }),
915
1201
  /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(SuggestedPrompts, { prompts: config?.suggestedPrompts ?? [], onPick: send, disabled: busy })
916
1202
  ] }) : /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "flex flex-col gap-4", children: messages.map(
@@ -936,7 +1222,9 @@ function ChatPanel({
936
1222
  busy,
937
1223
  disabled: Boolean(configError),
938
1224
  ...placeholder ? { placeholder } : {},
939
- ...autoFocus ? { autoFocus } : {}
1225
+ ...autoFocus ? { autoFocus } : {},
1226
+ ...features?.attachments.enabled ? { attachments: features.attachments } : {},
1227
+ ...pickerModels.length ? { modelPicker: { models: pickerModels, value: model ?? pickerModels[0]?.id, onChange: setModel } } : {}
940
1228
  }
941
1229
  ) }),
942
1230
  footer
@@ -959,13 +1247,13 @@ function FrankieWidget({
959
1247
  }) {
960
1248
  const { config } = useFrankie();
961
1249
  const theme = useFrankieTheme();
962
- const [uncontrolledOpen, setUncontrolledOpen] = (0, import_react8.useState)(defaultOpen);
1250
+ const [uncontrolledOpen, setUncontrolledOpen] = (0, import_react9.useState)(defaultOpen);
963
1251
  const open = controlledOpen ?? uncontrolledOpen;
964
1252
  const setOpen = (next) => {
965
1253
  if (controlledOpen === void 0) setUncontrolledOpen(next);
966
1254
  onOpenChange?.(next);
967
1255
  };
968
- (0, import_react8.useEffect)(() => {
1256
+ (0, import_react9.useEffect)(() => {
969
1257
  if (!open) return;
970
1258
  const onKeyDown = (event) => {
971
1259
  if (event.key === "Escape") setOpen(false);
@@ -993,7 +1281,7 @@ function FrankieWidget({
993
1281
  "aria-expanded": open,
994
1282
  "aria-label": open ? "Close the assistant" : label,
995
1283
  title: label,
996
- children: open ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_lucide_react4.X, { className: "size-5" }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_lucide_react4.MessageCircle, { className: "size-5" })
1284
+ children: open ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_lucide_react5.X, { className: "size-5" }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_lucide_react5.MessageCircle, { className: "size-5" })
997
1285
  }
998
1286
  )
999
1287
  ] })
@@ -1017,10 +1305,45 @@ function FrankieInline({ className, style, ...panelProps }) {
1017
1305
  );
1018
1306
  }
1019
1307
 
1020
- // src/components/ui/separator.tsx
1308
+ // src/components/chat/FrankieChatPage.tsx
1021
1309
  var import_jsx_runtime16 = require("react/jsx-runtime");
1310
+ function FrankieChatPage({
1311
+ className,
1312
+ style,
1313
+ header,
1314
+ footer,
1315
+ emptyState,
1316
+ renderMessage,
1317
+ renderTool,
1318
+ placeholder,
1319
+ autoFocus,
1320
+ ...providerProps
1321
+ }) {
1322
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
1323
+ "main",
1324
+ {
1325
+ className,
1326
+ style: { height: "100dvh", minHeight: "100dvh", width: "100%", ...style },
1327
+ children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(FrankieProvider, { ...providerProps, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
1328
+ FrankieInline,
1329
+ {
1330
+ header,
1331
+ footer,
1332
+ emptyState,
1333
+ renderMessage,
1334
+ renderTool,
1335
+ placeholder,
1336
+ autoFocus
1337
+ }
1338
+ ) })
1339
+ }
1340
+ );
1341
+ }
1342
+
1343
+ // src/components/ui/separator.tsx
1344
+ var import_jsx_runtime17 = require("react/jsx-runtime");
1022
1345
  function Separator({ className }) {
1023
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { role: "separator", className: cn("h-px w-full bg-border", className) });
1346
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { role: "separator", className: cn("h-px w-full bg-border", className) });
1024
1347
  }
1025
1348
  // Annotate the CommonJS export names for ESM import in node:
1026
1349
  0 && (module.exports = {
@@ -1037,6 +1360,7 @@ function Separator({ className }) {
1037
1360
  Composer,
1038
1361
  FRANKIE_CLOUD_URL,
1039
1362
  FrankieApiError,
1363
+ FrankieChatPage,
1040
1364
  FrankieClient,
1041
1365
  FrankieInline,
1042
1366
  FrankieProvider,