d25d482dc2
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
282 lines
8.8 KiB
TypeScript
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,
|
|
})),
|
|
})),
|
|
}),
|
|
}
|
|
)
|
|
)
|
|
)
|