mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(chat): partialize chat store to only persist image URL instead of full image in floating chat (#2842)
This commit is contained in:
@@ -94,6 +94,9 @@ interface ProcessedAttachment {
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
/** Timeout for FileReader operations in milliseconds */
|
||||
const FILE_READ_TIMEOUT_MS = 60000
|
||||
|
||||
/**
|
||||
* Reads files and converts them to data URLs for image display
|
||||
* @param chatFiles - Array of chat files to process
|
||||
@@ -107,8 +110,37 @@ const processFileAttachments = async (chatFiles: ChatFile[]): Promise<ProcessedA
|
||||
try {
|
||||
dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
let settled = false
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
reader.abort()
|
||||
reject(new Error(`File read timed out after ${FILE_READ_TIMEOUT_MS}ms`))
|
||||
}
|
||||
}, FILE_READ_TIMEOUT_MS)
|
||||
|
||||
reader.onload = () => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
clearTimeout(timeoutId)
|
||||
resolve(reader.result as string)
|
||||
}
|
||||
}
|
||||
reader.onerror = () => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
clearTimeout(timeoutId)
|
||||
reject(reader.error)
|
||||
}
|
||||
}
|
||||
reader.onabort = () => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
clearTimeout(timeoutId)
|
||||
reject(new Error('File read aborted'))
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file.file)
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -202,7 +234,6 @@ export function Chat() {
|
||||
const triggerWorkflowUpdate = useWorkflowStore((state) => state.triggerUpdate)
|
||||
const setSubBlockValue = useSubBlockStore((state) => state.setValue)
|
||||
|
||||
// Chat state (UI and messages from unified store)
|
||||
const {
|
||||
isChatOpen,
|
||||
chatPosition,
|
||||
@@ -230,19 +261,16 @@ export function Chat() {
|
||||
const { data: session } = useSession()
|
||||
const { addToQueue } = useOperationQueue()
|
||||
|
||||
// Local state
|
||||
const [chatMessage, setChatMessage] = useState('')
|
||||
const [promptHistory, setPromptHistory] = useState<string[]>([])
|
||||
const [historyIndex, setHistoryIndex] = useState(-1)
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false)
|
||||
|
||||
// Refs
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const streamReaderRef = useRef<ReadableStreamDefaultReader<Uint8Array> | null>(null)
|
||||
const preventZoomRef = usePreventZoom()
|
||||
|
||||
// File upload hook
|
||||
const {
|
||||
chatFiles,
|
||||
uploadErrors,
|
||||
@@ -257,6 +285,38 @@ export function Chat() {
|
||||
handleDrop,
|
||||
} = useChatFileUpload()
|
||||
|
||||
const filePreviewUrls = useRef<Map<string, string>>(new Map())
|
||||
|
||||
const getFilePreviewUrl = useCallback((file: ChatFile): string | null => {
|
||||
if (!file.type.startsWith('image/')) return null
|
||||
|
||||
const existing = filePreviewUrls.current.get(file.id)
|
||||
if (existing) return existing
|
||||
|
||||
const url = URL.createObjectURL(file.file)
|
||||
filePreviewUrls.current.set(file.id, url)
|
||||
return url
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const currentFileIds = new Set(chatFiles.map((f) => f.id))
|
||||
const urlMap = filePreviewUrls.current
|
||||
|
||||
for (const [fileId, url] of urlMap.entries()) {
|
||||
if (!currentFileIds.has(fileId)) {
|
||||
URL.revokeObjectURL(url)
|
||||
urlMap.delete(fileId)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const url of urlMap.values()) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
urlMap.clear()
|
||||
}
|
||||
}, [chatFiles])
|
||||
|
||||
/**
|
||||
* Resolves the unified start block for chat execution, if available.
|
||||
*/
|
||||
@@ -322,13 +382,11 @@ export function Chat() {
|
||||
const shouldShowConfigureStartInputsButton =
|
||||
Boolean(startBlockId) && missingStartReservedFields.length > 0
|
||||
|
||||
// Get actual position (default if not set)
|
||||
const actualPosition = useMemo(
|
||||
() => getChatPosition(chatPosition, chatWidth, chatHeight),
|
||||
[chatPosition, chatWidth, chatHeight]
|
||||
)
|
||||
|
||||
// Drag hook
|
||||
const { handleMouseDown } = useFloatDrag({
|
||||
position: actualPosition,
|
||||
width: chatWidth,
|
||||
@@ -336,7 +394,6 @@ export function Chat() {
|
||||
onPositionChange: setChatPosition,
|
||||
})
|
||||
|
||||
// Boundary sync hook - keeps chat within bounds when layout changes
|
||||
useFloatBoundarySync({
|
||||
isOpen: isChatOpen,
|
||||
position: actualPosition,
|
||||
@@ -345,7 +402,6 @@ export function Chat() {
|
||||
onPositionChange: setChatPosition,
|
||||
})
|
||||
|
||||
// Resize hook - enables resizing from all edges and corners
|
||||
const {
|
||||
cursor: resizeCursor,
|
||||
handleMouseMove: handleResizeMouseMove,
|
||||
@@ -359,13 +415,11 @@ export function Chat() {
|
||||
onDimensionsChange: setChatDimensions,
|
||||
})
|
||||
|
||||
// Get output entries from console
|
||||
const outputEntries = useMemo(() => {
|
||||
if (!activeWorkflowId) return []
|
||||
return entries.filter((entry) => entry.workflowId === activeWorkflowId && entry.output)
|
||||
}, [entries, activeWorkflowId])
|
||||
|
||||
// Get filtered messages for current workflow
|
||||
const workflowMessages = useMemo(() => {
|
||||
if (!activeWorkflowId) return []
|
||||
return messages
|
||||
@@ -373,14 +427,11 @@ export function Chat() {
|
||||
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
||||
}, [messages, activeWorkflowId])
|
||||
|
||||
// Check if any message is currently streaming
|
||||
const isStreaming = useMemo(() => {
|
||||
// Match copilot semantics: only treat as streaming if the LAST message is streaming
|
||||
const lastMessage = workflowMessages[workflowMessages.length - 1]
|
||||
return Boolean(lastMessage?.isStreaming)
|
||||
}, [workflowMessages])
|
||||
|
||||
// Map chat messages to copilot message format (type -> role) for scroll hook
|
||||
const messagesForScrollHook = useMemo(() => {
|
||||
return workflowMessages.map((msg) => ({
|
||||
...msg,
|
||||
@@ -388,8 +439,6 @@ export function Chat() {
|
||||
}))
|
||||
}, [workflowMessages])
|
||||
|
||||
// Scroll management hook - reuse copilot's implementation
|
||||
// Use immediate scroll behavior to keep the view pinned to the bottom during streaming
|
||||
const { scrollAreaRef, scrollToBottom } = useScrollManagement(
|
||||
messagesForScrollHook,
|
||||
isStreaming,
|
||||
@@ -398,7 +447,6 @@ export function Chat() {
|
||||
}
|
||||
)
|
||||
|
||||
// Memoize user messages for performance
|
||||
const userMessages = useMemo(() => {
|
||||
return workflowMessages
|
||||
.filter((msg) => msg.type === 'user')
|
||||
@@ -406,7 +454,6 @@ export function Chat() {
|
||||
.filter((content): content is string => typeof content === 'string')
|
||||
}, [workflowMessages])
|
||||
|
||||
// Update prompt history when workflow changes
|
||||
useEffect(() => {
|
||||
if (!activeWorkflowId) {
|
||||
setPromptHistory([])
|
||||
@@ -419,7 +466,7 @@ export function Chat() {
|
||||
}, [activeWorkflowId, userMessages])
|
||||
|
||||
/**
|
||||
* Auto-scroll to bottom when messages load
|
||||
* Auto-scroll to bottom when messages load and chat is open
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (workflowMessages.length > 0 && isChatOpen) {
|
||||
@@ -427,7 +474,6 @@ export function Chat() {
|
||||
}
|
||||
}, [workflowMessages.length, scrollToBottom, isChatOpen])
|
||||
|
||||
// Get selected workflow outputs (deduplicated)
|
||||
const selectedOutputs = useMemo(() => {
|
||||
if (!activeWorkflowId) return []
|
||||
const selected = selectedWorkflowOutputs[activeWorkflowId]
|
||||
@@ -448,7 +494,6 @@ export function Chat() {
|
||||
}, delay)
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timeoutRef.current && clearTimeout(timeoutRef.current)
|
||||
@@ -456,7 +501,6 @@ export function Chat() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// React to execution cancellation from run button
|
||||
useEffect(() => {
|
||||
if (!isExecuting && isStreaming) {
|
||||
const lastMessage = workflowMessages[workflowMessages.length - 1]
|
||||
@@ -500,7 +544,6 @@ export function Chat() {
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
buffer += chunk
|
||||
|
||||
// Process only complete SSE messages; keep any partial trailing data in buffer
|
||||
const separatorIndex = buffer.lastIndexOf('\n\n')
|
||||
if (separatorIndex === -1) {
|
||||
continue
|
||||
@@ -550,7 +593,6 @@ export function Chat() {
|
||||
}
|
||||
finalizeMessageStream(responseMessageId)
|
||||
} finally {
|
||||
// Only clear ref if it's still our reader (prevents clobbering a new stream)
|
||||
if (streamReaderRef.current === reader) {
|
||||
streamReaderRef.current = null
|
||||
}
|
||||
@@ -979,8 +1021,7 @@ export function Chat() {
|
||||
{chatFiles.length > 0 && (
|
||||
<div className='mt-[4px] flex gap-[6px] overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||
{chatFiles.map((file) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const previewUrl = isImage ? URL.createObjectURL(file.file) : null
|
||||
const previewUrl = getFilePreviewUrl(file)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -997,7 +1038,6 @@ export function Chat() {
|
||||
src={previewUrl}
|
||||
alt={file.name}
|
||||
className='h-full w-full object-cover'
|
||||
onLoad={() => URL.revokeObjectURL(previewUrl)}
|
||||
/>
|
||||
) : (
|
||||
<div className='min-w-0 flex-1'>
|
||||
|
||||
@@ -113,16 +113,17 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className='mb-2 flex flex-wrap gap-[6px]'>
|
||||
{message.attachments.map((attachment) => {
|
||||
const isImage = attachment.type.startsWith('image/')
|
||||
const hasValidDataUrl =
|
||||
attachment.dataUrl?.trim() && attachment.dataUrl.startsWith('data:')
|
||||
// Only treat as displayable image if we have both image type AND valid data URL
|
||||
const canDisplayAsImage = attachment.type.startsWith('image/') && hasValidDataUrl
|
||||
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className={`group relative flex-shrink-0 overflow-hidden rounded-[6px] bg-[var(--surface-2)] ${
|
||||
hasValidDataUrl ? 'cursor-pointer' : ''
|
||||
} ${isImage ? 'h-[40px] w-[40px]' : 'flex min-w-[80px] max-w-[120px] items-center justify-center px-[8px] py-[2px]'}`}
|
||||
} ${canDisplayAsImage ? 'h-[40px] w-[40px]' : 'flex min-w-[80px] max-w-[120px] items-center justify-center px-[8px] py-[2px]'}`}
|
||||
onClick={(e) => {
|
||||
if (hasValidDataUrl) {
|
||||
e.preventDefault()
|
||||
@@ -131,7 +132,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isImage && hasValidDataUrl ? (
|
||||
{canDisplayAsImage ? (
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
alt={attachment.name}
|
||||
|
||||
@@ -24,10 +24,11 @@ export function useChatFileUpload() {
|
||||
|
||||
/**
|
||||
* Validate and add files
|
||||
* Uses functional state update to avoid stale closure issues with rapid file additions
|
||||
*/
|
||||
const addFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
const remainingSlots = Math.max(0, MAX_FILES - chatFiles.length)
|
||||
const addFiles = useCallback((files: File[]) => {
|
||||
setChatFiles((currentFiles) => {
|
||||
const remainingSlots = Math.max(0, MAX_FILES - currentFiles.length)
|
||||
const candidateFiles = files.slice(0, remainingSlots)
|
||||
const errors: string[] = []
|
||||
const validNewFiles: ChatFile[] = []
|
||||
@@ -39,11 +40,14 @@ export function useChatFileUpload() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
const isDuplicate = chatFiles.some(
|
||||
// Check for duplicates against current files and newly added valid files
|
||||
const isDuplicateInCurrent = currentFiles.some(
|
||||
(existingFile) => existingFile.name === file.name && existingFile.size === file.size
|
||||
)
|
||||
if (isDuplicate) {
|
||||
const isDuplicateInNew = validNewFiles.some(
|
||||
(newFile) => newFile.name === file.name && newFile.size === file.size
|
||||
)
|
||||
if (isDuplicateInCurrent || isDuplicateInNew) {
|
||||
errors.push(`${file.name} already added`)
|
||||
continue
|
||||
}
|
||||
@@ -57,20 +61,20 @@ export function useChatFileUpload() {
|
||||
})
|
||||
}
|
||||
|
||||
// Update errors outside the state setter to avoid nested state updates
|
||||
if (errors.length > 0) {
|
||||
setUploadErrors(errors)
|
||||
// Use setTimeout to avoid state update during render
|
||||
setTimeout(() => setUploadErrors(errors), 0)
|
||||
} else if (validNewFiles.length > 0) {
|
||||
setTimeout(() => setUploadErrors([]), 0)
|
||||
}
|
||||
|
||||
if (validNewFiles.length > 0) {
|
||||
setChatFiles([...chatFiles, ...validNewFiles])
|
||||
// Clear errors when files are successfully added
|
||||
if (errors.length === 0) {
|
||||
setUploadErrors([])
|
||||
}
|
||||
return [...currentFiles, ...validNewFiles]
|
||||
}
|
||||
},
|
||||
[chatFiles]
|
||||
)
|
||||
return currentFiles
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Remove a file
|
||||
|
||||
@@ -26,7 +26,6 @@ export const useChatStore = create<ChatState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// UI State
|
||||
isChatOpen: false,
|
||||
chatPosition: null,
|
||||
chatWidth: DEFAULT_WIDTH,
|
||||
@@ -51,7 +50,6 @@ export const useChatStore = create<ChatState>()(
|
||||
set({ chatPosition: null })
|
||||
},
|
||||
|
||||
// Message State
|
||||
messages: [],
|
||||
selectedWorkflowOutputs: {},
|
||||
conversationIds: {},
|
||||
@@ -60,12 +58,10 @@ export const useChatStore = create<ChatState>()(
|
||||
set((state) => {
|
||||
const newMessage: ChatMessage = {
|
||||
...message,
|
||||
// Preserve provided id and timestamp if they exist; otherwise generate new ones
|
||||
id: (message as any).id ?? crypto.randomUUID(),
|
||||
timestamp: (message as any).timestamp ?? new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Keep only the last MAX_MESSAGES
|
||||
const newMessages = [newMessage, ...state.messages].slice(0, MAX_MESSAGES)
|
||||
|
||||
return { messages: newMessages }
|
||||
@@ -80,7 +76,6 @@ export const useChatStore = create<ChatState>()(
|
||||
),
|
||||
}
|
||||
|
||||
// Generate a new conversationId when clearing chat for a specific workflow
|
||||
if (workflowId) {
|
||||
const newConversationIds = { ...state.conversationIds }
|
||||
newConversationIds[workflowId] = uuidv4()
|
||||
@@ -89,7 +84,6 @@ export const useChatStore = create<ChatState>()(
|
||||
conversationIds: newConversationIds,
|
||||
}
|
||||
}
|
||||
// When clearing all chats (workflowId is null), also clear all conversationIds
|
||||
return {
|
||||
...newState,
|
||||
conversationIds: {},
|
||||
@@ -131,15 +125,12 @@ export const useChatStore = create<ChatState>()(
|
||||
return stringValue
|
||||
}
|
||||
|
||||
// CSV Headers
|
||||
const headers = ['timestamp', 'type', 'content']
|
||||
|
||||
// Sort messages by timestamp (oldest first)
|
||||
const sortedMessages = messages.sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
)
|
||||
|
||||
// Generate CSV rows
|
||||
const csvRows = [
|
||||
headers.join(','),
|
||||
...sortedMessages.map((message) =>
|
||||
@@ -151,15 +142,12 @@ export const useChatStore = create<ChatState>()(
|
||||
),
|
||||
]
|
||||
|
||||
// Create CSV content
|
||||
const csvContent = csvRows.join('\n')
|
||||
|
||||
// Generate filename with timestamp
|
||||
const now = new Date()
|
||||
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
const filename = `chat-${workflowId}-${timestamp}.csv`
|
||||
|
||||
// Create and trigger download
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
|
||||
@@ -177,15 +165,11 @@ export const useChatStore = create<ChatState>()(
|
||||
|
||||
setSelectedWorkflowOutput: (workflowId, outputIds) => {
|
||||
set((state) => {
|
||||
// Create a new copy of the selections state
|
||||
const newSelections = { ...state.selectedWorkflowOutputs }
|
||||
|
||||
// If empty array, explicitly remove the key to prevent empty arrays from persisting
|
||||
if (outputIds.length === 0) {
|
||||
// Delete the key entirely instead of setting to empty array
|
||||
delete newSelections[workflowId]
|
||||
} else {
|
||||
// Ensure no duplicates in the selection by using Set
|
||||
newSelections[workflowId] = [...new Set(outputIds)]
|
||||
}
|
||||
|
||||
@@ -200,7 +184,6 @@ export const useChatStore = create<ChatState>()(
|
||||
getConversationId: (workflowId) => {
|
||||
const state = get()
|
||||
if (!state.conversationIds[workflowId]) {
|
||||
// Generate a new conversation ID if one doesn't exist
|
||||
return get().generateNewConversationId(workflowId)
|
||||
}
|
||||
return state.conversationIds[workflowId]
|
||||
@@ -270,6 +253,16 @@ export const useChatStore = create<ChatState>()(
|
||||
}),
|
||||
{
|
||||
name: 'chat-store',
|
||||
partialize: (state) => ({
|
||||
...state,
|
||||
messages: state.messages.map((msg) => ({
|
||||
...msg,
|
||||
attachments: msg.attachments?.map((att) => ({
|
||||
...att,
|
||||
dataUrl: '',
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user