@melony/react 0.1.22 → 0.1.24

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
@@ -584,11 +584,22 @@ function Composer({
584
584
  className,
585
585
  options = [],
586
586
  autoFocus = false,
587
- defaultSelectedIds = []
587
+ defaultSelectedIds = [],
588
+ fileAttachments,
589
+ // Legacy props for backward compatibility
590
+ accept: legacyAccept,
591
+ maxFiles: legacyMaxFiles,
592
+ maxFileSize: legacyMaxFileSize
588
593
  }) {
594
+ const enabled = fileAttachments?.enabled !== false;
595
+ const accept = fileAttachments?.accept ?? legacyAccept;
596
+ const maxFiles = fileAttachments?.maxFiles ?? legacyMaxFiles ?? 10;
597
+ const maxFileSize = fileAttachments?.maxFileSize ?? legacyMaxFileSize ?? 10 * 1024 * 1024;
589
598
  const [selectedOptions, setSelectedOptions] = React10__namespace.default.useState(
590
599
  () => new Set(defaultSelectedIds)
591
600
  );
601
+ const [attachedFiles, setAttachedFiles] = React10__namespace.default.useState([]);
602
+ const fileInputRef = React10__namespace.default.useRef(null);
592
603
  const toggleOption = (id, groupOptions, type = "multiple") => {
593
604
  const next = new Set(selectedOptions);
594
605
  if (type === "single") {
@@ -608,7 +619,34 @@ function Composer({
608
619
  }
609
620
  setSelectedOptions(next);
610
621
  };
611
- const handleInternalSubmit = () => {
622
+ const handleFileSelect = (e) => {
623
+ const files = Array.from(e.target.files || []);
624
+ const validFiles = files.filter((file) => {
625
+ if (file.size > maxFileSize) {
626
+ console.warn(`File ${file.name} exceeds maximum size of ${maxFileSize} bytes`);
627
+ return false;
628
+ }
629
+ return true;
630
+ });
631
+ const remainingSlots = maxFiles - attachedFiles.length;
632
+ const filesToAdd = validFiles.slice(0, remainingSlots);
633
+ if (filesToAdd.length < validFiles.length) {
634
+ console.warn(`Only ${filesToAdd.length} files can be added (max: ${maxFiles})`);
635
+ }
636
+ setAttachedFiles((prev) => [...prev, ...filesToAdd]);
637
+ if (fileInputRef.current) {
638
+ fileInputRef.current.value = "";
639
+ }
640
+ };
641
+ const handleRemoveFile = (index) => {
642
+ setAttachedFiles((prev) => prev.filter((_, i) => i !== index));
643
+ };
644
+ const formatFileSize = (bytes) => {
645
+ if (bytes < 1024) return bytes + " B";
646
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
647
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
648
+ };
649
+ const handleInternalSubmit = async () => {
612
650
  const state = {};
613
651
  options.forEach((group) => {
614
652
  const selectedInGroup = group.options.filter(
@@ -625,85 +663,178 @@ function Composer({
625
663
  }
626
664
  }
627
665
  });
666
+ if (attachedFiles.length > 0) {
667
+ const filePromises = attachedFiles.map((file) => {
668
+ return new Promise((resolve, reject) => {
669
+ const reader = new FileReader();
670
+ reader.onload = () => {
671
+ try {
672
+ const base64 = reader.result;
673
+ if (!base64) {
674
+ reject(new Error("FileReader returned empty result"));
675
+ return;
676
+ }
677
+ const base64Data = base64.includes(",") ? base64.split(",")[1] : base64;
678
+ resolve({
679
+ name: file.name,
680
+ type: file.type,
681
+ size: file.size,
682
+ data: base64Data
683
+ });
684
+ } catch (error) {
685
+ reject(error);
686
+ }
687
+ };
688
+ reader.onerror = (error) => {
689
+ reject(new Error(`Failed to read file ${file.name}: ${error}`));
690
+ };
691
+ reader.onabort = () => {
692
+ reject(new Error(`File read aborted for ${file.name}`));
693
+ };
694
+ reader.readAsDataURL(file);
695
+ });
696
+ });
697
+ try {
698
+ const convertedFiles = await Promise.all(filePromises);
699
+ if (convertedFiles.length > 0) {
700
+ state.files = convertedFiles;
701
+ }
702
+ } catch (error) {
703
+ console.error("Failed to convert files to base64:", error);
704
+ }
705
+ }
628
706
  onSubmit(state);
707
+ setAttachedFiles([]);
629
708
  };
630
709
  const handleKeyDown = (e) => {
631
710
  if (e.key === "Enter" && !e.shiftKey) {
632
711
  e.preventDefault();
633
- handleInternalSubmit();
712
+ handleInternalSubmit().catch(console.error);
634
713
  }
635
714
  };
636
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: cn("relative flex flex-col w-full", className), children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative flex flex-col w-full border-input border-[1.5px] rounded-3xl bg-background shadow-sm focus-within:border-ring transition-all p-2", children: [
637
- /* @__PURE__ */ jsxRuntime.jsx(
638
- Textarea,
715
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: cn("relative flex flex-col w-full", className), children: [
716
+ enabled && attachedFiles.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "mb-2 flex flex-wrap gap-2", children: attachedFiles.map((file, index) => /* @__PURE__ */ jsxRuntime.jsxs(
717
+ "div",
639
718
  {
640
- value,
641
- onChange: (e) => onChange(e.target.value),
642
- onKeyDown: handleKeyDown,
643
- placeholder,
644
- className: "min-h-[44px] max-h-[200px] border-none bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-3 py-2 text-[15px] resize-none",
645
- autoFocus
646
- }
647
- ),
648
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex justify-between items-center px-1", children: [
649
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-center gap-1", children: options.map((group) => {
650
- const selectedInGroup = group.options.filter(
651
- (o) => selectedOptions.has(o.id)
652
- );
653
- const label = selectedInGroup.length === 0 ? group.label : selectedInGroup.length === 1 ? selectedInGroup[0].label : `${group.label} (${selectedInGroup.length})`;
654
- const isSingle = group.type === "single";
655
- return /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenu, { children: [
719
+ className: "flex items-center gap-2 px-3 py-1.5 bg-muted rounded-lg text-sm",
720
+ children: [
721
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate max-w-[200px]", title: file.name, children: file.name }),
722
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-muted-foreground text-xs", children: formatFileSize(file.size) }),
656
723
  /* @__PURE__ */ jsxRuntime.jsx(
657
- DropdownMenuTrigger,
724
+ "button",
658
725
  {
659
- render: /* @__PURE__ */ jsxRuntime.jsxs(
660
- Button,
661
- {
662
- variant: "ghost",
663
- size: "sm",
664
- className: cn(
665
- selectedInGroup.length > 0 ? "text-foreground bg-muted/50" : "text-muted-foreground"
666
- ),
667
- children: [
668
- label,
669
- /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconChevronDown, { className: "h-3 w-3 opacity-50" })
670
- ]
671
- }
672
- )
726
+ type: "button",
727
+ onClick: () => handleRemoveFile(index),
728
+ className: "ml-1 hover:bg-muted-foreground/20 rounded p-0.5 transition-colors",
729
+ "aria-label": "Remove file",
730
+ children: /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconX, { className: "h-3.5 w-3.5" })
673
731
  }
674
- ),
675
- /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuContent, { align: "start", className: "w-56", children: /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenuGroup, { children: [
676
- /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuLabel, { children: group.label }),
677
- /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuSeparator, {}),
678
- group.options.map((option) => /* @__PURE__ */ jsxRuntime.jsx(
679
- DropdownMenuCheckboxItem,
680
- {
681
- checked: selectedOptions.has(option.id),
682
- onCheckedChange: () => toggleOption(
683
- option.id,
684
- group.options,
685
- isSingle ? "single" : "multiple"
686
- ),
687
- onSelect: (e) => e.preventDefault(),
688
- children: option.label
689
- },
690
- option.id
691
- ))
692
- ] }) })
693
- ] }, group.id);
694
- }) }),
732
+ )
733
+ ]
734
+ },
735
+ index
736
+ )) }),
737
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative flex flex-col w-full border-input border-[1.5px] rounded-3xl bg-background shadow-sm focus-within:border-ring transition-all p-2", children: [
695
738
  /* @__PURE__ */ jsxRuntime.jsx(
696
- Button,
739
+ Textarea,
697
740
  {
698
- type: "submit",
699
- disabled: !value.trim() && !isLoading || isLoading,
700
- size: "icon-lg",
701
- onClick: handleInternalSubmit,
702
- children: isLoading ? /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconLoader2, { className: "h-5 w-5 animate-spin" }) : /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconArrowUp, { className: "h-5 w-5" })
741
+ value,
742
+ onChange: (e) => onChange(e.target.value),
743
+ onKeyDown: handleKeyDown,
744
+ placeholder,
745
+ className: "min-h-[44px] max-h-[200px] border-none bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-3 py-2 text-[15px] resize-none",
746
+ autoFocus
703
747
  }
