Compare commits

...

10 Commits

Author SHA1 Message Date
Sam Crowder
8b9382501e feat: make urls clickable in interrupt (#165)
Some checks failed
CI / Check formatting (push) Has been cancelled
CI / Check linting (push) Has been cancelled
CI / Check README spelling (push) Has been cancelled
CI / Check code spelling (push) Has been cancelled
2025-08-11 12:29:42 -07:00
Brace Sproul
9234a380fc fix text pasting (#140) 2025-05-20 16:07:50 -07:00
starmorph
51bafbab98 fix text pasting 2025-05-20 16:06:30 -07:00
Dylan Boudro
93f848efad Drag & Drop Improvements (#139) 2025-05-20 14:50:40 -07:00
starmorph
ef6454a157 CR: fix ++ --> +=1 2025-05-20 14:48:17 -07:00
bracesproul
8b4845494a flex wrap 2025-05-20 14:37:26 -07:00
starmorph
be73c9424e fix pasting files, global window drag&drop 2025-05-20 14:26:09 -07:00
starmorph
3b01d7293b drag and drop styling 2025-05-20 14:21:01 -07:00
starmorph
4472616d05 move isBase64ContentBlock to multimodal-utils.ts 2025-05-20 13:56:11 -07:00
starmorph
87bdf5be01 file preview styling: full width -> wrap 2025-05-20 13:54:39 -07:00
8 changed files with 123 additions and 101 deletions

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import type { Base64ContentBlock } from "@langchain/core/messages"; import type { Base64ContentBlock } from "@langchain/core/messages";
import { MultimodalPreview } from "../ui/MultimodalPreview"; import { MultimodalPreview } from "./MultimodalPreview";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface ContentBlocksPreviewProps { interface ContentBlocksPreviewProps {

View File

@@ -18,15 +18,6 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
className, className,
size = "md", size = "md",
}) => { }) => {
// Sizing
const sizeMap = {
sm: "h-10 w-10 text-base",
md: "h-16 w-16 text-lg",
lg: "h-24 w-24 text-xl",
};
const iconSize: string =
typeof sizeMap[size] === "string" ? sizeMap[size] : sizeMap["md"];
// Image block // Image block
if ( if (
block.type === "image" && block.type === "image" &&
@@ -72,28 +63,28 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
return ( return (
<div <div
className={cn( className={cn(
"relative flex items-center gap-2 rounded-md border bg-gray-100 px-3 py-2", "relative flex items-start gap-2 rounded-md border bg-gray-100 px-3 py-2",
className, className,
)} )}
> >
<File <div className="flex flex-shrink-0 flex-col items-start justify-start">
className={cn( <File
"flex-shrink-0 text-teal-700", className={cn(
size === "sm" ? "h-5 w-5" : "h-7 w-7", "text-teal-700",
)} size === "sm" ? "h-5 w-5" : "h-7 w-7",
/> )}
/>
</div>
<span <span
className={cn( className={cn("min-w-0 flex-1 text-sm break-all text-gray-800")}
"truncate text-sm text-gray-800", style={{ wordBreak: "break-all", whiteSpace: "pre-wrap" }}
size === "sm" ? "max-w-[80px]" : "max-w-[160px]",
)}
> >
{String(filename)} {String(filename)}
</span> </span>
{removable && ( {removable && (
<button <button
type="button" type="button"
className="ml-2 rounded-full bg-gray-200 p-1 text-teal-700 hover:bg-gray-300" className="ml-2 self-start rounded-full bg-gray-200 p-1 text-teal-700 hover:bg-gray-300"
onClick={onRemove} onClick={onRemove}
aria-label="Remove PDF" aria-label="Remove PDF"
> >

View File

@@ -445,17 +445,12 @@ export function Thread() {
<div <div
ref={dropRef} ref={dropRef}
className={cn( className={cn(
"bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl border shadow-xs transition-all", "bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl shadow-xs transition-all",
dragOver ? "border-primary border-2 border-dotted" : "", dragOver
? "border-primary border-2 border-dotted"
: "border border-solid",
)} )}
> >
{dragOver && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-2xl bg-black/40">
<span className="text-lg font-semibold text-white">
Drop file here
</span>
</div>
)}
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2" className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2"

View File

@@ -86,7 +86,7 @@ function Interrupt({
)} )}
{interruptValue && {interruptValue &&
!isAgentInboxInterruptSchema(interruptValue) && !isAgentInboxInterruptSchema(interruptValue) &&
isLastMessage ? ( (isLastMessage || hasNoAIOrToolMessages) ? (
<GenericInterruptView interrupt={interruptValue} /> <GenericInterruptView interrupt={interruptValue} />
) : null} ) : null}
</> </>

View File

@@ -6,6 +6,39 @@ function isComplexValue(value: any): boolean {
return Array.isArray(value) || (typeof value === "object" && value !== null); return Array.isArray(value) || (typeof value === "object" && value !== null);
} }
function isUrl(value: any): boolean {
if (typeof value !== "string") return false;
try {
new URL(value);
return value.startsWith("http://") || value.startsWith("https://");
} catch {
return false;
}
}
function renderInterruptStateItem(value: any): React.ReactNode {
if (isComplexValue(value)) {
return (
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm">
{JSON.stringify(value, null, 2)}
</code>
);
} else if (isUrl(value)) {
return (
<a
href={value}
target="_blank"
rel="noopener noreferrer"
className="break-all text-blue-600 underline hover:text-blue-800"
>
{value}
</a>
);
} else {
return String(value);
}
}
export function GenericInterruptView({ export function GenericInterruptView({
interrupt, interrupt,
}: { }: {
@@ -17,9 +50,13 @@ export function GenericInterruptView({
const contentLines = contentStr.split("\n"); const contentLines = contentStr.split("\n");
const shouldTruncate = contentLines.length > 4 || contentStr.length > 500; const shouldTruncate = contentLines.length > 4 || contentStr.length > 500;
// Function to truncate long string values // Function to truncate long string values (but preserve URLs)
const truncateValue = (value: any): any => { const truncateValue = (value: any): any => {
if (typeof value === "string" && value.length > 100) { if (typeof value === "string" && value.length > 100) {
// Don't truncate URLs so they remain clickable
if (isUrl(value)) {
return value;
}
return value.substring(0, 100) + "..."; return value.substring(0, 100) + "...";
} }
@@ -95,13 +132,7 @@ export function GenericInterruptView({
{key} {key}
</td> </td>
<td className="px-4 py-2 text-sm text-gray-500"> <td className="px-4 py-2 text-sm text-gray-500">
{isComplexValue(value) ? ( {renderInterruptStateItem(value)}
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm">
{JSON.stringify(value, null, 2)}
</code>
) : (
String(value)
)}
</td> </td>
</tr> </tr>
); );

View File

@@ -5,8 +5,8 @@ import { getContentString } from "../utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { BranchSwitcher, CommandBar } from "./shared"; import { BranchSwitcher, CommandBar } from "./shared";
import { MultimodalPreview } from "@/components/ui/MultimodalPreview"; import { MultimodalPreview } from "@/components/thread/MultimodalPreview";
import type { Base64ContentBlock } from "@langchain/core/messages"; import { isBase64ContentBlock } from "@/lib/multimodal-utils";
function EditableContent({ function EditableContent({
value, value,
@@ -34,36 +34,6 @@ function EditableContent({
); );
} }
// Type guard for Base64ContentBlock
function isBase64ContentBlock(block: unknown): block is Base64ContentBlock {
if (typeof block !== "object" || block === null || !("type" in block))
return false;
// file type (legacy)
if (
(block as { type: unknown }).type === "file" &&
"source_type" in block &&
(block as { source_type: unknown }).source_type === "base64" &&
"mime_type" in block &&
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
((block as { mime_type: string }).mime_type.startsWith("image/") ||
(block as { mime_type: string }).mime_type === "application/pdf")
) {
return true;
}
// image type (new)
if (
(block as { type: unknown }).type === "image" &&
"source_type" in block &&
(block as { source_type: unknown }).source_type === "base64" &&
"mime_type" in block &&
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
(block as { mime_type: string }).mime_type.startsWith("image/")
) {
return true;
}
return false;
}
export function HumanMessage({ export function HumanMessage({
message, message,
isLoading, isLoading,
@@ -119,7 +89,7 @@ export function HumanMessage({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{/* Render images and files if no text */} {/* Render images and files if no text */}
{Array.isArray(message.content) && message.content.length > 0 && ( {Array.isArray(message.content) && message.content.length > 0 && (
<div className="flex flex-col items-end gap-2"> <div className="flex flex-wrap items-end justify-end gap-2">
{message.content.reduce<React.ReactNode[]>( {message.content.reduce<React.ReactNode[]>(
(acc, block, idx) => { (acc, block, idx) => {
if (isBase64ContentBlock(block)) { if (isBase64ContentBlock(block)) {

View File

@@ -86,41 +86,23 @@ export function useFileUpload({
// Global drag events with counter for robust dragOver state // Global drag events with counter for robust dragOver state
const handleWindowDragEnter = (e: DragEvent) => { const handleWindowDragEnter = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) { if (e.dataTransfer?.types?.includes("Files")) {
dragCounter.current++; dragCounter.current += 1;
setDragOver(true); setDragOver(true);
} }
}; };
const handleWindowDragLeave = (e: DragEvent) => { const handleWindowDragLeave = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) { if (e.dataTransfer?.types?.includes("Files")) {
dragCounter.current--; dragCounter.current -= 1;
if (dragCounter.current <= 0) { if (dragCounter.current <= 0) {
setDragOver(false); setDragOver(false);
dragCounter.current = 0; dragCounter.current = 0;
} }
} }
}; };
const handleWindowDrop = (e: DragEvent) => { const handleWindowDrop = async (e: DragEvent) => {
dragCounter.current = 0;
setDragOver(false);
};
const handleWindowDragEnd = (e: DragEvent) => {
dragCounter.current = 0;
setDragOver(false);
};
window.addEventListener("dragenter", handleWindowDragEnter);
window.addEventListener("dragleave", handleWindowDragLeave);
window.addEventListener("drop", handleWindowDrop);
window.addEventListener("dragend", handleWindowDragEnd);
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
};
const handleDrop = async (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
dragCounter.current = 0;
setDragOver(false); setDragOver(false);
if (!e.dataTransfer) return; if (!e.dataTransfer) return;
@@ -155,34 +137,52 @@ export function useFileUpload({
: []; : [];
setContentBlocks((prev) => [...prev, ...newBlocks]); setContentBlocks((prev) => [...prev, ...newBlocks]);
}; };
const handleWindowDragEnd = (e: DragEvent) => {
dragCounter.current = 0;
setDragOver(false);
};
window.addEventListener("dragenter", handleWindowDragEnter);
window.addEventListener("dragleave", handleWindowDragLeave);
window.addEventListener("drop", handleWindowDrop);
window.addEventListener("dragend", handleWindowDragEnd);
// Prevent default browser behavior for dragover globally
const handleWindowDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
window.addEventListener("dragover", handleWindowDragOver);
// Remove element-specific drop event (handled globally)
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
};
const handleDragEnter = (e: DragEvent) => { const handleDragEnter = (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setDragOver(true); setDragOver(true);
}; };
const handleDragLeave = (e: DragEvent) => { const handleDragLeave = (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setDragOver(false); setDragOver(false);
}; };
const element = dropRef.current; const element = dropRef.current;
element.addEventListener("dragover", handleDragOver); element.addEventListener("dragover", handleDragOver);
element.addEventListener("drop", handleDrop);
element.addEventListener("dragenter", handleDragEnter); element.addEventListener("dragenter", handleDragEnter);
element.addEventListener("dragleave", handleDragLeave); element.addEventListener("dragleave", handleDragLeave);
return () => { return () => {
element.removeEventListener("dragover", handleDragOver); element.removeEventListener("dragover", handleDragOver);
element.removeEventListener("drop", handleDrop);
element.removeEventListener("dragenter", handleDragEnter); element.removeEventListener("dragenter", handleDragEnter);
element.removeEventListener("dragleave", handleDragLeave); element.removeEventListener("dragleave", handleDragLeave);
window.removeEventListener("dragenter", handleWindowDragEnter); window.removeEventListener("dragenter", handleWindowDragEnter);
window.removeEventListener("dragleave", handleWindowDragLeave); window.removeEventListener("dragleave", handleWindowDragLeave);
window.removeEventListener("drop", handleWindowDrop); window.removeEventListener("drop", handleWindowDrop);
window.removeEventListener("dragend", handleWindowDragEnd); window.removeEventListener("dragend", handleWindowDragEnd);
window.removeEventListener("dragover", handleWindowDragOver);
dragCounter.current = 0; dragCounter.current = 0;
}; };
}, [contentBlocks]); }, [contentBlocks]);
@@ -203,14 +203,17 @@ export function useFileUpload({
const items = e.clipboardData.items; const items = e.clipboardData.items;
if (!items) return; if (!items) return;
const files: File[] = []; const files: File[] = [];
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i += 1) {
const item = items[i]; const item = items[i];
if (item.kind === "file") { if (item.kind === "file") {
const file = item.getAsFile(); const file = item.getAsFile();
if (file) files.push(file); if (file) files.push(file);
} }
} }
if (files.length === 0) return; if (files.length === 0) {
return;
}
e.preventDefault();
const validFiles = files.filter((file) => const validFiles = files.filter((file) =>
SUPPORTED_FILE_TYPES.includes(file.type), SUPPORTED_FILE_TYPES.includes(file.type),
); );

View File

@@ -55,3 +55,35 @@ export async function fileToBase64(file: File): Promise<string> {
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
} }
// Type guard for Base64ContentBlock
export function isBase64ContentBlock(
block: unknown,
): block is Base64ContentBlock {
if (typeof block !== "object" || block === null || !("type" in block))
return false;
// file type (legacy)
if (
(block as { type: unknown }).type === "file" &&
"source_type" in block &&
(block as { source_type: unknown }).source_type === "base64" &&
"mime_type" in block &&
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
((block as { mime_type: string }).mime_type.startsWith("image/") ||
(block as { mime_type: string }).mime_type === "application/pdf")
) {
return true;
}
// image type (new)
if (
(block as { type: unknown }).type === "image" &&
"source_type" in block &&
(block as { source_type: unknown }).source_type === "base64" &&
"mime_type" in block &&
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
(block as { mime_type: string }).mime_type.startsWith("image/")
) {
return true;
}
return false;
}