mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
feat: enhance chat functionality with improved attachment handling and user experience
- Updated system prompt to clarify usage of the display_image tool, emphasizing URL requirements and restrictions on user-uploaded images. - Enhanced the streaming chat process to provide more context about user attachments and documents during analysis. - Implemented state resets when switching between chats to prevent stale data and race conditions. - Added new components for displaying image previews and document attachments in the chat interface. - Improved attachment processing to support image data URLs for persistent display after uploads.
This commit is contained in:
parent
eb6192d480
commit
9e7f8d7fe3
7 changed files with 389 additions and 53 deletions
|
|
@ -64,14 +64,18 @@ You have access to the following tools:
|
|||
- The preview card will automatically be displayed in the chat.
|
||||
|
||||
4. display_image: Display an image in the chat with metadata.
|
||||
- Use this tool when you want to show an image to the user.
|
||||
- Use this tool when you want to show an image from a URL to the user.
|
||||
- This displays the image with an optional title, description, and source attribution.
|
||||
- Common use cases:
|
||||
* Showing an image from a URL mentioned in the conversation
|
||||
* Displaying a diagram, chart, or illustration you're referencing
|
||||
* Showing visual examples when explaining concepts
|
||||
- IMPORTANT: Do NOT use this tool for user-uploaded image attachments!
|
||||
* User attachments are already visible in the chat UI - the user can see them
|
||||
* This tool requires a valid HTTP/HTTPS URL, not a local file path
|
||||
* When a user uploads an image, just analyze it and respond - don't try to display it again
|
||||
- Args:
|
||||
- src: The URL of the image to display (must be a valid HTTP/HTTPS image URL)
|
||||
- src: The URL of the image to display (must be a valid HTTP/HTTPS image URL, not a local path)
|
||||
- alt: Alternative text describing the image (for accessibility)
|
||||
- title: Optional title to display below the image
|
||||
- description: Optional description providing context about the image
|
||||
|
|
|
|||
|
|
@ -255,13 +255,59 @@ async def stream_new_chat(
|
|||
# Initial thinking step - analyzing the request
|
||||
analyze_step_id = next_thinking_step_id()
|
||||
last_active_step_id = analyze_step_id
|
||||
last_active_step_title = "Understanding your request"
|
||||
last_active_step_items = [
|
||||
f"Processing: {user_query[:80]}{'...' if len(user_query) > 80 else ''}"
|
||||
]
|
||||
|
||||
# Determine step title and action verb based on context
|
||||
if attachments and mentioned_documents:
|
||||
last_active_step_title = "Analyzing your content"
|
||||
action_verb = "Reading"
|
||||
elif attachments:
|
||||
last_active_step_title = "Reading your content"
|
||||
action_verb = "Reading"
|
||||
elif mentioned_documents:
|
||||
last_active_step_title = "Analyzing referenced content"
|
||||
action_verb = "Analyzing"
|
||||
else:
|
||||
last_active_step_title = "Understanding your request"
|
||||
action_verb = "Processing"
|
||||
|
||||
# Build the message with inline context about attachments/documents
|
||||
processing_parts = []
|
||||
|
||||
# Add the user query
|
||||
query_text = user_query[:80] + ("..." if len(user_query) > 80 else "")
|
||||
processing_parts.append(query_text)
|
||||
|
||||
# Add file attachment names inline
|
||||
if attachments:
|
||||
attachment_names = []
|
||||
for attachment in attachments:
|
||||
name = attachment.name
|
||||
if len(name) > 30:
|
||||
name = name[:27] + "..."
|
||||
attachment_names.append(name)
|
||||
if len(attachment_names) == 1:
|
||||
processing_parts.append(f"[{attachment_names[0]}]")
|
||||
else:
|
||||
processing_parts.append(f"[{len(attachment_names)} files]")
|
||||
|
||||
# Add mentioned document names inline
|
||||
if mentioned_documents:
|
||||
doc_names = []
|
||||
for doc in mentioned_documents:
|
||||
title = doc.title
|
||||
if len(title) > 30:
|
||||
title = title[:27] + "..."
|
||||
doc_names.append(title)
|
||||
if len(doc_names) == 1:
|
||||
processing_parts.append(f"[{doc_names[0]}]")
|
||||
else:
|
||||
processing_parts.append(f"[{len(doc_names)} documents]")
|
||||
|
||||
last_active_step_items = [f"{action_verb}: {' '.join(processing_parts)}"]
|
||||
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=analyze_step_id,
|
||||
title="Understanding your request",
|
||||
title=last_active_step_title,
|
||||
status="in_progress",
|
||||
items=last_active_step_items,
|
||||
)
|
||||
|
|
@ -369,13 +415,13 @@ async def stream_new_chat(
|
|||
if isinstance(tool_input, dict)
|
||||
else ""
|
||||
)
|
||||
last_active_step_title = "Displaying image"
|
||||
last_active_step_title = "Analyzing the image"
|
||||
last_active_step_items = [
|
||||
f"Image: {title[:50] if title else src[:50]}{'...' if len(title or src) > 50 else ''}"
|
||||
f"Analyzing: {title[:50] if title else src[:50]}{'...' if len(title or src) > 50 else ''}"
|
||||
]
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=tool_step_id,
|
||||
title="Displaying image",
|
||||
title="Analyzing the image",
|
||||
status="in_progress",
|
||||
items=last_active_step_items,
|
||||
)
|
||||
|
|
@ -471,7 +517,7 @@ async def stream_new_chat(
|
|||
else str(tool_input)
|
||||
)
|
||||
yield streaming_service.format_terminal_info(
|
||||
f"Displaying image: {src[:60]}{'...' if len(src) > 60 else ''}",
|
||||
f"Analyzing image: {src[:60]}{'...' if len(src) > 60 else ''}",
|
||||
"info",
|
||||
)
|
||||
elif tool_name == "scrape_webpage":
|
||||
|
|
@ -575,20 +621,20 @@ async def stream_new_chat(
|
|||
items=completed_items,
|
||||
)
|
||||
elif tool_name == "display_image":
|
||||
# Build completion items for image display
|
||||
# Build completion items for image analysis
|
||||
if isinstance(tool_output, dict):
|
||||
title = tool_output.get("title", "")
|
||||
alt = tool_output.get("alt", "Image")
|
||||
display_name = title or alt
|
||||
completed_items = [
|
||||
*last_active_step_items,
|
||||
f"Showing: {display_name[:50]}{'...' if len(display_name) > 50 else ''}",
|
||||
f"Analyzed: {display_name[:50]}{'...' if len(display_name) > 50 else ''}",
|
||||
]
|
||||
else:
|
||||
completed_items = [*last_active_step_items, "Image displayed"]
|
||||
completed_items = [*last_active_step_items, "Image analyzed"]
|
||||
yield streaming_service.format_thinking_step(
|
||||
step_id=original_step_id,
|
||||
title="Displaying image",
|
||||
title="Analyzing the image",
|
||||
status="completed",
|
||||
items=completed_items,
|
||||
)
|
||||
|
|
@ -744,7 +790,7 @@ async def stream_new_chat(
|
|||
"alt", "Image"
|
||||
)
|
||||
yield streaming_service.format_terminal_info(
|
||||
f"Image displayed: {title[:40]}{'...' if len(title) > 40 else ''}",
|
||||
f"Image analyzed: {title[:40]}{'...' if len(title) > 40 else ''}",
|
||||
"success",
|
||||
)
|
||||
elif tool_name == "scrape_webpage":
|
||||
|
|
|
|||
|
|
@ -171,6 +171,14 @@ export default function NewChatPage() {
|
|||
const initializeThread = useCallback(async () => {
|
||||
setIsInitializing(true);
|
||||
|
||||
// Reset all state when switching between chats to prevent stale data
|
||||
setMessages([]);
|
||||
setThreadId(null);
|
||||
setMessageThinkingSteps(new Map());
|
||||
setMentionedDocumentIds([]);
|
||||
setMentionedDocuments([]);
|
||||
setMessageDocumentsMap({});
|
||||
|
||||
try {
|
||||
if (urlChatId > 0) {
|
||||
// Thread exists - load messages
|
||||
|
|
@ -219,7 +227,7 @@ export default function NewChatPage() {
|
|||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
}, [urlChatId, setMessageDocumentsMap]);
|
||||
}, [urlChatId, setMessageDocumentsMap, setMentionedDocumentIds, setMentionedDocuments]);
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -238,6 +246,13 @@ export default function NewChatPage() {
|
|||
// Handle new message from user
|
||||
const onNew = useCallback(
|
||||
async (message: AppendMessage) => {
|
||||
// Abort any previous streaming request to prevent race conditions
|
||||
// when user sends a second query while the first is still streaming
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
// Extract user query text from content parts
|
||||
let userQuery = "";
|
||||
for (const part of message.content) {
|
||||
|
|
@ -297,6 +312,8 @@ export default function NewChatPage() {
|
|||
role: "user",
|
||||
content: message.content,
|
||||
createdAt: new Date(),
|
||||
// Include attachments so they can be displayed
|
||||
attachments: message.attachments || [],
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
|
|
|
|||
|
|
@ -41,13 +41,23 @@ const useAttachmentSrc = () => {
|
|||
const { file, src } = useAssistantState(
|
||||
useShallow(({ attachment }): { file?: File; src?: string } => {
|
||||
if (!attachment || attachment.type !== "image") return {};
|
||||
|
||||
// First priority: use File object if available (for new uploads)
|
||||
if (attachment.file) return { file: attachment.file };
|
||||
// Only try to filter if content is an array (standard assistant-ui format)
|
||||
// Our custom ChatAttachment has content as a string, so skip this
|
||||
if (Array.isArray(attachment.content)) {
|
||||
const src = attachment.content.filter((c) => c.type === "image")[0]?.image;
|
||||
if (src) return { src };
|
||||
|
||||
// Second priority: use stored imageDataUrl (for persisted messages)
|
||||
// This is stored in our custom ChatAttachment interface
|
||||
const customAttachment = attachment as { imageDataUrl?: string };
|
||||
if (customAttachment.imageDataUrl) {
|
||||
return { src: customAttachment.imageDataUrl };
|
||||
}
|
||||
|
||||
// Third priority: try to extract from content array (standard assistant-ui format)
|
||||
if (Array.isArray(attachment.content)) {
|
||||
const contentSrc = attachment.content.filter((c) => c.type === "image")[0]?.image;
|
||||
if (contentSrc) return { src: contentSrc };
|
||||
}
|
||||
|
||||
return {};
|
||||
})
|
||||
);
|
||||
|
|
@ -218,11 +228,77 @@ const AttachmentRemove: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Image attachment with preview thumbnail (click to expand)
|
||||
*/
|
||||
const MessageImageAttachment: FC = () => {
|
||||
const attachmentName = useAssistantState(({ attachment }) => attachment?.name || "Image");
|
||||
const src = useAttachmentSrc();
|
||||
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
<AttachmentPreviewDialog>
|
||||
<div
|
||||
className="relative group cursor-pointer overflow-hidden rounded-xl border border-border/50 bg-muted transition-all hover:border-primary/30 hover:shadow-md"
|
||||
title={`Click to expand: ${attachmentName}`}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={attachmentName}
|
||||
width={120}
|
||||
height={90}
|
||||
className="object-cover w-[120px] h-[90px] transition-transform group-hover:scale-105"
|
||||
/>
|
||||
{/* Hover overlay with filename */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="absolute bottom-1.5 left-1.5 right-1.5">
|
||||
<span className="text-[10px] text-white/90 font-medium truncate block">
|
||||
{attachmentName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AttachmentPreviewDialog>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Document/file attachment as chip (similar to mentioned documents)
|
||||
*/
|
||||
const MessageDocumentAttachment: FC = () => {
|
||||
const attachmentName = useAssistantState(({ attachment }) => attachment?.name || "Attachment");
|
||||
|
||||
return (
|
||||
<AttachmentPreviewDialog>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20 cursor-pointer hover:bg-primary/20 transition-colors"
|
||||
title={attachmentName}
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
<span className="max-w-[150px] truncate">{attachmentName}</span>
|
||||
</span>
|
||||
</AttachmentPreviewDialog>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Attachment component for user messages
|
||||
* Shows image preview for images, chip for documents
|
||||
*/
|
||||
const MessageAttachmentChip: FC = () => {
|
||||
const isImage = useAssistantState(({ attachment }) => attachment?.type === "image");
|
||||
|
||||
if (isImage) {
|
||||
return <MessageImageAttachment />;
|
||||
}
|
||||
|
||||
return <MessageDocumentAttachment />;
|
||||
};
|
||||
|
||||
export const UserMessageAttachments: FC = () => {
|
||||
return (
|
||||
<div className="aui-user-message-attachments-end col-span-full col-start-1 row-start-1 flex w-full flex-row justify-end gap-2">
|
||||
<MessagePrimitive.Attachments components={{ Attachment: AttachmentUI }} />
|
||||
</div>
|
||||
<MessagePrimitive.Attachments components={{ Attachment: MessageAttachmentChip }} />
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -426,6 +426,9 @@ const Composer: FC = () => {
|
|||
// Check if thread is empty (new chat)
|
||||
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
|
||||
|
||||
// Check if thread is currently running (streaming response)
|
||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
|
||||
// Auto-focus editor when on new chat page
|
||||
useEffect(() => {
|
||||
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
|
||||
|
|
@ -504,6 +507,10 @@ const Composer: FC = () => {
|
|||
|
||||
// Handle submit from inline editor (Enter key)
|
||||
const handleSubmit = useCallback(() => {
|
||||
// Prevent sending while a response is still streaming
|
||||
if (isThreadRunning) {
|
||||
return;
|
||||
}
|
||||
if (!showDocumentPopover) {
|
||||
composerRuntime.send();
|
||||
// Clear the editor after sending
|
||||
|
|
@ -511,7 +518,7 @@ const Composer: FC = () => {
|
|||
setMentionedDocuments([]);
|
||||
setMentionedDocumentIds([]);
|
||||
}
|
||||
}, [showDocumentPopover, composerRuntime, setMentionedDocuments, setMentionedDocumentIds]);
|
||||
}, [showDocumentPopover, isThreadRunning, composerRuntime, setMentionedDocuments, setMentionedDocumentIds]);
|
||||
|
||||
// Handle document removal from inline editor
|
||||
const handleDocumentRemove = useCallback(
|
||||
|
|
@ -973,19 +980,23 @@ const UserMessage: FC = () => {
|
|||
const messageId = useAssistantState(({ message }) => message?.id);
|
||||
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
|
||||
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
|
||||
const hasAttachments = useAssistantState(
|
||||
({ message }) => message?.attachments && message.attachments.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
|
||||
data-role="user"
|
||||
>
|
||||
<UserMessageAttachments />
|
||||
|
||||
<div className="aui-user-message-content-wrapper col-start-2 min-w-0">
|
||||
{/* Display mentioned documents as chips */}
|
||||
{mentionedDocs && mentionedDocs.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2 justify-end">
|
||||
{mentionedDocs.map((doc) => (
|
||||
{/* Display attachments and mentioned documents */}
|
||||
{(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && (
|
||||
<div className="flex flex-wrap items-end gap-2 mb-2 justify-end">
|
||||
{/* Attachments (images show as thumbnails, documents as chips) */}
|
||||
<UserMessageAttachments />
|
||||
{/* Mentioned documents as chips */}
|
||||
{mentionedDocs?.map((doc) => (
|
||||
<span
|
||||
key={doc.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
|
||||
|
|
|
|||
|
|
@ -1,24 +1,130 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Circle,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Search,
|
||||
Sparkles,
|
||||
File,
|
||||
FileAudio,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileVideo,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Custom hook for entrance animation
|
||||
* Returns true after mount to trigger CSS transitions
|
||||
*/
|
||||
function useEntranceAnimation(delay = 0) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
return isVisible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file icon based on file extension (all icons are muted/gray)
|
||||
*/
|
||||
function getFileIcon(name: string): React.ReactNode {
|
||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
// PDF / Word documents
|
||||
if (ext === "pdf" || ["doc", "docx"].includes(ext)) {
|
||||
return <FileText className="size-3.5" />;
|
||||
}
|
||||
// Spreadsheets
|
||||
if (["xls", "xlsx", "csv"].includes(ext)) {
|
||||
return <FileSpreadsheet className="size-3.5" />;
|
||||
}
|
||||
// Images
|
||||
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) {
|
||||
return <FileImage className="size-3.5" />;
|
||||
}
|
||||
// Audio
|
||||
if (["mp3", "wav", "m4a", "ogg", "webm"].includes(ext)) {
|
||||
return <FileAudio className="size-3.5" />;
|
||||
}
|
||||
// Video
|
||||
if (["mp4", "mov", "avi", "mkv"].includes(ext)) {
|
||||
return <FileVideo className="size-3.5" />;
|
||||
}
|
||||
// Code files
|
||||
if (["js", "ts", "tsx", "jsx", "py", "html", "css", "json", "md"].includes(ext)) {
|
||||
return <FileCode className="size-3.5" />;
|
||||
}
|
||||
// Default
|
||||
return <File className="size-3.5" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact attachment tile component - matches the chat UI style
|
||||
*/
|
||||
const AttachmentTile: React.FC<{ name: string }> = ({ name }) => {
|
||||
const icon = getFileIcon(name);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-muted px-2 py-1 text-xs text-muted-foreground"
|
||||
title={name}
|
||||
>
|
||||
<span className="shrink-0">{icon}</span>
|
||||
<span className="truncate max-w-[120px]">{name}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse text and render bracketed items (like [filename.pdf]) as styled tiles
|
||||
*/
|
||||
function parseAndRenderWithBadges(text: string): React.ReactNode {
|
||||
// Match patterns like [filename.ext] or [N files] or [N documents]
|
||||
const regex = /\[([^\]]+)\]/g;
|
||||
const matches = Array.from(text.matchAll(regex));
|
||||
|
||||
if (matches.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of matches) {
|
||||
const matchIndex = match.index ?? 0;
|
||||
|
||||
// Add text before the match
|
||||
if (matchIndex > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, matchIndex));
|
||||
}
|
||||
|
||||
const content = match[1];
|
||||
|
||||
// Render as a compact tile matching chat UI style with file-type colors
|
||||
parts.push(<AttachmentTile key={`tile-${matchIndex}`} name={content} />);
|
||||
|
||||
lastIndex = matchIndex + match[0].length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
export type ChainOfThoughtItemProps = React.ComponentProps<"div">;
|
||||
|
||||
export const ChainOfThoughtItem = ({ children, className, ...props }: ChainOfThoughtItemProps) => (
|
||||
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children}
|
||||
<div className={cn("text-muted-foreground text-sm flex flex-wrap items-center gap-1", className)} {...props}>
|
||||
{typeof children === "string" ? parseAndRenderWithBadges(children) : children}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -80,9 +186,28 @@ export const ChainOfThoughtContent = ({
|
|||
{...props}
|
||||
>
|
||||
<div className="grid grid-cols-[min-content_minmax(0,1fr)] gap-x-4">
|
||||
<div className="bg-primary/20 ml-1.75 h-full w-px group-data-[last=true]:hidden" />
|
||||
{/* Animated vertical connection line */}
|
||||
<div
|
||||
className={cn(
|
||||
"ml-1.75 w-px bg-primary/20 group-data-[last=true]:hidden",
|
||||
"animate-in fade-in slide-in-from-top-1 duration-300"
|
||||
)}
|
||||
/>
|
||||
<div className="ml-1.75 h-full w-px bg-transparent group-data-[last=false]:hidden" />
|
||||
<div className="mt-2 space-y-2">{children}</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{React.Children.map(children, (child, index) => {
|
||||
const key = React.isValidElement(child) ? child.key : `cot-item-${index}`;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="animate-in fade-in slide-in-from-left-2 duration-200"
|
||||
style={{ animationDelay: `${index * 50}ms`, animationFillMode: "backwards" }}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
|
|
@ -98,14 +223,19 @@ export function ChainOfThought({ children, className }: ChainOfThoughtProps) {
|
|||
|
||||
return (
|
||||
<div className={cn("space-y-0", className)}>
|
||||
{childrenArray.map((child, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{React.isValidElement(child) &&
|
||||
React.cloneElement(child as React.ReactElement<ChainOfThoughtStepProps>, {
|
||||
isLast: index === childrenArray.length - 1,
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{childrenArray.map((child, index) => {
|
||||
// React.Children.toArray assigns stable keys to each child
|
||||
const key = React.isValidElement(child) ? child.key : `cot-step-${index}`;
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
{React.isValidElement(child) &&
|
||||
React.cloneElement(child as React.ReactElement<ChainOfThoughtStepProps>, {
|
||||
isLast: index === childrenArray.length - 1,
|
||||
stepIndex: index,
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -114,19 +244,42 @@ export type ChainOfThoughtStepProps = {
|
|||
children: React.ReactNode;
|
||||
className?: string;
|
||||
isLast?: boolean;
|
||||
/** Index of the step for staggered animation */
|
||||
stepIndex?: number;
|
||||
};
|
||||
|
||||
export const ChainOfThoughtStep = ({
|
||||
children,
|
||||
className,
|
||||
isLast = false,
|
||||
stepIndex = 0,
|
||||
...props
|
||||
}: ChainOfThoughtStepProps & React.ComponentProps<typeof Collapsible>) => {
|
||||
// Staggered entrance animation based on step index
|
||||
const isVisible = useEntranceAnimation(stepIndex * 50);
|
||||
|
||||
return (
|
||||
<Collapsible className={cn("group", className)} data-last={isLast} {...props}>
|
||||
<Collapsible
|
||||
className={cn(
|
||||
"group transition-all duration-300 ease-out",
|
||||
// Fade and slide in animation
|
||||
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2",
|
||||
className
|
||||
)}
|
||||
data-last={isLast}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{/* Animated connection line to next step */}
|
||||
<div className="flex justify-start group-data-[last=true]:hidden">
|
||||
<div className="bg-primary/20 ml-1.75 h-4 w-px" />
|
||||
<div
|
||||
className={cn(
|
||||
"ml-1.75 w-px bg-primary/20 transition-all duration-500 ease-out origin-top",
|
||||
// Animate line height from 0 to full
|
||||
isVisible ? "h-4 scale-y-100" : "h-0 scale-y-0"
|
||||
)}
|
||||
style={{ transitionDelay: `${stepIndex * 50 + 150}ms` }}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -60,9 +60,11 @@ interface ProcessAttachmentResponse {
|
|||
/**
|
||||
* Extended CompleteAttachment with our custom extractedContent field
|
||||
* We store the extracted text in a custom field so we can access it in onNew
|
||||
* For images, we also store the data URL so it can be displayed after persistence
|
||||
*/
|
||||
export interface ChatAttachment extends CompleteAttachment {
|
||||
extractedContent: string;
|
||||
imageDataUrl?: string; // Base64 data URL for images (persists across page reloads)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -118,6 +120,21 @@ async function processAttachment(file: File): Promise<ProcessAttachmentResponse>
|
|||
// Store processed results for the send() method
|
||||
const processedAttachments = new Map<string, ProcessAttachmentResponse>();
|
||||
|
||||
// Store image data URLs for attachments (so they persist after File objects are lost)
|
||||
const imageDataUrls = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Convert a File to a data URL (base64) for images
|
||||
*/
|
||||
async function fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the attachment adapter for assistant-ui
|
||||
*
|
||||
|
|
@ -170,6 +187,12 @@ export function createAttachmentAdapter(): AttachmentAdapter {
|
|||
} as PendingAttachment;
|
||||
|
||||
try {
|
||||
// For images, convert to data URL so we can display them after persistence
|
||||
if (attachmentType === "image") {
|
||||
const dataUrl = await fileToDataUrl(file);
|
||||
imageDataUrls.set(id, dataUrl);
|
||||
}
|
||||
|
||||
// Process the file through the backend ETL service
|
||||
const result = await processAttachment(file);
|
||||
|
||||
|
|
@ -204,10 +227,14 @@ export function createAttachmentAdapter(): AttachmentAdapter {
|
|||
*/
|
||||
async send(pendingAttachment: PendingAttachment): Promise<ChatAttachment> {
|
||||
const result = processedAttachments.get(pendingAttachment.id);
|
||||
const imageDataUrl = imageDataUrls.get(pendingAttachment.id);
|
||||
|
||||
if (result) {
|
||||
// Clean up stored result
|
||||
processedAttachments.delete(pendingAttachment.id);
|
||||
if (imageDataUrl) {
|
||||
imageDataUrls.delete(pendingAttachment.id);
|
||||
}
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
|
|
@ -222,6 +249,7 @@ export function createAttachmentAdapter(): AttachmentAdapter {
|
|||
},
|
||||
],
|
||||
extractedContent: result.content,
|
||||
imageDataUrl, // Store data URL for images so they can be displayed after persistence
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -238,6 +266,7 @@ export function createAttachmentAdapter(): AttachmentAdapter {
|
|||
status: { type: "complete" },
|
||||
content: [],
|
||||
extractedContent: "",
|
||||
imageDataUrl, // Still include data URL if available
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue