chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
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,
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user