Files
simstudioai--sim/apps/sim/stores/chat/store.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

282 lines
8.8 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import type { ChatMessage, ChatState } from './types'
import { MAX_CHAT_HEIGHT, MAX_CHAT_WIDTH, MIN_CHAT_HEIGHT, MIN_CHAT_WIDTH } from './utils'
const logger = createLogger('ChatStore')
/**
* Maximum number of messages to store across all workflows
*/
const MAX_MESSAGES = 50
/**
* Floating chat dimensions
*/
const DEFAULT_WIDTH = 305
const DEFAULT_HEIGHT = 286
/**
* Floating chat store
* Manages the open/close state, position, messages, and all chat functionality
*/
export const useChatStore = create<ChatState>()(
devtools(
persist(
(set, get) => ({
isChatOpen: false,
chatPosition: null,
chatWidth: DEFAULT_WIDTH,
chatHeight: DEFAULT_HEIGHT,
setIsChatOpen: (open) => {
set({ isChatOpen: open })
},
setChatPosition: (position) => {
set({ chatPosition: position })
},
setChatDimensions: (dimensions) => {
set({
chatWidth: Math.max(MIN_CHAT_WIDTH, Math.min(MAX_CHAT_WIDTH, dimensions.width)),
chatHeight: Math.max(MIN_CHAT_HEIGHT, Math.min(MAX_CHAT_HEIGHT, dimensions.height)),
})
},
resetChatPosition: () => {
set({ chatPosition: null })
},
messages: [],
selectedWorkflowOutputs: {},
conversationIds: {},
addMessage: (message) => {
set((state) => {
const newMessage: ChatMessage = {
...message,
id: (message as any).id ?? generateId(),
timestamp: (message as any).timestamp ?? new Date().toISOString(),
}
const newMessages = [newMessage, ...state.messages].slice(0, MAX_MESSAGES)
return { messages: newMessages }
})
},
clearChat: (workflowId: string | null) => {
set((state) => {
const newState = {
messages: state.messages.filter(
(message) => !workflowId || message.workflowId !== workflowId
),
}
if (workflowId) {
const newConversationIds = { ...state.conversationIds }
newConversationIds[workflowId] = generateId()
return {
...newState,
conversationIds: newConversationIds,
}
}
return {
...newState,
conversationIds: {},
}
})
},
exportChatCSV: (workflowId: string) => {
const messages = get().messages.filter((message) => message.workflowId === workflowId)
if (messages.length === 0) {
return
}
/**
* Safely stringify and escape CSV values
*/
const formatCSVValue = (value: any): string => {
if (value === null || value === undefined) {
return ''
}
let stringValue = typeof value === 'object' ? JSON.stringify(value) : String(value)
// Truncate very long strings
stringValue = truncate(stringValue, 2000)
// Escape quotes and wrap in quotes if contains special characters
if (
stringValue.includes('"') ||
stringValue.includes(',') ||
stringValue.includes('\n')
) {
stringValue = `"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
}
const headers = ['timestamp', 'type', 'content']
const sortedMessages = [...messages].sort(
(a: ChatMessage, b: ChatMessage) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
)
const csvRows = [
headers.join(','),
...sortedMessages.map((message: ChatMessage) =>
[
formatCSVValue(message.timestamp),
formatCSVValue(message.type),
formatCSVValue(message.content),
].join(',')
),
]
const csvContent = csvRows.join('\n')
const now = new Date()
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19)
const filename = `chat-${workflowId}-${timestamp}.csv`
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
if (link.download !== undefined) {
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', filename)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
},
setSelectedWorkflowOutput: (workflowId, outputIds) => {
set((state) => {
const newSelections = { ...state.selectedWorkflowOutputs }
if (outputIds.length === 0) {
delete newSelections[workflowId]
} else {
newSelections[workflowId] = [...new Set(outputIds)]
}
return { selectedWorkflowOutputs: newSelections }
})
},
getSelectedWorkflowOutput: (workflowId) => {
return get().selectedWorkflowOutputs[workflowId] || []
},
getConversationId: (workflowId) => {
const state = get()
if (!state.conversationIds[workflowId]) {
return get().generateNewConversationId(workflowId)
}
return state.conversationIds[workflowId]
},
generateNewConversationId: (workflowId) => {
const newId = generateId()
set((state) => {
const newConversationIds = { ...state.conversationIds }
newConversationIds[workflowId] = newId
return { conversationIds: newConversationIds }
})
return newId
},
appendMessageContent: (messageId, content) => {
logger.debug('[ChatStore] appendMessageContent called', {
messageId,
contentLength: content.length,
content: content.substring(0, 30),
})
set((state) => {
const message = state.messages.find((m) => m.id === messageId)
if (!message) {
logger.warn('[ChatStore] Message not found for appending', { messageId })
}
const newMessages = state.messages.map((message) => {
if (message.id === messageId) {
const newContent =
typeof message.content === 'string'
? message.content + content
: message.content
? String(message.content) + content
: content
logger.debug('[ChatStore] Updated message content', {
messageId,
oldLength: typeof message.content === 'string' ? message.content.length : 0,
newLength: newContent.length,
addedLength: content.length,
})
return {
...message,
content: newContent,
}
}
return message
})
return { messages: newMessages }
})
},
finalizeMessageStream: (messageId) => {
set((state) => {
const newMessages = state.messages.map((message) => {
if (message.id === messageId) {
const { isStreaming, ...rest } = message
return rest
}
return message
})
return { messages: newMessages }
})
},
}),
{
name: 'chat-store',
/**
* Persist only the durable chat state — message history (with transient
* blob `previewUrl`s stripped since they are not valid across reloads),
* per-workflow output selections and conversation ids, and the floating
* chat's open state, position, and dimensions. Actions and any transient
* UI flags are intentionally excluded.
*/
partialize: (state) => ({
isChatOpen: state.isChatOpen,
chatPosition: state.chatPosition,
chatWidth: state.chatWidth,
chatHeight: state.chatHeight,
selectedWorkflowOutputs: state.selectedWorkflowOutputs,
conversationIds: state.conversationIds,
messages: state.messages.map((msg) => ({
...msg,
attachments: msg.attachments?.map((att) => ({
...att,
previewUrl: undefined,
})),
})),
}),
}
)
)
)