@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/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode, CSSProperties, ButtonHTMLAttributes, HTMLAttributes, TextareaHTMLAttributes } from 'react';
3
- import { PublicWidgetConfig, CreateSessionResponse, FrankieServerEvent, WidgetTheme } from '@frankieai/core';
3
+ import { PublicWidgetConfig, CreateSessionResponse, ChatAttachment, FrankieServerEvent, WidgetTheme } from '@frankieai/core';
4
4
  export { FrankieServerEvent, PublicWidgetConfig, WidgetTheme } from '@frankieai/core';
5
5
  import * as class_variance_authority_types from 'class-variance-authority/types';
6
6
  import { VariantProps } from 'class-variance-authority';
@@ -61,6 +61,10 @@ declare class FrankieClient {
61
61
  send(input: {
62
62
  message: string;
63
63
  context?: PageContext;
64
+ /** Model id chosen by the user, honoured only if the workspace allows it. */
65
+ model?: string;
66
+ /** Files attached to this turn. */
67
+ attachments?: ChatAttachment[];
64
68
  signal?: AbortSignal;
65
69
  }, onEvent: (event: FrankieServerEvent) => void): Promise<void>;
66
70
  private unwrap;
@@ -100,10 +104,16 @@ interface ToolActivity {
100
104
  status: "running" | "done" | "failed" | "denied";
101
105
  detail?: string;
102
106
  }
107
+ /** A file shown on a user message (name/type only — the bytes aren't kept in the transcript). */
108
+ interface MessageAttachment {
109
+ name: string;
110
+ mimeType: string;
111
+ }
103
112
  interface UserChatMessage {
104
113
  id: string;
105
114
  role: "user";
106
115
  text: string;
116
+ attachments?: MessageAttachment[];
107
117
  createdAt: number;
108
118
  }
109
119
  interface AssistantChatMessage {
@@ -125,15 +135,20 @@ type ChatMessage = UserChatMessage | AssistantChatMessage;
125
135
  */
126
136
  declare function applyEvent(message: AssistantChatMessage, event: FrankieServerEvent): AssistantChatMessage;
127
137
  declare function newAssistantMessage(id: string): AssistantChatMessage;
128
- declare function newUserMessage(id: string, text: string): UserChatMessage;
138
+ declare function newUserMessage(id: string, text: string, attachments?: MessageAttachment[]): UserChatMessage;
129
139
 
140
+ /** Extra inputs a turn can carry: attached files and a chosen model. */
141
+ interface SendOptions {
142
+ attachments?: ChatAttachment[];
143
+ model?: string;
144
+ }
130
145
  interface UseFrankieChatResult {
131
146
  messages: ChatMessage[];
132
147
  /** True from the moment a message is sent until the turn ends. */
133
148
  busy: boolean;
134
149
  /** Set when the turn failed outright (network, auth, rate limit). */
135
150
  error?: string;
136
- send: (text: string) => Promise<void>;
151
+ send: (text: string, opts?: SendOptions) => Promise<void>;
137
152
  /** Abort the in-flight turn. The partial answer stays in the transcript. */
138
153
  stop: () => void;
139
154
  /** Clear the transcript and start a brand-new server session. */
@@ -200,15 +215,37 @@ interface FrankieInlineProps extends ChatPanelProps {
200
215
  */
201
216
  declare function FrankieInline({ className, style, ...panelProps }: FrankieInlineProps): react.JSX.Element;
202
217
 
218
+ interface ComposerAttachmentsConfig {
219
+ enabled: boolean;
220
+ /** Accepted MIME patterns, e.g. "image/*". */
221
+ accept: string[];
222
+ maxCount: number;
223
+ maxMb: number;
224
+ }
225
+ interface ComposerModelPicker {
226
+ models: {
227
+ id: string;
228
+ label: string;
229
+ }[];
230
+ value?: string;
231
+ onChange: (id: string) => void;
232
+ }
203
233
  interface ComposerProps {
204
- onSend: (text: string) => void;
234
+ onSend: (text: string, opts?: {
235
+ attachments?: ChatAttachment[];
236
+ model?: string;
237
+ }) => void;
205
238
  onStop: () => void;
206
239
  busy: boolean;
207
240
  placeholder?: string;
208
241
  disabled?: boolean;
209
242
  autoFocus?: boolean;
243
+ /** Enables the attach button when provided and `enabled`. */
244
+ attachments?: ComposerAttachmentsConfig;
245
+ /** Enables a model dropdown when provided with at least one model. */
246
+ modelPicker?: ComposerModelPicker;
210
247
  }
211
- declare function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus }: ComposerProps): react.JSX.Element;
248
+ declare function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, attachments, modelPicker }: ComposerProps): react.JSX.Element;
212
249
 
