message.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. 'use client';
  2. import { Button } from '@/components/ui/button';
  3. import { ButtonGroup, ButtonGroupText } from '@/components/ui/button-group';
  4. import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
  5. import { cn } from '@/lib/utils';
  6. import type { FileUIPart, UIMessage } from 'ai';
  7. import { ChevronLeftIcon, ChevronRightIcon, PaperclipIcon, XIcon } from 'lucide-react';
  8. import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
  9. import { createContext, memo, useContext, useEffect, useMemo, useState } from 'react';
  10. import { Streamdown } from 'streamdown';
  11. export type MessageProps = HTMLAttributes<HTMLDivElement> & {
  12. from: UIMessage['role'];
  13. };
  14. export const Message = ({ className, from, ...props }: MessageProps) => (
  15. <div
  16. className={cn(
  17. 'group flex w-full max-w-[95%] flex-col gap-2',
  18. from === 'user' ? 'is-user ml-auto justify-end' : 'is-assistant',
  19. className,
  20. )}
  21. {...props}
  22. />
  23. );
  24. export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
  25. export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
  26. <div
  27. className={cn(
  28. 'is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm',
  29. 'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground',
  30. 'group-[.is-assistant]:text-foreground',
  31. className,
  32. )}
  33. {...props}
  34. >
  35. {children}
  36. </div>
  37. );
  38. export type MessageActionsProps = ComponentProps<'div'>;
  39. export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
  40. <div className={cn('flex items-center gap-1', className)} {...props}>
  41. {children}
  42. </div>
  43. );
  44. export type MessageActionProps = ComponentProps<typeof Button> & {
  45. tooltip?: string;
  46. label?: string;
  47. };
  48. export const MessageAction = ({
  49. tooltip,
  50. children,
  51. label,
  52. variant = 'ghost',
  53. size = 'icon-sm',
  54. ...props
  55. }: MessageActionProps) => {
  56. const button = (
  57. <Button size={size} type="button" variant={variant} {...props}>
  58. {children}
  59. <span className="sr-only">{label || tooltip}</span>
  60. </Button>
  61. );
  62. if (tooltip) {
  63. return (
  64. <TooltipProvider>
  65. <Tooltip>
  66. <TooltipTrigger asChild>{button}</TooltipTrigger>
  67. <TooltipContent>
  68. <p>{tooltip}</p>
  69. </TooltipContent>
  70. </Tooltip>
  71. </TooltipProvider>
  72. );
  73. }
  74. return button;
  75. };
  76. type MessageBranchContextType = {
  77. currentBranch: number;
  78. totalBranches: number;
  79. goToPrevious: () => void;
  80. goToNext: () => void;
  81. branches: ReactElement[];
  82. setBranches: (branches: ReactElement[]) => void;
  83. };
  84. const MessageBranchContext = createContext<MessageBranchContextType | null>(null);
  85. const useMessageBranch = () => {
  86. const context = useContext(MessageBranchContext);
  87. if (!context) {
  88. throw new Error('MessageBranch components must be used within MessageBranch');
  89. }
  90. return context;
  91. };
  92. export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
  93. defaultBranch?: number;
  94. onBranchChange?: (branchIndex: number) => void;
  95. };
  96. export const MessageBranch = ({
  97. defaultBranch = 0,
  98. onBranchChange,
  99. className,
  100. ...props
  101. }: MessageBranchProps) => {
  102. const [currentBranch, setCurrentBranch] = useState(defaultBranch);
  103. const [branches, setBranches] = useState<ReactElement[]>([]);
  104. const handleBranchChange = (newBranch: number) => {
  105. setCurrentBranch(newBranch);
  106. onBranchChange?.(newBranch);
  107. };
  108. const goToPrevious = () => {
  109. const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
  110. handleBranchChange(newBranch);
  111. };
  112. const goToNext = () => {
  113. const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
  114. handleBranchChange(newBranch);
  115. };
  116. const contextValue: MessageBranchContextType = {
  117. currentBranch,
  118. totalBranches: branches.length,
  119. goToPrevious,
  120. goToNext,
  121. branches,
  122. setBranches,
  123. };
  124. return (
  125. <MessageBranchContext.Provider value={contextValue}>
  126. <div className={cn('grid w-full gap-2 [&>div]:pb-0', className)} {...props} />
  127. </MessageBranchContext.Provider>
  128. );
  129. };
  130. export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
  131. export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => {
  132. const { currentBranch, setBranches, branches } = useMessageBranch();
  133. const childrenArray = useMemo(
  134. () => (Array.isArray(children) ? children : [children]),
  135. [children],
  136. );
  137. // Use useEffect to update branches when they change
  138. useEffect(() => {
  139. if (branches.length !== childrenArray.length) {
  140. setBranches(childrenArray);
  141. }
  142. }, [childrenArray, branches, setBranches]);
  143. return childrenArray.map((branch, index) => (
  144. <div
  145. className={cn(
  146. 'grid gap-2 overflow-hidden [&>div]:pb-0',
  147. index === currentBranch ? 'block' : 'hidden',
  148. )}
  149. key={branch.key}
  150. {...props}
  151. >
  152. {branch}
  153. </div>
  154. ));
  155. };
  156. export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
  157. from: UIMessage['role'];
  158. };
  159. export const MessageBranchSelector = ({
  160. className: _className,
  161. from: _from,
  162. ...props
  163. }: MessageBranchSelectorProps) => {
  164. const { totalBranches } = useMessageBranch();
  165. // Don't render if there's only one branch
  166. if (totalBranches <= 1) {
  167. return null;
  168. }
  169. return (
  170. <ButtonGroup
  171. className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
  172. orientation="horizontal"
  173. {...props}
  174. />
  175. );
  176. };
  177. export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
  178. export const MessageBranchPrevious = ({ children, ...props }: MessageBranchPreviousProps) => {
  179. const { goToPrevious, totalBranches } = useMessageBranch();
  180. return (
  181. <Button
  182. aria-label="Previous branch"
  183. disabled={totalBranches <= 1}
  184. onClick={goToPrevious}
  185. size="icon-sm"
  186. type="button"
  187. variant="ghost"
  188. {...props}
  189. >
  190. {children ?? <ChevronLeftIcon size={14} />}
  191. </Button>
  192. );
  193. };
  194. export type MessageBranchNextProps = ComponentProps<typeof Button>;
  195. export const MessageBranchNext = ({
  196. children,
  197. className: _className,
  198. ...props
  199. }: MessageBranchNextProps) => {
  200. const { goToNext, totalBranches } = useMessageBranch();
  201. return (
  202. <Button
  203. aria-label="Next branch"
  204. disabled={totalBranches <= 1}
  205. onClick={goToNext}
  206. size="icon-sm"
  207. type="button"
  208. variant="ghost"
  209. {...props}
  210. >
  211. {children ?? <ChevronRightIcon size={14} />}
  212. </Button>
  213. );
  214. };
  215. export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
  216. export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProps) => {
  217. const { currentBranch, totalBranches } = useMessageBranch();
  218. return (
  219. <ButtonGroupText
  220. className={cn('border-none bg-transparent text-muted-foreground shadow-none', className)}
  221. {...props}
  222. >
  223. {currentBranch + 1} of {totalBranches}
  224. </ButtonGroupText>
  225. );
  226. };
  227. export type MessageResponseProps = ComponentProps<typeof Streamdown>;
  228. export const MessageResponse = memo(
  229. ({ className, ...props }: MessageResponseProps) => (
  230. <Streamdown
  231. className={cn('size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0', className)}
  232. {...props}
  233. />
  234. ),
  235. (prevProps, nextProps) => prevProps.children === nextProps.children,
  236. );
  237. MessageResponse.displayName = 'MessageResponse';
  238. export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
  239. data: FileUIPart;
  240. className?: string;
  241. onRemove?: () => void;
  242. };
  243. export function MessageAttachment({ data, className, onRemove, ...props }: MessageAttachmentProps) {
  244. const filename = data.filename || '';
  245. const mediaType = data.mediaType?.startsWith('image/') && data.url ? 'image' : 'file';
  246. const isImage = mediaType === 'image';
  247. const attachmentLabel = filename || (isImage ? 'Image' : 'Attachment');
  248. return (
  249. <div className={cn('group relative size-24 overflow-hidden rounded-lg', className)} {...props}>
  250. {isImage ? (
  251. <>
  252. <img
  253. alt={filename || 'attachment'}
  254. className="size-full object-cover"
  255. height={100}
  256. src={data.url}
  257. width={100}
  258. />
  259. {onRemove && (
  260. <Button
  261. aria-label="Remove attachment"
  262. className="absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
  263. onClick={(e) => {
  264. e.stopPropagation();
  265. onRemove();
  266. }}
  267. type="button"
  268. variant="ghost"
  269. >
  270. <XIcon />
  271. <span className="sr-only">Remove</span>
  272. </Button>
  273. )}
  274. </>
  275. ) : (
  276. <>
  277. <Tooltip>
  278. <TooltipTrigger asChild>
  279. <div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
  280. <PaperclipIcon className="size-4" />
  281. </div>
  282. </TooltipTrigger>
  283. <TooltipContent>
  284. <p>{attachmentLabel}</p>
  285. </TooltipContent>
  286. </Tooltip>
  287. {onRemove && (
  288. <Button
  289. aria-label="Remove attachment"
  290. className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
  291. onClick={(e) => {
  292. e.stopPropagation();
  293. onRemove();
  294. }}
  295. type="button"
  296. variant="ghost"
  297. >
  298. <XIcon />
  299. <span className="sr-only">Remove</span>
  300. </Button>
  301. )}
  302. </>
  303. )}
  304. </div>
  305. );
  306. }
  307. export type MessageAttachmentsProps = ComponentProps<'div'>;
  308. export function MessageAttachments({ children, className, ...props }: MessageAttachmentsProps) {
  309. if (!children) {
  310. return null;
  311. }
  312. return (
  313. <div className={cn('ml-auto flex w-fit flex-wrap items-start gap-2', className)} {...props}>
  314. {children}
  315. </div>
  316. );
  317. }
  318. export type MessageToolbarProps = ComponentProps<'div'>;
  319. export const MessageToolbar = ({ className, children, ...props }: MessageToolbarProps) => (
  320. <div className={cn('mt-4 flex w-full items-center justify-between gap-4', className)} {...props}>
  321. {children}
  322. </div>
  323. );