diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-loading.tsx index aad70d0ed4..89f4f7a7f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-loading.tsx @@ -1,6 +1,7 @@ 'use client' import { Plus, Search } from 'lucide-react' +import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { useSidebarStore } from '@/stores/sidebar/store' import { KnowledgeHeader } from '../../../components/knowledge-header/knowledge-header' @@ -18,6 +19,8 @@ export function DocumentLoading({ documentName, }: DocumentLoadingProps) { const { mode, isExpanded } = useSidebarStore() + const params = useParams() + const workspaceId = params?.workspaceId as string const isSidebarCollapsed = mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover' @@ -25,12 +28,12 @@ export function DocumentLoading({ { id: 'knowledge-root', label: 'Knowledge', - href: '/knowledge', + href: `/workspace/${workspaceId}/knowledge`, }, { id: `knowledge-base-${knowledgeBaseId}`, label: knowledgeBaseName, - href: `/knowledge/${knowledgeBaseId}`, + href: `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`, }, { id: `document-${knowledgeBaseId}-${documentName}`, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index fc25327f6d..9fe8c73f51 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -12,6 +12,7 @@ import { Trash2, X, } from 'lucide-react' +import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -54,6 +55,7 @@ export function Document({ }: DocumentProps) { const { mode, isExpanded } = useSidebarStore() const { getCachedKnowledgeBase, getCachedDocuments } = useKnowledgeStore() + const { workspaceId } = useParams() const isSidebarCollapsed = mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover' @@ -170,10 +172,10 @@ export function Document({ const effectiveDocumentName = document?.filename || documentName || 'Document' const breadcrumbs = [ - { label: 'Knowledge', href: '/knowledge' }, + { label: 'Knowledge', href: `/workspace/${workspaceId}/knowledge` }, { label: effectiveKnowledgeBaseName, - href: `/knowledge/${knowledgeBaseId}`, + href: `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`, }, { label: effectiveDocumentName }, ] @@ -360,10 +362,10 @@ export function Document({ if (combinedError && !isLoadingChunks) { const errorBreadcrumbs = [ - { label: 'Knowledge', href: '/knowledge' }, + { label: 'Knowledge', href: `/workspace/${workspaceId}/knowledge` }, { label: effectiveKnowledgeBaseName, - href: `/knowledge/${knowledgeBaseId}`, + href: `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`, }, { label: 'Error' }, ] diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index eeffdb79e7..664623b0c8 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -743,7 +743,7 @@ export function KnowledgeBase({ { id: 'knowledge-root', label: 'Knowledge', - href: '/knowledge', + href: `/workspace/${workspaceId}/knowledge`, }, { id: `knowledge-base-${id}`, @@ -762,7 +762,7 @@ export function KnowledgeBase({ { id: 'knowledge-root', label: 'Knowledge', - href: '/knowledge', + href: `/workspace/${workspaceId}/knowledge`, }, { id: 'error', diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx index 150979ddb6..a189d2d193 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/knowledge-base-loading/knowledge-base-loading.tsx @@ -1,6 +1,7 @@ 'use client' import { Search } from 'lucide-react' +import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { useSidebarStore } from '@/stores/sidebar/store' import { KnowledgeHeader } from '../../../components/knowledge-header/knowledge-header' @@ -12,6 +13,8 @@ interface KnowledgeBaseLoadingProps { export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps) { const { mode, isExpanded } = useSidebarStore() + const params = useParams() + const workspaceId = params?.workspaceId as string const isSidebarCollapsed = mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover' @@ -19,7 +22,7 @@ export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoading { id: 'knowledge-root', label: 'Knowledge', - href: '/knowledge', + href: `/workspace/${workspaceId}/knowledge`, }, { id: 'knowledge-base-loading', diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx index 223c9e9c90..1ecf717700 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { Check, Copy, LibraryBig } from 'lucide-react' import Link from 'next/link' +import { useParams } from 'next/navigation' interface BaseOverviewProps { id?: string @@ -13,12 +14,14 @@ interface BaseOverviewProps { export function BaseOverview({ id, title, docCount, description }: BaseOverviewProps) { const [isCopied, setIsCopied] = useState(false) + const params = useParams() + const workspaceId = params?.workspaceId as string // Create URL with knowledge base name as query parameter - const params = new URLSearchParams({ + const searchParams = new URLSearchParams({ kbName: title, }) - const href = `/knowledge/${id || title.toLowerCase().replace(/\s+/g, '-')}?${params.toString()}` + const href = `/workspace/${workspaceId}/knowledge/${id || title.toLowerCase().replace(/\s+/g, '-')}?${searchParams.toString()}` const handleCopy = async (e: React.MouseEvent) => { e.preventDefault() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/combobox.tsx new file mode 100644 index 0000000000..488f2fa387 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/combobox.tsx @@ -0,0 +1,461 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Check, ChevronDown } from 'lucide-react' +import { useReactFlow } from 'reactflow' +import { Button } from '@/components/ui/button' +import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown' +import { formatDisplayText } from '@/components/ui/formatted-text' +import { Input } from '@/components/ui/input' +import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' +import { createLogger } from '@/lib/logs/console-logger' +import { cn } from '@/lib/utils' +import type { SubBlockConfig } from '@/blocks/types' +import { useSubBlockValue } from '../hooks/use-sub-block-value' + +const logger = createLogger('ComboBox') + +interface ComboBoxProps { + options: + | Array + | (() => Array) + defaultValue?: string + blockId: string + subBlockId: string + value?: string + isPreview?: boolean + previewValue?: string | null + disabled?: boolean + placeholder?: string + isConnecting: boolean + config: SubBlockConfig +} + +export function ComboBox({ + options, + defaultValue, + blockId, + subBlockId, + value: propValue, + isPreview = false, + previewValue, + disabled, + placeholder = 'Type or select an option...', + isConnecting, + config, +}: ComboBoxProps) { + const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) + const [storeInitialized, setStoreInitialized] = useState(false) + const [open, setOpen] = useState(false) + const [isFocused, setIsFocused] = useState(false) + const [showEnvVars, setShowEnvVars] = useState(false) + const [showTags, setShowTags] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + const [cursorPosition, setCursorPosition] = useState(0) + const [activeSourceBlockId, setActiveSourceBlockId] = useState(null) + + const inputRef = useRef(null) + const overlayRef = useRef(null) + const reactFlowInstance = useReactFlow() + + // Use preview value when in preview mode, otherwise use store value or prop value + const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue + + // Evaluate options if it's a function + const evaluatedOptions = useMemo(() => { + return typeof options === 'function' ? options() : options + }, [options]) + + const getOptionValue = (option: string | { label: string; id: string }) => { + return typeof option === 'string' ? option : option.id + } + + const getOptionLabel = (option: string | { label: string; id: string }) => { + return typeof option === 'string' ? option : option.label + } + + // Get the default option value (prefer gpt-4o, then provided defaultValue, then first option) + const defaultOptionValue = useMemo(() => { + if (defaultValue !== undefined) { + return defaultValue + } + + // For model field, default to gpt-4o if available + if (subBlockId === 'model') { + const gpt4o = evaluatedOptions.find((opt) => getOptionValue(opt) === 'gpt-4o') + if (gpt4o) { + return getOptionValue(gpt4o) + } + } + + if (evaluatedOptions.length > 0) { + return getOptionValue(evaluatedOptions[0]) + } + + return undefined + }, [defaultValue, evaluatedOptions, getOptionValue, subBlockId]) + + // Mark store as initialized on first render + useEffect(() => { + setStoreInitialized(true) + }, []) + + // Only set default value once the store is confirmed to be initialized + // and we know the actual value is null/undefined (not just loading) + useEffect(() => { + if ( + storeInitialized && + (value === null || value === undefined) && + defaultOptionValue !== undefined + ) { + setStoreValue(defaultOptionValue) + } + }, [storeInitialized, value, defaultOptionValue, setStoreValue]) + + // Filter options based on current value for display + const filteredOptions = useMemo(() => { + // Always show all options when dropdown is not open + if (!open) return evaluatedOptions + + // If no value or value matches an exact option, show all options + if (!value) return evaluatedOptions + + const currentValue = value.toString() + const exactMatch = evaluatedOptions.find( + (opt) => getOptionValue(opt) === currentValue || getOptionLabel(opt) === currentValue + ) + + // If current value exactly matches an option, show all options (user just selected it) + if (exactMatch) return evaluatedOptions + + // Otherwise filter based on current input + return evaluatedOptions.filter((option) => { + const label = getOptionLabel(option).toLowerCase() + const optionValue = getOptionValue(option).toLowerCase() + const search = currentValue.toLowerCase() + return label.includes(search) || optionValue.includes(search) + }) + }, [evaluatedOptions, value, open, getOptionLabel, getOptionValue]) + + // Event handlers + const handleChange = (e: React.ChangeEvent) => { + if (disabled) { + e.preventDefault() + return + } + + const newValue = e.target.value + const newCursorPosition = e.target.selectionStart ?? 0 + + // Update store value immediately (allow free text) + if (!isPreview) { + setStoreValue(newValue) + } + + setCursorPosition(newCursorPosition) + + // Check for environment variables trigger + const envVarTrigger = checkEnvVarTrigger(newValue, newCursorPosition) + setShowEnvVars(envVarTrigger.show) + setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '') + + // Check for tag trigger + const tagTrigger = checkTagTrigger(newValue, newCursorPosition) + setShowTags(tagTrigger.show) + } + + const handleSelect = (selectedValue: string) => { + if (!isPreview && !disabled) { + setStoreValue(selectedValue) + } + setOpen(false) + inputRef.current?.blur() + } + + const handleDropdownClick = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + if (!disabled) { + setOpen(!open) + if (!open) { + inputRef.current?.focus() + } + } + } + + const handleFocus = () => { + setIsFocused(true) + setOpen(true) + } + + const handleBlur = (e: React.FocusEvent) => { + setIsFocused(false) + setShowEnvVars(false) + setShowTags(false) + + // Delay closing to allow dropdown selection + setTimeout(() => { + const activeElement = document.activeElement + if (!activeElement || !activeElement.closest('.absolute.top-full')) { + setOpen(false) + } + }, 150) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setShowEnvVars(false) + setShowTags(false) + setOpen(false) + return + } + + if (e.key === 'ArrowDown' && !open) { + setOpen(true) + e.preventDefault() + } + } + + // Drag and drop handlers + const handleDragOver = (e: React.DragEvent) => { + if (config?.connectionDroppable === false) return + e.preventDefault() + } + + const handleDrop = (e: React.DragEvent) => { + if (config?.connectionDroppable === false) return + e.preventDefault() + + try { + const data = JSON.parse(e.dataTransfer.getData('application/json')) + if (data.type !== 'connectionBlock') return + + const dropPosition = inputRef.current?.selectionStart ?? value?.toString().length ?? 0 + const currentValue = value?.toString() ?? '' + const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}` + + inputRef.current?.focus() + + Promise.resolve().then(() => { + setStoreValue(newValue) + setCursorPosition(dropPosition + 1) + setShowTags(true) + + if (data.connectionData?.sourceBlockId) { + setActiveSourceBlockId(data.connectionData.sourceBlockId) + } + + setTimeout(() => { + if (inputRef.current) { + inputRef.current.selectionStart = dropPosition + 1 + inputRef.current.selectionEnd = dropPosition + 1 + } + }, 0) + }) + } catch (error) { + logger.error('Failed to parse drop data:', { error }) + } + } + + // Scroll and paste handlers + const handleScroll = (e: React.UIEvent) => { + if (overlayRef.current) { + overlayRef.current.scrollLeft = e.currentTarget.scrollLeft + } + } + + const handlePaste = (e: React.ClipboardEvent) => { + setTimeout(() => { + if (inputRef.current && overlayRef.current) { + overlayRef.current.scrollLeft = inputRef.current.scrollLeft + } + }, 0) + } + + // ReactFlow zoom handler + const handleWheel = (e: React.WheelEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault() + e.stopPropagation() + + const currentZoom = reactFlowInstance.getZoom() + const { x: viewportX, y: viewportY } = reactFlowInstance.getViewport() + + const delta = e.deltaY > 0 ? 1 : -1 + const zoomFactor = 0.96 ** delta + const newZoom = Math.min(Math.max(currentZoom * zoomFactor, 0.1), 1) + + const { x: pointerX, y: pointerY } = reactFlowInstance.screenToFlowPosition({ + x: e.clientX, + y: e.clientY, + }) + + const newViewportX = viewportX + (pointerX * currentZoom - pointerX * newZoom) + const newViewportY = viewportY + (pointerY * currentZoom - pointerY * newZoom) + + reactFlowInstance.setViewport( + { x: newViewportX, y: newViewportY, zoom: newZoom }, + { duration: 0 } + ) + + return false + } + return true + } + + // Environment variable and tag selection handler + const handleEnvVarSelect = (newValue: string) => { + if (!isPreview) { + setStoreValue(newValue) + } + } + + // Effects + useEffect(() => { + if (inputRef.current && overlayRef.current) { + overlayRef.current.scrollLeft = inputRef.current.scrollLeft + } + }, [value]) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Element + if ( + inputRef.current && + !inputRef.current.contains(target) && + !target.closest('[data-radix-popper-content-wrapper]') && + !target.closest('.absolute.top-full') + ) { + setOpen(false) + } + } + + if (open) { + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + } + }, [open]) + + // Display value with formatting + const displayValue = value?.toString() ?? '' + + // Render component + return ( +
+
+ +
+
+ {formatDisplayText(displayValue, true)} +
+
+ {/* Chevron button */} + +
+ + {/* Dropdown */} + {open && ( +
+
+
+ {filteredOptions.length === 0 ? ( +
+ No matching options found. +
+ ) : ( + filteredOptions.map((option) => { + const optionValue = getOptionValue(option) + const optionLabel = getOptionLabel(option) + const OptionIcon = + typeof option === 'object' && 'icon' in option + ? (option.icon as React.ComponentType<{ className?: string }>) + : null + const isSelected = displayValue === optionValue || displayValue === optionLabel + + return ( +
handleSelect(optionValue)} + onMouseDown={(e) => { + e.preventDefault() + handleSelect(optionValue) + }} + className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground' + > + {OptionIcon && } + {optionLabel} + {isSelected && } +
+ ) + }) + )} +
+
+
+ )} + + { + setShowEnvVars(false) + setSearchTerm('') + }} + /> + { + setShowTags(false) + setActiveSourceBlockId(null) + }} + /> +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx index 4b48f2798b..7d3c663da5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx @@ -7,6 +7,7 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { ChannelSelectorInput } from './components/channel-selector/channel-selector-input' import { CheckboxList } from './components/checkbox-list' import { Code } from './components/code' +import { ComboBox } from './components/combobox' import { ConditionInput } from './components/condition-input' import { CredentialSelector } from './components/credential-selector/credential-selector' import { DateInput } from './components/date-input' @@ -114,6 +115,22 @@ export function SubBlock({ /> ) + case 'combobox': + return ( +
+ +
+ ) case 'slider': return ( = { { id: 'model', title: 'Model', - type: 'dropdown', + type: 'combobox', layout: 'half', + placeholder: 'Type or select a model...', options: () => { const ollamaModels = useOllamaStore.getState().models const baseModels = Object.keys(getBaseModelProviders()) - return [...baseModels, ...ollamaModels] + const allModels = [...baseModels, ...ollamaModels] + + return allModels.map((model) => { + const icon = getProviderIcon(model) + return { label: model, id: model, ...(icon && { icon }) } + }) + }, + }, + { + id: 'temperature', + title: 'Temperature', + type: 'slider', + layout: 'half', + min: 0, + max: 1, + condition: { + field: 'model', + value: MODELS_TEMP_RANGE_0_1, }, }, { @@ -111,12 +131,20 @@ export const AgentBlock: BlockConfig = { id: 'temperature', title: 'Temperature', type: 'slider', - layout: 'half', + layout: 'full', min: 0, - max: 1, + max: 2, condition: { field: 'model', - value: MODELS_TEMP_RANGE_0_1, + value: [...MODELS_TEMP_RANGE_0_1, ...MODELS_TEMP_RANGE_0_2], + not: true, + and: { + field: 'model', + value: Object.keys(getBaseModelProviders()).filter( + (model) => !MODELS_WITH_TEMPERATURE_SUPPORT.includes(model) + ), + not: true, + }, }, }, { diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 56baf61592..ea2ff6f37e 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -14,6 +14,7 @@ export type SubBlockType = | 'short-input' // Single line input | 'long-input' // Multi-line input | 'dropdown' // Select menu + | 'combobox' // Searchable dropdown with text input | 'slider' // Range input | 'table' // Grid layout | 'code' // Code editor @@ -92,8 +93,10 @@ export interface SubBlockConfig { mode?: 'basic' | 'advanced' | 'both' // Default is 'both' if not specified options?: | string[] - | { label: string; id: string }[] - | (() => string[] | { label: string; id: string }[]) + | { label: string; id: string; icon?: React.ComponentType<{ className?: string }> }[] + | (() => + | string[] + | { label: string; id: string; icon?: React.ComponentType<{ className?: string }> }[]) min?: number max?: number columns?: string[] diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index cdd13dcfb3..57231f378b 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2953,3 +2953,162 @@ export const ResponseIcon = (props: SVGProps) => ( /> ) + +export const AnthropicIcon = (props: SVGProps) => ( + + Anthropic + + +) + +export const AzureIcon = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + +) + +export const GroqIcon = (props: SVGProps) => ( + + Groq + + +) + +export const DeepseekIcon = (props: SVGProps) => ( + + DeepSeek + + +) + +export const GeminiIcon = (props: SVGProps) => ( + + Gemini + + + + + + + + + +) + +export const CerebrasIcon = (props: SVGProps) => ( + + Cerebras + + + +) + +export const OllamaIcon = (props: SVGProps) => ( + + Ollama + + +) diff --git a/apps/sim/contexts/socket-context.test.tsx b/apps/sim/contexts/socket-context.test.tsx deleted file mode 100644 index 5f1c8df744..0000000000 --- a/apps/sim/contexts/socket-context.test.tsx +++ /dev/null @@ -1,278 +0,0 @@ -/** - * @vitest-environment jsdom - */ - -import { act, renderHook, waitFor } from '@testing-library/react' -import { io } from 'socket.io-client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { SocketProvider, useSocket } from './socket-context' - -vi.mock('socket.io-client') -const mockIo = vi.mocked(io) - -global.fetch = vi.fn() -const mockFetch = vi.mocked(fetch) - -vi.mock('@/lib/logs/console-logger', () => ({ - createLogger: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), -})) - -describe('SocketContext Token Refresh', () => { - let mockSocket: any - let eventHandlers: Record - - beforeEach(() => { - eventHandlers = {} - mockSocket = { - id: 'test-socket-id', - connected: true, - io: { engine: { transport: { name: 'websocket' } } }, - auth: { token: 'initial-token' }, - on: vi.fn((event, handler) => { - eventHandlers[event] = handler - }), - connect: vi.fn(), - disconnect: vi.fn(), - emit: vi.fn(), - close: vi.fn(), - } - - mockIo.mockReturnValue(mockSocket) - - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ token: 'fresh-token' }), - } as Response) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - const renderSocketProvider = async (user = { id: 'test-user', name: 'Test User' }) => { - const result = renderHook(() => useSocket(), { - wrapper: ({ children }) => {children}, - }) - - await waitFor(() => { - expect(mockSocket.on).toHaveBeenCalledWith('connect_error', expect.any(Function)) - }) - - vi.clearAllMocks() - - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ token: 'fresh-token' }), - } as Response) - - return result - } - - describe('Token Refresh on Connection Error', () => { - it('should refresh token on authentication failure', async () => { - const { result } = await renderSocketProvider() - - const error = { message: 'Token validation failed' } - - await act(async () => { - await eventHandlers.connect_error(error) - }) - - expect(mockFetch).toHaveBeenCalledWith('/api/auth/socket-token', { - method: 'POST', - credentials: 'include', - }) - - // Should update socket auth and reconnect - expect(mockSocket.auth.token).toBe('fresh-token') - expect(mockSocket.connect).toHaveBeenCalled() - }) - - it('should limit token refresh attempts to 3', async () => { - const { result } = await renderSocketProvider() - - const error = { message: 'Token validation failed' } - - for (let i = 0; i < 4; i++) { - await act(async () => { - await eventHandlers.connect_error(error) - }) - } - - // Should only call fetch 3 times (max attempts) - expect(mockFetch).toHaveBeenCalledTimes(3) - expect(mockSocket.connect).toHaveBeenCalledTimes(3) - }) - - it('should prevent concurrent token refresh attempts', async () => { - const { result } = await renderSocketProvider() - - let resolveTokenFetch!: (value: { - ok: boolean - json: () => Promise<{ token: string }> - }) => void - const slowTokenPromise = new Promise((resolve) => { - resolveTokenFetch = resolve - }) - - mockFetch.mockReturnValue(slowTokenPromise as any) - - const error = { message: 'Authentication failed' } - - // Start two concurrent refresh attempts - const promise1 = act(async () => { - await eventHandlers.connect_error(error) - }) - - const promise2 = act(async () => { - await eventHandlers.connect_error(error) - }) - - // Resolve the slow fetch - resolveTokenFetch({ - ok: true, - json: async () => ({ token: 'fresh-token' }), - }) - - await Promise.all([promise1, promise2]) - - // Should only call fetch once (concurrent protection) - expect(mockFetch).toHaveBeenCalledTimes(1) - }) - - it('should reset retry counter on successful connection', async () => { - const { result } = await renderSocketProvider() - - const error = { message: 'Token validation failed' } - - // Use up 2 retry attempts - await act(async () => { - await eventHandlers.connect_error(error) - }) - await act(async () => { - await eventHandlers.connect_error(error) - }) - - expect(mockFetch).toHaveBeenCalledTimes(2) - - // Simulate successful connection (resets counter) - await act(async () => { - eventHandlers.connect() - }) - - // Should be able to retry again (counter reset) - await act(async () => { - await eventHandlers.connect_error(error) - }) - - expect(mockFetch).toHaveBeenCalledTimes(3) - }) - - it('should handle token refresh failure gracefully', async () => { - const { result } = await renderSocketProvider() - - // Mock failed token refresh after initialization - mockFetch.mockResolvedValue({ - ok: false, - status: 401, - } as Response) - - const error = { message: 'Token validation failed' } - - await act(async () => { - await eventHandlers.connect_error(error) - }) - - // Should attempt refresh but not update auth or reconnect - expect(mockFetch).toHaveBeenCalled() - expect(mockSocket.auth.token).toBe('initial-token') // unchanged - expect(mockSocket.connect).not.toHaveBeenCalled() - }) - - it('should handle fetch errors gracefully', async () => { - const { result } = await renderSocketProvider() - - // Mock fetch error after initialization - mockFetch.mockRejectedValue(new Error('Network error')) - - const error = { message: 'Authentication failed' } - - // Should not throw error - await act(async () => { - await eventHandlers.connect_error(error) - }) - - expect(mockFetch).toHaveBeenCalled() - expect(mockSocket.connect).not.toHaveBeenCalled() - }) - - it('should only refresh token on authentication-related errors', async () => { - const { result } = await renderSocketProvider() - - // Non-authentication error - const networkError = { message: 'Network timeout' } - - await act(async () => { - await eventHandlers.connect_error(networkError) - }) - - // Should not attempt token refresh - expect(mockFetch).not.toHaveBeenCalled() - expect(mockSocket.connect).not.toHaveBeenCalled() - }) - }) - - describe('Interaction with Socket.IO Reconnection', () => { - it('should work with Socket.IO built-in reconnection attempts', async () => { - const { result } = await renderSocketProvider() - - // Simulate Socket.IO reconnection cycle - await act(async () => { - // Reconnection attempt starts - eventHandlers.reconnect_attempt(1) - }) - - await act(async () => { - // Fails with auth error - await eventHandlers.connect_error({ message: 'Token validation failed' }) - }) - - // Should refresh token and attempt reconnection - expect(mockFetch).toHaveBeenCalled() - expect(mockSocket.connect).toHaveBeenCalled() - }) - - it('should reset counters on successful reconnect', async () => { - const { result } = await renderSocketProvider() - - // Use up retry attempts - const error = { message: 'Authentication failed' } - await act(async () => { - await eventHandlers.connect_error(error) - }) - - await act(async () => { - await eventHandlers.connect_error(error) - }) - - expect(mockFetch).toHaveBeenCalledTimes(2) - - // Simulate successful reconnection - await act(async () => { - eventHandlers.reconnect(1) - }) - - // Should reset and allow new attempts - await act(async () => { - await eventHandlers.connect_error(error) - }) - - expect(mockFetch).toHaveBeenCalledTimes(3) - }) - }) -}) diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 114b646d04..82ea739586 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -132,7 +132,7 @@ const nextConfig: NextConfig = { }, { // For main app routes, Google Drive Picker, and Vercel resources - use permissive policies - source: '/(w/.*|api/tools/drive|_next/.*|_vercel/.*)', + source: '/(w/.*|workspace/.*|api/tools/drive|_next/.*|_vercel/.*)', headers: [ { key: 'Cross-Origin-Embedder-Policy', diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 7632787edf..960b5fd7ee 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -7,6 +7,19 @@ * - Provider configurations */ +import type React from 'react' +import { + AnthropicIcon, + AzureIcon, + CerebrasIcon, + DeepseekIcon, + GeminiIcon, + GroqIcon, + OllamaIcon, + OpenAIIcon, + xAIIcon, +} from '@/components/icons' + export interface ModelPricing { input: number // Per 1M tokens cachedInput?: number // Per 1M tokens (if supported) @@ -36,6 +49,7 @@ export interface ProviderDefinition { models: ModelDefinition[] defaultModel: string modelPatterns?: RegExp[] + icon?: React.ComponentType<{ className?: string }> } /** @@ -48,6 +62,7 @@ export const PROVIDER_DEFINITIONS: Record = { description: "OpenAI's models", defaultModel: 'gpt-4o', modelPatterns: [/^gpt/, /^o1/], + icon: OpenAIIcon, models: [ { id: 'gpt-4o', @@ -142,6 +157,7 @@ export const PROVIDER_DEFINITIONS: Record = { description: 'Microsoft Azure OpenAI Service models', defaultModel: 'azure/gpt-4o', modelPatterns: [/^azure\//], + icon: AzureIcon, models: [ { id: 'azure/gpt-4o', @@ -212,6 +228,7 @@ export const PROVIDER_DEFINITIONS: Record = { description: "Anthropic's Claude models", defaultModel: 'claude-sonnet-4-0', modelPatterns: [/^claude/], + icon: AnthropicIcon, models: [ { id: 'claude-sonnet-4-0', @@ -275,6 +292,7 @@ export const PROVIDER_DEFINITIONS: Record = { description: "Google's Gemini models", defaultModel: 'gemini-2.5-pro', modelPatterns: [/^gemini/], + icon: GeminiIcon, models: [ { id: 'gemini-2.5-pro', @@ -310,6 +328,7 @@ export const PROVIDER_DEFINITIONS: Record = { description: "Deepseek's chat models", defaultModel: 'deepseek-chat', modelPatterns: [], + icon: DeepseekIcon, models: [ { id: 'deepseek-chat', @@ -356,6 +375,7 @@ export const PROVIDER_DEFINITIONS: Record = { description: "xAI's Grok models", defaultModel: 'grok-3-latest', modelPatterns: [/^grok/], + icon: xAIIcon, models: [ { id: 'grok-3-latest', @@ -391,6 +411,7 @@ export const PROVIDER_DEFINITIONS: Record = { description: 'Cerebras Cloud LLMs', defaultModel: 'cerebras/llama-3.3-70b', modelPatterns: [/^cerebras/], + icon: CerebrasIcon, models: [ { id: 'cerebras/llama-3.3-70b', @@ -412,6 +433,7 @@ export const PROVIDER_DEFINITIONS: Record = { description: "Groq's LLM models with high-performance inference", defaultModel: 'groq/meta-llama/llama-4-scout-17b-16e-instruct', modelPatterns: [/^groq/], + icon: GroqIcon, models: [ { id: 'groq/meta-llama/llama-4-scout-17b-16e-instruct', @@ -457,6 +479,7 @@ export const PROVIDER_DEFINITIONS: Record = { description: 'Local LLM models via Ollama', defaultModel: '', modelPatterns: [], + icon: OllamaIcon, models: [], // Populated dynamically }, } diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index b48c3c40ff..845d82c37a 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -178,6 +178,14 @@ export function getProviderModels(providerId: ProviderId): string[] { return getProviderModelsFromDefinitions(providerId) } +/** + * Get provider icon for a given model + */ +export function getProviderIcon(model: string): React.ComponentType<{ className?: string }> | null { + const providerId = getProviderFromModel(model) + return PROVIDER_DEFINITIONS[providerId]?.icon || null +} + export function generateStructuredOutputInstructions(responseFormat: any): string { // Handle null/undefined input if (!responseFormat) return '' diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index a410a4d151..955264c706 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -70,9 +70,16 @@ export class Serializer { // For non-custom tools, we determine the tool ID const nonCustomTools = tools.filter((tool: any) => tool.type !== 'custom-tool') if (nonCustomTools.length > 0) { - toolId = blockConfig.tools.config?.tool - ? blockConfig.tools.config.tool(params) - : blockConfig.tools.access[0] + try { + toolId = blockConfig.tools.config?.tool + ? blockConfig.tools.config.tool(params) + : blockConfig.tools.access[0] + } catch (error) { + logger.warn('Tool selection failed during serialization, using default:', { + error: error instanceof Error ? error.message : String(error), + }) + toolId = blockConfig.tools.access[0] + } } } catch (error) { logger.error('Error processing tools in agent block:', { error }) @@ -81,9 +88,16 @@ export class Serializer { } } else { // For non-agent blocks, get tool ID from block config as usual - toolId = blockConfig.tools.config?.tool - ? blockConfig.tools.config.tool(params) - : blockConfig.tools.access[0] + try { + toolId = blockConfig.tools.config?.tool + ? blockConfig.tools.config.tool(params) + : blockConfig.tools.access[0] + } catch (error) { + logger.warn('Tool selection failed during serialization, using default:', { + error: error instanceof Error ? error.message : String(error), + }) + toolId = blockConfig.tools.access[0] + } } // Get inputs from block config diff --git a/packages/cli/package.json b/packages/cli/package.json index db91da2291..8aba88e6d6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,8 +1,9 @@ { "name": "simstudio", - "version": "0.1.18", + "version": "0.1.19", "description": "Sim Studio CLI - Run Sim Studio with a single command", "main": "dist/index.js", + "type": "module", "bin": { "simstudio": "dist/index.js" }, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c40b718804..a2959fad49 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -188,7 +188,7 @@ async function main() { 'ghcr.io/simstudioai/migrations:latest', 'bun', 'run', - 'db:push', + 'db:migrate', ]) if (!migrationsSuccess) { @@ -259,7 +259,7 @@ async function main() { ) console.log( chalk.yellow( - `🛑 To stop all containers, run: ${chalk.bold('docker stop simstudio-app simstudio-db')}` + `🛑 To stop all containers, run: ${chalk.bold('docker stop simstudio-app simstudio-db simstudio-realtime')}` ) ) @@ -275,6 +275,7 @@ async function main() { // Stop containers await stopAndRemoveContainer(APP_CONTAINER) await stopAndRemoveContainer(DB_CONTAINER) + await stopAndRemoveContainer(REALTIME_CONTAINER) console.log(chalk.green('✅ Sim Studio has been stopped')) process.exit(0)