Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ declare namespace Cloudflare {
interface Env {
Chat: DurableObjectNamespace<import("./src/server").Chat>;
AI: Ai;
FILE_BUCKET: R2Bucket;
}
}
interface Env extends Cloudflare.Env {}
281 changes: 246 additions & 35 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,26 @@ import {
Robot,
Sun,
Trash,
X,
Paperclip,
PaperPlaneTilt,
Stop
} from "@phosphor-icons/react";
import { getFileExtension } from "./utils";

// List of tools that require human confirmation
// NOTE: this should match the tools that don't have execute functions in tools.ts
const toolsRequiringConfirmation: (keyof typeof tools)[] = [
"getWeatherInformation"
];

// Attachments type matching Vercel AI SDK
type Attachment = {
name?: string;
contentType?: string;
url: string;
};

export default function Chat() {
const [theme, setTheme] = useState<"dark" | "light">(() => {
// Check localStorage first, default to dark if not found
Expand All @@ -41,6 +51,9 @@ export default function Chat() {
const [showDebug, setShowDebug] = useState(false);
const [textareaHeight, setTextareaHeight] = useState("auto");
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);

const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
Expand Down Expand Up @@ -81,28 +94,6 @@ export default function Chat() {
setAgentInput(e.target.value);
};

const handleAgentSubmit = async (
e: React.FormEvent,
extraData: Record<string, unknown> = {}
) => {
e.preventDefault();
if (!agentInput.trim()) return;

const message = agentInput;
setAgentInput("");

// Send message to agent
await sendMessage(
{
role: "user",
parts: [{ type: "text", text: message }]
},
{
body: extraData
}
);
};

const {
messages: agentMessages,
addToolResult,
Expand Down Expand Up @@ -135,6 +126,63 @@ export default function Chat() {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};

const handleFileSelect = () => {
fileInputRef.current?.click();
};

const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
const newFiles = Array.from(event.target.files);
setAttachedFiles((prevFiles) => {
const existingFileNames = new Set(prevFiles.map((f) => f.name));
const uniqueNewFiles = newFiles.filter(
(f) => !existingFileNames.has(f.name)
);
return [...prevFiles, ...uniqueNewFiles];
});
event.target.value = ""; // Clear input to allow selecting the same file again
}
};

const removeFile = (fileName: string) => {
setAttachedFiles((prevFiles) =>
prevFiles.filter((file) => file.name !== fileName)
);
};

const uploadFiles = async (files: File[]) => {
setIsUploading(true);
const formData = new FormData();
files.forEach((file) => {
formData.append("files", file);
});

try {
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});

if (!response.ok) {
throw new Error(`File upload failed: ${response.statusText}`);
}

const result: {
attachments: Attachment[];
} = await response.json();

return result.attachments;
} catch (error) {
console.error("Error uploading files:", error);
alert(
`Failed to upload files: ${error instanceof Error ? error.message : String(error)}`
);
return [];
} finally {
setIsUploading(false);
}
};

