Skip to content

Commit fe3e9af

Browse files
committed
feat: attachments
1 parent 179d433 commit fe3e9af

File tree

13 files changed

+656
-87
lines changed

13 files changed

+656
-87
lines changed

chat-agent-ui/components/AgentRuntimeProvider.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import type {ReactNode} from "react";
44
import {
55
AssistantRuntimeProvider,
66
type ChatModelAdapter,
7+
CompositeAttachmentAdapter,
8+
SimpleImageAttachmentAdapter,
9+
SimpleTextAttachmentAdapter,
710
type ThreadAssistantMessagePart,
811
useLocalRuntime,
912
} from "@assistant-ui/react";
@@ -80,7 +83,14 @@ export function AgentRuntimeProvider({
8083
}: Readonly<{
8184
children: ReactNode;
8285
}>) {
83-
const runtime = useLocalRuntime(AgentModelAdapter);
86+
const runtime = useLocalRuntime(AgentModelAdapter, {
87+
adapters: {
88+
attachments: new CompositeAttachmentAdapter([
89+
new SimpleImageAttachmentAdapter(),
90+
new SimpleTextAttachmentAdapter()
91+
]),
92+
}
93+
});
8494

8595
return (
8696
<AssistantRuntimeProvider runtime={runtime}>

chat-agent-ui/components/AgentUI.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
import {AgentRuntimeProvider} from "@/components/AgentRuntimeProvider";
44
import {ThreadList} from "@/components/assistant-ui/thread-list";
55
import {Thread} from "@/components/assistant-ui/thread";
6+
import {TooltipProvider} from "@/components/ui/tooltip";
67

78
export function AgentUI() {
89
return (
910
<AgentRuntimeProvider>
10-
<main className="grid h-dvh grid-cols-[200px_1fr] gap-x-2 px-4 py-4">
11-
<ThreadList/>
12-
<Thread/>
13-
</main>
11+
<TooltipProvider>
12+
<main className="grid h-dvh grid-cols-[200px_1fr] gap-x-2 px-4 py-4">
13+
<ThreadList/>
14+
<Thread/>
15+
</main>
16+
</TooltipProvider>
1417
</AgentRuntimeProvider>
1518
);
1619
}
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
"use client";
2+
3+
import {type FC, PropsWithChildren, useEffect, useState} from "react";
4+
import Image from "next/image";
5+
import {FileText, PlusIcon, XIcon} from "lucide-react";
6+
import {
7+
AttachmentPrimitive,
8+
ComposerPrimitive,
9+
MessagePrimitive,
10+
useAssistantApi,
11+
useAssistantState,
12+
} from "@assistant-ui/react";
13+
import {useShallow} from "zustand/shallow";
14+
import {Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip";
15+
import {Dialog, DialogContent, DialogTitle, DialogTrigger,} from "@/components/ui/dialog";
16+
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
17+
import {TooltipIconButton} from "@/components/assistant-ui/tooltip-icon-button";
18+
import {cn} from "@/lib/utils";
19+
20+
const useFileSrc = (file: File | undefined) => {
21+
const [src, setSrc] = useState<string | undefined>(undefined);
22+
23+
useEffect(() => {
24+
if (!file) {
25+
setSrc(undefined);
26+
return;
27+
}
28+
29+
const objectUrl = URL.createObjectURL(file);
30+
setSrc(objectUrl);
31+
32+
return () => {
33+
URL.revokeObjectURL(objectUrl);
34+
};
35+
}, [file]);
36+
37+
return src;
38+
};
39+
40+
const useAttachmentSrc = () => {
41+
const {file, src} = useAssistantState(
42+
useShallow(({attachment}): { file?: File; src?: string } => {
43+
if (attachment.type !== "image") return {};
44+
if (attachment.file) return {file: attachment.file};
45+
const src = attachment.content?.filter((c) => c.type === "image")[0]
46+
?.image;
47+
if (!src) return {};
48+
return {src};
49+
}),
50+
);
51+
52+
return useFileSrc(file) ?? src;
53+
};
54+
55+
type AttachmentPreviewProps = {
56+
src: string;
57+
};
58+
59+
const AttachmentPreview: FC<AttachmentPreviewProps> = ({src}) => {
60+
const [isLoaded, setIsLoaded] = useState(false);
61+
return (
62+
<Image
63+
src={src}
64+
alt="Image Preview"
65+
width={1}
66+
height={1}
67+
className={
68+
isLoaded
69+
? "aui-attachment-preview-image-loaded block h-auto max-h-[80vh] w-auto max-w-full object-contain"
70+
: "aui-attachment-preview-image-loading hidden"
71+
}
72+
onLoadingComplete={() => setIsLoaded(true)}
73+
priority={false}
74+
/>
75+
);
76+
};
77+
78+
const AttachmentPreviewDialog: FC<PropsWithChildren> = ({children}) => {
79+
const src = useAttachmentSrc();
80+
81+
if (!src) return children;
82+
83+
return (
84+
<Dialog>
85+
<DialogTrigger
86+
className="aui-attachment-preview-trigger cursor-pointer transition-colors hover:bg-accent/50"
87+
asChild
88+
>
89+
{children}
90+
</DialogTrigger>
91+
<DialogContent
92+
className="aui-attachment-preview-dialog-content p-2 sm:max-w-3xl [&>button]:rounded-full [&>button]:bg-foreground/60 [&>button]:p-1 [&>button]:opacity-100 [&>button]:ring-0! [&_svg]:text-background [&>button]:hover:[&_svg]:text-destructive">
93+
<DialogTitle className="aui-sr-only sr-only">
94+
Image Attachment Preview
95+
</DialogTitle>
96+
<div
97+
className="aui-attachment-preview relative mx-auto flex max-h-[80dvh] w-full items-center justify-center overflow-hidden bg-background">
98+
<AttachmentPreview src={src}/>
99+
</div>
100+
</DialogContent>
101+
</Dialog>
102+
);
103+
};
104+
105+
const AttachmentThumb: FC = () => {
106+
const isImage = useAssistantState(
107+
({attachment}) => attachment.type === "image",
108+
);
109+
const src = useAttachmentSrc();
110+
111+
return (
112+
<Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none">
113+
<AvatarImage
114+
src={src}
115+
alt="Attachment preview"
116+
className="aui-attachment-tile-image object-cover"
117+
/>
118+
<AvatarFallback delayMs={isImage ? 200 : 0}>
119+
<FileText className="aui-attachment-tile-fallback-icon size-8 text-muted-foreground"/>
120+
</AvatarFallback>
121+
</Avatar>
122+
);
123+
};
124+
125+
const AttachmentUI: FC = () => {
126+
const api = useAssistantApi();
127+
const isComposer = api.attachment.source === "composer";
128+
129+
const isImage = useAssistantState(
130+
({attachment}) => attachment.type === "image",
131+
);
132+
const typeLabel = useAssistantState(({attachment}) => {
133+
const type = attachment.type;
134+
switch (type) {
135+
case "image":
136+
return "Image";
137+
case "document":
138+
return "Document";
139+
case "file":
140+
return "File";
141+
default:
142+
const _exhaustiveCheck: never = type;
143+
throw new Error(`Unknown attachment type: ${_exhaustiveCheck}`);
144+
}
145+
});
146+
147+
return (
148+
149+
<Tooltip>
150+
<AttachmentPrimitive.Root
151+
className={cn(
152+
"aui-attachment-root relative",
153+
isImage &&
154+
"aui-attachment-root-composer only:[&>#attachment-tile]:size-24",
155+
)}
156+
>
157+
<AttachmentPreviewDialog>
158+
<TooltipTrigger asChild>
159+
<div
160+
className={cn(
161+
"aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[14px] border bg-muted transition-opacity hover:opacity-75",
162+
isComposer &&
163+
"aui-attachment-tile-composer border-foreground/20",
164+
)}
165+
role="button"
166+
id="attachment-tile"
167+
aria-label={`${typeLabel} attachment`}
168+
>
169+
<AttachmentThumb/>
170+
</div>
171+
</TooltipTrigger>
172+
</AttachmentPreviewDialog>
173+
{isComposer && <AttachmentRemove/>}
174+
</AttachmentPrimitive.Root>
175+
<TooltipContent side="top">
176+
<AttachmentPrimitive.Name/>
177+
</TooltipContent>
178+
</Tooltip>
179+
);
180+
};
181+
182+
const AttachmentRemove: FC = () => {
183+
return (
184+
<AttachmentPrimitive.Remove asChild>
185+
<TooltipIconButton
186+
tooltip="Remove file"
187+
className="aui-attachment-tile-remove absolute top-1.5 right-1.5 size-3.5 rounded-full bg-white text-muted-foreground opacity-100 shadow-sm hover:bg-white! [&_svg]:text-black hover:[&_svg]:text-destructive"
188+
side="top"
189+
>
190+
<XIcon className="aui-attachment-remove-icon size-3 dark:stroke-[2.5px]"/>
191+
</TooltipIconButton>
192+
</AttachmentPrimitive.Remove>
193+
);
194+
};
195+
196+
export const UserMessageAttachments: FC = () => {
197+
return (
198+
<div
199+
className="aui-user-message-attachments-end col-span-full col-start-1 row-start-1 flex w-full flex-row justify-end gap-2">
200+
<MessagePrimitive.Attachments components={{Attachment: AttachmentUI}}/>
201+
</div>
202+
);
203+
};
204+
205+
export const ComposerAttachments: FC = () => {
206+
return (
207+
<div
208+
className="aui-composer-attachments mb-2 flex w-full flex-row items-center gap-2 overflow-x-auto px-1.5 pt-0.5 pb-1 empty:hidden">
209+
<ComposerPrimitive.Attachments
210+
components={{Attachment: AttachmentUI}}
211+
/>
212+
</div>
213+
);
214+
};
215+
216+
export const ComposerAddAttachment: FC = () => {
217+
return (
218+
<ComposerPrimitive.AddAttachment asChild>
219+
<TooltipIconButton
220+
tooltip="Add Attachment"
221+
side="bottom"
222+
variant="ghost"
223+
size="icon"
224+
className="aui-composer-add-attachment size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
225+
aria-label="Add Attachment"
226+
>
227+
<PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]"/>
228+
</TooltipIconButton>
229+
</ComposerPrimitive.AddAttachment>
230+
);
231+
};

chat-agent-ui/components/assistant-ui/thread.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {cn} from "@/lib/utils";
2323
import {Button} from "@/components/ui/button";
2424
import {MarkdownText} from "@/components/assistant-ui/markdown-text";
2525
import {TooltipIconButton} from "@/components/assistant-ui/tooltip-icon-button";
26+
import {ComposerAddAttachment, ComposerAttachments} from "@/components/assistant-ui/attachment";
2627

2728
export const Thread: FC = () => {
2829
return (
@@ -90,6 +91,8 @@ const Composer: FC = () => {
9091
return (
9192
<ComposerPrimitive.Root
9293
className="focus-within:border-ring/20 flex w-full flex-wrap items-end rounded-lg border bg-inherit px-2.5 shadow-sm transition-colors ease-in">
94+
<ComposerAttachments/>
95+
<ComposerAddAttachment/>
9396
<ComposerPrimitive.Input
9497
rows={1}
9598
autoFocus

chat-agent-ui/components/assistant-ui/tooltip-icon-button.tsx

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import {ComponentPropsWithRef, forwardRef} from "react";
44
import {Slottable} from "@radix-ui/react-slot";
55

6-
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from "@/components/ui/tooltip";
6+
import {Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip";
77
import {Button} from "@/components/ui/button";
88
import {cn} from "@/lib/utils";
99

@@ -17,23 +17,21 @@ export const TooltipIconButton = forwardRef<
1717
TooltipIconButtonProps
1818
>(({children, tooltip, side = "bottom", className, ...rest}, ref) => {
1919
return (
20-
<TooltipProvider>
21-
<Tooltip>
22-
<TooltipTrigger asChild>
23-
<Button
24-
variant="ghost"
25-
size="icon"
26-
{...rest}
27-
className={cn("aui-button-icon size-6 p-1", className)}
28-
ref={ref}
29-
>
30-
<Slottable>{children}</Slottable>
31-
<span className="aui-sr-only sr-only">{tooltip}</span>
32-
</Button>
33-
</TooltipTrigger>
34-
<TooltipContent side={side}>{tooltip}</TooltipContent>
35-
</Tooltip>
36-
</TooltipProvider>
20+
<Tooltip>
21+
<TooltipTrigger asChild>
22+
<Button
23+
variant="ghost"
24+
size="icon"
25+
{...rest}
26+
className={cn("aui-button-icon size-6 p-1", className)}
27+
ref={ref}
28+
>
29+
<Slottable>{children}</Slottable>
30+
<span className="aui-sr-only sr-only">{tooltip}</span>
31+
</Button>
32+
</TooltipTrigger>
33+
<TooltipContent side={side}>{tooltip}</TooltipContent>
34+
</Tooltip>
3735
);
3836
});
3937

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import * as AvatarPrimitive from "@radix-ui/react-avatar"
5+
6+
import { cn } from "@/lib/utils"
7+
8+
const Avatar = React.forwardRef<
9+
React.ElementRef<typeof AvatarPrimitive.Root>,
10+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
11+
>(({ className, ...props }, ref) => (
12+
<AvatarPrimitive.Root
13+
ref={ref}
14+
className={cn(
15+
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
16+
className
17+
)}
18+
{...props}
19+
/>
20+
))
21+
Avatar.displayName = AvatarPrimitive.Root.displayName
22+
23+
const AvatarImage = React.forwardRef<
24+
React.ElementRef<typeof AvatarPrimitive.Image>,
25+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
26+
>(({ className, ...props }, ref) => (
27+
<AvatarPrimitive.Image
28+
ref={ref}
29+
className={cn("aspect-square h-full w-full", className)}
30+
{...props}
31+
/>
32+
))
33+
AvatarImage.displayName = AvatarPrimitive.Image.displayName
34+
35+
const AvatarFallback = React.forwardRef<
36+
React.ElementRef<typeof AvatarPrimitive.Fallback>,
37+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
38+
>(({ className, ...props }, ref) => (
39+
<AvatarPrimitive.Fallback
40+
ref={ref}
41+
className={cn(
42+
"flex h-full w-full items-center justify-center rounded-full bg-muted",
43+
className
44+
)}
45+
{...props}
46+
/>
47+
))
48+
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49+
50+
export { Avatar, AvatarImage, AvatarFallback }

0 commit comments

Comments
 (0)