213
250
  interface MessageBubbleProps {
214
251
  message: ChatMessage;
@@ -307,4 +344,4 @@ declare function resolveMode(mode: WidgetTheme["mode"]): "light" | "dark";
307
344
  /** shadcn/ui's class helper: conditional classes with Tailwind conflict resolution. */
308
345
  declare function cn(...inputs: ClassValue[]): string;
309
346
 
310
- export { type AssistantChatMessage, Avatar, Badge, Button, type ButtonProps, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, type ChatMessage, ChatPanel, type ChatPanelProps, Composer, FRANKIE_CLOUD_URL, FrankieApiError, FrankieClient, type FrankieClientOptions, FrankieInline, type FrankieInlineProps, FrankieProvider, type FrankieProviderProps, FrankieWidget, type FrankieWidgetProps, Markdown, MessageBubble, type PageContext, ScrollArea, Separator, SuggestedPrompts, Textarea, type ToolActivity, ToolActivityList, ToolActivityRow, TypingDots, type UseFrankieChatResult, type UserChatMessage, applyEvent, buttonVariants, cn, newAssistantMessage, newUserMessage, readEventStream, resolveMode, themeToCssVars, useFrankie, useFrankieChat, useFrankieTheme };
347
+ export { type AssistantChatMessage, Avatar, Badge, Button, type ButtonProps, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, type ChatMessage, ChatPanel, type ChatPanelProps, Composer, FRANKIE_CLOUD_URL, FrankieApiError, FrankieClient, type FrankieClientOptions, FrankieInline, type FrankieInlineProps, FrankieProvider, type FrankieProviderProps, FrankieWidget, type FrankieWidgetProps, Markdown, type MessageAttachment, MessageBubble, type PageContext, ScrollArea, type SendOptions, Separator, SuggestedPrompts, Textarea, type ToolActivity, ToolActivityList, ToolActivityRow, TypingDots, type UseFrankieChatResult, type UserChatMessage, applyEvent, buttonVariants, cn, newAssistantMessage, newUserMessage, readEventStream, resolveMode, themeToCssVars, useFrankie, useFrankieChat, useFrankieTheme };
package/dist/index.js CHANGED
@@ -91,7 +91,9 @@ var FrankieClient = class {
91
91
  const body = {
92
92
  sessionId: session.sessionId,
93
93
  message: input.message,
94
- ...input.context ? { context: input.context } : {}
94
+ ...input.context ? { context: input.context } : {},
95
+ ...input.model ? { model: input.model } : {},
96
+ ...input.attachments?.length ? { attachments: input.attachments } : {}
95
97
  };
96
98
  const response = await this.doFetch(`${this.baseUrl}/v1/chat`, {
97
99
  method: "POST",
@@ -285,8 +287,8 @@ function fallbackText(stopReason) {
285
287
  function newAssistantMessage(id) {
286
288
  return { id, role: "assistant", text: "", thinking: "", tools: [], status: "streaming", createdAt: Date.now() };
287
289
  }
288
- function newUserMessage(id, text) {
289
- return { id, role: "user", text, createdAt: Date.now() };
290
+ function newUserMessage(id, text, attachments) {
291
+ return { id, role: "user", text, ...attachments?.length ? { attachments } : {}, createdAt: Date.now() };
290
292
  }
291
293
 
292
294
  // src/hooks/useFrankieChat.ts
@@ -332,24 +334,39 @@ function useFrankieChat() {
332
334
  );
333
335
  }, []);
334
336
  const send = useCallback(
335
- async (text) => {
337
+ async (text, opts) => {
336
338
  const trimmed = text.trim();
337
- if (!trimmed || busy) return;
339
+ const attachments = opts?.attachments ?? [];
340
+ if (!trimmed && attachments.length === 0 || busy) return;
341
+ const outgoing = trimmed || "Please take a look at the attached file(s).";
338
342
  const assistantId = nextId("a");
339
343
  setError(void 0);
340
344
  setBusy(true);
341
345
  setMessages((current) => [
342
346
  ...current,
343
- newUserMessage(nextId("u"), trimmed),
347
+ newUserMessage(
348
+ nextId("u"),
349
+ trimmed,
350
+ attachments.map((file) => ({ name: file.name, mimeType: file.mimeType }))
351
+ ),
344
352
  newAssistantMessage(assistantId)
345
353
  ]);
346
354
  const abort = new AbortController();
347
355
  abortRef.current = abort;
348
356
  try {
349
- await client.send({ message: trimmed, ...context ? { context } : {}, signal: abort.signal }, (event) => {
350
- if (event.type === "ping" || event.type === "session") return;
351
- patchAssistant(assistantId, (message) => applyEvent(message, event));
352
- });
357
+ await client.send(
358
+ {
359
+ message: outgoing,
360
+ ...context ? { context } : {},
361
+ ...opts?.model ? { model: opts.model } : {},
362
+ ...attachments.length ? { attachments } : {},
363
+ signal: abort.signal
364
+ },
365
+ (event) => {
366
+ if (event.type === "ping" || event.type === "session") return;
367
+ patchAssistant(assistantId, (message) => applyEvent(message, event));
368
+ }
369
+ );
353
370
  } catch (caught) {
354
371
  if (abort.signal.aborted) {
355
372
  patchAssistant(assistantId, (message) => ({
@@ -391,8 +408,8 @@ function useFrankieChat() {
391
408
  }
392
409
 
393
410
  // src/components/chat/FrankieWidget.tsx
394
- import { MessageCircle, X as X3 } from "lucide-react";
395
- import { useEffect as useEffect5, useState as useState4 } from "react";
411
+ import { MessageCircle, X as X4 } from "lucide-react";
412
+ import { useEffect as useEffect5, useState as useState5 } from "react";
396
413
 
397
414
  // src/lib/theme.ts
398
415
  function themeToCssVars(theme) {
@@ -452,8 +469,8 @@ function Button({ className, variant, size, asChild = false, ...props }) {
452
469
  }
453
470
 
454
471
  // src/components/chat/ChatPanel.tsx
455
- import { RotateCcw, X as X2 } from "lucide-react";
456
- import { useEffect as useEffect4, useRef as useRef3 } from "react";
472
+ import { RotateCcw, X as X3 } from "lucide-react";
473
+ import { useEffect as useEffect4, useRef as useRef3, useState as useState4 } from "react";
457
474
 
458
475
  // src/components/ui/avatar.tsx
459
476
  import { jsx as jsx3 } from "react/jsx-runtime";
@@ -512,7 +529,7 @@ var ScrollArea = forwardRef(
512
529
  );
513
530
 
514
531
  // src/components/chat/Composer.tsx
515
- import { ArrowUp, Square } from "lucide-react";
532
+ import { ArrowUp, Paperclip, Square, X } from "lucide-react";
516
533
  import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
517
534
 
518
535
  // src/components/ui/textarea.tsx
@@ -535,60 +552,189 @@ var Textarea = forwardRef2(
535
552
  );
536
553
 
537
554
  // src/components/chat/Composer.tsx
538
- import { jsx as jsx7, jsxs } from "react/jsx-runtime";
555
+ import { Fragment, jsx as jsx7, jsxs } from "react/jsx-runtime";
539
556
  var MAX_ROWS_PX = 140;
540
- function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus }) {
557
+ function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, attachments, modelPicker }) {
541
558
  const [value, setValue] = useState3("");
559
+ const [files, setFiles] = useState3([]);
560
+ const [fileError, setFileError] = useState3(void 0);
542
561
  const textareaRef = useRef2(null);
562
+ const fileInputRef = useRef2(null);
563
+ const canAttach = Boolean(attachments?.enabled);
564
+ const models = modelPicker?.models ?? [];
543
565
  useEffect3(() => {
544
566
  const node = textareaRef.current;
545
567
  if (!node) return;
546
568
  node.style.height = "auto";
547
569
  node.style.height = `${Math.min(node.scrollHeight, MAX_ROWS_PX)}px`;
548
570
  }, [value]);
571
+ const addFiles = useCallback2(
572
+ async (list) => {
573
+ if (!list || !attachments) return;
574
+ setFileError(void 0);
575
+ const room = attachments.maxCount - files.length;
576
+ const chosen = Array.from(list).slice(0, Math.max(0, room));
577
+ if (Array.from(list).length > room) setFileError(`You can attach up to ${attachments.maxCount} file(s).`);
578
+ const next = [];
579
+ for (const file of chosen) {
580
+ if (!mimeAccepted(file.type || "application/octet-stream", attachments.accept)) {
581
+ setFileError(`Files of type ${file.type || "unknown"} aren't allowed.`);
582
+ continue;
583
+ }
584
+ if (file.size > attachments.maxMb * 1024 * 1024) {
585
+ setFileError(`Each file must be under ${attachments.maxMb} MB.`);
586
+ continue;
587
+ }
588
+ try {
589
+ next.push({
590
+ id: `${file.name}-${file.size}-${Math.random().toString(36).slice(2, 7)}`,
591
+ name: file.name,
592
+ mimeType: file.type || "application/octet-stream",
593
+ data: await readAsBase64(file)
594
+ });
595
+ } catch {
596
+ setFileError("Couldn't read that file.");
597
+ }
598
+ }
599
+ if (next.length) setFiles((current) => [...current, ...next]);
600
+ },
601
+ [attachments, files.length]
602
+ );
603
+ const removeFile = (id) => setFiles((current) => current.filter((file) => file.id !== id));
549
604
  const submit = useCallback2(() => {
550
605
  const text = value.trim();
551
- if (!text || busy || disabled) return;
606
+ if (!text && files.length === 0 || busy || disabled) return;
552
607
  setValue("");
553
- onSend(text);
554
- }, [busy, disabled, onSend, value]);
608
+ const attached = files.map(({ name, mimeType, data }) => ({ name, mimeType, data }));
609
+ setFiles([]);
610
+ setFileError(void 0);
611
+ onSend(text, {
612
+ ...attached.length ? { attachments: attached } : {},
613
+ ...modelPicker?.value ? { model: modelPicker.value } : {}
614
+ });
615
+ }, [busy, disabled, files, modelPicker?.value, onSend, value]);
555
616
  const onKeyDown = (event) => {
556
617
  if (event.key === "Enter" && !event.shiftKey) {
557
618
  event.preventDefault();
558
619
  submit();
559
620
  }
560
621
  };
622
+ const nothingToSend = value.trim().length === 0 && files.length === 0;
561
623
  return /* @__PURE__ */ jsxs(
562
624
  "form",
563
625
  {
564
- className: "flex items-end gap-2",
626
+ className: "flex flex-col gap-2",
565
627
  onSubmit: (event) => {
566
628
  event.preventDefault();
567
629
  submit();
568
630
  },
569
631
  children: [
570
- /* @__PURE__ */ jsx7(
571
- Textarea,
632
+ files.length > 0 ? /* @__PURE__ */ jsx7("div", { className: "flex flex-wrap gap-2", children: files.map((file) => /* @__PURE__ */ jsxs(
633
+ "span",
572
634
  {
573
- ref: textareaRef,
574
- rows: 1,
575
- value,
576
- disabled,
577
- autoFocus,
578
- onChange: (event) => setValue(event.target.value),
579
- onKeyDown,
580
- placeholder: placeholder ?? "Ask anything\u2026",
581
- "aria-label": "Message"
582
- }
583
- ),
584
- busy ? /* @__PURE__ */ jsx7(Button, { type: "button", size: "icon", variant: "outline", onClick: onStop, "aria-label": "Stop", children: /* @__PURE__ */ jsx7(Square, {}) }) : /* @__PURE__ */ jsx7(Button, { type: "submit", size: "icon", disabled: disabled || value.trim().length === 0, "aria-label": "Send", children: /* @__PURE__ */ jsx7(ArrowUp, {}) })
635
+ 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",
636
+ children: [
637
+ /* @__PURE__ */ jsx7(Paperclip, { className: "size-3 shrink-0 opacity-70" }),
638
+ /* @__PURE__ */ jsx7("span", { className: "truncate", children: file.name }),
639
+ /* @__PURE__ */ jsx7(
640
+ "button",
641
+ {
642
+ type: "button",
643
+ onClick: () => removeFile(file.id),
644
+ "aria-label": `Remove ${file.name}`,
645
+ className: "frankie-focus shrink-0 opacity-60 hover:opacity-100",
646
+ children: /* @__PURE__ */ jsx7(X, { className: "size-3" })
647
+ }
648
+ )
649
+ ]
650
+ },
651
+ file.id
652
+ )) }) : null,
653
+ fileError ? /* @__PURE__ */ jsx7("p", { className: "text-xs text-red-600 dark:text-red-400", children: fileError }) : null,
654
+ /* @__PURE__ */ jsxs("div", { className: "flex items-end gap-2", children: [
655
+ canAttach ? /* @__PURE__ */ jsxs(Fragment, { children: [
656
+ /* @__PURE__ */ jsx7(
657
+ "input",
658
+ {
659
+ ref: fileInputRef,
660
+ type: "file",
661
+ multiple: true,
662
+ hidden: true,
663
+ accept: attachments?.accept.join(","),
664
+ onChange: (event) => {
665
+ void addFiles(event.target.files);
666
+ event.target.value = "";
667
+ }
668
+ }
669
+ ),
670
+ /* @__PURE__ */ jsx7(
671
+ Button,
672
+ {
673
+ type: "button",
674
+ size: "icon",
675
+ variant: "outline",
676
+ disabled: disabled || files.length >= (attachments?.maxCount ?? 0),
677
+ onClick: () => fileInputRef.current?.click(),
678
+ "aria-label": "Attach files",
679
+ title: "Attach files",
680
+ children: /* @__PURE__ */ jsx7(Paperclip, {})
681
+ }
682
+ )
683
+ ] }) : null,
684
+ /* @__PURE__ */ jsx7(
685
+ Textarea,
686
+ {
687
+ ref: textareaRef,
688
+ rows: 1,
689
+ value,
690
+ disabled,
691
+ autoFocus,
692
+ onChange: (event) => setValue(event.target.value),
693
+ onKeyDown,
694
+ placeholder: placeholder ?? "Ask anything\u2026",
695
+ "aria-label": "Message"
696
+ }
697
+ ),
698
+ models.length > 0 ? /* @__PURE__ */ jsx7(
699
+ "select",
700
+ {
701
+ value: modelPicker?.value ?? models[0]?.id,
702
+ onChange: (event) => modelPicker?.onChange(event.target.value),
703
+ disabled,
704
+ "aria-label": "Model",
705
+ className: "frankie-focus h-9 max-w-[8rem] rounded-[var(--frankie-radius)] border border-border bg-background px-2 text-sm",
706
+ children: models.map((model) => /* @__PURE__ */ jsx7("option", { value: model.id, children: model.label }, model.id))
707
+ }
708
+ ) : null,
709
+ busy ? /* @__PURE__ */ jsx7(Button, { type: "button", size: "icon", variant: "outline", onClick: onStop, "aria-label": "Stop", children: /* @__PURE__ */ jsx7(Square, {}) }) : /* @__PURE__ */ jsx7(Button, { type: "submit", size: "icon", disabled: disabled || nothingToSend, "aria-label": "Send", children: /* @__PURE__ */ jsx7(ArrowUp, {}) })
710
+ ] })
585
711
  ]
586
712
  }
587
713
  );
588
714
  }
715
+ function mimeAccepted(mimeType, accept) {
716
+ return accept.some(
717
+ (pattern) => pattern.endsWith("/*") ? mimeType.startsWith(pattern.slice(0, -1)) : pattern === mimeType
718
+ );
719
+ }
720
+ function readAsBase64(file) {
721
+ return new Promise((resolve, reject) => {
722
+ const reader = new FileReader();
723
+ reader.onload = () => {
724
+ const result = String(reader.result);
725
+ const comma = result.indexOf(",");
726
+ resolve(comma >= 0 ? result.slice(comma + 1) : result);
727
+ };
728
+ reader.onerror = () => reject(reader.error);
729
+ reader.readAsDataURL(file);
730
+ });
731
+ }
732
+
733
+ // src/components/chat/MessageBubble.tsx
734
+ import { Paperclip as Paperclip2 } from "lucide-react";
589
735
 
590
736
  // src/components/chat/Markdown.tsx
591
- import { Fragment } from "react";
737
+ import { Fragment as Fragment2 } from "react";
592
738
  import { jsx as jsx8 } from "react/jsx-runtime";
593
739
  function Markdown({ text, className }) {
594
740
  return /* @__PURE__ */ jsx8("div", { className: cn("frankie-prose", className), children: renderBlocks(text) });
@@ -673,7 +819,7 @@ function renderInline(text) {
673
819
  const token = match[0];
674
820
  const start = match.index;
675
821
  if (start > lastIndex) nodes.push(text.slice(lastIndex, start));
676
- nodes.push(/* @__PURE__ */ jsx8(Fragment, { children: renderToken(token) }, start));
822
+ nodes.push(/* @__PURE__ */ jsx8(Fragment2, { children: renderToken(token) }, start));
677
823
  lastIndex = start + token.length;
678
824
  }
679
825
  if (lastIndex < text.length) nodes.push(text.slice(lastIndex));
@@ -690,7 +836,7 @@ function renderToken(token) {
690
836
  }
691
837
 
692
838
  // src/components/chat/ToolActivityList.tsx
693
- import { Check, Loader2, ShieldAlert, X } from "lucide-react";
839
+ import { Check, Loader2, ShieldAlert, X as X2 } from "lucide-react";
694
840
 
695
841
  // src/components/ui/badge.tsx
696
842
  import { cva as cva2 } from "class-variance-authority";
@@ -738,7 +884,7 @@ function ToolIcon({ status }) {
738
884
  case "denied":
739
885
  return /* @__PURE__ */ jsx10(ShieldAlert, { className: "size-3", "aria-hidden": "true" });
740
886
  default:
741
- return /* @__PURE__ */ jsx10(X, { className: "size-3", "aria-hidden": "true" });
887
+ return /* @__PURE__ */ jsx10(X2, { className: "size-3", "aria-hidden": "true" });
742
888
  }
743
889
  }
744
890
  function badgeVariant(status) {
@@ -758,7 +904,20 @@ function MessageBubble({
758
904
  renderTool
759
905
  }) {
760
906
  if (message.role === "user") {
761
- return /* @__PURE__ */ jsx11("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx11("div", { className: "max-w-[85%] rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground", children: message.text }) });
907
+ return /* @__PURE__ */ jsxs3("div", { className: "flex flex-col items-end gap-1", children: [
908
+ message.attachments?.length ? /* @__PURE__ */ jsx11("div", { className: "flex max-w-[85%] flex-wrap justify-end gap-1", children: message.attachments.map((file, index) => /* @__PURE__ */ jsxs3(
909
+ "span",
910
+ {
911
+ 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",
912
+ children: [
913
+ /* @__PURE__ */ jsx11(Paperclip2, { className: "size-3 shrink-0 opacity-70" }),
914
+ /* @__PURE__ */ jsx11("span", { className: "truncate", children: file.name })
915
+ ]
916
+ },
917
+ `${file.name}-${index}`
918
+ )) }) : null,
919
+ message.text ? /* @__PURE__ */ jsx11("div", { className: "max-w-[85%] rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground", children: message.text }) : null
920
+ ] });
762
921
  }
763
922
  const empty = message.text.length === 0 && message.tools.length === 0;
764
923
  return /* @__PURE__ */ jsxs3("div", { className: "flex gap-2", children: [
@@ -820,6 +979,9 @@ function ChatPanel({
820
979
  const theme = useFrankieTheme();
821
980
  const { messages, busy, error, send, stop, reset } = useFrankieChat();
822
981
  const scrollRef = useRef3(null);
982
+ const features = config?.features;
983
+ const pickerModels = features?.modelPicker.enabled ? features.modelPicker.models : [];
984
+ const [model, setModel] = useState4(void 0);
823
985
  useEffect4(() => {
824
986
  const node = scrollRef.current;
825
987
  if (!node) return;
@@ -847,7 +1009,7 @@ function ChatPanel({
847
1009
  config?.branding.tagline ? /* @__PURE__ */ jsx13(CardDescription, { children: config.branding.tagline }) : null
848
1010
  ] }),
849
1011
  /* @__PURE__ */ jsx13(Button, { size: "icon", variant: "ghost", onClick: reset, "aria-label": "Start a new conversation", title: "New conversation", children: /* @__PURE__ */ jsx13(RotateCcw, {}) }),
850
- onClose ? /* @__PURE__ */ jsx13(Button, { size: "icon", variant: "ghost", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ jsx13(X2, {}) }) : null
1012
+ onClose ? /* @__PURE__ */ jsx13(Button, { size: "icon", variant: "ghost", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ jsx13(X3, {}) }) : null
851
1013
  ] }),
852
1014
  /* @__PURE__ */ jsx13(CardContent, { children: /* @__PURE__ */ jsx13(ScrollArea, { ref: scrollRef, className: "flex h-full flex-col gap-4 p-4", "aria-live": "polite", children: configError ? /* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: configError }) : loading ? /* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: "Loading\u2026" }) : messages.length === 0 ? emptyState ?? /* @__PURE__ */ jsxs4("div", { className: "flex flex-col gap-4", children: [
853
1015
  /* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: config?.greeting }),
@@ -875,7 +1037,9 @@ function ChatPanel({
875
1037
  busy,
876
1038
  disabled: Boolean(configError),
877
1039
  ...placeholder ? { placeholder } : {},
878
- ...autoFocus ? { autoFocus } : {}
1040
+ ...autoFocus ? { autoFocus } : {},
1041
+ ...features?.attachments.enabled ? { attachments: features.attachments } : {},
1042
+ ...pickerModels.length ? { modelPicker: { models: pickerModels, value: model ?? pickerModels[0]?.id, onChange: setModel } } : {}
879
1043
  }
880
1044
  ) }),
881
1045
  footer
@@ -898,7 +1062,7 @@ function FrankieWidget({
898
1062
  }) {
899
1063
  const { config } = useFrankie();
900
1064
  const theme = useFrankieTheme();
901
- const [uncontrolledOpen, setUncontrolledOpen] = useState4(defaultOpen);
1065
+ const [uncontrolledOpen, setUncontrolledOpen] = useState5(defaultOpen);
902
1066
  const open = controlledOpen ?? uncontrolledOpen;
903
1067
  const setOpen = (next) => {
904
1068
  if (controlledOpen === void 0) setUncontrolledOpen(next);
@@ -932,7 +1096,7 @@ function FrankieWidget({
932
1096
  "aria-expanded": open,
933
1097
  "aria-label": open ? "Close the assistant" : label,
934
1098
  title: label,
935
- children: open ? /* @__PURE__ */ jsx14(X3, { className: "size-5" }) : /* @__PURE__ */ jsx14(MessageCircle, { className: "size-5" })
1099
+ children: open ? /* @__PURE__ */ jsx14(X4, { className: "size-5" }) : /* @__PURE__ */ jsx14(MessageCircle, { className: "size-5" })
936
1100
  }
937
1101
  )
938
1102
  ] })