704
- )
748
+ ),
749
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex justify-between items-center px-1", children: [
750
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-1", children: [
751
+ enabled && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
752
+ /* @__PURE__ */ jsxRuntime.jsx(
753
+ "input",
754
+ {
755
+ ref: fileInputRef,
756
+ type: "file",
757
+ multiple: true,
758
+ accept,
759
+ onChange: handleFileSelect,
760
+ className: "hidden",
761
+ disabled: isLoading || attachedFiles.length >= maxFiles
762
+ }
763
+ ),
764
+ /* @__PURE__ */ jsxRuntime.jsx(
765
+ Button,
766
+ {
767
+ type: "button",
768
+ variant: "ghost",
769
+ size: "sm",
770
+ onClick: () => fileInputRef.current?.click(),
771
+ disabled: isLoading || attachedFiles.length >= maxFiles,
772
+ className: "text-muted-foreground",
773
+ title: attachedFiles.length >= maxFiles ? `Maximum ${maxFiles} files allowed` : "Attach file",
774
+ children: /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconPaperclip, { className: "h-4 w-4" })
775
+ }
776
+ )
777
+ ] }),
778
+ options.map((group) => {
779
+ const selectedInGroup = group.options.filter(
780
+ (o) => selectedOptions.has(o.id)
781
+ );
782
+ const label = selectedInGroup.length === 0 ? group.label : selectedInGroup.length === 1 ? selectedInGroup[0].label : `${group.label} (${selectedInGroup.length})`;
783
+ const isSingle = group.type === "single";
784
+ return /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenu, { children: [
785
+ /* @__PURE__ */ jsxRuntime.jsx(
786
+ DropdownMenuTrigger,
787
+ {
788
+ render: /* @__PURE__ */ jsxRuntime.jsxs(
789
+ Button,
790
+ {
791
+ variant: "ghost",
792
+ size: "sm",
793
+ className: cn(
794
+ selectedInGroup.length > 0 ? "text-foreground bg-muted/50" : "text-muted-foreground"
795
+ ),
796
+ children: [
797
+ label,
798
+ /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconChevronDown, { className: "h-3 w-3 opacity-50" })
799
+ ]
800
+ }
801
+ )
802
+ }
803
+ ),
804
+ /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuContent, { align: "start", className: "w-56", children: /* @__PURE__ */ jsxRuntime.jsxs(DropdownMenuGroup, { children: [
805
+ /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuLabel, { children: group.label }),
806
+ /* @__PURE__ */ jsxRuntime.jsx(DropdownMenuSeparator, {}),
807
+ group.options.map((option) => /* @__PURE__ */ jsxRuntime.jsx(
808
+ DropdownMenuCheckboxItem,
809
+ {
810
+ checked: selectedOptions.has(option.id),
811
+ onCheckedChange: () => toggleOption(
812
+ option.id,
813
+ group.options,
814
+ isSingle ? "single" : "multiple"
815
+ ),
816
+ onSelect: (e) => e.preventDefault(),
817
+ children: option.label
818
+ },
819
+ option.id
820
+ ))
821
+ ] }) })
822
+ ] }, group.id);
823
+ })
824
+ ] }),
825
+ /* @__PURE__ */ jsxRuntime.jsx(
826
+ Button,
827
+ {
828
+ type: "submit",
829
+ disabled: !value.trim() && attachedFiles.length === 0 && !isLoading || isLoading,
830
+ size: "icon-lg",
831
+ onClick: () => handleInternalSubmit().catch(console.error),
832
+ children: isLoading ? /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconLoader2, { className: "h-5 w-5 animate-spin" }) : /* @__PURE__ */ jsxRuntime.jsx(ICONS.IconArrowUp, { className: "h-5 w-5" })
833
+ }
834
+ )
835
+ ] })
705
836
  ] })
