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,
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ChatMessageAttachment } from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
/**
|
||||
* Position interface for floating chat
|
||||
*/
|
||||
export interface ChatPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat message interface
|
||||
*/
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
content: string | any
|
||||
workflowId: string
|
||||
type: 'user' | 'workflow'
|
||||
timestamp: string
|
||||
blockId?: string
|
||||
isStreaming?: boolean
|
||||
attachments?: ChatMessageAttachment[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Output configuration for chat deployments
|
||||
*/
|
||||
export interface OutputConfig {
|
||||
blockId: string
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat dimensions interface
|
||||
*/
|
||||
interface ChatDimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat store state interface combining UI state and message data
|
||||
*/
|
||||
export interface ChatState {
|
||||
// UI State
|
||||
isChatOpen: boolean
|
||||
chatPosition: ChatPosition | null
|
||||
chatWidth: number
|
||||
chatHeight: number
|
||||
setIsChatOpen: (open: boolean) => void
|
||||
setChatPosition: (position: ChatPosition) => void
|
||||
setChatDimensions: (dimensions: ChatDimensions) => void
|
||||
resetChatPosition: () => void
|
||||
|
||||
// Message State
|
||||
messages: ChatMessage[]
|
||||
selectedWorkflowOutputs: Record<string, string[]>
|
||||
conversationIds: Record<string, string>
|
||||
|
||||
// Message Actions
|
||||
addMessage: (message: Omit<ChatMessage, 'id' | 'timestamp'> & { id?: string }) => void
|
||||
clearChat: (workflowId: string | null) => void
|
||||
exportChatCSV: (workflowId: string) => void
|
||||
setSelectedWorkflowOutput: (workflowId: string, outputIds: string[]) => void
|
||||
getSelectedWorkflowOutput: (workflowId: string) => string[]
|
||||
appendMessageContent: (messageId: string, content: string) => void
|
||||
finalizeMessageStream: (messageId: string) => void
|
||||
getConversationId: (workflowId: string) => string
|
||||
generateNewConversationId: (workflowId: string) => string
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { ChatPosition } from './types'
|
||||
|
||||
/**
|
||||
* Floating chat dimensions
|
||||
*/
|
||||
const DEFAULT_WIDTH = 305
|
||||
const DEFAULT_HEIGHT = 286
|
||||
|
||||
/**
|
||||
* Minimum chat dimensions (same as baseline default)
|
||||
*/
|
||||
export const MIN_CHAT_WIDTH = DEFAULT_WIDTH
|
||||
export const MIN_CHAT_HEIGHT = DEFAULT_HEIGHT
|
||||
|
||||
/**
|
||||
* Maximum chat dimensions
|
||||
*/
|
||||
export const MAX_CHAT_WIDTH = 500
|
||||
export const MAX_CHAT_HEIGHT = 600
|
||||
|
||||
/** Inset gap between the viewport edge and the content window */
|
||||
const CONTENT_WINDOW_GAP = 8
|
||||
|
||||
/**
|
||||
* Calculate default position in top right of canvas, offset from panel edge
|
||||
*/
|
||||
const calculateDefaultPosition = (): ChatPosition => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { x: 100, y: 100 }
|
||||
}
|
||||
|
||||
const panelWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
|
||||
)
|
||||
|
||||
const x = window.innerWidth - CONTENT_WINDOW_GAP - panelWidth - 32 - DEFAULT_WIDTH
|
||||
const y = CONTENT_WINDOW_GAP + 32
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default chat dimensions
|
||||
*/
|
||||
export const getDefaultChatDimensions = () => ({
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
})
|
||||
|
||||
/**
|
||||
* Calculate constrained position ensuring chat stays within bounds
|
||||
*/
|
||||
export const constrainChatPosition = (
|
||||
position: ChatPosition,
|
||||
width: number = DEFAULT_WIDTH,
|
||||
height: number = DEFAULT_HEIGHT
|
||||
): ChatPosition => {
|
||||
if (typeof window === 'undefined') {
|
||||
return position
|
||||
}
|
||||
|
||||
const sidebarWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
|
||||
)
|
||||
const panelWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
|
||||
)
|
||||
const terminalHeight = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
|
||||
)
|
||||
|
||||
const minX = sidebarWidth
|
||||
const maxX = window.innerWidth - CONTENT_WINDOW_GAP - panelWidth - width
|
||||
const minY = CONTENT_WINDOW_GAP
|
||||
const maxY = window.innerHeight - CONTENT_WINDOW_GAP - terminalHeight - height
|
||||
|
||||
return {
|
||||
x: Math.max(minX, Math.min(maxX, position.x)),
|
||||
y: Math.max(minY, Math.min(maxY, position.y)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat position (default if not set or if invalid)
|
||||
* @param storedPosition - Stored position from store
|
||||
* @param width - Chat width
|
||||
* @param height - Chat height
|
||||
* @returns Valid chat position
|
||||
*/
|
||||
export const getChatPosition = (
|
||||
storedPosition: ChatPosition | null,
|
||||
width: number = DEFAULT_WIDTH,
|
||||
height: number = DEFAULT_HEIGHT
|
||||
): ChatPosition => {
|
||||
if (!storedPosition) {
|
||||
return calculateDefaultPosition()
|
||||
}
|
||||
|
||||
// Validate stored position is still within bounds
|
||||
const constrained = constrainChatPosition(storedPosition, width, height)
|
||||
|
||||
// If position significantly changed, it's likely invalid (window resized, etc)
|
||||
// Return default position
|
||||
const deltaX = Math.abs(constrained.x - storedPosition.x)
|
||||
const deltaY = Math.abs(constrained.y - storedPosition.y)
|
||||
|
||||
if (deltaX > 100 || deltaY > 100) {
|
||||
return calculateDefaultPosition()
|
||||
}
|
||||
|
||||
return constrained
|
||||
}
|
||||
Reference in New Issue
Block a user