return (
<div className="h-[100vh] w-full p-4 flex justify-center items-center bg-fixed overflow-hidden">
<HasOpenAIKey />
Expand Down Expand Up @@ -215,6 +263,10 @@ export default function Chat() {
<span className="text-[#F48120]">•</span>
<span>Local time in different locations</span>
</li>
<li className="flex items-center gap-2">
<span className="text-[#F48120]">•</span>
<span>Uploading and discussing files</span>
</li>
</ul>
</div>
</Card>
Expand Down Expand Up @@ -249,11 +301,49 @@ export default function Chat() {

<div>
<div>
{m.parts
?.filter((part) => part.type === "file")
.map((part, i) => (
<div
key={`file-${i}`}
className={`mb-2 space-y-2 ${isUser ? "pl-10" : "pr-10"}`}
>
<div className="flex items-center gap-2">
{part.mediaType?.startsWith("image/") ? (
<a
href={part.url}
target="_blank"
rel="noopener noreferrer"
className="block max-w-[200px] max-h-[200px] rounded border border-neutral-300 dark:border-neutral-700 overflow-hidden shadow-sm"
>
<img
src={part.url}
alt="Uploaded image"
className="w-full h-full object-cover"
loading="lazy"
/>
</a>
) : (
<a
href={part.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 p-2 rounded border border-neutral-300 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors text-sm"
>
<Paperclip size={16} />
<span className="truncate max-w-[150px]">
File
</span>
</a>
)}
</div>
</div>
))}

{m.parts?.map((part, i) => {
if (part.type === "text") {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: immutable index
<div key={i}>
<div key={`text-${i}`}>
<Card
className={`p-3 rounded-md bg-neutral-100 dark:bg-neutral-900 ${
isUser
Expand Down Expand Up @@ -344,27 +434,142 @@ export default function Chat() {

{/* Input Area */}
<form
onSubmit={(e) => {
onSubmit={async (e) => {
e.preventDefault();
handleAgentSubmit(e, {
annotations: {
hello: "world"
if (!agentInput.trim() && attachedFiles.length === 0) return;
if (isUploading) return;

let uploadedAttachments: Attachment[] = [];

if (attachedFiles.length > 0) {
uploadedAttachments = await uploadFiles(attachedFiles);
console.log("uploadedAttachments", uploadedAttachments);

// Clear local files only if upload resulted in some valid attachments
if (
uploadedAttachments.length > 0 ||
attachedFiles.length === 0
) {
setAttachedFiles([]);
}

// If upload failed completely or returned no valid attachments, stop.
if (
uploadedAttachments.length === 0 &&
attachedFiles.length > 0
) {
console.warn(
"File upload failed or returned invalid data, submission stopped."
);

// Optionally alert the user here
return; // Stop submission
}
}

const messageParts: Array<
| { type: "text"; text: string }
| { type: "file"; url: string; mediaType: string; name?: string }
> = [];

// Add text part if there's input
if (agentInput.trim()) {
messageParts.push({ type: "text", text: agentInput });
}

// Add file parts if there are uploaded attachments
uploadedAttachments.forEach((attachment) => {
messageParts.push({
type: "file",
url: attachment.url,
mediaType: attachment.contentType || "application/octet-stream",
name: attachment.name
});
});

setAgentInput("");

await sendMessage(
{
role: "user",
parts: messageParts
},
{
body: {
annotations: {
hello: "world"
}
}
}
);
setTextareaHeight("auto"); // Reset height after submission
}}
className="p-3 bg-neutral-50 absolute bottom-0 left-0 right-0 z-10 border-t border-neutral-300 dark:border-neutral-800 dark:bg-neutral-900"
>
{attachedFiles.length > 0 && (
<div className="flex space-x-2 mb-2 pb-2 overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-thumb-neutral-300 dark:scrollbar-thumb-neutral-700 scrollbar-track-transparent bg-input-background">
{attachedFiles.map((file) => (
<div
key={file.name}
className="relative group flex-shrink-0 w-16 h-16 border border-neutral-300 dark:border-neutral-700 rounded-md overflow-hidden bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center"
>
{file.type.startsWith("image/") ? (
<img
src={URL.createObjectURL(file)}
alt={file.name}
className="w-full h-full object-cover"
onLoad={(e) =>
URL.revokeObjectURL((e.target as HTMLImageElement).src)
}
/>
) : (
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
{getFileExtension(file.name)}
</span>
)}
<button
type="button"
onClick={() => removeFile(file.name)}
disabled={isUploading}
className="absolute top-0.5 right-0.5 p-0.5 bg-black/50 rounded-full text-white cursor-pointer focus:outline-none focus:ring-1 focus:ring-white disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={`Remove ${file.name}`}
>
<X size={10} weight="bold" />
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
multiple
className="hidden"
disabled={isUploading}
/>
<Button
type="button"
shape="square"
className="rounded-full h-10 w-10 flex-shrink-0"
onClick={handleFileSelect}
disabled={pendingToolCallConfirmation || isUploading}
aria-label="Attach file"
>
<Paperclip size={16} />
</Button>
<div className="flex-1 relative">
<Textarea
disabled={pendingToolCallConfirmation}
disabled={pendingToolCallConfirmation || isUploading}
placeholder={
pendingToolCallConfirmation
? "Please respond to the tool confirmation above..."
: "Send a message..."
isUploading
? "Uploading files..."
: pendingToolCallConfirmation
? "Please respond to the tool confirmation above..."
: "Type your message or add files..."
}
className="flex w-full border border-neutral-200 dark:border-neutral-700 px-3 py-2 ring-offset-background placeholder:text-neutral-500 dark:placeholder:text-neutral-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-300 dark:focus-visible:ring-neutral-700 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-neutral-900 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm min-h-[24px] max-h-[calc(75dvh)] overflow-hidden resize-none rounded-2xl !text-base pb-10 dark:bg-neutral-900"
className="flex w-full border border-neutral-200 dark:border-neutral-700 px-3 py-2 ring-offset-background placeholder:text-neutral-500 dark:placeholder:text-neutral-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-300 dark:focus-visible:ring-neutral-700 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-neutral-900 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm min-h-[24px] max-h-[calc(75dvh)] overflow-hidden resize-none rounded-2xl !text-base pb-10 dark:bg-neutral-900"
value={agentInput}
onChange={(e) => {
handleAgentInputChange(e);
Expand All @@ -380,7 +585,9 @@ export default function Chat() {
!e.nativeEvent.isComposing
) {
e.preventDefault();
handleAgentSubmit(e as unknown as React.FormEvent);
(e.target as HTMLInputElement)
.closest("form")
?.requestSubmit();
setTextareaHeight("auto"); // Reset height on Enter submission
}
}}
Expand All @@ -401,7 +608,11 @@ export default function Chat() {
<button
type="submit"
className="inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 bg-primary text-primary-foreground hover:bg-primary/90 rounded-full p-1.5 h-fit border border-neutral-200 dark:border-neutral-800"
disabled={pendingToolCallConfirmation || !agentInput.trim()}
disabled={
pendingToolCallConfirmation ||
(!agentInput.trim() && attachedFiles.length === 0) ||
isUploading
}
aria-label="Send message"
>
<PaperPlaneTilt size={16} />
Expand Down
2 changes: 1 addition & 1 deletion src/components/avatar/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const AvatarComponent = ({
<Slot
as={as ?? "div"}
className={cn(
"btn btn-secondary circular add-focus relative overflow-hidden",
"btn btn-secondary circular add-focus relative overflow-hidden shrink-0",
{
"add-size-sm": size === "sm",
"add-size-md": size === "md",
Expand Down
Loading
Loading