diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 29f13af99b..046a647354 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -44,8 +44,12 @@ import { SearchInput, } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useKnowledgeBase, useKnowledgeBaseDocuments } from '@/hooks/use-knowledge' -import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store' +import { + useKnowledgeBase, + useKnowledgeBaseDocuments, + useKnowledgeBasesList, +} from '@/hooks/use-knowledge' +import type { DocumentData } from '@/stores/knowledge/store' const logger = createLogger('KnowledgeBase') @@ -125,10 +129,10 @@ export function KnowledgeBase({ id, knowledgeBaseName: passedKnowledgeBaseName, }: KnowledgeBaseProps) { - const { removeKnowledgeBase } = useKnowledgeStore() - const userPermissions = useUserPermissionsContext() const params = useParams() const workspaceId = params.workspaceId as string + const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false }) + const userPermissions = useUserPermissionsContext() const [searchQuery, setSearchQuery] = useState('') diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx index 6441e5eb17..c9def4a817 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/folder.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { Check, ChevronDown } from 'lucide-react' import { useParams } from 'next/navigation' import { Button } from '@/components/emcn' @@ -22,7 +22,8 @@ import { filterButtonClass, folderDropdownListStyle, } from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared' -import { useFolderStore } from '@/stores/folders/store' +import { useFolders } from '@/hooks/queries/folders' +import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' import { useFilterStore } from '@/stores/logs/filters/store' const logger = createLogger('LogsFolderFilter') @@ -36,56 +37,37 @@ interface FolderOption { export default function FolderFilter() { const { folderIds, toggleFolderId, setFolderIds } = useFilterStore() - const { getFolderTree, fetchFolders } = useFolderStore() + const { getFolderTree } = useFolderStore() const params = useParams() const workspaceId = params.workspaceId as string - const [folders, setFolders] = useState([]) - const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') + const { isLoading: foldersLoading } = useFolders(workspaceId) - // Fetch all available folders from the API - useEffect(() => { - const fetchFoldersData = async () => { - try { - setLoading(true) - if (workspaceId) { - await fetchFolders(workspaceId) - const folderTree = getFolderTree(workspaceId) + const folderTree = workspaceId ? getFolderTree(workspaceId) : [] - // Flatten the folder tree and create options with full paths - const flattenFolders = (nodes: any[], parentPath = ''): FolderOption[] => { - const result: FolderOption[] = [] + const folders: FolderOption[] = useMemo(() => { + const flattenFolders = (nodes: FolderTreeNode[], parentPath = ''): FolderOption[] => { + const result: FolderOption[] = [] - for (const node of nodes) { - const currentPath = parentPath ? `${parentPath} / ${node.name}` : node.name - result.push({ - id: node.id, - name: node.name, - color: node.color || '#6B7280', - path: currentPath, - }) + for (const node of nodes) { + const currentPath = parentPath ? `${parentPath} / ${node.name}` : node.name + result.push({ + id: node.id, + name: node.name, + color: node.color || '#6B7280', + path: currentPath, + }) - // Add children recursively - if (node.children && node.children.length > 0) { - result.push(...flattenFolders(node.children, currentPath)) - } - } - - return result - } - - const folderOptions = flattenFolders(folderTree) - setFolders(folderOptions) + if (node.children && node.children.length > 0) { + result.push(...flattenFolders(node.children, currentPath)) } - } catch (error) { - logger.error('Failed to fetch folders', { error }) - } finally { - setLoading(false) } + + return result } - fetchFoldersData() - }, [workspaceId, fetchFolders, getFolderTree]) + return flattenFolders(folderTree) + }, [folderTree]) // Get display text for the dropdown button const getSelectedFoldersText = () => { @@ -111,7 +93,7 @@ export default function FolderFilter() { @@ -125,7 +107,9 @@ export default function FolderFilter() { setSearch(v)} /> - {loading ? 'Loading folders...' : 'No folders found.'} + + {foldersLoading ? 'Loading folders...' : 'No folders found.'} + { let cancelled = false @@ -138,7 +140,6 @@ export default function Logs() { if (!cancelled) setAvailableWorkflows([]) } - await fetchFolders(workspaceId) const tree = getFolderTree(workspaceId) const flatten = (nodes: any[], parentPath = ''): string[] => { @@ -168,7 +169,7 @@ export default function Logs() { return () => { cancelled = true } - }, [workspaceId, fetchFolders, getFolderTree]) + }, [workspaceId, getFolderTree, foldersQuery.data]) useEffect(() => { if (isInitialized.current && debouncedSearchQuery !== storeSearchQuery) { diff --git a/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.tsx b/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.tsx new file mode 100644 index 0000000000..ab75063217 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/providers/provider-models-loader.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useEffect } from 'react' +import { createLogger } from '@/lib/logs/console/logger' +import { useProviderModels } from '@/hooks/queries/providers' +import { updateOllamaProviderModels, updateOpenRouterProviderModels } from '@/providers/utils' +import { useProvidersStore } from '@/stores/providers/store' +import type { ProviderName } from '@/stores/providers/types' + +const logger = createLogger('ProviderModelsLoader') + +function useSyncProvider(provider: ProviderName) { + const setProviderModels = useProvidersStore((state) => state.setProviderModels) + const setProviderLoading = useProvidersStore((state) => state.setProviderLoading) + const { data, isLoading, isFetching, error } = useProviderModels(provider) + + useEffect(() => { + setProviderLoading(provider, isLoading || isFetching) + }, [provider, isLoading, isFetching, setProviderLoading]) + + useEffect(() => { + if (!data) return + + try { + if (provider === 'ollama') { + updateOllamaProviderModels(data) + } else if (provider === 'openrouter') { + void updateOpenRouterProviderModels(data) + } + } catch (syncError) { + logger.warn(`Failed to sync provider definitions for ${provider}`, syncError as Error) + } + + setProviderModels(provider, data) + }, [provider, data, setProviderModels]) + + useEffect(() => { + if (error) { + logger.error(`Failed to load ${provider} models`, error) + } + }, [provider, error]) +} + +export function ProviderModelsLoader() { + useSyncProvider('base') + useSyncProvider('ollama') + useSyncProvider('openrouter') + return null +} diff --git a/apps/sim/app/workspace/[workspaceId]/providers/providers.tsx b/apps/sim/app/workspace/[workspaceId]/providers/providers.tsx index 024fcdbb24..8633d8b0df 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/providers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/providers.tsx @@ -4,6 +4,7 @@ import React from 'react' import { Tooltip } from '@/components/emcn' import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { ProviderModelsLoader } from './provider-models-loader' import { SettingsLoader } from './settings-loader' interface ProvidersProps { @@ -14,6 +15,7 @@ const Providers = React.memo(({ children }) => { return ( <> + {children} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/document-selector/document-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/document-selector/document-selector.tsx index 5d783d07fc..5257c770a3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/document-selector/document-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/document-selector/document-selector.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Check, ChevronDown, FileText, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -15,8 +15,9 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value' import type { SubBlockConfig } from '@/blocks/types' +import { useKnowledgeBaseDocuments } from '@/hooks/use-knowledge' import { useDisplayNamesStore } from '@/stores/display-names/store' -import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store' +import type { DocumentData } from '@/stores/knowledge/store' interface DocumentSelectorProps { blockId: string @@ -45,68 +46,29 @@ export function DocumentSelector({ ? knowledgeBaseId : null - const documentsCache = useKnowledgeStore( - useCallback( - (state) => - normalizedKnowledgeBaseId ? state.documents[normalizedKnowledgeBaseId] : undefined, - [normalizedKnowledgeBaseId] - ) - ) - - const isDocumentsLoading = useKnowledgeStore( - useCallback( - (state) => - normalizedKnowledgeBaseId ? state.isDocumentsLoading(normalizedKnowledgeBaseId) : false, - [normalizedKnowledgeBaseId] - ) - ) - - const getDocuments = useKnowledgeStore((state) => state.getDocuments) - const value = isPreview ? previewValue : storeValue const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview }) const isDisabled = finalDisabled - const documents = useMemo(() => { - if (!documentsCache) return [] - return documentsCache.documents ?? [] - }, [documentsCache]) - - const loadDocuments = useCallback(async () => { - if (!normalizedKnowledgeBaseId) { - setError('No knowledge base selected') - return - } - - setError(null) - - try { - const fetchedDocuments = await getDocuments(normalizedKnowledgeBaseId) - - if (fetchedDocuments.length > 0) { - const documentMap = fetchedDocuments.reduce>((acc, doc) => { - acc[doc.id] = doc.filename - return acc - }, {}) - - useDisplayNamesStore - .getState() - .setDisplayNames('documents', normalizedKnowledgeBaseId, documentMap) - } - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') return - setError(err instanceof Error ? err.message : 'Failed to fetch documents') - } - }, [normalizedKnowledgeBaseId, getDocuments]) + const { + documents, + isLoading: documentsLoading, + error: documentsError, + refreshDocuments, + } = useKnowledgeBaseDocuments(normalizedKnowledgeBaseId ?? '', { + limit: 500, + offset: 0, + enabled: open && Boolean(normalizedKnowledgeBaseId), + }) const handleOpenChange = (isOpen: boolean) => { if (isPreview || isDisabled) return setOpen(isOpen) - if (isOpen && (!documentsCache || !documentsCache.documents.length)) { - void loadDocuments() + if (isOpen && normalizedKnowledgeBaseId) { + void refreshDocuments() } } @@ -119,9 +81,15 @@ export function DocumentSelector({ } useEffect(() => { - setError(null) + if (!normalizedKnowledgeBaseId) { + setError(null) + } }, [normalizedKnowledgeBaseId]) + useEffect(() => { + setError(documentsError) + }, [documentsError]) + useEffect(() => { if (!normalizedKnowledgeBaseId || documents.length === 0) return @@ -152,7 +120,7 @@ export function DocumentSelector({ } const label = subBlock.placeholder || 'Select document' - const isLoading = isDocumentsLoading && !error + const isLoading = documentsLoading && !error // Always use cached display name const displayName = useDisplayNamesStore( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx index 1ce8b0184a..31b4d4b48b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx @@ -1,13 +1,16 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo } from 'react' +import { useQueries } from '@tanstack/react-query' import { X } from 'lucide-react' import { useParams } from 'next/navigation' import { Combobox, type ComboboxOption } from '@/components/emcn/components/combobox/combobox' import { PackageSearchIcon } from '@/components/icons' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value' import type { SubBlockConfig } from '@/blocks/types' -import { type KnowledgeBaseData, useKnowledgeStore } from '@/stores/knowledge/store' +import { fetchKnowledgeBase, knowledgeKeys } from '@/hooks/queries/knowledge' +import { useKnowledgeBasesList } from '@/hooks/use-knowledge' +import type { KnowledgeBaseData } from '@/stores/knowledge/store' interface KnowledgeBaseSelectorProps { blockId: string @@ -29,14 +32,11 @@ export function KnowledgeBaseSelector({ const params = useParams() const workspaceId = params.workspaceId as string - const knowledgeBasesList = useKnowledgeStore((state) => state.knowledgeBasesList) - const knowledgeBasesMap = useKnowledgeStore((state) => state.knowledgeBases) - const loadingKnowledgeBasesList = useKnowledgeStore((state) => state.loadingKnowledgeBasesList) - const getKnowledgeBasesList = useKnowledgeStore((state) => state.getKnowledgeBasesList) - const getKnowledgeBase = useKnowledgeStore((state) => state.getKnowledgeBase) - - const [error, setError] = useState(null) - const hasRequestedListRef = useRef(false) + const { + knowledgeBases, + isLoading: isKnowledgeBasesLoading, + error, + } = useKnowledgeBasesList(workspaceId) // Use the proper hook to get the current value and setter - this prevents infinite loops const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) @@ -46,28 +46,6 @@ export function KnowledgeBaseSelector({ const isMultiSelect = subBlock.multiSelect === true - /** - * Convert knowledge bases to combobox options format - */ - const combinedKnowledgeBases = useMemo(() => { - const merged = new Map() - knowledgeBasesList.forEach((kb) => { - merged.set(kb.id, kb) - }) - Object.values(knowledgeBasesMap).forEach((kb) => { - merged.set(kb.id, kb) - }) - return Array.from(merged.values()) - }, [knowledgeBasesList, knowledgeBasesMap]) - - const options = useMemo(() => { - return combinedKnowledgeBases.map((kb) => ({ - label: kb.name, - value: kb.id, - icon: PackageSearchIcon, - })) - }, [combinedKnowledgeBases]) - /** * Parse value into array of selected IDs */ @@ -84,6 +62,39 @@ export function KnowledgeBaseSelector({ return [] }, [value]) + /** + * Convert knowledge bases to combobox options format + */ + const selectedKnowledgeBaseQueries = useQueries({ + queries: selectedIds.map((selectedId) => ({ + queryKey: knowledgeKeys.detail(selectedId), + queryFn: () => fetchKnowledgeBase(selectedId), + enabled: Boolean(selectedId), + staleTime: 60 * 1000, + })), + }) + + const combinedKnowledgeBases = useMemo(() => { + const merged = new Map() + knowledgeBases.forEach((kb) => merged.set(kb.id, kb)) + + selectedKnowledgeBaseQueries.forEach((query) => { + if (query.data) { + merged.set(query.data.id, query.data) + } + }) + + return Array.from(merged.values()) + }, [knowledgeBases, selectedKnowledgeBaseQueries]) + + const options = useMemo(() => { + return combinedKnowledgeBases.map((kb) => ({ + label: kb.name, + value: kb.id, + icon: PackageSearchIcon, + })) + }, [combinedKnowledgeBases]) + /** * Compute selected knowledge bases for tag display */ @@ -144,44 +155,6 @@ export function KnowledgeBaseSelector({ [isPreview, selectedIds, setStoreValue, onKnowledgeBaseSelect] ) - /** - * Fetch knowledge bases on initial mount - */ - useEffect(() => { - if (hasRequestedListRef.current) return - - let cancelled = false - hasRequestedListRef.current = true - setError(null) - getKnowledgeBasesList(workspaceId).catch((err) => { - if (cancelled) return - setError(err instanceof Error ? err.message : 'Failed to load knowledge bases') - }) - - return () => { - cancelled = true - } - }, [workspaceId, getKnowledgeBasesList]) - - /** - * Ensure selected knowledge bases are cached - */ - useEffect(() => { - if (selectedIds.length === 0) return - - selectedIds.forEach((id) => { - const isKnown = - Boolean(knowledgeBasesMap[id]) || - knowledgeBasesList.some((knowledgeBase) => knowledgeBase.id === id) - - if (!isKnown) { - void getKnowledgeBase(id).catch(() => { - // Ignore fetch errors here; they will surface via display hooks if needed - }) - } - }) - }, [selectedIds, knowledgeBasesList, knowledgeBasesMap, getKnowledgeBase]) - const label = subBlock.placeholder || (isMultiSelect ? 'Select knowledge bases' : 'Select knowledge base') @@ -221,7 +194,7 @@ export function KnowledgeBaseSelector({ onMultiSelectChange={handleMultiSelectChange} placeholder={label} disabled={disabled || isPreview} - isLoading={loadingKnowledgeBasesList} + isLoading={isKnowledgeBasesLoading} error={error} /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index b781b081a7..6d0dbfc505 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -85,7 +85,7 @@ export function useWorkflowExecution() { const currentWorkflow = useCurrentWorkflow() const { activeWorkflowId, workflows } = useWorkflowRegistry() const { toggleConsole, addConsole } = useTerminalConsoleStore() - const { getAllVariables, loadWorkspaceEnvironment } = useEnvironmentStore() + const { getAllVariables } = useEnvironmentStore() const { getVariablesByWorkflowId, variables } = useVariablesStore() const { isExecuting, @@ -650,7 +650,6 @@ export function useWorkflowExecution() { currentWorkflow, toggleConsole, getAllVariables, - loadWorkspaceEnvironment, getVariablesByWorkflowId, setIsExecuting, setIsDebugging, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx index dc5c9ef519..e7ee7eb38a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx @@ -34,6 +34,7 @@ import { getSubscriptionPermissions, getVisiblePlans, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription-permissions' +import { useUpdateGeneralSetting } from '@/hooks/queries/general-settings' import { useOrganizationBilling, useOrganizations } from '@/hooks/queries/organization' import { useSubscriptionData, useUsageLimitData } from '@/hooks/queries/subscription' import { useUpdateWorkspaceSettings, useWorkspaceSettings } from '@/hooks/queries/workspace' @@ -626,9 +627,9 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { } function BillingUsageNotificationsToggle() { - const isLoading = useGeneralStore((s) => s.isBillingUsageNotificationsLoading) const enabled = useGeneralStore((s) => s.isBillingUsageNotificationsEnabled) - const setEnabled = useGeneralStore((s) => s.setBillingUsageNotificationsEnabled) + const updateSetting = useUpdateGeneralSetting() + const isLoading = updateSetting.isPending // Settings are automatically loaded by SettingsLoader provider // No need to load here - Zustand is synced from React Query @@ -643,7 +644,9 @@ function BillingUsageNotificationsToggle() { checked={!!enabled} disabled={isLoading} onCheckedChange={(v: boolean) => { - void setEnabled(v) + if (v !== enabled) { + updateSetting.mutate({ key: 'billingUsageNotificationsEnabled', value: v }) + } }} /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx index 4215305ca2..da6d553134 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx @@ -13,7 +13,8 @@ import { useItemRename, } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useDeleteFolder, useDuplicateFolder } from '@/app/workspace/[workspaceId]/w/hooks' -import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' +import { useUpdateFolder } from '@/hooks/queries/folders' +import type { FolderTreeNode } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' interface FolderItemProps { @@ -37,7 +38,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string - const { updateFolderAPI } = useFolderStore() + const updateFolderMutation = useUpdateFolder() const { createWorkflow } = useWorkflowRegistry() // Delete modal state @@ -125,7 +126,11 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { } = useItemRename({ initialName: folder.name, onSave: async (newName) => { - await updateFolderAPI(folder.id, { name: newName }) + await updateFolderMutation.mutateAsync({ + workspaceId, + id: folder.id, + updates: { name: newName }, + }) }, itemType: 'folder', itemId: folder.id, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/workflow-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/workflow-list.tsx index 9c90ec623e..c72f99bb82 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/workflow-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/workflow-list.tsx @@ -10,6 +10,7 @@ import { useWorkflowSelection, } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks/use-import-workflow' +import { useFolders } from '@/hooks/queries/folders' import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -56,14 +57,9 @@ export function WorkflowList({ const workspaceId = params.workspaceId as string const workflowId = params.workflowId as string - const { - getFolderTree, - expandedFolders, - fetchFolders, - isLoading: foldersLoading, - getFolderPath, - setExpanded, - } = useFolderStore() + const { isLoading: foldersLoading } = useFolders(workspaceId) + + const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore() const { dropTargetId, @@ -169,15 +165,6 @@ export function WorkflowList({ } }, [workflowId, activeWorkflowFolderId, isLoading, foldersLoading, getFolderPath, setExpanded]) - /** - * Fetch folders when workspace changes - */ - useEffect(() => { - if (workspaceId) { - fetchFolders(workspaceId) - } - }, [workspaceId, fetchFolders]) - const renderWorkflowItem = useCallback( (workflow: WorkflowMetadata, level: number, parentFolderId: string | null = null) => (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx index 68bf5f03c2..8aa2514590 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/create-menu.tsx @@ -14,7 +14,7 @@ import { extractWorkflowsFromZip, } from '@/lib/workflows/import-export' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useFolderStore } from '@/stores/folders/store' +import { useCreateFolder } from '@/hooks/queries/folders' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { parseWorkflowJson } from '@/stores/workflows/json/importer' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -43,7 +43,7 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string - const { createFolder } = useFolderStore() + const createFolderMutation = useCreateFolder() const { createWorkflow } = useWorkflowRegistry() const userPermissions = useUserPermissionsContext() const fileInputRef = useRef(null) @@ -110,14 +110,14 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre try { setIsCreating(true) const folderName = await generateFolderName(workspaceId) - await createFolder({ name: folderName, workspaceId }) + await createFolderMutation.mutateAsync({ name: folderName, workspaceId }) logger.info(`Created folder: ${folderName}`) } catch (error) { logger.error('Failed to create folder:', { error }) } finally { setIsCreating(false) } - }, [createFolder, workspaceId, isCreating]) + }, [createFolderMutation, workspaceId, isCreating]) const handleImportWorkflow = useCallback(() => { setIsOpen(false) @@ -143,9 +143,8 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre const { workflows: extractedWorkflows, metadata } = await extractWorkflowsFromZip(zipFile) importedWorkflows = extractedWorkflows - const { createFolder } = useFolderStore.getState() const folderName = metadata?.workspaceName || zipFile.name.replace(/\.zip$/i, '') - const importFolder = await createFolder({ + const importFolder = await createFolderMutation.mutateAsync({ name: folderName, workspaceId, }) @@ -175,7 +174,7 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre const pathSegment = workflow.folderPath.slice(0, i + 1).join('/') if (!folderMap.has(pathSegment)) { - const subFolder = await createFolder({ + const subFolder = await createFolderMutation.mutateAsync({ name: workflow.folderPath[i], workspaceId, parentId, @@ -302,9 +301,6 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre const { loadWorkflows } = useWorkflowRegistry.getState() await loadWorkflows(workspaceId) - - const { fetchFolders } = useFolderStore.getState() - await fetchFolders(workspaceId) } catch (error) { logger.error('Failed to import workflows:', error) } finally { @@ -314,7 +310,7 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre } } }, - [workspaceId, createWorkflow] + [workspaceId, createWorkflow, createFolderMutation] ) // Button event handlers diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/folder-item.tsx index e17d829519..9f68353489 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/folder-item.tsx @@ -18,6 +18,7 @@ import { import { Button } from '@/components/ui/button' import { createLogger } from '@/lib/logs/console/logger' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders' import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' const logger = createLogger('FolderItem') @@ -45,7 +46,9 @@ export function FolderItem({ isFirstItem = false, level, }: FolderItemProps) { - const { expandedFolders, toggleExpanded, updateFolderAPI, deleteFolder } = useFolderStore() + const { expandedFolders, toggleExpanded } = useFolderStore() + const updateFolderMutation = useUpdateFolder() + const deleteFolderMutation = useDeleteFolderMutation() const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isDragging, setIsDragging] = useState(false) const [isEditing, setIsEditing] = useState(false) @@ -127,7 +130,11 @@ export function FolderItem({ setIsRenaming(true) try { - await updateFolderAPI(folder.id, { name: editValue.trim() }) + await updateFolderMutation.mutateAsync({ + workspaceId, + id: folder.id, + updates: { name: editValue.trim() }, + }) logger.info(`Successfully renamed folder from "${folder.name}" to "${editValue.trim()}"`) setIsEditing(false) } catch (error) { @@ -171,7 +178,7 @@ export function FolderItem({ setShowDeleteDialog(false) try { - await deleteFolder(folder.id, workspaceId) + await deleteFolderMutation.mutateAsync({ id: folder.id, workspaceId }) } catch (error) { logger.error('Failed to delete folder:', { error }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/folder-tree.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/folder-tree.tsx index 9dc0dfe90d..99daa7b328 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/folder-tree.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/folder-tree.tsx @@ -7,7 +7,8 @@ import { Skeleton } from '@/components/ui/skeleton' import { createLogger } from '@/lib/logs/console/logger' import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/folder-item' import { WorkflowItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/folder-tree/components/workflow-item' -import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' +import { useFolders, useUpdateFolder } from '@/hooks/queries/folders' +import { type FolderTreeNode, useFolderStore, type WorkflowFolder } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -390,24 +391,18 @@ export function FolderTree({ const params = useParams() const workspaceId = params.workspaceId as string const workflowId = params.workflowId as string - const { - getFolderTree, - expandedFolders, - fetchFolders, - isLoading: foldersLoading, - clearSelection, - updateFolderAPI, - getFolderPath, - setExpanded, - } = useFolderStore() + const foldersQuery = useFolders(workspaceId) + const updateFolder = useUpdateFolder() + const { getFolderTree, expandedFolders, clearSelection, getFolderPath, setExpanded } = + useFolderStore() const { updateWorkflow } = useWorkflowRegistry() // Memoize the active workflow's folder ID to avoid unnecessary re-runs const activeWorkflowFolderId = useMemo(() => { - if (!workflowId || isLoading || foldersLoading) return null + if (!workflowId || isLoading || foldersQuery.isLoading) return null const activeWorkflow = regularWorkflows.find((workflow) => workflow.id === workflowId) return activeWorkflow?.folderId || null - }, [workflowId, regularWorkflows, isLoading, foldersLoading]) + }, [workflowId, regularWorkflows, isLoading, foldersQuery.isLoading]) // Auto-expand folders when a workflow is active useEffect(() => { @@ -426,7 +421,7 @@ export function FolderTree({ // Clean up any existing folders with 3+ levels of nesting const cleanupDeepNesting = useCallback(async () => { - const { getFolderTree, updateFolderAPI } = useFolderStore.getState() + const { getFolderTree } = useFolderStore.getState() const folderTree = getFolderTree(workspaceId) const findDeepFolders = (nodes: FolderTreeNode[], currentLevel = 0): FolderTreeNode[] => { @@ -452,23 +447,24 @@ export function FolderTree({ // Move deeply nested folders to root level for (const folder of deepFolders) { try { - await updateFolderAPI(folder.id, { parentId: null }) + await updateFolder.mutateAsync({ + workspaceId, + id: folder.id, + updates: { parentId: null }, + }) logger.info(`Moved deeply nested folder "${folder.name}" to root level`) } catch (error) { logger.error(`Failed to move folder "${folder.name}":`, error) } } - }, [workspaceId]) + }, [workspaceId, updateFolder]) // Fetch folders when workspace changes useEffect(() => { - if (workspaceId) { - fetchFolders(workspaceId).then(() => { - // Clean up any existing deep nesting after folders are loaded - cleanupDeepNesting() - }) + if (workspaceId && foldersQuery.data) { + cleanupDeepNesting() } - }, [workspaceId, fetchFolders, cleanupDeepNesting]) + }, [workspaceId, foldersQuery.data, cleanupDeepNesting]) useEffect(() => { clearSelection() @@ -487,13 +483,19 @@ export function FolderTree({ {} as Record ) + const updateFolderFn = useCallback( + (id: string, updates: Partial) => + updateFolder.mutateAsync({ workspaceId, id, updates }), + [updateFolder, workspaceId] + ) + const { isDragOver: rootDragOver, isInvalidDrop: rootInvalidDrop, handleDragOver: handleRootDragOver, handleDragLeave: handleRootDragLeave, handleDrop: handleRootDrop, - } = useDragHandlers(updateWorkflow, updateFolderAPI, null, 'Moved workflow(s) to root') + } = useDragHandlers(updateWorkflow, updateFolderFn, null, 'Moved workflow(s) to root') const renderFolderTree = ( nodes: FolderTreeNode[], @@ -510,7 +512,7 @@ export function FolderTree({ expandedFolders={expandedFolders} pathname={pathname} updateWorkflow={updateWorkflow} - updateFolder={updateFolderAPI} + updateFolder={updateFolderFn} renderFolderTree={renderFolderTree} parentDragOver={parentDragOver} isFirstItem={level === 0 && index === 0} @@ -518,7 +520,7 @@ export function FolderTree({ )) } - const showLoading = isLoading || foldersLoading + const showLoading = isLoading || foldersQuery.isLoading const rootWorkflows = workflowsByFolder.root || [] // Render skeleton loading state diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts index 1b5c36ef5b..7cb86a27f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts @@ -1,5 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { useParams } from 'next/navigation' import { createLogger } from '@/lib/logs/console/logger' +import { useUpdateFolder } from '@/hooks/queries/folders' import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -31,7 +33,10 @@ export function useDragDrop() { const hoverExpandTimerRef = useRef(null) const lastDragYRef = useRef(0) - const { updateFolderAPI, getFolderPath, setExpanded, expandedFolders } = useFolderStore() + const params = useParams() + const workspaceId = params.workspaceId as string | undefined + const updateFolderMutation = useUpdateFolder() + const { setExpanded, expandedFolders } = useFolderStore() const { updateWorkflow } = useWorkflowRegistry() /** @@ -192,13 +197,21 @@ export function useDragDrop() { return } - await updateFolderAPI(draggedFolderId, { parentId: targetFolderId }) + if (!workspaceId) { + logger.warn('No workspaceId available for folder move') + return + } + await updateFolderMutation.mutateAsync({ + workspaceId, + id: draggedFolderId, + updates: { parentId: targetFolderId }, + }) logger.info(`Moved folder to ${targetFolderId ? `folder ${targetFolderId}` : 'root'}`) } catch (error) { logger.error('Failed to move folder:', error) } }, - [updateFolderAPI] + [updateFolderMutation, workspaceId] ) /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts index 2c255f291c..3fc9371c3c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-folder-operations.ts @@ -1,7 +1,7 @@ import { useCallback, useState } from 'react' import { createLogger } from '@/lib/logs/console/logger' import { generateFolderName } from '@/lib/naming' -import { useFolderStore } from '@/stores/folders/store' +import { useCreateFolder } from '@/hooks/queries/folders' const logger = createLogger('useFolderOperations') @@ -17,7 +17,7 @@ interface UseFolderOperationsProps { * @returns Folder operations state and handlers */ export function useFolderOperations({ workspaceId }: UseFolderOperationsProps) { - const { createFolder } = useFolderStore() + const createFolderMutation = useCreateFolder() const [isCreatingFolder, setIsCreatingFolder] = useState(false) /** @@ -32,7 +32,7 @@ export function useFolderOperations({ workspaceId }: UseFolderOperationsProps) { try { setIsCreatingFolder(true) const folderName = await generateFolderName(workspaceId) - const folder = await createFolder({ name: folderName, workspaceId }) + const folder = await createFolderMutation.mutateAsync({ name: folderName, workspaceId }) logger.info(`Created folder: ${folderName}`) return folder.id } catch (error) { @@ -41,7 +41,7 @@ export function useFolderOperations({ workspaceId }: UseFolderOperationsProps) { } finally { setIsCreatingFolder(false) } - }, [createFolder, workspaceId, isCreatingFolder]) + }, [createFolderMutation, workspaceId, isCreatingFolder]) return { // State diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-folder.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-folder.ts index 00ce35d465..fa27c8c442 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-folder.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react' import { createLogger } from '@/lib/logs/console/logger' +import { useDeleteFolderMutation } from '@/hooks/queries/folders' import { useFolderStore } from '@/stores/folders/store' const logger = createLogger('useDeleteFolder') @@ -34,7 +35,7 @@ interface UseDeleteFolderProps { * @returns Delete folder handlers and state */ export function useDeleteFolder({ workspaceId, getFolderIds, onSuccess }: UseDeleteFolderProps) { - const { deleteFolder } = useFolderStore() + const deleteFolderMutation = useDeleteFolderMutation() const [isDeleting, setIsDeleting] = useState(false) /** @@ -58,7 +59,7 @@ export function useDeleteFolder({ workspaceId, getFolderIds, onSuccess }: UseDel // Delete each folder sequentially for (const folderId of folderIdsToDelete) { - await deleteFolder(folderId, workspaceId) + await deleteFolderMutation.mutateAsync({ id: folderId, workspaceId }) } // Clear selection after successful deletion @@ -73,7 +74,7 @@ export function useDeleteFolder({ workspaceId, getFolderIds, onSuccess }: UseDel } finally { setIsDeleting(false) } - }, [getFolderIds, isDeleting, deleteFolder, workspaceId, onSuccess]) + }, [getFolderIds, isDeleting, deleteFolderMutation, workspaceId, onSuccess]) return { isDeleting, diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts index 94a8e642c9..23dd08bfd4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-folder.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react' import { createLogger } from '@/lib/logs/console/logger' +import { useDuplicateFolderMutation } from '@/hooks/queries/folders' import { useFolderStore } from '@/stores/folders/store' const logger = createLogger('useDuplicateFolder') @@ -38,7 +39,7 @@ export function useDuplicateFolder({ getFolderIds, onSuccess, }: UseDuplicateFolderProps) { - const { duplicateFolder } = useFolderStore() + const duplicateFolderMutation = useDuplicateFolderMutation() const [isDuplicating, setIsDuplicating] = useState(false) /** @@ -64,7 +65,8 @@ export function useDuplicateFolder({ // Duplicate each folder sequentially for (const folderId of folderIdsToDuplicate) { - const newFolderId = await duplicateFolder(folderId) + const result = await duplicateFolderMutation.mutateAsync({ id: folderId, workspaceId }) + const newFolderId = result?.id if (newFolderId) { duplicatedIds.push(newFolderId) } @@ -86,7 +88,7 @@ export function useDuplicateFolder({ } finally { setIsDuplicating(false) } - }, [getFolderIds, isDuplicating, duplicateFolder, onSuccess]) + }, [getFolderIds, isDuplicating, duplicateFolderMutation, workspaceId, onSuccess]) return { isDuplicating, diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts index 8e4733cac2..8e1e8c9c04 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts @@ -1,4 +1,5 @@ import { useCallback, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { createLogger } from '@/lib/logs/console/logger' import { @@ -6,7 +7,7 @@ import { extractWorkflowsFromFiles, extractWorkflowsFromZip, } from '@/lib/workflows/import-export' -import { useFolderStore } from '@/stores/folders/store' +import { folderKeys, useCreateFolder } from '@/hooks/queries/folders' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { parseWorkflowJson } from '@/stores/workflows/json/importer' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -30,6 +31,8 @@ interface UseImportWorkflowProps { export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) { const router = useRouter() const { createWorkflow, loadWorkflows } = useWorkflowRegistry() + const queryClient = useQueryClient() + const createFolderMutation = useCreateFolder() const [isImporting, setIsImporting] = useState(false) /** @@ -119,9 +122,11 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) { const zipFile = fileArray[0] const { workflows: extractedWorkflows, metadata } = await extractWorkflowsFromZip(zipFile) - const { createFolder } = useFolderStore.getState() const folderName = metadata?.workspaceName || zipFile.name.replace(/\.zip$/i, '') - const importFolder = await createFolder({ name: folderName, workspaceId }) + const importFolder = await createFolderMutation.mutateAsync({ + name: folderName, + workspaceId, + }) const folderMap = new Map() for (const workflow of extractedWorkflows) { @@ -139,7 +144,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) { const pathSegment = workflow.folderPath.slice(0, i + 1).join('/') if (!folderMap.has(pathSegment)) { - const subFolder = await createFolder({ + const subFolder = await createFolderMutation.mutateAsync({ name: workflow.folderPath[i], workspaceId, parentId, @@ -181,7 +186,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) { // Reload workflows to show newly imported ones await loadWorkflows(workspaceId) - await useFolderStore.getState().fetchFolders(workspaceId) + await queryClient.invalidateQueries({ queryKey: folderKeys.list(workspaceId) }) logger.info(`Import complete. Imported ${importedWorkflowIds.length} workflow(s)`) @@ -200,7 +205,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) { } } }, - [importSingleWorkflow, workspaceId, loadWorkflows, router] + [importSingleWorkflow, workspaceId, loadWorkflows, router, createFolderMutation, queryClient] ) return { diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts index 04f2c1c190..518d390d6b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts @@ -2,7 +2,7 @@ import { useCallback, useState } from 'react' import { useRouter } from 'next/navigation' import { createLogger } from '@/lib/logs/console/logger' import { extractWorkflowName, extractWorkflowsFromZip } from '@/lib/workflows/import-export' -import { useFolderStore } from '@/stores/folders/store' +import { useCreateFolder } from '@/hooks/queries/folders' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { parseWorkflowJson } from '@/stores/workflows/json/importer' @@ -33,6 +33,7 @@ interface UseImportWorkspaceProps { export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {}) { const router = useRouter() const [isImporting, setIsImporting] = useState(false) + const createFolderMutation = useCreateFolder() /** * Handle workspace import from ZIP file @@ -75,7 +76,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {}) const { workspace: newWorkspace } = await createResponse.json() logger.info('Created new workspace:', newWorkspace) - const { createFolder } = useFolderStore.getState() const folderMap = new Map() // Import workflows @@ -100,7 +100,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {}) const pathSegment = workflow.folderPath.slice(0, i + 1).join('/') if (!folderMap.has(pathSegment)) { - const subFolder = await createFolder({ + const subFolder = await createFolderMutation.mutateAsync({ name: workflow.folderPath[i], workspaceId: newWorkspace.id, parentId: parentId || undefined, @@ -192,7 +192,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {}) setIsImporting(false) } }, - [isImporting, router, onSuccess] + [isImporting, router, onSuccess, createFolderMutation] ) return { diff --git a/apps/sim/hooks/queries/custom-tools.ts b/apps/sim/hooks/queries/custom-tools.ts index 66a2c2e8ac..97e8c37660 100644 --- a/apps/sim/hooks/queries/custom-tools.ts +++ b/apps/sim/hooks/queries/custom-tools.ts @@ -1,5 +1,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { createLogger } from '@/lib/logs/console/logger' +import { useCustomToolsStore } from '@/stores/custom-tools/store' +import type { CustomToolDefinition, CustomToolSchema } from '@/stores/custom-tools/types' const logger = createLogger('CustomToolsQueries') const API_ENDPOINT = '/api/tools/custom' @@ -14,32 +16,62 @@ export const customToolsKeys = { detail: (toolId: string) => [...customToolsKeys.all, 'detail', toolId] as const, } -/** - * Custom Tool Types - */ -export interface CustomToolSchema { - function?: { - name?: string - description?: string - parameters?: any - } -} +export type CustomTool = CustomToolDefinition -export interface CustomTool { +type ApiCustomTool = Partial & { id: string title: string - schema?: CustomToolSchema - code: string - workspaceId?: string - userId?: string - createdAt?: string - updatedAt?: string + schema: Partial & { + function?: Partial & { + parameters?: Partial + } + } + code?: string +} + +function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomToolDefinition { + const fallbackName = tool.schema.function?.name || tool.id + const parameters = tool.schema.function?.parameters ?? { + type: 'object', + properties: {}, + } + + return { + id: tool.id, + title: tool.title, + code: typeof tool.code === 'string' ? tool.code : '', + workspaceId: tool.workspaceId ?? workspaceId ?? null, + userId: tool.userId ?? null, + createdAt: + typeof tool.createdAt === 'string' + ? tool.createdAt + : tool.updatedAt && typeof tool.updatedAt === 'string' + ? tool.updatedAt + : new Date().toISOString(), + updatedAt: typeof tool.updatedAt === 'string' ? tool.updatedAt : undefined, + schema: { + type: tool.schema.type ?? 'function', + function: { + name: fallbackName, + description: tool.schema.function?.description, + parameters: { + type: parameters.type ?? 'object', + properties: parameters.properties ?? {}, + required: parameters.required, + }, + }, + }, + } +} + +function syncCustomToolsToStore(tools: CustomToolDefinition[]) { + useCustomToolsStore.getState().setTools(tools) } /** * Fetch custom tools for a workspace */ -async function fetchCustomTools(workspaceId: string): Promise { +async function fetchCustomTools(workspaceId: string): Promise { const response = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`) if (!response.ok) { @@ -53,45 +85,68 @@ async function fetchCustomTools(workspaceId: string): Promise { throw new Error('Invalid response format') } - // Filter and validate tools - const validTools = data.filter((tool, index) => { + const normalizedTools: CustomToolDefinition[] = [] + + data.forEach((tool, index) => { if (!tool || typeof tool !== 'object') { logger.warn(`Skipping invalid tool at index ${index}: not an object`) - return false + return } if (!tool.id || typeof tool.id !== 'string') { logger.warn(`Skipping invalid tool at index ${index}: missing or invalid id`) - return false + return } if (!tool.title || typeof tool.title !== 'string') { logger.warn(`Skipping invalid tool at index ${index}: missing or invalid title`) - return false + return } if (!tool.schema || typeof tool.schema !== 'object') { logger.warn(`Skipping invalid tool at index ${index}: missing or invalid schema`) - return false + return + } + if (!tool.schema.function || typeof tool.schema.function !== 'object') { + logger.warn(`Skipping invalid tool at index ${index}: missing function schema`) + return + } + + const apiTool: ApiCustomTool = { + id: tool.id, + title: tool.title, + schema: tool.schema, + code: typeof tool.code === 'string' ? tool.code : '', + workspaceId: tool.workspaceId ?? null, + userId: tool.userId ?? null, + createdAt: tool.createdAt ?? undefined, + updatedAt: tool.updatedAt ?? undefined, } - if (!tool.code || typeof tool.code !== 'string') { - logger.warn(`Tool at index ${index} missing code field, defaulting to empty string`) - tool.code = '' + + try { + normalizedTools.push(normalizeCustomTool(apiTool, workspaceId)) + } catch (error) { + logger.warn(`Failed to normalize custom tool at index ${index}`, { error }) } - return true }) - return validTools + return normalizedTools } /** * Hook to fetch custom tools */ export function useCustomTools(workspaceId: string) { - return useQuery({ + const query = useQuery({ queryKey: customToolsKeys.list(workspaceId), queryFn: () => fetchCustomTools(workspaceId), enabled: !!workspaceId, staleTime: 60 * 1000, // 1 minute - tools don't change frequently placeholderData: keepPreviousData, }) + + if (query.data) { + syncCustomToolsToStore(query.data) + } + + return query } /** @@ -169,7 +224,9 @@ export function useUpdateCustomTool() { logger.info(`Updating custom tool: ${toolId} in workspace ${workspaceId}`) // Get the current tool to merge with updates - const currentTools = queryClient.getQueryData(customToolsKeys.list(workspaceId)) + const currentTools = queryClient.getQueryData( + customToolsKeys.list(workspaceId) + ) const currentTool = currentTools?.find((t) => t.id === toolId) if (!currentTool) { @@ -210,13 +267,13 @@ export function useUpdateCustomTool() { await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) }) // Snapshot the previous value - const previousTools = queryClient.getQueryData( + const previousTools = queryClient.getQueryData( customToolsKeys.list(workspaceId) ) // Optimistically update to the new value if (previousTools) { - queryClient.setQueryData( + queryClient.setQueryData( customToolsKeys.list(workspaceId), previousTools.map((tool) => tool.id === toolId @@ -285,13 +342,13 @@ export function useDeleteCustomTool() { await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) }) // Snapshot the previous value - const previousTools = queryClient.getQueryData( + const previousTools = queryClient.getQueryData( customToolsKeys.list(workspaceId) ) // Optimistically update to the new value if (previousTools) { - queryClient.setQueryData( + queryClient.setQueryData( customToolsKeys.list(workspaceId), previousTools.filter((tool) => tool.id !== toolId) ) diff --git a/apps/sim/hooks/queries/environment.ts b/apps/sim/hooks/queries/environment.ts index 87ef7c2b93..fc4236a7df 100644 --- a/apps/sim/hooks/queries/environment.ts +++ b/apps/sim/hooks/queries/environment.ts @@ -1,6 +1,14 @@ +import { useEffect } from 'react' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { WorkspaceEnvironmentData } from '@/lib/environment/api' +import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/environment/api' import { createLogger } from '@/lib/logs/console/logger' import { API_ENDPOINTS } from '@/stores/constants' +import { useEnvironmentStore } from '@/stores/settings/environment/store' +import type { EnvironmentVariable } from '@/stores/settings/environment/types' + +export type { WorkspaceEnvironmentData } from '@/lib/environment/api' +export type { EnvironmentVariable } from '@/stores/settings/environment/types' const logger = createLogger('EnvironmentQueries') @@ -16,65 +24,26 @@ export const environmentKeys = { /** * Environment Variable Types */ -export interface EnvironmentVariable { - key: string - value: string -} - -export interface WorkspaceEnvironmentData { - workspace: Record - personal: Record - conflicts: string[] -} - -/** - * Fetch personal environment variables - */ -async function fetchPersonalEnvironment(): Promise> { - const response = await fetch(API_ENDPOINTS.ENVIRONMENT) - - if (!response.ok) { - throw new Error(`Failed to load environment variables: ${response.statusText}`) - } - - const { data } = await response.json() - - if (data && typeof data === 'object') { - return data - } - - return {} -} - /** * Hook to fetch personal environment variables */ export function usePersonalEnvironment() { - return useQuery({ + const setVariables = useEnvironmentStore((state) => state.setVariables) + + const query = useQuery({ queryKey: environmentKeys.personal(), queryFn: fetchPersonalEnvironment, staleTime: 60 * 1000, // 1 minute placeholderData: keepPreviousData, }) -} - -/** - * Fetch workspace environment variables - */ -async function fetchWorkspaceEnvironment(workspaceId: string): Promise { - const response = await fetch(API_ENDPOINTS.WORKSPACE_ENVIRONMENT(workspaceId)) - - if (!response.ok) { - throw new Error(`Failed to load workspace environment: ${response.statusText}`) - } - const { data } = await response.json() + useEffect(() => { + if (query.data) { + setVariables(query.data) + } + }, [query.data, setVariables]) - return { - workspace: data.workspace || {}, - personal: data.personal || {}, - conflicts: data.conflicts || [], - } + return query } /** diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts new file mode 100644 index 0000000000..b986938847 --- /dev/null +++ b/apps/sim/hooks/queries/folders.ts @@ -0,0 +1,186 @@ +import { useEffect } from 'react' +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { createLogger } from '@/lib/logs/console/logger' +import { useFolderStore, type WorkflowFolder } from '@/stores/folders/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +const logger = createLogger('FolderQueries') + +export const folderKeys = { + all: ['folders'] as const, + lists: () => [...folderKeys.all, 'list'] as const, + list: (workspaceId: string | undefined) => [...folderKeys.lists(), workspaceId ?? ''] as const, +} + +function mapFolder(folder: any): WorkflowFolder { + return { + id: folder.id, + name: folder.name, + userId: folder.userId, + workspaceId: folder.workspaceId, + parentId: folder.parentId, + color: folder.color, + isExpanded: folder.isExpanded, + sortOrder: folder.sortOrder, + createdAt: new Date(folder.createdAt), + updatedAt: new Date(folder.updatedAt), + } +} + +async function fetchFolders(workspaceId: string): Promise { + const response = await fetch(`/api/folders?workspaceId=${workspaceId}`) + + if (!response.ok) { + throw new Error('Failed to fetch folders') + } + + const { folders }: { folders: any[] } = await response.json() + return folders.map(mapFolder) +} + +export function useFolders(workspaceId?: string) { + const setFolders = useFolderStore((state) => state.setFolders) + + const query = useQuery({ + queryKey: folderKeys.list(workspaceId), + queryFn: () => fetchFolders(workspaceId as string), + enabled: Boolean(workspaceId), + placeholderData: keepPreviousData, + staleTime: 60 * 1000, + }) + + useEffect(() => { + if (query.data) { + setFolders(query.data) + } + }, [query.data, setFolders]) + + return query +} + +interface CreateFolderVariables { + workspaceId: string + name: string + parentId?: string + color?: string +} + +interface UpdateFolderVariables { + workspaceId: string + id: string + updates: Partial> +} + +interface DeleteFolderVariables { + workspaceId: string + id: string +} + +interface DuplicateFolderVariables { + workspaceId: string + id: string +} + +export function useCreateFolder() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ workspaceId, ...payload }: CreateFolderVariables) => { + const response = await fetch('/api/folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...payload, workspaceId }), + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.error || 'Failed to create folder') + } + + const { folder } = await response.json() + return mapFolder(folder) + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) + }, + }) +} + +export function useUpdateFolder() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ workspaceId, id, updates }: UpdateFolderVariables) => { + const response = await fetch(`/api/folders/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.error || 'Failed to update folder') + } + + const { folder } = await response.json() + return mapFolder(folder) + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) + }, + }) +} + +export function useDeleteFolderMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ workspaceId: _workspaceId, id }: DeleteFolderVariables) => { + const response = await fetch(`/api/folders/${id}`, { method: 'DELETE' }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.error || 'Failed to delete folder') + } + + return response.json() + }, + onSuccess: async (_data, variables) => { + queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) + try { + await useWorkflowRegistry.getState().loadWorkflows(variables.workspaceId) + } catch (error) { + logger.error('Failed to reload workflows after folder delete', { error }) + } + }, + }) +} + +export function useDuplicateFolderMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id, workspaceId }: DuplicateFolderVariables) => { + const response = await fetch(`/api/folders/${id}/duplicate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId }), + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.error || 'Failed to duplicate folder') + } + + return response.json() + }, + onSuccess: async (_data, variables) => { + queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) + try { + await useWorkflowRegistry.getState().loadWorkflows(variables.workspaceId) + } catch (error) { + logger.error('Failed to reload workflows after folder duplicate', { error }) + } + }, + }) +} diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index c7ab8c4c14..3f73c29a88 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -57,11 +57,9 @@ async function fetchGeneralSettings(): Promise { * This ensures the rest of the app (which uses Zustand) stays in sync */ function syncSettingsToZustand(settings: GeneralSettings) { - const store = useGeneralStore.getState() + const { setSettings } = useGeneralStore.getState() - // Update Zustand store to match React Query cache - // This allows the rest of the app to continue using Zustand for reading values - useGeneralStore.setState({ + setSettings({ isAutoConnectEnabled: settings.autoConnect, isAutoPanEnabled: settings.autoPan, isConsoleExpandedByDefault: settings.consoleExpandedByDefault, diff --git a/apps/sim/hooks/queries/knowledge.ts b/apps/sim/hooks/queries/knowledge.ts new file mode 100644 index 0000000000..5c571a345e --- /dev/null +++ b/apps/sim/hooks/queries/knowledge.ts @@ -0,0 +1,297 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { createLogger } from '@/lib/logs/console/logger' +import type { + ChunkData, + ChunksPagination, + DocumentData, + DocumentsPagination, + KnowledgeBaseData, +} from '@/stores/knowledge/store' + +const logger = createLogger('KnowledgeQueries') + +export const knowledgeKeys = { + all: ['knowledge'] as const, + list: (workspaceId?: string) => [...knowledgeKeys.all, 'list', workspaceId ?? 'all'] as const, + detail: (knowledgeBaseId?: string) => + [...knowledgeKeys.all, 'detail', knowledgeBaseId ?? ''] as const, + documents: (knowledgeBaseId: string, paramsKey: string) => + [...knowledgeKeys.detail(knowledgeBaseId), 'documents', paramsKey] as const, + chunks: (knowledgeBaseId: string, documentId: string, paramsKey: string) => + [ + ...knowledgeKeys.detail(knowledgeBaseId), + 'document', + documentId, + 'chunks', + paramsKey, + ] as const, +} + +export async function fetchKnowledgeBases(workspaceId?: string): Promise { + const url = workspaceId ? `/api/knowledge?workspaceId=${workspaceId}` : '/api/knowledge' + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`Failed to fetch knowledge bases: ${response.status} ${response.statusText}`) + } + + const result = await response.json() + if (result?.success === false) { + throw new Error(result.error || 'Failed to fetch knowledge bases') + } + + return Array.isArray(result?.data) ? result.data : [] +} + +export async function fetchKnowledgeBase(knowledgeBaseId: string): Promise { + const response = await fetch(`/api/knowledge/${knowledgeBaseId}`) + + if (!response.ok) { + throw new Error(`Failed to fetch knowledge base: ${response.status} ${response.statusText}`) + } + + const result = await response.json() + if (!result?.success || !result?.data) { + throw new Error(result?.error || 'Failed to fetch knowledge base') + } + + return result.data +} + +export interface KnowledgeDocumentsParams { + knowledgeBaseId: string + search?: string + limit?: number + offset?: number + sortBy?: string + sortOrder?: string +} + +export interface KnowledgeDocumentsResponse { + documents: DocumentData[] + pagination: DocumentsPagination +} + +export async function fetchKnowledgeDocuments({ + knowledgeBaseId, + search, + limit = 50, + offset = 0, + sortBy, + sortOrder, +}: KnowledgeDocumentsParams): Promise { + const params = new URLSearchParams() + if (search) params.set('search', search) + if (sortBy) params.set('sortBy', sortBy) + if (sortOrder) params.set('sortOrder', sortOrder) + params.set('limit', limit.toString()) + params.set('offset', offset.toString()) + + const url = `/api/knowledge/${knowledgeBaseId}/documents${params.toString() ? `?${params.toString()}` : ''}` + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`Failed to fetch documents: ${response.status} ${response.statusText}`) + } + + const result = await response.json() + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch documents') + } + + const documents: DocumentData[] = result.data?.documents ?? result.data ?? [] + const pagination: DocumentsPagination = result.data?.pagination ?? + result.pagination ?? { + total: documents.length, + limit, + offset, + hasMore: false, + } + + return { + documents, + pagination: { + total: pagination.total ?? documents.length, + limit: pagination.limit ?? limit, + offset: pagination.offset ?? offset, + hasMore: Boolean(pagination.hasMore), + }, + } +} + +export interface KnowledgeChunksParams { + knowledgeBaseId: string + documentId: string + search?: string + limit?: number + offset?: number +} + +export interface KnowledgeChunksResponse { + chunks: ChunkData[] + pagination: ChunksPagination +} + +export async function fetchKnowledgeChunks({ + knowledgeBaseId, + documentId, + search, + limit = 50, + offset = 0, +}: KnowledgeChunksParams): Promise { + const params = new URLSearchParams() + if (search) params.set('search', search) + if (limit) params.set('limit', limit.toString()) + if (offset) params.set('offset', offset.toString()) + + const response = await fetch( + `/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks${params.toString() ? `?${params.toString()}` : ''}` + ) + + if (!response.ok) { + throw new Error(`Failed to fetch chunks: ${response.status} ${response.statusText}`) + } + + const result = await response.json() + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch chunks') + } + + const chunks: ChunkData[] = result.data ?? [] + const pagination: ChunksPagination = { + total: result.pagination?.total ?? chunks.length, + limit: result.pagination?.limit ?? limit, + offset: result.pagination?.offset ?? offset, + hasMore: Boolean(result.pagination?.hasMore), + } + + return { chunks, pagination } +} + +export function useKnowledgeBasesQuery( + workspaceId?: string, + options?: { + enabled?: boolean + } +) { + return useQuery({ + queryKey: knowledgeKeys.list(workspaceId), + queryFn: () => fetchKnowledgeBases(workspaceId), + enabled: options?.enabled ?? true, + staleTime: 60 * 1000, + placeholderData: keepPreviousData, + }) +} + +export function useKnowledgeBaseQuery(knowledgeBaseId?: string) { + return useQuery({ + queryKey: knowledgeKeys.detail(knowledgeBaseId), + queryFn: () => fetchKnowledgeBase(knowledgeBaseId as string), + enabled: Boolean(knowledgeBaseId), + staleTime: 60 * 1000, + }) +} + +export const serializeDocumentParams = (params: KnowledgeDocumentsParams) => + JSON.stringify({ + search: params.search ?? '', + limit: params.limit ?? 50, + offset: params.offset ?? 0, + sortBy: params.sortBy ?? '', + sortOrder: params.sortOrder ?? '', + }) + +export function useKnowledgeDocumentsQuery( + params: KnowledgeDocumentsParams, + options?: { + enabled?: boolean + } +) { + const paramsKey = serializeDocumentParams(params) + return useQuery({ + queryKey: knowledgeKeys.documents(params.knowledgeBaseId, paramsKey), + queryFn: () => fetchKnowledgeDocuments(params), + enabled: (options?.enabled ?? true) && Boolean(params.knowledgeBaseId), + placeholderData: keepPreviousData, + }) +} + +export const serializeChunkParams = (params: KnowledgeChunksParams) => + JSON.stringify({ + search: params.search ?? '', + limit: params.limit ?? 50, + offset: params.offset ?? 0, + }) + +export function useKnowledgeChunksQuery( + params: KnowledgeChunksParams, + options?: { + enabled?: boolean + } +) { + const paramsKey = serializeChunkParams(params) + return useQuery({ + queryKey: knowledgeKeys.chunks(params.knowledgeBaseId, params.documentId, paramsKey), + queryFn: () => fetchKnowledgeChunks(params), + enabled: (options?.enabled ?? true) && Boolean(params.knowledgeBaseId && params.documentId), + placeholderData: keepPreviousData, + }) +} + +interface UpdateDocumentPayload { + knowledgeBaseId: string + documentId: string + updates: Partial +} + +export function useMutateKnowledgeDocument() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ knowledgeBaseId, documentId, updates }: UpdateDocumentPayload) => { + const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(updates), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || 'Failed to update document') + } + + const result = await response.json() + if (!result?.success) { + throw new Error(result?.error || 'Failed to update document') + } + + return result + }, + onMutate: async ({ knowledgeBaseId, documentId, updates }) => { + await queryClient.cancelQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId) }) + + const documentQueries = queryClient + .getQueriesData({ + queryKey: knowledgeKeys.detail(knowledgeBaseId), + }) + .filter(([key]) => Array.isArray(key) && key.includes('documents')) + + documentQueries.forEach(([key, data]) => { + if (!data) return + queryClient.setQueryData(key, { + ...data, + documents: data.documents.map((doc) => + doc.id === documentId ? { ...doc, ...updates } : doc + ), + }) + }) + }, + onError: (error) => { + logger.error('Failed to mutate document', error) + }, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(variables.knowledgeBaseId) }) + }, + }) +} diff --git a/apps/sim/hooks/queries/providers.ts b/apps/sim/hooks/queries/providers.ts new file mode 100644 index 0000000000..21455e351e --- /dev/null +++ b/apps/sim/hooks/queries/providers.ts @@ -0,0 +1,36 @@ +import { useQuery } from '@tanstack/react-query' +import { createLogger } from '@/lib/logs/console/logger' +import type { ProviderName } from '@/stores/providers/types' + +const logger = createLogger('ProviderModelsQuery') + +const providerEndpoints: Record = { + base: '/api/providers/base/models', + ollama: '/api/providers/ollama/models', + openrouter: '/api/providers/openrouter/models', +} + +async function fetchProviderModels(provider: ProviderName): Promise { + const response = await fetch(providerEndpoints[provider]) + + if (!response.ok) { + logger.warn(`Failed to fetch ${provider} models`, { + status: response.status, + statusText: response.statusText, + }) + throw new Error(`Failed to fetch ${provider} models`) + } + + const data = await response.json() + const models: string[] = Array.isArray(data.models) ? data.models : [] + + return provider === 'openrouter' ? Array.from(new Set(models)) : models +} + +export function useProviderModels(provider: ProviderName) { + return useQuery({ + queryKey: ['provider-models', provider], + queryFn: () => fetchProviderModels(provider), + staleTime: 5 * 60 * 1000, + }) +} diff --git a/apps/sim/hooks/use-knowledge.ts b/apps/sim/hooks/use-knowledge.ts index 4364ed1f55..459d9b1fe7 100644 --- a/apps/sim/hooks/use-knowledge.ts +++ b/apps/sim/hooks/use-knowledge.ts @@ -1,46 +1,47 @@ import { useCallback, useEffect, useMemo, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import Fuse from 'fuse.js' import { createLogger } from '@/lib/logs/console/logger' -import { type ChunkData, type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store' +import { + fetchKnowledgeChunks, + knowledgeKeys, + serializeChunkParams, + serializeDocumentParams, + useKnowledgeBaseQuery, + useKnowledgeBasesQuery, + useKnowledgeChunksQuery, + useKnowledgeDocumentsQuery, +} from '@/hooks/queries/knowledge' +import { + type ChunkData, + type ChunksPagination, + type DocumentData, + type DocumentsCache, + type DocumentsPagination, + type KnowledgeBaseData, + useKnowledgeStore, +} from '@/stores/knowledge/store' const logger = createLogger('UseKnowledgeBase') export function useKnowledgeBase(id: string) { - const { getKnowledgeBase, getCachedKnowledgeBase, loadingKnowledgeBases } = useKnowledgeStore() - - const [error, setError] = useState(null) - - const knowledgeBase = getCachedKnowledgeBase(id) - const isLoading = loadingKnowledgeBases.has(id) - + const query = useKnowledgeBaseQuery(id) useEffect(() => { - if (!id || knowledgeBase || isLoading) return - - let isMounted = true - - const loadData = async () => { - try { - setError(null) - await getKnowledgeBase(id) - } catch (err) { - if (isMounted) { - setError(err instanceof Error ? err.message : 'Failed to load knowledge base') - logger.error(`Failed to load knowledge base ${id}:`, err) - } - } + if (query.data) { + const knowledgeBase = query.data + useKnowledgeStore.setState((state) => ({ + knowledgeBases: { + ...state.knowledgeBases, + [knowledgeBase.id]: knowledgeBase, + }, + })) } - - loadData() - - return () => { - isMounted = false - } - }, [id, knowledgeBase, isLoading]) // Removed getKnowledgeBase from dependencies + }, [query.data]) return { - knowledgeBase, - isLoading, - error, + knowledgeBase: query.data ?? null, + isLoading: query.isLoading, + error: query.error instanceof Error ? query.error.message : null, } } @@ -55,230 +56,201 @@ export function useKnowledgeBaseDocuments( offset?: number sortBy?: string sortOrder?: string + enabled?: boolean } ) { - const { getDocuments, getCachedDocuments, loadingDocuments, updateDocument, refreshDocuments } = - useKnowledgeStore() - - const [error, setError] = useState(null) - - const documentsCache = getCachedDocuments(knowledgeBaseId) - const isLoading = loadingDocuments.has(knowledgeBaseId) - - // Load documents with server-side pagination, search, and sorting - const requestLimit = options?.limit || DEFAULT_PAGE_SIZE - const requestOffset = options?.offset || 0 + const queryClient = useQueryClient() + const requestLimit = options?.limit ?? DEFAULT_PAGE_SIZE + const requestOffset = options?.offset ?? 0 const requestSearch = options?.search const requestSortBy = options?.sortBy const requestSortOrder = options?.sortOrder + const paramsKey = serializeDocumentParams({ + knowledgeBaseId, + limit: requestLimit, + offset: requestOffset, + search: requestSearch, + sortBy: requestSortBy, + sortOrder: requestSortOrder, + }) - useEffect(() => { - if (!knowledgeBaseId || isLoading) return - - let isMounted = true - - const loadDocuments = async () => { - try { - setError(null) - await getDocuments(knowledgeBaseId, { - search: requestSearch, - limit: requestLimit, - offset: requestOffset, - sortBy: requestSortBy, - sortOrder: requestSortOrder, - }) - } catch (err) { - if (isMounted) { - setError(err instanceof Error ? err.message : 'Failed to load documents') - logger.error(`Failed to load documents for knowledge base ${knowledgeBaseId}:`, err) - } - } + const query = useKnowledgeDocumentsQuery( + { + knowledgeBaseId, + limit: requestLimit, + offset: requestOffset, + search: requestSearch, + sortBy: requestSortBy, + sortOrder: requestSortOrder, + }, + { + enabled: (options?.enabled ?? true) && Boolean(knowledgeBaseId), } + ) - loadDocuments() - - return () => { - isMounted = false + useEffect(() => { + if (!query.data || !knowledgeBaseId) return + const documentsCache = { + documents: query.data.documents, + pagination: query.data.pagination, + searchQuery: requestSearch, + sortBy: requestSortBy, + sortOrder: requestSortOrder, + lastFetchTime: Date.now(), } - }, [ - knowledgeBaseId, - isLoading, - getDocuments, - requestSearch, - requestLimit, - requestOffset, - requestSortBy, - requestSortOrder, - ]) - - // Use server-side filtered and paginated results directly - const documents = documentsCache?.documents || [] - const pagination = documentsCache?.pagination || { - total: 0, - limit: requestLimit, - offset: requestOffset, - hasMore: false, - } + useKnowledgeStore.setState((state) => ({ + documents: { + ...state.documents, + [knowledgeBaseId]: documentsCache, + }, + })) + }, [query.data, knowledgeBaseId, requestSearch, requestSortBy, requestSortOrder]) + + const documents = query.data?.documents ?? [] + const pagination = + query.data?.pagination ?? + ({ + total: 0, + limit: requestLimit, + offset: requestOffset, + hasMore: false, + } satisfies DocumentsCache['pagination']) const refreshDocumentsData = useCallback(async () => { - try { - setError(null) - await refreshDocuments(knowledgeBaseId, { - search: requestSearch, - limit: requestLimit, - offset: requestOffset, - sortBy: requestSortBy, - sortOrder: requestSortOrder, - }) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to refresh documents') - logger.error(`Failed to refresh documents for knowledge base ${knowledgeBaseId}:`, err) - } - }, [ - knowledgeBaseId, - refreshDocuments, - requestSearch, - requestLimit, - requestOffset, - requestSortBy, - requestSortOrder, - ]) + await queryClient.invalidateQueries({ + queryKey: knowledgeKeys.documents(knowledgeBaseId, paramsKey), + }) + }, [queryClient, knowledgeBaseId, paramsKey]) const updateDocumentLocal = useCallback( (documentId: string, updates: Partial) => { - updateDocument(knowledgeBaseId, documentId, updates) + queryClient.setQueryData<{ + documents: DocumentData[] + pagination: DocumentsPagination + }>(knowledgeKeys.documents(knowledgeBaseId, paramsKey), (previous) => { + if (!previous) return previous + return { + ...previous, + documents: previous.documents.map((doc) => + doc.id === documentId ? { ...doc, ...updates } : doc + ), + } + }) + useKnowledgeStore.setState((state) => { + const existing = state.documents[knowledgeBaseId] + if (!existing) return state + return { + documents: { + ...state.documents, + [knowledgeBaseId]: { + ...existing, + documents: existing.documents.map((doc) => + doc.id === documentId ? { ...doc, ...updates } : doc + ), + }, + }, + } + }) logger.info(`Updated document ${documentId} for knowledge base ${knowledgeBaseId}`) }, - [knowledgeBaseId, updateDocument] + [knowledgeBaseId, paramsKey, queryClient] ) return { documents, pagination, - isLoading, - error, + isLoading: query.isLoading, + error: query.error instanceof Error ? query.error.message : null, refreshDocuments: refreshDocumentsData, updateDocument: updateDocumentLocal, } } -export function useKnowledgeBasesList(workspaceId?: string) { - const { - getKnowledgeBasesList, - knowledgeBasesList, - loadingKnowledgeBasesList, - knowledgeBasesListLoaded, - addKnowledgeBase, - removeKnowledgeBase, - clearKnowledgeBasesList, - } = useKnowledgeStore() - - const [error, setError] = useState(null) - const [retryCount, setRetryCount] = useState(0) - const maxRetries = 3 - +export function useKnowledgeBasesList( + workspaceId?: string, + options?: { + enabled?: boolean + } +) { + const queryClient = useQueryClient() + const query = useKnowledgeBasesQuery(workspaceId, { enabled: options?.enabled ?? true }) useEffect(() => { - // Only load if we haven't loaded before AND we're not currently loading - if (knowledgeBasesListLoaded || loadingKnowledgeBasesList) return - - let isMounted = true - let retryTimeoutId: NodeJS.Timeout | null = null - - const loadData = async (attempt = 0) => { - // Don't proceed if component is unmounted - if (!isMounted) return - - try { - setError(null) - await getKnowledgeBasesList(workspaceId) - - // Reset retry count on success - if (isMounted) { - setRetryCount(0) - } - } catch (err) { - if (!isMounted) return - - const errorMessage = err instanceof Error ? err.message : 'Failed to load knowledge bases' - - // Only set error and retry if we haven't exceeded max retries - if (attempt < maxRetries) { - console.warn(`Knowledge bases load attempt ${attempt + 1} failed, retrying...`, err) - setRetryCount(attempt + 1) - - // Exponential backoff: 1s, 2s, 4s - const delay = 2 ** attempt * 1000 - retryTimeoutId = setTimeout(() => { - if (isMounted) { - loadData(attempt + 1) - logger.warn(`Failed to load knowledge bases list, retrying... ${attempt + 1}`) - } - }, delay) - } else { - logger.error('All retry attempts failed for knowledge bases list:', err) - setError(errorMessage) - setRetryCount(maxRetries) - } - } + if (query.data) { + useKnowledgeStore.setState((state) => ({ + knowledgeBasesList: query.data as KnowledgeBaseData[], + knowledgeBasesListLoaded: true, + loadingKnowledgeBasesList: query.isLoading, + knowledgeBases: query.data!.reduce>( + (acc, kb) => { + acc[kb.id] = kb + return acc + }, + { ...state.knowledgeBases } + ), + })) + } else if (query.isLoading) { + useKnowledgeStore.setState((state) => ({ + loadingKnowledgeBasesList: true, + })) } + }, [query.data, query.isLoading]) + + const addKnowledgeBase = useCallback( + (knowledgeBase: KnowledgeBaseData) => { + queryClient.setQueryData( + knowledgeKeys.list(workspaceId), + (previous = []) => { + if (previous.some((kb) => kb.id === knowledgeBase.id)) { + return previous + } + return [knowledgeBase, ...previous] + } + ) + useKnowledgeStore.setState((state) => ({ + knowledgeBases: { + ...state.knowledgeBases, + [knowledgeBase.id]: knowledgeBase, + }, + knowledgeBasesList: state.knowledgeBasesList.some((kb) => kb.id === knowledgeBase.id) + ? state.knowledgeBasesList + : [knowledgeBase, ...state.knowledgeBasesList], + })) + }, + [queryClient, workspaceId] + ) - // Always start from attempt 0 - loadData(0) - - // Cleanup function - return () => { - isMounted = false - if (retryTimeoutId) { - clearTimeout(retryTimeoutId) - } - } - }, [knowledgeBasesListLoaded, loadingKnowledgeBasesList, getKnowledgeBasesList, workspaceId]) - - const refreshList = async () => { - try { - setError(null) - setRetryCount(0) - clearKnowledgeBasesList() - await getKnowledgeBasesList(workspaceId) - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to refresh knowledge bases' - setError(errorMessage) - logger.error('Error refreshing knowledge bases list:', err) - } - } + const removeKnowledgeBase = useCallback( + (knowledgeBaseId: string) => { + queryClient.setQueryData( + knowledgeKeys.list(workspaceId), + (previous) => previous?.filter((kb) => kb.id !== knowledgeBaseId) ?? [] + ) + useKnowledgeStore.setState((state) => ({ + knowledgeBases: Object.fromEntries( + Object.entries(state.knowledgeBases).filter(([id]) => id !== knowledgeBaseId) + ), + knowledgeBasesList: state.knowledgeBasesList.filter((kb) => kb.id !== knowledgeBaseId), + })) + }, + [queryClient, workspaceId] + ) - // Force refresh function that bypasses cache and resets everything - const forceRefresh = async () => { - setError(null) - setRetryCount(0) - clearKnowledgeBasesList() - - // Force reload by clearing cache and loading state - useKnowledgeStore.setState({ - knowledgeBasesList: [], - loadingKnowledgeBasesList: false, - knowledgeBasesListLoaded: false, // Reset store's loaded state - }) + const refreshList = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: knowledgeKeys.list(workspaceId) }) + }, [queryClient, workspaceId]) - try { - await getKnowledgeBasesList(workspaceId) - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to refresh knowledge bases' - setError(errorMessage) - logger.error('Error force refreshing knowledge bases list:', err) - } - } + const forceRefresh = refreshList return { - knowledgeBases: knowledgeBasesList, - isLoading: loadingKnowledgeBasesList, - error, + knowledgeBases: query.data ?? [], + isLoading: query.isLoading, + error: query.error instanceof Error ? query.error.message : null, refreshList, forceRefresh, addKnowledgeBase, removeKnowledgeBase, - retryCount, - maxRetries, + retryCount: 0, + maxRetries: 0, } } @@ -292,12 +264,9 @@ export function useDocumentChunks( urlSearch = '', options: { enableClientSearch?: boolean } = {} ) { - const { getChunks, refreshChunks, updateChunk, getCachedChunks, clearChunks, isChunksLoading } = - useKnowledgeStore() - const { enableClientSearch = false } = options + const queryClient = useQueryClient() - // State for both modes const [chunks, setChunks] = useState([]) const [allChunks, setAllChunks] = useState([]) const [isLoading, setIsLoading] = useState(true) @@ -308,19 +277,18 @@ export function useDocumentChunks( offset: 0, hasMore: false, }) - const [initialLoadDone, setInitialLoadDone] = useState(false) - // Client-side search state const [searchQuery, setSearchQuery] = useState('') const [currentPage, setCurrentPage] = useState(urlPage) - // Sync with URL page changes useEffect(() => { setCurrentPage(urlPage) }, [urlPage]) - const isStoreLoading = isChunksLoading(documentId) - const combinedIsLoading = isLoading || isStoreLoading + useEffect(() => { + if (!enableClientSearch) return + setSearchQuery(urlSearch) + }, [enableClientSearch, urlSearch]) if (enableClientSearch) { const loadAllChunks = useCallback(async () => { @@ -330,53 +298,51 @@ export function useDocumentChunks( setIsLoading(true) setError(null) - const allChunksData: ChunkData[] = [] - let hasMore = true + const aggregated: ChunkData[] = [] + const limit = DEFAULT_PAGE_SIZE let offset = 0 - const limit = 50 + let hasMore = true while (hasMore) { - const response = await fetch( - `/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks?limit=${limit}&offset=${offset}` - ) - - if (!response.ok) { - throw new Error('Failed to fetch chunks') - } - - const result = await response.json() - - if (result.success) { - allChunksData.push(...result.data) - hasMore = result.pagination.hasMore - offset += limit - } else { - throw new Error(result.error || 'Failed to fetch chunks') - } + const { chunks: batch, pagination: batchPagination } = await fetchKnowledgeChunks({ + knowledgeBaseId, + documentId, + limit, + offset, + }) + + aggregated.push(...batch) + hasMore = batchPagination.hasMore + offset = batchPagination.offset + batchPagination.limit } - setAllChunks(allChunksData) - setChunks(allChunksData) // For compatibility + setAllChunks(aggregated) + setChunks(aggregated) + setPagination({ + total: aggregated.length, + limit, + offset: 0, + hasMore: false, + }) } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load chunks') + const message = err instanceof Error ? err.message : 'Failed to load chunks' + setError(message) logger.error(`Failed to load chunks for document ${documentId}:`, err) } finally { setIsLoading(false) } - }, [knowledgeBaseId, documentId]) + }, [documentId, knowledgeBaseId]) - // Load chunks when knowledgeBaseId or documentId changes useEffect(() => { loadAllChunks() }, [loadAllChunks]) - // Client-side filtering with fuzzy search const filteredChunks = useMemo(() => { if (!searchQuery.trim()) return allChunks const fuse = new Fuse(allChunks, { keys: ['content'], - threshold: 0.3, // Lower = more strict matching + threshold: 0.3, includeScore: true, includeMatches: true, minMatchCharLength: 2, @@ -387,8 +353,7 @@ export function useDocumentChunks( return results.map((result) => result.item) }, [allChunks, searchQuery]) - // Client-side pagination - const CHUNKS_PER_PAGE = 50 + const CHUNKS_PER_PAGE = DEFAULT_PAGE_SIZE const totalPages = Math.max(1, Math.ceil(filteredChunks.length / CHUNKS_PER_PAGE)) const hasNextPage = currentPage < totalPages const hasPrevPage = currentPage > 1 @@ -399,21 +364,18 @@ export function useDocumentChunks( return filteredChunks.slice(startIndex, endIndex) }, [filteredChunks, currentPage]) - // Reset to page 1 when search changes useEffect(() => { if (currentPage > 1) { setCurrentPage(1) } - }, [searchQuery]) + }, [searchQuery, currentPage]) - // Reset to valid page if current page exceeds total useEffect(() => { if (currentPage > totalPages && totalPages > 0) { setCurrentPage(totalPages) } }, [currentPage, totalPages]) - // Navigation functions const goToPage = useCallback( (page: number) => { if (page >= 1 && page <= totalPages) { @@ -435,32 +397,21 @@ export function useDocumentChunks( } }, [hasPrevPage]) - // Operations - const refreshChunksData = useCallback(async () => { - await loadAllChunks() - }, [loadAllChunks]) - - const updateChunkLocal = useCallback((chunkId: string, updates: Partial) => { - setAllChunks((prev) => - prev.map((chunk) => (chunk.id === chunkId ? { ...chunk, ...updates } : chunk)) - ) - setChunks((prev) => - prev.map((chunk) => (chunk.id === chunkId ? { ...chunk, ...updates } : chunk)) - ) - }, []) - return { - // Data - return paginatedChunks as chunks for display chunks: paginatedChunks, allChunks, filteredChunks, paginatedChunks, - - // Search searchQuery, setSearchQuery, - - // Pagination + isLoading, + error, + pagination: { + total: filteredChunks.length, + limit: CHUNKS_PER_PAGE, + offset: (currentPage - 1) * CHUNKS_PER_PAGE, + hasMore: hasNextPage, + }, currentPage, totalPages, hasNextPage, @@ -468,275 +419,213 @@ export function useDocumentChunks( goToPage, nextPage, prevPage, - - // State - isLoading: combinedIsLoading, - error, - pagination: { - total: filteredChunks.length, - limit: CHUNKS_PER_PAGE, - offset: (currentPage - 1) * CHUNKS_PER_PAGE, - hasMore: hasNextPage, + refreshChunks: loadAllChunks, + searchChunks: async () => filteredChunks, + updateChunk: (chunkId: string, updates: Partial) => { + setAllChunks((previous) => + previous.map((chunk) => (chunk.id === chunkId ? { ...chunk, ...updates } : chunk)) + ) + setChunks((previous) => + previous.map((chunk) => (chunk.id === chunkId ? { ...chunk, ...updates } : chunk)) + ) }, - - // Operations - refreshChunks: refreshChunksData, - updateChunk: updateChunkLocal, - clearChunks: () => clearChunks(documentId), - - // Legacy compatibility - searchChunks: async (newSearchQuery: string) => { - setSearchQuery(newSearchQuery) - return paginatedChunks + clearChunks: () => { + setAllChunks([]) + setChunks([]) }, } } - const serverCurrentPage = urlPage - const serverSearchQuery = urlSearch - - // Computed pagination properties - const serverTotalPages = Math.ceil(pagination.total / pagination.limit) - const serverHasNextPage = serverCurrentPage < serverTotalPages - const serverHasPrevPage = serverCurrentPage > 1 - - // Single effect to handle all data loading and syncing - useEffect(() => { - if (!knowledgeBaseId || !documentId) return - - let isMounted = true - - const loadAndSyncData = async () => { - try { - // Check cache first - const cached = getCachedChunks(documentId) - const expectedOffset = (serverCurrentPage - 1) * 50 // Use hardcoded limit - - if ( - cached && - cached.searchQuery === serverSearchQuery && - cached.pagination.offset === expectedOffset - ) { - if (isMounted) { - setChunks(cached.chunks) - setPagination(cached.pagination) - setIsLoading(false) - setInitialLoadDone(true) - } - return - } - - // Fetch from API - setIsLoading(true) - setError(null) - - const limit = 50 - const offset = (serverCurrentPage - 1) * limit - - const fetchedChunks = await getChunks(knowledgeBaseId, documentId, { - limit, - offset, - search: serverSearchQuery || undefined, - }) - - if (isMounted) { - setChunks(fetchedChunks) + const serverCurrentPage = Math.max(1, urlPage) + const serverSearchQuery = urlSearch ?? '' + const serverLimit = DEFAULT_PAGE_SIZE + const serverOffset = (serverCurrentPage - 1) * serverLimit + + const chunkQueryParams = useMemo( + () => ({ + knowledgeBaseId, + documentId, + limit: serverLimit, + offset: serverOffset, + search: serverSearchQuery ? serverSearchQuery : undefined, + }), + [documentId, knowledgeBaseId, serverLimit, serverOffset, serverSearchQuery] + ) - // Update pagination from cache after fetch - const updatedCache = getCachedChunks(documentId) - if (updatedCache) { - setPagination(updatedCache.pagination) - } + const chunkParamsKey = useMemo(() => serializeChunkParams(chunkQueryParams), [chunkQueryParams]) - setInitialLoadDone(true) - } - } catch (err) { - if (isMounted) { - setError(err instanceof Error ? err.message : 'Failed to load chunks') - logger.error(`Failed to load chunks for document ${documentId}:`, err) - } - } finally { - if (isMounted) { - setIsLoading(false) - } - } - } - - loadAndSyncData() + const chunkQuery = useKnowledgeChunksQuery(chunkQueryParams, { + enabled: Boolean(knowledgeBaseId && documentId), + }) - return () => { - isMounted = false - } - }, [ - knowledgeBaseId, - documentId, - serverCurrentPage, - serverSearchQuery, - isStoreLoading, - initialLoadDone, - ]) - - // Separate effect to sync with store state changes (no API calls) useEffect(() => { - if (!documentId || !initialLoadDone) return - - const cached = getCachedChunks(documentId) - const expectedOffset = (serverCurrentPage - 1) * 50 - - if ( - cached && - cached.searchQuery === serverSearchQuery && - cached.pagination.offset === expectedOffset - ) { - setChunks(cached.chunks) - setPagination(cached.pagination) + if (chunkQuery.data) { + setChunks(chunkQuery.data.chunks) + setPagination(chunkQuery.data.pagination) } + }, [chunkQuery.data]) - // Update loading state based on store - if (!isStoreLoading && isLoading) { - logger.info(`Chunks loaded for document ${documentId}`) - setIsLoading(false) - } - }, [documentId, isStoreLoading, isLoading, initialLoadDone, serverSearchQuery, serverCurrentPage]) - - const goToPage = async (page: number) => { - if (page < 1 || page > serverTotalPages || page === serverCurrentPage) return + useEffect(() => { + setIsLoading(chunkQuery.isFetching || chunkQuery.isLoading) + }, [chunkQuery.isFetching, chunkQuery.isLoading]) - try { - setIsLoading(true) - setError(null) + useEffect(() => { + const message = chunkQuery.error instanceof Error ? chunkQuery.error.message : chunkQuery.error + setError(message ?? null) + }, [chunkQuery.error]) + + const totalPages = Math.max( + 1, + Math.ceil( + (pagination.total || 0) / + (pagination.limit && pagination.limit > 0 ? pagination.limit : DEFAULT_PAGE_SIZE) + ) + ) + const hasNextPage = serverCurrentPage < totalPages + const hasPrevPage = serverCurrentPage > 1 - const limit = 50 - const offset = (page - 1) * limit + const goToPage = useCallback( + async (page: number) => { + if (!knowledgeBaseId || !documentId) return + if (page < 1 || page > totalPages) return - const fetchedChunks = await getChunks(knowledgeBaseId, documentId, { - limit, + const offset = (page - 1) * serverLimit + const paramsKey = serializeChunkParams({ + knowledgeBaseId, + documentId, + limit: serverLimit, offset, - search: serverSearchQuery || undefined, + search: chunkQueryParams.search, }) - // Update local state from cache - const cached = getCachedChunks(documentId) - if (cached) { - setChunks(cached.chunks) - setPagination(cached.pagination) - } - - return fetchedChunks - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load page') - logger.error(`Failed to load page for document ${documentId}:`, err) - throw err - } finally { - setIsLoading(false) - } - } + await queryClient.fetchQuery({ + queryKey: knowledgeKeys.chunks(knowledgeBaseId, documentId, paramsKey), + queryFn: () => + fetchKnowledgeChunks({ + knowledgeBaseId, + documentId, + limit: serverLimit, + offset, + search: chunkQueryParams.search, + }), + }) + }, + [chunkQueryParams.search, documentId, knowledgeBaseId, queryClient, serverLimit, totalPages] + ) - const nextPage = () => { - if (serverHasNextPage) { - return goToPage(serverCurrentPage + 1) + const nextPage = useCallback(async () => { + if (hasNextPage) { + await goToPage(serverCurrentPage + 1) } - } + }, [goToPage, hasNextPage, serverCurrentPage]) - const prevPage = () => { - if (serverHasPrevPage) { - return goToPage(serverCurrentPage - 1) + const prevPage = useCallback(async () => { + if (hasPrevPage) { + await goToPage(serverCurrentPage - 1) } - } + }, [goToPage, hasPrevPage, serverCurrentPage]) - const refreshChunksData = async (options?: { - search?: string - limit?: number - offset?: number - preservePage?: boolean - }) => { - try { - setIsLoading(true) - setError(null) - - const limit = 50 - const offset = options?.offset ?? (serverCurrentPage - 1) * limit - - const fetchedChunks = await refreshChunks(knowledgeBaseId, documentId, { - search: options?.search, - limit, - offset, + const refreshChunksData = useCallback(async () => { + if (!knowledgeBaseId || !documentId) return + await queryClient.invalidateQueries({ + queryKey: knowledgeKeys.chunks(knowledgeBaseId, documentId, chunkParamsKey), + }) + }, [chunkParamsKey, documentId, knowledgeBaseId, queryClient]) + + const searchChunks = useCallback( + async (newSearchQuery: string) => { + if (!knowledgeBaseId || !documentId) return [] + const paramsKey = serializeChunkParams({ + knowledgeBaseId, + documentId, + limit: serverLimit, + offset: 0, + search: newSearchQuery || undefined, }) - // Update local state from cache - const cached = getCachedChunks(documentId) - if (cached) { - setChunks(cached.chunks) - setPagination(cached.pagination) - } - - return fetchedChunks - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to refresh chunks') - logger.error(`Failed to refresh chunks for document ${documentId}:`, err) - throw err - } finally { - setIsLoading(false) - } - } + const result = await queryClient.fetchQuery({ + queryKey: knowledgeKeys.chunks(knowledgeBaseId, documentId, paramsKey), + queryFn: () => + fetchKnowledgeChunks({ + knowledgeBaseId, + documentId, + limit: serverLimit, + offset: 0, + search: newSearchQuery || undefined, + }), + }) - const searchChunks = async (newSearchQuery: string) => { - try { - setIsLoading(true) - setError(null) + return result.chunks + }, + [documentId, knowledgeBaseId, queryClient, serverLimit] + ) - const limit = 50 - const searchResults = await getChunks(knowledgeBaseId, documentId, { - search: newSearchQuery, - limit, - offset: 0, // Always start from first page for search - }) + const updateChunkLocal = useCallback( + (chunkId: string, updates: Partial) => { + queryClient.setQueriesData<{ + chunks: ChunkData[] + pagination: ChunksPagination + }>( + { + predicate: (query) => + Array.isArray(query.queryKey) && + query.queryKey[0] === knowledgeKeys.all[0] && + query.queryKey[1] === knowledgeKeys.detail('')[1] && + query.queryKey[2] === knowledgeBaseId && + query.queryKey[3] === 'documents' && + query.queryKey[4] === documentId && + query.queryKey[5] === 'chunks', + }, + (oldData) => { + if (!oldData) return oldData + return { + ...oldData, + chunks: oldData.chunks.map((chunk) => + chunk.id === chunkId ? { ...chunk, ...updates } : chunk + ), + } + } + ) - // Update local state from cache - const cached = getCachedChunks(documentId) - if (cached) { - setChunks(cached.chunks) - setPagination(cached.pagination) - } + setChunks((previous) => + previous.map((chunk) => (chunk.id === chunkId ? { ...chunk, ...updates } : chunk)) + ) + useKnowledgeStore.getState().updateChunk(documentId, chunkId, updates) + }, + [documentId, knowledgeBaseId, queryClient] + ) - return searchResults - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to search chunks') - logger.error(`Failed to search chunks for document ${documentId}:`, err) - throw err - } finally { - setIsLoading(false) - } - } + const clearChunksLocal = useCallback(() => { + useKnowledgeStore.getState().clearChunks(documentId) + setChunks([]) + setPagination({ + total: 0, + limit: DEFAULT_PAGE_SIZE, + offset: 0, + hasMore: false, + }) + }, [documentId]) return { chunks, - allChunks: chunks, // In server mode, allChunks is the same as chunks - filteredChunks: chunks, // In server mode, filteredChunks is the same as chunks - paginatedChunks: chunks, // In server mode, paginatedChunks is the same as chunks - - // Search (not used in server mode but needed for consistency) - searchQuery: urlSearch, - setSearchQuery: () => {}, // No-op in server mode - - isLoading: combinedIsLoading, + allChunks: chunks, + filteredChunks: chunks, + paginatedChunks: chunks, + searchQuery: serverSearchQuery, + setSearchQuery: () => {}, + isLoading, error, pagination, currentPage: serverCurrentPage, - totalPages: serverTotalPages, - hasNextPage: serverHasNextPage, - hasPrevPage: serverHasPrevPage, + totalPages, + hasNextPage, + hasPrevPage, goToPage, nextPage, prevPage, refreshChunks: refreshChunksData, searchChunks, - updateChunk: (chunkId: string, updates: Partial) => { - updateChunk(documentId, chunkId, updates) - setChunks((prevChunks) => - prevChunks.map((chunk) => (chunk.id === chunkId ? { ...chunk, ...updates } : chunk)) - ) - }, - clearChunks: () => clearChunks(documentId), + updateChunk: updateChunkLocal, + clearChunks: clearChunksLocal, } } diff --git a/apps/sim/lib/environment/api.ts b/apps/sim/lib/environment/api.ts new file mode 100644 index 0000000000..13f258f693 --- /dev/null +++ b/apps/sim/lib/environment/api.ts @@ -0,0 +1,42 @@ +import { API_ENDPOINTS } from '@/stores/constants' +import type { EnvironmentVariable } from '@/stores/settings/environment/types' + +export interface WorkspaceEnvironmentData { + workspace: Record + personal: Record + conflicts: string[] +} + +export async function fetchPersonalEnvironment(): Promise> { + const response = await fetch(API_ENDPOINTS.ENVIRONMENT) + + if (!response.ok) { + throw new Error(`Failed to load environment variables: ${response.statusText}`) + } + + const { data } = await response.json() + + if (data && typeof data === 'object') { + return data + } + + return {} +} + +export async function fetchWorkspaceEnvironment( + workspaceId: string +): Promise { + const response = await fetch(API_ENDPOINTS.WORKSPACE_ENVIRONMENT(workspaceId)) + + if (!response.ok) { + throw new Error(`Failed to load workspace environment: ${response.statusText}`) + } + + const { data } = await response.json() + + return { + workspace: data.workspace || {}, + personal: data.personal || {}, + conflicts: data.conflicts || [], + } +} diff --git a/apps/sim/providers/ollama/index.ts b/apps/sim/providers/ollama/index.ts index 21a50efac4..35fc219de0 100644 --- a/apps/sim/providers/ollama/index.ts +++ b/apps/sim/providers/ollama/index.ts @@ -78,13 +78,13 @@ export const ollamaProvider: ProviderConfig = { try { const response = await fetch(`${OLLAMA_HOST}/api/tags`) if (!response.ok) { - useProvidersStore.getState().setModels('ollama', []) + useProvidersStore.getState().setProviderModels('ollama', []) logger.warn('Ollama service is not available. The provider will be disabled.') return } const data = (await response.json()) as ModelsObject this.models = data.models.map((model) => model.name) - useProvidersStore.getState().setModels('ollama', this.models) + useProvidersStore.getState().setProviderModels('ollama', this.models) } catch (error) { logger.warn('Ollama model instantiation failed. The provider will be disabled.', { error: error instanceof Error ? error.message : 'Unknown error', diff --git a/apps/sim/stores/custom-tools/store.ts b/apps/sim/stores/custom-tools/store.ts index 3f4f907ab0..21ad8b46fd 100644 --- a/apps/sim/stores/custom-tools/store.ts +++ b/apps/sim/stores/custom-tools/store.ts @@ -1,25 +1,12 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { createLogger } from '@/lib/logs/console/logger' -import { withOptimisticUpdate } from '@/lib/utils' import type { CustomToolsState, CustomToolsStore } from './types' const logger = createLogger('CustomToolsStore') -const API_ENDPOINT = '/api/tools/custom' - -class ApiError extends Error { - status: number - constructor(message: string, status: number) { - super(message) - this.status = status - this.name = 'ApiError' - } -} const initialState: CustomToolsState = { tools: [], - isLoading: false, - error: null, } export const useCustomToolsStore = create()( @@ -27,218 +14,9 @@ export const useCustomToolsStore = create()( (set, get) => ({ ...initialState, - fetchTools: async (workspaceId: string) => { - set({ isLoading: true, error: null }) - - try { - logger.info(`Fetching custom tools for workspace ${workspaceId}`) - - const response = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error( - errorData.error || `Failed to fetch custom tools: ${response.statusText}` - ) - } - - const { data } = await response.json() - - if (!Array.isArray(data)) { - throw new Error('Invalid response format') - } - - // Filter and validate tools - const validTools = data.filter((tool, index) => { - if (!tool || typeof tool !== 'object') { - logger.warn(`Skipping invalid tool at index ${index}: not an object`) - return false - } - if (!tool.id || typeof tool.id !== 'string') { - logger.warn(`Skipping invalid tool at index ${index}: missing or invalid id`) - return false - } - if (!tool.title || typeof tool.title !== 'string') { - logger.warn(`Skipping invalid tool at index ${index}: missing or invalid title`) - return false - } - if (!tool.schema || typeof tool.schema !== 'object') { - logger.warn(`Skipping invalid tool at index ${index}: missing or invalid schema`) - return false - } - if (!tool.code || typeof tool.code !== 'string') { - logger.warn(`Tool at index ${index} missing code field, defaulting to empty string`) - tool.code = '' - } - return true - }) - - set({ - tools: validTools, - isLoading: false, - }) - - logger.info(`Fetched ${validTools.length} custom tools for workspace ${workspaceId}`) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to fetch tools' - logger.error('Error fetching custom tools:', error) - set({ - error: errorMessage, - isLoading: false, - }) - } - }, - - createTool: async (workspaceId: string, tool) => { - set({ isLoading: true, error: null }) - - try { - logger.info(`Creating custom tool: ${tool.title} in workspace ${workspaceId}`) - - const response = await fetch(API_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - tools: [ - { - title: tool.title, - schema: tool.schema, - code: tool.code, - }, - ], - workspaceId, - }), - }) - - const data = await response.json() - - if (!response.ok) { - throw new ApiError(data.error || 'Failed to create tool', response.status) - } - - if (!data.data || !Array.isArray(data.data)) { - throw new Error('Invalid API response: missing tools data') - } - - set({ tools: data.data, isLoading: false }) - - const createdTool = get().tools.find((t) => t.title === tool.title) - if (!createdTool) { - throw new Error('Failed to retrieve created tool') - } - - logger.info(`Created custom tool: ${createdTool.id}`) - return createdTool - } catch (error) { - logger.error('Error creating custom tool:', error) - set({ isLoading: false }) - throw error - } - }, - - updateTool: async (workspaceId: string, id: string, updates) => { - const tool = get().tools.find((t) => t.id === id) - if (!tool) { - throw new Error('Tool not found') - } - - await withOptimisticUpdate({ - getCurrentState: () => get().tools, - optimisticUpdate: () => { - set((state) => ({ - tools: state.tools.map((t) => - t.id === id - ? { - ...t, - title: updates.title ?? t.title, - schema: updates.schema ?? t.schema, - code: updates.code ?? t.code, - } - : t - ), - isLoading: true, - error: null, - })) - }, - apiCall: async () => { - logger.info(`Updating custom tool: ${id} in workspace ${workspaceId}`) - - const response = await fetch(API_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - tools: [ - { - id, - title: updates.title ?? tool.title, - schema: updates.schema ?? tool.schema, - code: updates.code ?? tool.code, - }, - ], - workspaceId, - }), - }) - - const data = await response.json() - - if (!response.ok) { - throw new ApiError(data.error || 'Failed to update tool', response.status) - } - - if (!data.data || !Array.isArray(data.data)) { - throw new Error('Invalid API response: missing tools data') - } - - set({ tools: data.data }) - logger.info(`Updated custom tool: ${id}`) - }, - rollback: (originalTools) => { - set({ tools: originalTools }) - }, - onComplete: () => { - set({ isLoading: false }) - }, - errorMessage: 'Error updating custom tool', - }) - }, - - deleteTool: async (workspaceId: string | null, id: string) => { - await withOptimisticUpdate({ - getCurrentState: () => get().tools, - optimisticUpdate: () => { - set((state) => ({ - tools: state.tools.filter((tool) => tool.id !== id), - isLoading: true, - error: null, - })) - }, - apiCall: async () => { - logger.info(`Deleting custom tool: ${id}`) - - const url = workspaceId - ? `${API_ENDPOINT}?id=${id}&workspaceId=${workspaceId}` - : `${API_ENDPOINT}?id=${id}` - - const response = await fetch(url, { - method: 'DELETE', - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to delete tool') - } - - logger.info(`Deleted custom tool: ${id}`) - }, - rollback: (originalTools) => { - set({ tools: originalTools }) - }, - onComplete: () => { - set({ isLoading: false }) - }, - errorMessage: 'Error deleting custom tool', - }) + setTools: (tools) => { + logger.info(`Synced ${tools.length} custom tools`) + set({ tools }) }, getTool: (id: string) => { @@ -249,8 +27,6 @@ export const useCustomToolsStore = create()( return get().tools }, - clearError: () => set({ error: null }), - reset: () => set(initialState), }), { diff --git a/apps/sim/stores/custom-tools/types.ts b/apps/sim/stores/custom-tools/types.ts index 90bb47b1bd..d9ebf6fddd 100644 --- a/apps/sim/stores/custom-tools/types.ts +++ b/apps/sim/stores/custom-tools/types.ts @@ -24,27 +24,12 @@ export interface CustomToolDefinition { export interface CustomToolsState { tools: CustomToolDefinition[] - isLoading: boolean - error: string | null } export interface CustomToolsActions { - fetchTools: (workspaceId: string) => Promise - createTool: ( - workspaceId: string, - tool: Omit - ) => Promise - updateTool: ( - workspaceId: string, - id: string, - updates: Partial< - Omit - > - ) => Promise - deleteTool: (workspaceId: string | null, id: string) => Promise + setTools: (tools: CustomToolDefinition[]) => void getTool: (id: string) => CustomToolDefinition | undefined getAllTools: () => CustomToolDefinition[] - clearError: () => void reset: () => void } diff --git a/apps/sim/stores/folders/store.ts b/apps/sim/stores/folders/store.ts index 1630391205..407ab9817d 100644 --- a/apps/sim/stores/folders/store.ts +++ b/apps/sim/stores/folders/store.ts @@ -1,8 +1,6 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { createLogger } from '@/lib/logs/console/logger' -import { withOptimisticUpdate } from '@/lib/utils' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('FoldersStore') @@ -36,16 +34,10 @@ export interface FolderTreeNode extends WorkflowFolder { interface FolderState { folders: Record - isLoading: boolean expandedFolders: Set selectedWorkflows: Set - // Actions setFolders: (folders: WorkflowFolder[]) => void - addFolder: (folder: WorkflowFolder) => void - updateFolder: (id: string, updates: Partial) => void - removeFolder: (id: string) => void - setLoading: (loading: boolean) => void toggleExpanded: (folderId: string) => void setExpanded: (folderId: string, expanded: boolean) => void @@ -63,29 +55,12 @@ interface FolderState { getFolderById: (id: string) => WorkflowFolder | undefined getChildFolders: (parentId: string | null) => WorkflowFolder[] getFolderPath: (folderId: string) => WorkflowFolder[] - - // API actions - fetchFolders: (workspaceId: string) => Promise - createFolder: (data: { - name: string - workspaceId: string - parentId?: string - color?: string - }) => Promise - updateFolderAPI: (id: string, updates: Partial) => Promise - deleteFolder: (id: string, workspaceId: string) => Promise - duplicateFolder: (id: string) => Promise - - // Helper functions - isWorkflowInDeletedSubfolder: (workflow: Workflow, deletedFolderId: string) => boolean - removeSubfoldersRecursively: (parentFolderId: string) => void } export const useFolderStore = create()( devtools( (set, get) => ({ folders: {}, - isLoading: false, expandedFolders: new Set(), selectedWorkflows: new Set(), @@ -100,28 +75,6 @@ export const useFolderStore = create()( ), })), - addFolder: (folder) => - set((state) => ({ - folders: { ...state.folders, [folder.id]: folder }, - })), - - updateFolder: (id, updates) => - set((state) => ({ - folders: { - ...state.folders, - [id]: state.folders[id] ? { ...state.folders[id], ...updates } : state.folders[id], - }, - })), - - removeFolder: (id) => - set((state) => { - const newFolders = { ...state.folders } - delete newFolders[id] - return { folders: newFolders } - }), - - setLoading: (loading) => set({ isLoading: loading }), - toggleExpanded: (folderId) => set((state) => { const newExpanded = new Set(state.expandedFolders) @@ -225,235 +178,6 @@ export const useFolderStore = create()( return path }, - - fetchFolders: async (workspaceId) => { - set({ isLoading: true }) - try { - const response = await fetch(`/api/folders?workspaceId=${workspaceId}`) - if (!response.ok) { - throw new Error('Failed to fetch folders') - } - const { folders }: { folders: any[] } = await response.json() - - // Convert date strings to Date objects - const processedFolders: WorkflowFolder[] = folders.map((folder: any) => ({ - id: folder.id, - name: folder.name, - userId: folder.userId, - workspaceId: folder.workspaceId, - parentId: folder.parentId, - color: folder.color, - isExpanded: folder.isExpanded, - sortOrder: folder.sortOrder, - createdAt: new Date(folder.createdAt), - updatedAt: new Date(folder.updatedAt), - })) - - get().setFolders(processedFolders) - - // Start with all folders collapsed - only active workflow path will be expanded by the UI - set({ expandedFolders: new Set() }) - } catch (error) { - logger.error('Error fetching folders:', error) - } finally { - set({ isLoading: false }) - } - }, - - createFolder: async (data) => { - const response = await fetch('/api/folders', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to create folder') - } - - const { folder } = await response.json() - const processedFolder = { - ...folder, - createdAt: new Date(folder.createdAt), - updatedAt: new Date(folder.updatedAt), - } - - get().addFolder(processedFolder) - return processedFolder - }, - - updateFolderAPI: async (id, updates) => { - const originalFolder = get().folders[id] - if (!originalFolder) { - throw new Error('Folder not found') - } - - let updatedFolder: WorkflowFolder | null = null - - await withOptimisticUpdate({ - getCurrentState: () => originalFolder, - optimisticUpdate: () => { - get().updateFolder(id, { ...updates, updatedAt: new Date() }) - }, - apiCall: async () => { - const response = await fetch(`/api/folders/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to update folder') - } - - const { folder } = await response.json() - const processedFolder = { - ...folder, - createdAt: new Date(folder.createdAt), - updatedAt: new Date(folder.updatedAt), - } - - get().updateFolder(id, processedFolder) - updatedFolder = processedFolder - }, - rollback: (original) => { - get().updateFolder(id, original) - }, - errorMessage: 'Failed to update folder', - }) - - return updatedFolder || { ...originalFolder, ...updates } - }, - - deleteFolder: async (id: string, workspaceId: string) => { - const getAllSubfolderIds = (parentId: string): string[] => { - const folders = get().folders - const childIds = Object.keys(folders).filter( - (folderId) => folders[folderId].parentId === parentId - ) - const allIds = [...childIds] - - childIds.forEach((childId) => { - allIds.push(...getAllSubfolderIds(childId)) - }) - - return allIds - } - - const deletedFolderIds = [id, ...getAllSubfolderIds(id)] - - await withOptimisticUpdate({ - getCurrentState: () => ({ - folders: { ...get().folders }, - expandedFolders: new Set(get().expandedFolders), - }), - optimisticUpdate: () => { - deletedFolderIds.forEach((folderId) => { - get().removeFolder(folderId) - }) - - set((state) => { - const newExpanded = new Set(state.expandedFolders) - deletedFolderIds.forEach((folderId) => newExpanded.delete(folderId)) - return { expandedFolders: newExpanded } - }) - }, - apiCall: async () => { - const response = await fetch(`/api/folders/${id}`, { method: 'DELETE' }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to delete folder') - } - - const responseData = await response.json() - logger.info( - `Deleted ${responseData.deletedItems.workflows} workflow(s) and ${responseData.deletedItems.folders} folder(s)` - ) - - const workflowRegistry = useWorkflowRegistry.getState() - await workflowRegistry.loadWorkflows(workspaceId) - }, - rollback: (originalState) => { - set({ folders: originalState.folders, expandedFolders: originalState.expandedFolders }) - }, - errorMessage: 'Failed to delete folder', - }) - }, - - duplicateFolder: async (id: string) => { - const sourceFolder = get().folders[id] - if (!sourceFolder) { - logger.error(`Folder ${id} not found`) - return null - } - - try { - const response = await fetch(`/api/folders/${id}/duplicate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: `${sourceFolder.name} (Copy)`, - workspaceId: sourceFolder.workspaceId, - parentId: sourceFolder.parentId, - color: sourceFolder.color, - }), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to duplicate folder') - } - - const result = await response.json() - - logger.info( - `Successfully duplicated folder ${id} to ${result.id} with ${result.foldersCount} folder(s) and ${result.workflowsCount} workflow(s)` - ) - - // Reload folders and workflows to reflect the duplication - const workflowRegistry = useWorkflowRegistry.getState() - await Promise.all([ - get().fetchFolders(sourceFolder.workspaceId), - workflowRegistry.loadWorkflows(sourceFolder.workspaceId), - ]) - - return result.id - } catch (error) { - logger.error(`Failed to duplicate folder ${id}:`, error) - throw error - } - }, - - isWorkflowInDeletedSubfolder: (workflow: Workflow, deletedFolderId: string) => { - if (!workflow.folderId) return false - - const folders = get().folders - let currentFolderId: string | null = workflow.folderId - - while (currentFolderId && folders[currentFolderId]) { - if (currentFolderId === deletedFolderId) { - return true - } - currentFolderId = folders[currentFolderId].parentId - } - - return false - }, - - removeSubfoldersRecursively: (parentFolderId: string) => { - const folders = get().folders - const childFolderIds = Object.keys(folders).filter( - (id) => folders[id].parentId === parentFolderId - ) - - childFolderIds.forEach((childId) => { - get().removeSubfoldersRecursively(childId) - get().removeFolder(childId) - }) - }, }), { name: 'folder-store' } ) diff --git a/apps/sim/stores/providers/store.ts b/apps/sim/stores/providers/store.ts index f51f0736c9..1416a16565 100644 --- a/apps/sim/stores/providers/store.ts +++ b/apps/sim/stores/providers/store.ts @@ -1,50 +1,9 @@ import { create } from 'zustand' import { createLogger } from '@/lib/logs/console/logger' -import { updateOllamaProviderModels, updateOpenRouterProviderModels } from '@/providers/utils' -import type { ProviderConfig, ProviderName, ProvidersStore } from './types' +import type { ProvidersStore } from './types' const logger = createLogger('ProvidersStore') -const PROVIDER_CONFIGS: Record = { - base: { - apiEndpoint: '/api/providers/base/models', - dedupeModels: true, - updateFunction: () => {}, - }, - ollama: { - apiEndpoint: '/api/providers/ollama/models', - updateFunction: updateOllamaProviderModels, - }, - openrouter: { - apiEndpoint: '/api/providers/openrouter/models', - dedupeModels: true, - updateFunction: updateOpenRouterProviderModels, - }, -} - -const fetchProviderModels = async (provider: ProviderName): Promise => { - try { - const config = PROVIDER_CONFIGS[provider] - const response = await fetch(config.apiEndpoint) - - if (!response.ok) { - logger.warn(`Failed to fetch ${provider} models from API`, { - status: response.status, - statusText: response.statusText, - }) - return [] - } - - const data = await response.json() - return data.models || [] - } catch (error) { - logger.error(`Error fetching ${provider} models`, { - error: error instanceof Error ? error.message : 'Unknown error', - }) - return [] - } -} - export const useProvidersStore = create((set, get) => ({ providers: { base: { models: [], isLoading: false }, @@ -52,86 +11,32 @@ export const useProvidersStore = create((set, get) => ({ openrouter: { models: [], isLoading: false }, }, - setModels: (provider, models) => { - const config = PROVIDER_CONFIGS[provider] - - const processedModels = config.dedupeModels ? Array.from(new Set(models)) : models - + setProviderModels: (provider, models) => { + logger.info(`Updated ${provider} models`, { count: models.length }) set((state) => ({ providers: { ...state.providers, [provider]: { ...state.providers[provider], - models: processedModels, + models, }, }, })) - - config.updateFunction(models) }, - fetchModels: async (provider) => { - if (typeof window === 'undefined') { - logger.info(`Skipping client-side ${provider} model fetch on server`) - return - } - - const currentState = get().providers[provider] - if (currentState.isLoading) { - logger.info(`${provider} model fetch already in progress`) - return - } - if (currentState.models.length > 0) { - logger.info(`Skipping ${provider} model fetch - models already loaded`) - return - } - - logger.info(`Fetching ${provider} models from API`) - + setProviderLoading: (provider, isLoading) => { set((state) => ({ providers: { ...state.providers, [provider]: { ...state.providers[provider], - isLoading: true, + isLoading, }, }, })) - - try { - const models = await fetchProviderModels(provider) - logger.info(`Successfully fetched ${provider} models`, { - count: models.length, - ...(provider === 'ollama' ? { models } : {}), - }) - get().setModels(provider, models) - } catch (error) { - logger.error(`Failed to fetch ${provider} models`, { - error: error instanceof Error ? error.message : 'Unknown error', - }) - } finally { - set((state) => ({ - providers: { - ...state.providers, - [provider]: { - ...state.providers[provider], - isLoading: false, - }, - }, - })) - } }, getProvider: (provider) => { return get().providers[provider] }, })) - -if (typeof window !== 'undefined') { - setTimeout(() => { - const store = useProvidersStore.getState() - store.fetchModels('base') - store.fetchModels('ollama') - store.fetchModels('openrouter') - }, 1000) -} diff --git a/apps/sim/stores/providers/types.ts b/apps/sim/stores/providers/types.ts index de42617281..80555826a8 100644 --- a/apps/sim/stores/providers/types.ts +++ b/apps/sim/stores/providers/types.ts @@ -7,13 +7,7 @@ export interface ProviderState { export interface ProvidersStore { providers: Record - setModels: (provider: ProviderName, models: string[]) => void - fetchModels: (provider: ProviderName) => Promise + setProviderModels: (provider: ProviderName, models: string[]) => void + setProviderLoading: (provider: ProviderName, isLoading: boolean) => void getProvider: (provider: ProviderName) => ProviderState } - -export interface ProviderConfig { - apiEndpoint: string - dedupeModels?: boolean - updateFunction: (models: string[]) => void | Promise -} diff --git a/apps/sim/stores/settings/environment/store.ts b/apps/sim/stores/settings/environment/store.ts index fb35c2e34d..54ee275e4d 100644 --- a/apps/sim/stores/settings/environment/store.ts +++ b/apps/sim/stores/settings/environment/store.ts @@ -1,12 +1,7 @@ import { create } from 'zustand' +import { fetchPersonalEnvironment } from '@/lib/environment/api' import { createLogger } from '@/lib/logs/console/logger' -import { withOptimisticUpdate } from '@/lib/utils' -import { API_ENDPOINTS } from '@/stores/constants' -import type { - CachedWorkspaceEnvData, - EnvironmentStore, - EnvironmentVariable, -} from '@/stores/settings/environment/types' +import type { EnvironmentStore, EnvironmentVariable } from '@/stores/settings/environment/types' const logger = createLogger('EnvironmentStore') @@ -14,187 +9,35 @@ export const useEnvironmentStore = create()((set, get) => ({ variables: {}, isLoading: false, error: null, - workspaceEnvCache: new Map(), loadEnvironmentVariables: async () => { try { set({ isLoading: true, error: null }) - - const response = await fetch(API_ENDPOINTS.ENVIRONMENT) - - if (!response.ok) { - throw new Error(`Failed to load environment variables: ${response.statusText}`) - } - - const { data } = await response.json() - - if (data && typeof data === 'object') { - set({ - variables: data, - isLoading: false, - }) - } else { - set({ - variables: {}, - isLoading: false, - }) - } + const data = await fetchPersonalEnvironment() + set({ variables: data, isLoading: false }) } catch (error) { logger.error('Error loading environment variables:', { error }) set({ error: error instanceof Error ? error.message : 'Unknown error', isLoading: false, }) + throw error } }, - saveEnvironmentVariables: async (variables: Record) => { - const transformedVariables = Object.entries(variables).reduce( - (acc, [key, value]) => ({ - ...acc, - [key]: { key, value }, - }), - {} - ) - - await withOptimisticUpdate({ - getCurrentState: () => get().variables, - optimisticUpdate: () => { - set({ variables: transformedVariables, isLoading: true, error: null }) - }, - apiCall: async () => { - const response = await fetch(API_ENDPOINTS.ENVIRONMENT, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - variables: Object.entries(transformedVariables).reduce( - (acc, [key, value]) => ({ - ...acc, - [key]: (value as EnvironmentVariable).value, - }), - {} - ), - }), - }) - - if (!response.ok) { - throw new Error(`Failed to save environment variables: ${response.statusText}`) - } - - get().clearWorkspaceEnvCache() - }, - rollback: (originalVariables) => { - set({ variables: originalVariables }) - }, - onComplete: () => { - set({ isLoading: false }) - }, - errorMessage: 'Error saving environment variables', - }) - }, - - loadWorkspaceEnvironment: async (workspaceId: string) => { - const cached = get().workspaceEnvCache.get(workspaceId) - if (cached) { - return { - workspace: cached.workspace, - personal: cached.personal, - conflicts: cached.conflicts, - } - } - - try { - set({ isLoading: true, error: null }) - - const response = await fetch(API_ENDPOINTS.WORKSPACE_ENVIRONMENT(workspaceId)) - if (!response.ok) { - throw new Error(`Failed to load workspace environment: ${response.statusText}`) - } - - const { data } = await response.json() - const envData = data as { - workspace: Record - personal: Record - conflicts: string[] - } - - const cache = new Map(get().workspaceEnvCache) - cache.set(workspaceId, { - ...envData, - cachedAt: Date.now(), - }) - set({ workspaceEnvCache: cache, isLoading: false }) - - return envData - } catch (error) { - logger.error('Error loading workspace environment:', { error }) - set({ error: error instanceof Error ? error.message : 'Unknown error', isLoading: false }) - return { workspace: {}, personal: {}, conflicts: [] } - } - }, - - upsertWorkspaceEnvironment: async (workspaceId: string, variables: Record) => { - try { - set({ isLoading: true, error: null }) - const response = await fetch(API_ENDPOINTS.WORKSPACE_ENVIRONMENT(workspaceId), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ variables }), - }) - if (!response.ok) { - throw new Error(`Failed to update workspace environment: ${response.statusText}`) - } - set({ isLoading: false }) - - get().clearWorkspaceEnvCache(workspaceId) - } catch (error) { - logger.error('Error updating workspace environment:', { error }) - set({ error: error instanceof Error ? error.message : 'Unknown error', isLoading: false }) - } - }, - - removeWorkspaceEnvironmentKeys: async (workspaceId: string, keys: string[]) => { - try { - set({ isLoading: true, error: null }) - const response = await fetch(API_ENDPOINTS.WORKSPACE_ENVIRONMENT(workspaceId), { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ keys }), - }) - if (!response.ok) { - throw new Error(`Failed to remove workspace environment keys: ${response.statusText}`) - } - set({ isLoading: false }) - - get().clearWorkspaceEnvCache(workspaceId) - } catch (error) { - logger.error('Error removing workspace environment keys:', { error }) - set({ error: error instanceof Error ? error.message : 'Unknown error', isLoading: false }) - } + setVariables: (variables: Record) => { + set({ variables }) }, - getAllVariables: (): Record => { + getAllVariables: () => { return get().variables }, - clearWorkspaceEnvCache: (workspaceId?: string) => { - const cache = new Map(get().workspaceEnvCache) - if (workspaceId) { - cache.delete(workspaceId) - set({ workspaceEnvCache: cache }) - } else { - set({ workspaceEnvCache: new Map() }) - } - }, - reset: () => { set({ variables: {}, isLoading: false, error: null, - workspaceEnvCache: new Map(), }) }, })) diff --git a/apps/sim/stores/settings/environment/types.ts b/apps/sim/stores/settings/environment/types.ts index 949f1ec29a..7e9a575a8d 100644 --- a/apps/sim/stores/settings/environment/types.ts +++ b/apps/sim/stores/settings/environment/types.ts @@ -14,25 +14,11 @@ export interface EnvironmentState { variables: Record isLoading: boolean error: string | null - workspaceEnvCache: Map } export interface EnvironmentStore extends EnvironmentState { loadEnvironmentVariables: () => Promise - saveEnvironmentVariables: (variables: Record) => Promise - - loadWorkspaceEnvironment: (workspaceId: string) => Promise<{ - workspace: Record - personal: Record - conflicts: string[] - }> - upsertWorkspaceEnvironment: ( - workspaceId: string, - variables: Record - ) => Promise - removeWorkspaceEnvironmentKeys: (workspaceId: string, keys: string[]) => Promise - + setVariables: (variables: Record) => void getAllVariables: () => Record - clearWorkspaceEnvCache: (workspaceId?: string) => void reset: () => void } diff --git a/apps/sim/stores/settings/general/store.ts b/apps/sim/stores/settings/general/store.ts index 3d4eba875d..093c4840c8 100644 --- a/apps/sim/stores/settings/general/store.ts +++ b/apps/sim/stores/settings/general/store.ts @@ -1,294 +1,37 @@ import { create } from 'zustand' -import { devtools, persist } from 'zustand/middleware' +import { devtools } from 'zustand/middleware' import { createLogger } from '@/lib/logs/console/logger' -// COMMENTED OUT: Theme switching disabled - dark mode is forced for workspace -// import { syncThemeToNextThemes } from '@/lib/theme-sync' -import { withOptimisticUpdate } from '@/lib/utils' -import type { General, GeneralStore, UserSettings } from '@/stores/settings/general/types' +import type { General, GeneralStore } from '@/stores/settings/general/types' const logger = createLogger('GeneralStore') -const CACHE_TIMEOUT = 3600000 // 1 hour - settings rarely change -const MAX_ERROR_RETRIES = 2 +const initialState: General = { + isAutoConnectEnabled: true, + isAutoPanEnabled: true, + isConsoleExpandedByDefault: true, + showFloatingControls: true, + showTrainingControls: false, + superUserModeEnabled: true, + theme: 'system', + telemetryEnabled: true, + isBillingUsageNotificationsEnabled: true, +} export const useGeneralStore = create()( devtools( - persist( - (set, get) => { - let lastLoadTime = 0 - let errorRetryCount = 0 - let hasLoadedFromDb = false // Track if we've loaded from DB in this session - - const store: General = { - isAutoConnectEnabled: true, - isAutoPanEnabled: true, - isConsoleExpandedByDefault: true, - showFloatingControls: true, - showTrainingControls: false, - superUserModeEnabled: true, - theme: 'system' as const, // Keep for compatibility but not used - telemetryEnabled: true, - isLoading: false, - error: null, - // Individual loading states - isAutoConnectLoading: false, - isAutoPanLoading: false, - isConsoleExpandedByDefaultLoading: false, - isThemeLoading: false, // Keep for compatibility but not used - isTelemetryLoading: false, - isBillingUsageNotificationsLoading: false, - isBillingUsageNotificationsEnabled: true, - isFloatingControlsLoading: false, - isTrainingControlsLoading: false, - isSuperUserModeLoading: false, - } - - const updateSettingOptimistic = async ( - key: K, - value: UserSettings[K], - loadingKey: keyof General, - stateKey: keyof General - ) => { - if ((get() as any)[loadingKey]) return - - await withOptimisticUpdate({ - getCurrentState: () => (get() as any)[stateKey], - optimisticUpdate: () => set({ [stateKey]: value, [loadingKey]: true } as any), - apiCall: async () => { - await get().updateSetting(key, value) - }, - rollback: (originalValue) => set({ [stateKey]: originalValue } as any), - onComplete: () => set({ [loadingKey]: false } as any), - errorMessage: `Failed to update ${String(key)}, rolled back`, - }) - } - - return { - ...store, - toggleAutoConnect: async () => { - if (get().isAutoConnectLoading) return - const newValue = !get().isAutoConnectEnabled - await updateSettingOptimistic( - 'autoConnect', - newValue, - 'isAutoConnectLoading', - 'isAutoConnectEnabled' - ) - }, - - toggleAutoPan: async () => { - if (get().isAutoPanLoading) return - const newValue = !get().isAutoPanEnabled - await updateSettingOptimistic( - 'autoPan', - newValue, - 'isAutoPanLoading', - 'isAutoPanEnabled' - ) - }, - - toggleConsoleExpandedByDefault: async () => { - if (get().isConsoleExpandedByDefaultLoading) return - const newValue = !get().isConsoleExpandedByDefault - await updateSettingOptimistic( - 'consoleExpandedByDefault', - newValue, - 'isConsoleExpandedByDefaultLoading', - 'isConsoleExpandedByDefault' - ) - }, - - toggleFloatingControls: async () => { - if (get().isFloatingControlsLoading) return - const newValue = !get().showFloatingControls - await updateSettingOptimistic( - 'showFloatingControls', - newValue, - 'isFloatingControlsLoading', - 'showFloatingControls' - ) - }, - - toggleTrainingControls: async () => { - if (get().isTrainingControlsLoading) return - const newValue = !get().showTrainingControls - await updateSettingOptimistic( - 'showTrainingControls', - newValue, - 'isTrainingControlsLoading', - 'showTrainingControls' - ) - }, - - toggleSuperUserMode: async () => { - if (get().isSuperUserModeLoading) return - const newValue = !get().superUserModeEnabled - await updateSettingOptimistic( - 'superUserModeEnabled', - newValue, - 'isSuperUserModeLoading', - 'superUserModeEnabled' - ) - }, - - // COMMENTED OUT: Theme switching disabled - dark mode is forced for workspace - setTheme: async (theme) => { - if (get().isThemeLoading) return - - // COMMENTED OUT: Dark mode is forced for workspace pages - // await withOptimisticUpdate({ - // getCurrentState: () => get().theme, - // optimisticUpdate: () => { - // set({ theme, isThemeLoading: true }) - // syncThemeToNextThemes(theme) - // }, - // apiCall: async () => { - // await get().updateSetting('theme', theme) - // }, - // rollback: (originalTheme) => { - // set({ theme: originalTheme }) - // syncThemeToNextThemes(originalTheme) - // }, - // onComplete: () => set({ isThemeLoading: false }), - // errorMessage: 'Failed to sync theme to database', - // }) - }, - - setTelemetryEnabled: async (enabled) => { - if (get().isTelemetryLoading) return - await updateSettingOptimistic( - 'telemetryEnabled', - enabled, - 'isTelemetryLoading', - 'telemetryEnabled' - ) - }, - - setBillingUsageNotificationsEnabled: async (enabled: boolean) => { - if (get().isBillingUsageNotificationsLoading) return - await updateSettingOptimistic( - 'isBillingUsageNotificationsEnabled', - enabled, - 'isBillingUsageNotificationsLoading', - 'isBillingUsageNotificationsEnabled' - ) - }, - - // API Actions - loadSettings: async (force = false) => { - // Skip if we've already loaded from DB and not forcing - if (hasLoadedFromDb && !force) { - logger.debug('Already loaded settings from DB, using cached data') - return - } - - // If we have persisted state and not forcing, check if we need to load - const persistedState = localStorage.getItem('general-settings') - if (persistedState && !force) { - try { - const parsed = JSON.parse(persistedState) - // If we have valid theme data, skip DB load unless forced - if (parsed.state?.theme) { - logger.debug('Using cached settings from localStorage') - hasLoadedFromDb = true // Mark as loaded to prevent future API calls - return - } - } catch (e) { - // If parsing fails, continue to load from DB - } - } - // Skip loading if on a chat path - if (typeof window !== 'undefined' && window.location.pathname.startsWith('/chat/')) { - logger.debug('Skipping settings load - on chat page') - return - } - - // Skip loading if settings were recently loaded (within 5 seconds) - const now = Date.now() - if (!force && now - lastLoadTime < CACHE_TIMEOUT) { - logger.debug('Skipping settings load - recently loaded') - return - } - - try { - set({ isLoading: true, error: null }) - - const response = await fetch('/api/users/me/settings') - - if (!response.ok) { - throw new Error('Failed to fetch settings') - } - - const { data } = await response.json() - - set({ - isAutoConnectEnabled: data.autoConnect, - isAutoPanEnabled: data.autoPan ?? true, - isConsoleExpandedByDefault: data.consoleExpandedByDefault ?? true, - showFloatingControls: data.showFloatingControls ?? true, - showTrainingControls: data.showTrainingControls ?? false, - superUserModeEnabled: data.superUserModeEnabled ?? true, - theme: data.theme || 'system', - telemetryEnabled: data.telemetryEnabled, - isBillingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true, - isLoading: false, - }) - - // COMMENTED OUT: Theme switching disabled - dark mode is forced for workspace - // // Sync theme to next-themes if it's different - // if (data.theme && typeof window !== 'undefined') { - // const currentTheme = localStorage.getItem('sim-theme') - // if (currentTheme !== data.theme) { - // syncThemeToNextThemes(data.theme) - // } - // } - - lastLoadTime = now - errorRetryCount = 0 - hasLoadedFromDb = true - } catch (error) { - logger.error('Error loading settings:', error) - set({ - error: error instanceof Error ? error.message : 'Unknown error', - isLoading: false, - }) - } - }, - - updateSetting: async (key, value) => { - if (typeof window !== 'undefined' && window.location.pathname.startsWith('/chat/')) { - logger.debug(`Skipping setting update for ${key} on chat page`) - return - } - - try { - const response = await fetch('/api/users/me/settings', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ [key]: value }), - }) - - if (!response.ok) { - throw new Error(`Failed to update setting: ${key}`) - } - - set({ error: null }) - lastLoadTime = Date.now() - errorRetryCount = 0 - } catch (error) { - logger.error(`Error updating setting ${key}:`, error) - set({ error: error instanceof Error ? error.message : 'Unknown error' }) - - // Don't auto-retry on individual setting updates to avoid conflicts - throw error - } - }, - } + (set) => ({ + ...initialState, + setSettings: (settings) => { + logger.debug('Updating general settings store', { + keys: Object.keys(settings), + }) + set((state) => ({ + ...state, + ...settings, + })) }, - { - name: 'general-settings', - } - ), + reset: () => set(initialState), + }), { name: 'general-store' } ) ) diff --git a/apps/sim/stores/settings/general/types.ts b/apps/sim/stores/settings/general/types.ts index 3d0f9c5ffb..1d98de2b61 100644 --- a/apps/sim/stores/settings/general/types.ts +++ b/apps/sim/stores/settings/general/types.ts @@ -7,36 +7,14 @@ export interface General { superUserModeEnabled: boolean theme: 'system' | 'light' | 'dark' telemetryEnabled: boolean - isLoading: boolean - error: string | null - isAutoConnectLoading: boolean - isAutoPanLoading: boolean - isConsoleExpandedByDefaultLoading: boolean - isThemeLoading: boolean - isTelemetryLoading: boolean - isBillingUsageNotificationsLoading: boolean isBillingUsageNotificationsEnabled: boolean - isFloatingControlsLoading: boolean - isTrainingControlsLoading: boolean - isSuperUserModeLoading: boolean } -export interface GeneralActions { - toggleAutoConnect: () => Promise - toggleAutoPan: () => Promise - toggleConsoleExpandedByDefault: () => Promise - toggleFloatingControls: () => Promise - toggleTrainingControls: () => Promise - toggleSuperUserMode: () => Promise - setTheme: (theme: 'system' | 'light' | 'dark') => Promise - setTelemetryEnabled: (enabled: boolean) => Promise - setBillingUsageNotificationsEnabled: (enabled: boolean) => Promise - loadSettings: (force?: boolean) => Promise - updateSetting: (key: K, value: UserSettings[K]) => Promise +export interface GeneralStore extends General { + setSettings: (settings: Partial) => void + reset: () => void } -export type GeneralStore = General & GeneralActions - export type UserSettings = { theme: 'system' | 'light' | 'dark' autoConnect: boolean