706
- ] }) });
837
+ ] });
707
838
  }
708
839
  function Card({
709
840
  className,
@@ -2362,6 +2493,14 @@ function Thread({
2362
2493
  });
2363
2494
  const starterPrompts = localStarterPrompts ?? config?.starterPrompts;
2364
2495
  const options = localOptions ?? config?.options;
2496
+ const allDefaultSelectedIds = React10.useMemo(() => {
2497
+ const defaultSelectedIdsFromOptions = options?.flatMap(
2498
+ (group) => group.defaultSelectedIds ?? []
2499
+ ) ?? [];
2500
+ return [
2501
+ .../* @__PURE__ */ new Set([...defaultSelectedIdsFromOptions, ...defaultSelectedIds ?? []])
2502
+ ];
2503
+ }, [options, defaultSelectedIds]);
2365
2504
  const [input, setInput] = React10.useState("");
2366
2505
  const messagesEndRef = React10.useRef(null);
2367
2506
  React10.useEffect(() => {
@@ -2369,13 +2508,14 @@ function Thread({
2369
2508
  }, [messages]);
2370
2509
  const handleSubmit = async (state, overrideInput) => {
2371
2510
  const text = (overrideInput ?? input).trim();
2372
- if (!text || isLoading) return;
2511
+ const hasFiles = state?.files && Array.isArray(state.files) && state.files.length > 0;
2512
+ if (!text && !hasFiles || isLoading) return;
2373
2513
  if (!overrideInput) setInput("");
2374
2514
  await sendEvent(
2375
2515
  {
2376
2516
  type: "text",
2377
2517
  role: "user",
2378
- data: { content: text }
2518
+ data: { content: text || "" }
2379
2519
  },
2380
2520
  { state: { ...state, threadId: activeThreadId ?? void 0 } }
2381
2521
  );
@@ -2433,7 +2573,8 @@ function Thread({
2433
2573
  isLoading,
2434
2574
  options,
2435
2575
  autoFocus,
2436
- defaultSelectedIds
2576
+ defaultSelectedIds: allDefaultSelectedIds,
2577
+ fileAttachments: config?.fileAttachments
2437
2578
  }
2438
2579
  ) }) })
2439
2580
  ]