From 578129c6e6fb93d79d6108d19c1127dbb043dd50 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 29 Jul 2025 17:10:06 -0700 Subject: [PATCH 01/19] fix(domain): fix telemetry endpoint, only add redirects for hosted version (#822) * fix(otel): change back telemetry endpoint * only add redirects for hosted version --------- Co-authored-by: waleedlatif --- apps/sim/app/api/telemetry/route.ts | 2 +- apps/sim/instrumentation-node.ts | 2 +- apps/sim/lib/telemetry.ts | 2 +- apps/sim/next.config.ts | 7 ++++++- apps/sim/telemetry.config.ts | 2 +- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/telemetry/route.ts b/apps/sim/app/api/telemetry/route.ts index a9738adcc96..5b7b44664fe 100644 --- a/apps/sim/app/api/telemetry/route.ts +++ b/apps/sim/app/api/telemetry/route.ts @@ -86,7 +86,7 @@ async function forwardToCollector(data: any): Promise { return false } - const endpoint = env.TELEMETRY_ENDPOINT || 'https://telemetry.sim.ai/v1/traces' + const endpoint = env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces' const timeout = DEFAULT_TIMEOUT try { diff --git a/apps/sim/instrumentation-node.ts b/apps/sim/instrumentation-node.ts index a1d074c8125..85d7251975e 100644 --- a/apps/sim/instrumentation-node.ts +++ b/apps/sim/instrumentation-node.ts @@ -13,7 +13,7 @@ const Sentry = isProd ? require('@sentry/nextjs') : { captureRequestError: () => const logger = createLogger('OtelInstrumentation') const DEFAULT_TELEMETRY_CONFIG = { - endpoint: env.TELEMETRY_ENDPOINT || 'https://telemetry.sim.ai/v1/traces', + endpoint: env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces', serviceName: 'sim-studio', serviceVersion: '0.1.0', serverSide: { enabled: true }, diff --git a/apps/sim/lib/telemetry.ts b/apps/sim/lib/telemetry.ts index 81b9d540ff2..c0480e4be72 100644 --- a/apps/sim/lib/telemetry.ts +++ b/apps/sim/lib/telemetry.ts @@ -30,7 +30,7 @@ export type TelemetryStatus = { const TELEMETRY_STATUS_KEY = 'simstudio-telemetry-status' let telemetryConfig = { - endpoint: env.TELEMETRY_ENDPOINT || 'https://telemetry.sim.ai/v1/traces', + endpoint: env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces', serviceName: 'sim-studio', serviceVersion: '0.1.0', } diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index b7897f7f9b6..b028826ed02 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -2,7 +2,7 @@ import path from 'path' import { withSentryConfig } from '@sentry/nextjs' import type { NextConfig } from 'next' import { env, isTruthy } from './lib/env' -import { isDev, isProd } from './lib/environment' +import { isDev, isHosted, isProd } from './lib/environment' import { getMainCSPPolicy, getWorkflowExecutionCSPPolicy } from './lib/security/csp' const nextConfig: NextConfig = { @@ -154,6 +154,11 @@ const nextConfig: NextConfig = { ] }, async redirects() { + // Only enable domain redirects for the hosted version + if (!isHosted) { + return [] + } + return [ { source: '/((?!api|_next|_vercel|favicon|static|.*\\..*).*)', diff --git a/apps/sim/telemetry.config.ts b/apps/sim/telemetry.config.ts index ba8316909d7..31c597860ff 100644 --- a/apps/sim/telemetry.config.ts +++ b/apps/sim/telemetry.config.ts @@ -29,7 +29,7 @@ const config = { * Endpoint URL where telemetry data is sent * Change this if you want to send telemetry to your own collector */ - endpoint: env.TELEMETRY_ENDPOINT || 'https://telemetry.sim.ai/v1/traces', + endpoint: env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces', /** * Service name used to identify this instance From b4faf08c2072db767e3c3b95b1a277b4bc68fef0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 29 Jul 2025 23:51:59 -0700 Subject: [PATCH 02/19] fix(search-modal): fixed search modal keyboard nav (#823) * fixed search modal keyboard nav * break down file --------- Co-authored-by: waleedlatif --- .../hooks/use-search-navigation.ts | 171 ++++ .../components/search-modal/search-modal.tsx | 768 ++++++++---------- 2 files changed, 516 insertions(+), 423 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/search-modal/hooks/use-search-navigation.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/hooks/use-search-navigation.ts b/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/hooks/use-search-navigation.ts new file mode 100644 index 00000000000..2249e3591ab --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/hooks/use-search-navigation.ts @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { NavigationPosition, NavigationSection } from '../search-modal' + +export function useSearchNavigation(sections: NavigationSection[], open: boolean) { + const [position, setPosition] = useState({ sectionIndex: 0, itemIndex: 0 }) + const scrollRefs = useRef>(new Map()) + const lastItemIndex = useRef>(new Map()) + + useEffect(() => { + if (open) { + setPosition({ sectionIndex: 0, itemIndex: 0 }) + } + }, [open, sections]) + + const getCurrentItem = useCallback(() => { + const section = sections[position.sectionIndex] + if (!section || position.itemIndex >= section.items.length) return null + + return { + section, + item: section.items[position.itemIndex], + position, + } + }, [sections, position]) + + const navigate = useCallback( + (direction: 'up' | 'down' | 'left' | 'right') => { + setPosition((prev) => { + const section = sections[prev.sectionIndex] + if (!section) return prev + + switch (direction) { + case 'down': + if (section.type === 'grid' && section.gridCols) { + const totalRows = section.id === 'templates' ? 1 : 2 + const currentCol = Math.floor(prev.itemIndex / totalRows) + const currentRow = prev.itemIndex % totalRows + + if (currentRow < totalRows - 1) { + const nextIndex = currentCol * totalRows + (currentRow + 1) + if (nextIndex < section.items.length) { + return { ...prev, itemIndex: nextIndex } + } + } + } else if (section.type === 'list') { + if (prev.itemIndex < section.items.length - 1) { + return { ...prev, itemIndex: prev.itemIndex + 1 } + } + } + if (prev.sectionIndex < sections.length - 1) { + const nextSection = sections[prev.sectionIndex + 1] + + lastItemIndex.current.set(section.id, prev.itemIndex) + + const rememberedIndex = lastItemIndex.current.get(nextSection.id) + const targetIndex = + rememberedIndex !== undefined + ? Math.min(rememberedIndex, nextSection.items.length - 1) + : 0 + + return { sectionIndex: prev.sectionIndex + 1, itemIndex: targetIndex } + } + return prev + + case 'up': + if (section.type === 'grid' && section.gridCols) { + const totalRows = section.id === 'templates' ? 1 : 2 + const currentCol = Math.floor(prev.itemIndex / totalRows) + const currentRow = prev.itemIndex % totalRows + + if (currentRow > 0) { + const prevIndex = currentCol * totalRows + (currentRow - 1) + return { ...prev, itemIndex: prevIndex } + } + } else if (section.type === 'list') { + if (prev.itemIndex > 0) { + return { ...prev, itemIndex: prev.itemIndex - 1 } + } + } + if (prev.sectionIndex > 0) { + const prevSection = sections[prev.sectionIndex - 1] + + lastItemIndex.current.set(section.id, prev.itemIndex) + + const rememberedIndex = lastItemIndex.current.get(prevSection.id) + const targetIndex = + rememberedIndex !== undefined + ? Math.min(rememberedIndex, prevSection.items.length - 1) + : prevSection.items.length - 1 + + return { sectionIndex: prev.sectionIndex - 1, itemIndex: targetIndex } + } + return prev + + case 'right': + if (section.type === 'grid' && section.gridCols) { + const totalRows = section.id === 'templates' ? 1 : 2 + const currentCol = Math.floor(prev.itemIndex / totalRows) + const currentRow = prev.itemIndex % totalRows + const totalCols = Math.ceil(section.items.length / totalRows) + + if (currentCol < totalCols - 1) { + const nextIndex = (currentCol + 1) * totalRows + currentRow + if (nextIndex < section.items.length) { + return { ...prev, itemIndex: nextIndex } + } + } + } else if (section.type === 'list') { + if (prev.itemIndex < section.items.length - 1) { + return { ...prev, itemIndex: prev.itemIndex + 1 } + } + } + return prev + + case 'left': + if (section.type === 'grid' && section.gridCols) { + const totalRows = section.id === 'templates' ? 1 : 2 + const currentCol = Math.floor(prev.itemIndex / totalRows) + const currentRow = prev.itemIndex % totalRows + + if (currentCol > 0) { + const prevIndex = (currentCol - 1) * totalRows + currentRow + return { ...prev, itemIndex: prevIndex } + } + } else if (section.type === 'list') { + if (prev.itemIndex > 0) { + return { ...prev, itemIndex: prev.itemIndex - 1 } + } + } + return prev + + default: + return prev + } + }) + }, + [sections] + ) + + const scrollIntoView = useCallback(() => { + const current = getCurrentItem() + if (!current) return + + const container = scrollRefs.current.get(current.section.id) + if (!container) return + + const itemSelector = `[data-nav-item="${current.section.id}-${current.position.itemIndex}"]` + const element = container.querySelector(itemSelector) as HTMLElement + if (!element) return + + element.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', + }) + }, [getCurrentItem]) + + useEffect(() => { + if (open) { + const timer = setTimeout(scrollIntoView, 10) + return () => clearTimeout(timer) + } + }, [position, open, scrollIntoView]) + + return { + position, + navigate, + getCurrentItem, + scrollRefs, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx index 11812463695..63633e4e046 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import * as DialogPrimitive from '@radix-ui/react-dialog' import * as VisuallyHidden from '@radix-ui/react-visually-hidden' import { BookOpen, Building2, LibraryBig, ScrollText, Search, Shapes, Workflow } from 'lucide-react' @@ -13,8 +13,9 @@ import { } from '@/app/workspace/[workspaceId]/templates/components/template-card' import { getKeyboardShortcutText } from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts' import { getAllBlocks } from '@/blocks' +import { useSearchNavigation } from './hooks/use-search-navigation' -interface SearchModalProps { +export interface SearchModalProps { open: boolean onOpenChange: (open: boolean) => void templates?: TemplateData[] @@ -24,7 +25,7 @@ interface SearchModalProps { isOnWorkflowPage?: boolean } -interface TemplateData { +export interface TemplateData { id: string title: string description: string @@ -39,21 +40,21 @@ interface TemplateData { isStarred?: boolean } -interface WorkflowItem { +export interface WorkflowItem { id: string name: string href: string isCurrent?: boolean } -interface WorkspaceItem { +export interface WorkspaceItem { id: string name: string href: string isCurrent?: boolean } -interface BlockItem { +export interface BlockItem { id: string name: string icon: React.ComponentType @@ -61,7 +62,7 @@ interface BlockItem { type: string } -interface ToolItem { +export interface ToolItem { id: string name: string icon: React.ComponentType @@ -69,7 +70,7 @@ interface ToolItem { type: string } -interface PageItem { +export interface PageItem { id: string name: string icon: React.ComponentType @@ -77,7 +78,7 @@ interface PageItem { shortcut?: string } -interface DocItem { +export interface DocItem { id: string name: string icon: React.ComponentType @@ -85,6 +86,19 @@ interface DocItem { type: 'main' | 'block' | 'tool' } +export interface NavigationPosition { + sectionIndex: number + itemIndex: number +} + +export interface NavigationSection { + id: string + name: string + type: 'grid' | 'list' + items: any[] + gridCols?: number // How many columns per row for grid sections +} + export function SearchModal({ open, onOpenChange, @@ -95,51 +109,16 @@ export function SearchModal({ isOnWorkflowPage = false, }: SearchModalProps) { const [searchQuery, setSearchQuery] = useState('') - const [selectedIndex, setSelectedIndex] = useState(0) const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string - // Local state for templates to handle star changes const [localTemplates, setLocalTemplates] = useState(templates) - // Update local templates when props change useEffect(() => { setLocalTemplates(templates) }, [templates]) - // Refs for synchronized scrolling - const blocksRow1Ref = useRef(null) - const blocksRow2Ref = useRef(null) - const toolsRow1Ref = useRef(null) - const toolsRow2Ref = useRef(null) - - // Synchronized scrolling functions - const handleBlocksRow1Scroll = useCallback(() => { - if (blocksRow1Ref.current && blocksRow2Ref.current) { - blocksRow2Ref.current.scrollLeft = blocksRow1Ref.current.scrollLeft - } - }, []) - - const handleBlocksRow2Scroll = useCallback(() => { - if (blocksRow1Ref.current && blocksRow2Ref.current) { - blocksRow1Ref.current.scrollLeft = blocksRow2Ref.current.scrollLeft - } - }, []) - - const handleToolsRow1Scroll = useCallback(() => { - if (toolsRow1Ref.current && toolsRow2Ref.current) { - toolsRow2Ref.current.scrollLeft = toolsRow1Ref.current.scrollLeft - } - }, []) - - const handleToolsRow2Scroll = useCallback(() => { - if (toolsRow1Ref.current && toolsRow2Ref.current) { - toolsRow1Ref.current.scrollLeft = toolsRow2Ref.current.scrollLeft - } - }, []) - - // Get all available blocks - only when on workflow page const blocks = useMemo(() => { if (!isOnWorkflowPage) return [] @@ -163,7 +142,6 @@ export function SearchModal({ .sort((a, b) => a.name.localeCompare(b.name)) }, [isOnWorkflowPage]) - // Get all available tools - only when on workflow page const tools = useMemo(() => { if (!isOnWorkflowPage) return [] @@ -182,7 +160,6 @@ export function SearchModal({ .sort((a, b) => a.name.localeCompare(b.name)) }, [isOnWorkflowPage]) - // Define pages const pages = useMemo( (): PageItem[] => [ { @@ -215,12 +192,10 @@ export function SearchModal({ [workspaceId] ) - // Define docs const docs = useMemo((): DocItem[] => { const allBlocks = getAllBlocks() const docsItems: DocItem[] = [] - // Add individual block/tool docs allBlocks.forEach((block) => { if (block.docsLink) { docsItems.push({ @@ -236,7 +211,6 @@ export function SearchModal({ return docsItems.sort((a, b) => a.name.localeCompare(b.name)) }, []) - // Filter all items based on search query const filteredBlocks = useMemo(() => { if (!searchQuery.trim()) return blocks const query = searchQuery.toLowerCase() @@ -285,71 +259,78 @@ export function SearchModal({ return docs.filter((doc) => doc.name.toLowerCase().includes(query)) }, [docs, searchQuery]) - // Create flattened list of navigatable items for keyboard navigation - const navigatableItems = useMemo(() => { - const items: Array<{ - type: 'workspace' | 'workflow' | 'page' | 'doc' - data: any - section: string - }> = [] - - // Add workspaces - filteredWorkspaces.forEach((workspace) => { - items.push({ type: 'workspace', data: workspace, section: 'Workspaces' }) - }) - - // Add workflows - filteredWorkflows.forEach((workflow) => { - items.push({ type: 'workflow', data: workflow, section: 'Workflows' }) - }) - - // Add pages - filteredPages.forEach((page) => { - items.push({ type: 'page', data: page, section: 'Pages' }) - }) + const navigationSections = useMemo((): NavigationSection[] => { + const sections: NavigationSection[] = [] - // Add docs - filteredDocs.forEach((doc) => { - items.push({ type: 'doc', data: doc, section: 'Docs' }) - }) - - return items - }, [filteredWorkspaces, filteredWorkflows, filteredPages, filteredDocs]) + if (filteredBlocks.length > 0) { + sections.push({ + id: 'blocks', + name: 'Blocks', + type: 'grid', + items: filteredBlocks, + gridCols: 4, // 4 items per row + }) + } - // Reset selected index when items change or modal opens - useEffect(() => { - setSelectedIndex(0) - }, [navigatableItems, open]) + if (filteredTools.length > 0) { + sections.push({ + id: 'tools', + name: 'Tools', + type: 'grid', + items: filteredTools, + gridCols: 4, // 4 items per row + }) + } - // Handle keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' && open) { - onOpenChange(false) - } + if (filteredTemplates.length > 0) { + sections.push({ + id: 'templates', + name: 'Templates', + type: 'grid', + items: filteredTemplates, + gridCols: 2, // 2 templates per row + }) } - if (open) { - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) + const listItems = [ + ...filteredWorkspaces.map((item) => ({ type: 'workspace', data: item })), + ...filteredWorkflows.map((item) => ({ type: 'workflow', data: item })), + ...filteredPages.map((item) => ({ type: 'page', data: item })), + ...filteredDocs.map((item) => ({ type: 'doc', data: item })), + ] + + if (listItems.length > 0) { + sections.push({ + id: 'list', + name: 'Navigation', + type: 'list', + items: listItems, + }) } - }, [open, onOpenChange]) - // Clear search when modal closes + return sections + }, [ + filteredBlocks, + filteredTools, + filteredTemplates, + filteredWorkspaces, + filteredWorkflows, + filteredPages, + filteredDocs, + ]) + + const { navigate, getCurrentItem, scrollRefs } = useSearchNavigation(navigationSections, open) + useEffect(() => { if (!open) { setSearchQuery('') } }, [open]) - // Handle block/tool click (same as toolbar interaction) const handleBlockClick = useCallback( (blockType: string) => { - // Dispatch a custom event to be caught by the workflow component const event = new CustomEvent('add-block-from-toolbar', { - detail: { - type: blockType, - }, + detail: { type: blockType }, }) window.dispatchEvent(event) onOpenChange(false) @@ -357,10 +338,8 @@ export function SearchModal({ [onOpenChange] ) - // Handle page navigation const handlePageClick = useCallback( (href: string) => { - // External links open in new tab if (href.startsWith('http')) { window.open(href, '_blank', 'noopener,noreferrer') } else { @@ -371,7 +350,6 @@ export function SearchModal({ [router, onOpenChange] ) - // Handle workflow/workspace navigation (same as page navigation) const handleNavigationClick = useCallback( (href: string) => { router.push(href) @@ -380,10 +358,8 @@ export function SearchModal({ [router, onOpenChange] ) - // Handle docs navigation const handleDocsClick = useCallback( (href: string) => { - // External links open in new tab if (href.startsWith('http')) { window.open(href, '_blank', 'noopener,noreferrer') } else { @@ -394,72 +370,17 @@ export function SearchModal({ [router, onOpenChange] ) - // Handle page navigation shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Only handle shortcuts when modal is open - if (!open) return - - const isMac = - typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0 - const isModifierPressed = isMac ? e.metaKey : e.ctrlKey - - // Check if this is one of our specific shortcuts - const isOurShortcut = - isModifierPressed && - e.shiftKey && - (e.key.toLowerCase() === 'l' || e.key.toLowerCase() === 'k') - - // Don't trigger other shortcuts if user is typing in the search input - // But allow our specific shortcuts to pass through - if (!isOurShortcut) { - const activeElement = document.activeElement - const isEditableElement = - activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement || - activeElement?.hasAttribute('contenteditable') - - if (isEditableElement) return - } - - if (isModifierPressed && e.shiftKey) { - // Command+Shift+L - Navigate to Logs - if (e.key.toLowerCase() === 'l') { - e.preventDefault() - handlePageClick(`/workspace/${workspaceId}/logs`) - } - // Command+Shift+K - Navigate to Knowledge - else if (e.key.toLowerCase() === 'k') { - e.preventDefault() - handlePageClick(`/workspace/${workspaceId}/knowledge`) - } - } - } + const handleItemSelection = useCallback(() => { + const current = getCurrentItem() + if (!current) return - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [open, handlePageClick, workspaceId]) + const { section, item } = current - // Handle template usage callback (closes modal after template is used) - const handleTemplateUsed = useCallback(() => { - onOpenChange(false) - }, [onOpenChange]) - - // Handle star change callback from template card - const handleStarChange = useCallback( - (templateId: string, isStarred: boolean, newStarCount: number) => { - setLocalTemplates((prevTemplates) => - prevTemplates.map((template) => - template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template - ) - ) - }, - [] - ) - - // Handle item selection based on type - const handleItemSelection = useCallback( - (item: (typeof navigatableItems)[0]) => { + if (section.id === 'blocks' || section.id === 'tools') { + handleBlockClick(item.type) + } else if (section.id === 'templates') { + onOpenChange(false) + } else if (section.id === 'list') { switch (item.type) { case 'workspace': if (item.data.isCurrent) { @@ -482,11 +403,16 @@ export function SearchModal({ handleDocsClick(item.data.href) break } - }, - [handleNavigationClick, handlePageClick, handleDocsClick, onOpenChange] - ) + } + }, [ + getCurrentItem, + handleBlockClick, + handleNavigationClick, + handlePageClick, + handleDocsClick, + onOpenChange, + ]) - // Handle keyboard navigation useEffect(() => { if (!open) return @@ -494,18 +420,23 @@ export function SearchModal({ switch (e.key) { case 'ArrowDown': e.preventDefault() - setSelectedIndex((prev) => Math.min(prev + 1, navigatableItems.length - 1)) + navigate('down') break case 'ArrowUp': e.preventDefault() - setSelectedIndex((prev) => Math.max(prev - 1, 0)) + navigate('up') + break + case 'ArrowRight': + e.preventDefault() + navigate('right') + break + case 'ArrowLeft': + e.preventDefault() + navigate('left') break case 'Enter': e.preventDefault() - if (navigatableItems.length > 0 && selectedIndex < navigatableItems.length) { - const selectedItem = navigatableItems[selectedIndex] - handleItemSelection(selectedItem) - } + handleItemSelection() break case 'Escape': onOpenChange(false) @@ -515,32 +446,27 @@ export function SearchModal({ document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [open, selectedIndex, navigatableItems, onOpenChange, handleItemSelection]) + }, [open, navigate, handleItemSelection, onOpenChange]) - // Helper function to check if an item is selected - const isItemSelected = useCallback( - (item: any, itemType: string) => { - if (navigatableItems.length === 0 || selectedIndex >= navigatableItems.length) return false - const selectedItem = navigatableItems[selectedIndex] - return selectedItem.type === itemType && selectedItem.data.id === item.id + const handleStarChange = useCallback( + (templateId: string, isStarred: boolean, newStarCount: number) => { + setLocalTemplates((prevTemplates) => + prevTemplates.map((template) => + template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template + ) + ) }, - [navigatableItems, selectedIndex] + [] ) - // Scroll selected item into view - useEffect(() => { - if (selectedIndex >= 0 && navigatableItems.length > 0) { - const selectedItem = navigatableItems[selectedIndex] - const itemElement = document.querySelector( - `[data-search-item="${selectedItem.type}-${selectedItem.data.id}"]` - ) - if (itemElement) { - itemElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) - } - } - }, [selectedIndex, navigatableItems]) + const isItemSelected = useCallback( + (sectionId: string, itemIndex: number) => { + const current = getCurrentItem() + return current?.section.id === sectionId && current.position.itemIndex === itemIndex + }, + [getCurrentItem] + ) - // Render skeleton cards for loading state const renderSkeletonCards = () => { return Array.from({ length: 8 }).map((_, index) => (
@@ -560,6 +486,7 @@ export function SearchModal({ Search + {/* Header with search input */}
@@ -584,61 +511,40 @@ export function SearchModal({

Blocks

-
- {/* First row */} +
{ + if (el) scrollRefs.current.set('blocks', el) + }} + className='scrollbar-none overflow-x-auto pr-6 pb-1 pl-6' + style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }} + >
- {filteredBlocks - .slice(0, Math.ceil(filteredBlocks.length / 2)) - .map((block) => ( - - ))} + +
+ + {block.name} + + + ))}
- {/* Second row */} - {filteredBlocks.length > Math.ceil(filteredBlocks.length / 2) && ( -
- {filteredBlocks.slice(Math.ceil(filteredBlocks.length / 2)).map((block) => ( - - ))} -
- )}
)} @@ -649,19 +555,27 @@ export function SearchModal({

Tools

-
- {/* First row */} +
{ + if (el) scrollRefs.current.set('tools', el) + }} + className='scrollbar-none overflow-x-auto pr-6 pb-1 pl-6' + style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }} + >
- {filteredTools.slice(0, Math.ceil(filteredTools.length / 2)).map((tool) => ( + {filteredTools.map((tool, index) => ( - ))} -
- )}
)} @@ -713,13 +600,22 @@ export function SearchModal({ Templates
{ + if (el) scrollRefs.current.set('templates', el) + }} className='scrollbar-none flex gap-4 overflow-x-auto pr-6 pb-1 pl-6' style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }} > {loading ? renderSkeletonCards() - : filteredTemplates.map((template) => ( -
+ : filteredTemplates.map((template, index) => ( +
onOpenChange(false)} onStarChange={handleStarChange} />
@@ -740,142 +636,168 @@ export function SearchModal({
)} - {/* Workspaces Section */} - {filteredWorkspaces.length > 0 && ( -
-

- Workspaces -

-
- {filteredWorkspaces.map((workspace) => ( - - ))} -
-
- )} - - {/* Workflows Section */} - {filteredWorkflows.length > 0 && ( -
-

- Workflows -

-
- {filteredWorkflows.map((workflow) => ( - - ))} -
-
- )} - - {/* Pages Section */} - {filteredPages.length > 0 && ( -
-

- Pages -

-
- {filteredPages.map((page) => ( - - ))} -
-
- )} - - {/* Docs Section */} - {filteredDocs.length > 0 && ( -
-

- Docs -

-
- {filteredDocs.map((doc) => ( - - ))} -
+ {/* List sections (Workspaces, Workflows, Pages, Docs) */} + {navigationSections.find((s) => s.id === 'list') && ( +
{ + if (el) scrollRefs.current.set('list', el) + }} + > + {/* Workspaces */} + {filteredWorkspaces.length > 0 && ( +
+

+ Workspaces +

+
+ {filteredWorkspaces.map((workspace, workspaceIndex) => { + const globalIndex = workspaceIndex + return ( + + ) + })} +
+
+ )} + + {/* Workflows */} + {filteredWorkflows.length > 0 && ( +
+

+ Workflows +

+
+ {filteredWorkflows.map((workflow, workflowIndex) => { + const globalIndex = filteredWorkspaces.length + workflowIndex + return ( + + ) + })} +
+
+ )} + + {/* Pages */} + {filteredPages.length > 0 && ( +
+

+ Pages +

+
+ {filteredPages.map((page, pageIndex) => { + const globalIndex = + filteredWorkspaces.length + filteredWorkflows.length + pageIndex + return ( + + ) + })} +
+
+ )} + + {/* Docs */} + {filteredDocs.length > 0 && ( +
+

+ Docs +

+
+ {filteredDocs.map((doc, docIndex) => { + const globalIndex = + filteredWorkspaces.length + + filteredWorkflows.length + + filteredPages.length + + docIndex + return ( + + ) + })} +
+
+ )}
)} From 27e49217cc1cb5bd246c3a0fe8a9c898f3e58ea5 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 30 Jul 2025 11:07:33 -0700 Subject: [PATCH 03/19] improvement(docs): add base exec charge info to docs (#826) --- apps/docs/content/docs/execution/advanced.mdx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/docs/execution/advanced.mdx b/apps/docs/content/docs/execution/advanced.mdx index d510b676cb8..a928211016e 100644 --- a/apps/docs/content/docs/execution/advanced.mdx +++ b/apps/docs/content/docs/execution/advanced.mdx @@ -136,12 +136,18 @@ Sim automatically calculates costs for all AI model usage: ### How Costs Are Calculated +Every workflow execution includes two cost components: + +**Base Execution Charge**: $0.001 per execution + +**AI Model Usage**: Variable cost based on token consumption ```javascript -cost = (inputTokens × inputPrice + outputTokens × outputPrice) / 1,000,000 +modelCost = (inputTokens × inputPrice + outputTokens × outputPrice) / 1,000,000 +totalCost = baseExecutionCharge + modelCost ``` - Prices are per million tokens. The calculation divides by 1,000,000 to get the actual cost. + AI model prices are per million tokens. The calculation divides by 1,000,000 to get the actual cost. Workflows without AI blocks only incur the base execution charge. ### Pricing Options From 1b929c72a5a3788a7a17f8e88b51aba0c6a551dc Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 30 Jul 2025 12:59:47 -0700 Subject: [PATCH 04/19] improvement(doc-tags-subblock): use table for doc tags subblock in create_document tool for KB (#827) * improvement(doc-tags-subblock): use table for doc tags create doc tool in KB block * enforce max tags * remove red warning text --- .../document-tag-entry/document-tag-entry.tsx | 488 ++++++++++++------ 1 file changed, 334 insertions(+), 154 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/document-tag-entry/document-tag-entry.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/document-tag-entry/document-tag-entry.tsx index 916b461bd68..95a253c0353 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/document-tag-entry/document-tag-entry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/document-tag-entry/document-tag-entry.tsx @@ -1,18 +1,29 @@ 'use client' -import { Plus, X } from 'lucide-react' +import { useMemo, useState } from 'react' +import { Plus, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { MAX_TAG_SLOTS } from '@/lib/constants/knowledge' +import { cn } from '@/lib/utils' import type { SubBlockConfig } from '@/blocks/types' import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions' import { useSubBlockValue } from '../../hooks/use-sub-block-value' -interface DocumentTag { +interface DocumentTagRow { id: string - tagName: string // This will be mapped to displayName for API - fieldType: string - value: string + cells: { + tagName: string + type: string + value: string + } } interface DocumentTagEntryProps { @@ -32,7 +43,7 @@ export function DocumentTagEntry({ previewValue, isConnecting = false, }: DocumentTagEntryProps) { - const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) + const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) // Get the knowledge base ID from other sub-blocks const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId') @@ -41,176 +52,345 @@ export function DocumentTagEntry({ // Use KB tag definitions hook to get available tags const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId) - // Parse the current value to extract tags - const parseTags = (tagValue: string): DocumentTag[] => { - if (!tagValue) return [] - try { - return JSON.parse(tagValue) - } catch { - return [] + // State for dropdown visibility - one for each row + const [dropdownStates, setDropdownStates] = useState>({}) + + // Use preview value when in preview mode, otherwise use store value + const currentValue = isPreview ? previewValue : storeValue + + // Transform stored JSON string to table format for display + const rows = useMemo(() => { + // If we have stored data, use it + if (currentValue) { + try { + const tagData = JSON.parse(currentValue) + if (Array.isArray(tagData) && tagData.length > 0) { + return tagData.map((tag: any, index: number) => ({ + id: `tag-${index}`, + cells: { + tagName: tag.tagName || '', + type: tag.fieldType || 'text', + value: tag.value || '', + }, + })) + } + } catch { + // If parsing fails, fall through to default + } } + + // Default: just one empty row + return [ + { + id: 'empty-row', + cells: { tagName: '', type: 'text', value: '' }, + }, + ] + }, [currentValue]) + + // Get available tag names and check for case-insensitive duplicates + const usedTagNames = new Set( + rows.map((row) => row.cells.tagName?.toLowerCase()).filter((name) => name && name.trim()) + ) + + const availableTagDefinitions = tagDefinitions.filter( + (def) => !usedTagNames.has(def.displayName.toLowerCase()) + ) + + // Check if we can add more tags based on MAX_TAG_SLOTS + const newTagsBeingCreated = rows.filter( + (row) => + row.cells.tagName?.trim() && + !tagDefinitions.some( + (def) => def.displayName.toLowerCase() === row.cells.tagName.toLowerCase() + ) + ).length + const canAddMoreTags = tagDefinitions.length + newTagsBeingCreated < MAX_TAG_SLOTS + + // Function to pre-fill existing tags + const handlePreFillTags = () => { + if (isPreview || disabled) return + + const existingTagRows = tagDefinitions.map((tagDef) => ({ + tagName: tagDef.displayName, + fieldType: tagDef.fieldType, + value: '', + })) + + const jsonString = existingTagRows.length > 0 ? JSON.stringify(existingTagRows) : '' + setStoreValue(jsonString) } - const currentValue = isPreview ? previewValue : storeValue - const tags = parseTags(currentValue || '') + const handleCellChange = (rowIndex: number, column: string, value: string) => { + if (isPreview || disabled) return + + // Check if this is a new tag name that would exceed the limit + if (column === 'tagName' && value.trim()) { + const isExistingTag = tagDefinitions.some( + (def) => def.displayName.toLowerCase() === value.toLowerCase() + ) - const updateTags = (newTags: DocumentTag[]) => { - if (isPreview) return - const value = newTags.length > 0 ? JSON.stringify(newTags) : null - setStoreValue(value) + if (!isExistingTag) { + // Count current new tags being created (excluding the current row) + const currentNewTags = rows.filter( + (row, idx) => + idx !== rowIndex && + row.cells.tagName?.trim() && + !tagDefinitions.some( + (def) => def.displayName.toLowerCase() === row.cells.tagName.toLowerCase() + ) + ).length + + if (tagDefinitions.length + currentNewTags >= MAX_TAG_SLOTS) { + // Don't allow creating new tags if we've reached the limit + return + } + } + } + + const updatedRows = [...rows].map((row, idx) => { + if (idx === rowIndex) { + const newCells = { ...row.cells, [column]: value } + + // Auto-select type when existing tag is selected + if (column === 'tagName' && value) { + const tagDef = tagDefinitions.find( + (def) => def.displayName.toLowerCase() === value.toLowerCase() + ) + if (tagDef) { + newCells.type = tagDef.fieldType + } + } + + return { + ...row, + cells: newCells, + } + } + return row + }) + + // No auto-add rows - user will manually add them with plus button + + // Store all rows including empty ones - don't auto-remove + const dataToStore = updatedRows.map((row) => ({ + tagName: row.cells.tagName || '', + fieldType: row.cells.type || 'text', + value: row.cells.value || '', + })) + + const jsonString = dataToStore.length > 0 ? JSON.stringify(dataToStore) : '' + setStoreValue(jsonString) } - const removeTag = (tagId: string) => { - updateTags(tags.filter((t) => t.id !== tagId)) + const handleAddRow = () => { + if (isPreview || disabled) return + + // Get current data and add a new empty row + const currentData = currentValue ? JSON.parse(currentValue) : [] + const newData = [...currentData, { tagName: '', fieldType: 'text', value: '' }] + setStoreValue(JSON.stringify(newData)) } - const updateTag = (tagId: string, updates: Partial) => { - updateTags(tags.map((tag) => (tag.id === tagId ? { ...tag, ...updates } : tag))) + const handleDeleteRow = (rowIndex: number) => { + if (isPreview || disabled || rows.length <= 1) return + const updatedRows = rows.filter((_, idx) => idx !== rowIndex) + + // Store all remaining rows including empty ones - don't auto-remove + const tableDataForStorage = updatedRows.map((row) => ({ + tagName: row.cells.tagName || '', + fieldType: row.cells.type || 'text', + value: row.cells.value || '', + })) + + const jsonString = tableDataForStorage.length > 0 ? JSON.stringify(tableDataForStorage) : '' + setStoreValue(jsonString) } - // Get available tag names that aren't already used - const usedTagNames = new Set(tags.map((tag) => tag.tagName).filter(Boolean)) - const availableTagNames = tagDefinitions - .map((def) => def.displayName) - .filter((name) => !usedTagNames.has(name)) + // Check for duplicate tag names (case-insensitive) + const getDuplicateStatus = (rowIndex: number, tagName: string) => { + if (!tagName.trim()) return false + const lowerTagName = tagName.toLowerCase() + return rows.some( + (row, idx) => + idx !== rowIndex && + row.cells.tagName?.toLowerCase() === lowerTagName && + row.cells.tagName.trim() + ) + } if (isLoading) { return
Loading tag definitions...
} - return ( -
- {/* Available Tags Section */} - {availableTagNames.length > 0 && ( -
-
- Available Tags (click to add) -
-
- {availableTagNames.map((tagName) => { - const tagDef = tagDefinitions.find((def) => def.displayName === tagName) - return ( - - ) - })} -
-
- )} + const renderHeader = () => ( + + + Tag Name + Type + Value + + + ) - {/* Selected Tags Section */} - {tags.length > 0 && ( -
-
- {tags.map((tag) => ( -
- {/* Tag Name */} -
-
- {tag.tagName || 'Unnamed Tag'} + const renderTagNameCell = (row: DocumentTagRow, rowIndex: number) => { + const cellValue = row.cells.tagName || '' + const isDuplicate = getDuplicateStatus(rowIndex, cellValue) + const showDropdown = dropdownStates[rowIndex] || false + + const setShowDropdown = (show: boolean) => { + setDropdownStates((prev) => ({ ...prev, [rowIndex]: show })) + } + + return ( + +
+ handleCellChange(rowIndex, 'tagName', e.target.value)} + onFocus={() => setShowDropdown(true)} + onBlur={() => setTimeout(() => setShowDropdown(false), 200)} + disabled={disabled || isConnecting} + className={cn(isDuplicate && 'border-red-500 bg-red-50')} + /> + {showDropdown && availableTagDefinitions.length > 0 && ( +
+ {availableTagDefinitions + .filter((tagDef) => + tagDef.displayName.toLowerCase().includes(cellValue.toLowerCase()) + ) + .map((tagDef) => ( +
{ + handleCellChange(rowIndex, 'tagName', tagDef.displayName) + setShowDropdown(false) + }} + > + {tagDef.displayName}
-
{tag.fieldType}
-
- - {/* Value Input */} -
- updateTag(tag.id, { value: e.target.value })} - placeholder='Value' - disabled={disabled || isConnecting} - className='h-9 placeholder:text-xs' - type={tag.fieldType === 'number' ? 'number' : 'text'} - /> -
- - {/* Remove Button */} - -
- ))} -
+ ))} +
+ )}
- )} + + ) + } - {/* Create New Tag Section */} -
-
Create New Tag
-
-
- = MAX_TAG_SLOTS ? '' : 'Tag name'} - disabled={disabled || isConnecting || tagDefinitions.length >= MAX_TAG_SLOTS} - className='h-9 border-0 bg-transparent p-0 placeholder:text-xs focus-visible:ring-0' - onKeyDown={(e) => { - if (e.key === 'Enter' && e.currentTarget.value.trim()) { - const tagName = e.currentTarget.value.trim() - - // Check for duplicates - if (usedTagNames.has(tagName)) { - // Visual feedback for duplicate - could add toast notification here - e.currentTarget.style.borderColor = '#ef4444' - setTimeout(() => { - e.currentTarget.style.borderColor = '' - }, 1000) - return - } - - const newTag: DocumentTag = { - id: Date.now().toString(), - tagName, - fieldType: 'text', - value: '', - } - updateTags([...tags, newTag]) - e.currentTarget.value = '' - } - }} - /> -
-
- {tagDefinitions.length >= MAX_TAG_SLOTS - ? `All ${MAX_TAG_SLOTS} tag slots used in this knowledge base` - : usedTagNames.size > 0 - ? 'Press Enter (no duplicates)' - : 'Press Enter to add'} -
+ const renderTypeCell = (row: DocumentTagRow, rowIndex: number) => { + const cellValue = row.cells.type || 'text' + const tagName = row.cells.tagName || '' + + // Check if this is an existing tag (should be read-only) + const existingTag = tagDefinitions.find( + (def) => def.displayName.toLowerCase() === tagName.toLowerCase() + ) + const isReadOnly = !!existingTag + + return ( + + + + ) + } + + const renderValueCell = (row: DocumentTagRow, rowIndex: number) => { + const cellValue = row.cells.value || '' + + return ( + + handleCellChange(rowIndex, 'value', e.target.value)} + disabled={disabled || isConnecting} + /> + + ) + } + + const renderDeleteButton = (rowIndex: number) => { + // Allow deletion of any row + const canDelete = !isPreview && !disabled + + return canDelete ? ( + + + + ) : null + } + + // Show pre-fill button if there are available tags and only empty rows + const showPreFillButton = + tagDefinitions.length > 0 && + rows.length === 1 && + !rows[0].cells.tagName && + !rows[0].cells.value && + !isPreview && + !disabled + + return ( +
+ {showPreFillButton && ( +
+
+ )} +
+ + {renderHeader()} + + {rows.map((row, rowIndex) => ( + + {renderTagNameCell(row, rowIndex)} + {renderTypeCell(row, rowIndex)} + {renderValueCell(row, rowIndex)} + {renderDeleteButton(rowIndex)} + + ))} + +
- {/* Empty State */} - {tags.length === 0 && availableTagNames.length === 0 && ( -
-
No tags available
-
Create a new tag above to get started
+ {/* Add Row Button */} + {!isPreview && !disabled && ( +
+ + + {/* Tag slots usage indicator */} +
+ {tagDefinitions.length + newTagsBeingCreated} of {MAX_TAG_SLOTS} tag slots used +
)}
From 12bb0b4589fd6ac173c969ef798720425e735da6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 30 Jul 2025 16:45:53 -0700 Subject: [PATCH 05/19] fix(bugs): fixed rb2b csp, fixed overly-verbose logs, fixed x URLs (#828) Co-authored-by: waleedlatif --- README.md | 2 +- .../(landing)/components/sections/footer.tsx | 4 ++-- .../document-tag-entry/document-tag-entry.tsx | 20 +++++++++---------- apps/sim/components/emails/footer.tsx | 2 +- apps/sim/lib/security/csp.ts | 1 + apps/sim/socket-server/database/operations.ts | 1 - 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 43e4d84c73e..f9855815e9f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

License: Apache-2.0 Discord - Twitter + Twitter PRs welcome Documentation

diff --git a/apps/sim/app/(landing)/components/sections/footer.tsx b/apps/sim/app/(landing)/components/sections/footer.tsx index 21d285487d2..c4a0ff6bbb7 100644 --- a/apps/sim/app/(landing)/components/sections/footer.tsx +++ b/apps/sim/app/(landing)/components/sections/footer.tsx @@ -159,7 +159,7 @@ function Footer() { row.cells.tagName?.toLowerCase()).filter((name) => name && name.trim()) + rows.map((row) => row.cells.tagName?.toLowerCase()).filter((name) => name?.trim()) ) const availableTagDefinitions = tagDefinitions.filter( @@ -226,8 +226,8 @@ export function DocumentTagEntry({ const renderHeader = () => ( - Tag Name - Type + Tag Name + Type Value @@ -243,7 +243,7 @@ export function DocumentTagEntry({ } return ( - +
{showDropdown && availableTagDefinitions.length > 0 && ( -
+
{availableTagDefinitions .filter((tagDef) => tagDef.displayName.toLowerCase().includes(cellValue.toLowerCase()) @@ -262,7 +262,7 @@ export function DocumentTagEntry({ .map((tagDef) => (
{ handleCellChange(rowIndex, 'tagName', tagDef.displayName) setShowDropdown(false) @@ -289,7 +289,7 @@ export function DocumentTagEntry({ const isReadOnly = !!existingTag return ( - + ;\nreturn input.toUpperCase();', - }, - }, - ], - } - - mockOpenAI.chat.completions.create.mockResolvedValueOnce(mockResponse) - - const req = createMockRequest('POST', { - prompt: 'Convert input to uppercase', - generationType: 'javascript-function-body', - }) - - const { POST } = await import('@/app/api/codegen/route') - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.generatedContent).toBe('const input = ;\nreturn input.toUpperCase();') - expect(mockOpenAI.chat.completions.create).toHaveBeenCalledWith({ - model: 'gpt-4o', - messages: expect.arrayContaining([ - expect.objectContaining({ role: 'system' }), - expect.objectContaining({ role: 'user' }), - ]), - temperature: 0.2, - max_tokens: 1500, - response_format: undefined, - }) - }) - - it('should generate custom tool schema successfully', async () => { - const mockResponse = { - choices: [ - { - message: { - content: JSON.stringify({ - type: 'function', - function: { - name: 'testFunction', - description: 'A test function', - parameters: { - type: 'object', - properties: { - input: { type: 'string', description: 'Test input' }, - }, - required: ['input'], - additionalProperties: false, - }, - }, - }), - }, - }, - ], - } - - mockOpenAI.chat.completions.create.mockResolvedValueOnce(mockResponse) - - const req = createMockRequest('POST', { - prompt: 'Create a custom tool for testing', - generationType: 'custom-tool-schema', - }) - - const { POST } = await import('@/app/api/codegen/route') - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.generatedContent).toBeDefined() - }) - - it('should include context in the prompt', async () => { - const mockResponse = { - choices: [ - { - message: { - content: 'const result = ;\nreturn result;', - }, - }, - ], - } - - mockOpenAI.chat.completions.create.mockResolvedValueOnce(mockResponse) - - const req = createMockRequest('POST', { - prompt: 'Modify this function', - generationType: 'javascript-function-body', - context: 'existing function code here', - }) - - const { POST } = await import('@/app/api/codegen/route') - - const response = await POST(req) - - expect(response.status).toBe(200) - expect(mockOpenAI.chat.completions.create).toHaveBeenCalledWith({ - model: 'gpt-4o', - messages: expect.arrayContaining([ - expect.objectContaining({ role: 'system' }), - expect.objectContaining({ - role: 'user', - content: - 'Prompt: Modify this function\\n\\nExisting Content/Context:\\nexisting function code here', - }), - ]), - temperature: 0.2, - max_tokens: 1500, - response_format: undefined, - }) - }) - - it('should include conversation history', async () => { - const mockResponse = { - choices: [ - { - message: { - content: 'Updated function code', - }, - }, - ], - } - - mockOpenAI.chat.completions.create.mockResolvedValueOnce(mockResponse) - - const req = createMockRequest('POST', { - prompt: 'Update the function', - generationType: 'javascript-function-body', - history: [ - { role: 'user', content: 'Create a function' }, - { role: 'assistant', content: 'function created' }, - ], - }) - - const { POST } = await import('@/app/api/codegen/route') - - const response = await POST(req) - - expect(response.status).toBe(200) - expect(mockOpenAI.chat.completions.create).toHaveBeenCalledWith({ - model: 'gpt-4o', - messages: expect.arrayContaining([ - expect.objectContaining({ role: 'system' }), - expect.objectContaining({ role: 'user', content: 'Create a function' }), - expect.objectContaining({ role: 'assistant', content: 'function created' }), - expect.objectContaining({ role: 'user', content: 'Update the function' }), - ]), - temperature: 0.2, - max_tokens: 1500, - response_format: undefined, - }) - }) - - it('should handle missing OpenAI API key', async () => { - mockEnv.OPENAI_API_KEY = '' - - const req = createMockRequest('POST', { - prompt: 'Test prompt', - generationType: 'json-schema', - }) - - const { POST } = await import('@/app/api/codegen/route') - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(503) - expect(data.success).toBe(false) - expect(data.error).toBe('Code generation service is not configured.') - }) - - it('should handle missing required fields', async () => { - const req = createMockRequest('POST', { - prompt: '', - }) - - const { POST } = await import('@/app/api/codegen/route') - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.success).toBe(false) - expect(data.error).toBe('Missing required fields: prompt and generationType.') - expect(mockLogger.warn).toHaveBeenCalled() - }) - - it('should handle invalid generation type', async () => { - const req = createMockRequest('POST', { - prompt: 'Test prompt', - generationType: 'invalid-type', - }) - - const { POST } = await import('@/app/api/codegen/route') - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data.success).toBe(false) - expect(data.error).toBe('Invalid generationType: invalid-type') - expect(mockLogger.warn).toHaveBeenCalled() - }) - - it('should handle empty OpenAI response', async () => { - const mockResponse = { - choices: [ - { - message: { - content: null, - }, - }, - ], - } - - mockOpenAI.chat.completions.create.mockResolvedValueOnce(mockResponse) - - const req = createMockRequest('POST', { - prompt: 'Test prompt', - generationType: 'javascript-function-body', - }) - - const { POST } = await import('@/app/api/codegen/route') - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.success).toBe(false) - expect(data.error).toBe('Failed to generate content. OpenAI response was empty.') - expect(mockLogger.error).toHaveBeenCalled() - }) - - it('should handle invalid JSON schema generation', async () => { - const mockResponse = { - choices: [ - { - message: { - content: 'invalid json content', - }, - }, - ], - } - - mockOpenAI.chat.completions.create.mockResolvedValueOnce(mockResponse) - - const req = createMockRequest('POST', { - prompt: 'Create a schema', - generationType: 'json-schema', - }) - - const { POST } = await import('@/app/api/codegen/route') - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(500) - expect(data.success).toBe(false) - expect(data.error).toBe('Generated JSON schema was invalid.') - expect(mockLogger.error).toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/codegen/route.ts b/apps/sim/app/api/codegen/route.ts deleted file mode 100644 index 8fd3eec1a2d..00000000000 --- a/apps/sim/app/api/codegen/route.ts +++ /dev/null @@ -1,535 +0,0 @@ -import { unstable_noStore as noStore } from 'next/cache' -import { type NextRequest, NextResponse } from 'next/server' -import OpenAI from 'openai' -import { env } from '@/lib/env' -import { createLogger } from '@/lib/logs/console/logger' - -export const dynamic = 'force-dynamic' -export const runtime = 'edge' -export const maxDuration = 60 - -const logger = createLogger('GenerateCodeAPI') - -const openai = env.OPENAI_API_KEY - ? new OpenAI({ - apiKey: env.OPENAI_API_KEY, - }) - : null - -if (!env.OPENAI_API_KEY) { - logger.warn('OPENAI_API_KEY not found. Code generation API will not function.') -} - -type GenerationType = - | 'json-schema' - | 'javascript-function-body' - | 'typescript-function-body' - | 'custom-tool-schema' - | 'json-object' - -// Define the structure for a single message in the history -interface ChatMessage { - role: 'user' | 'assistant' | 'system' // System role might be needed if we include the initial system prompt in history - content: string -} - -interface RequestBody { - prompt: string - generationType: GenerationType - context?: string - stream?: boolean - history?: ChatMessage[] // Optional conversation history -} - -const systemPrompts: Record = { - 'json-schema': `You are an expert programmer specializing in creating JSON schemas according to a specific format. -Generate ONLY the JSON schema based on the user's request. -The output MUST be a single, valid JSON object, starting with { and ending with }. -The JSON object MUST have the following top-level properties: 'name' (string), 'description' (string), 'strict' (boolean, usually true), and 'schema' (object). -The 'schema' object must define the structure and MUST contain 'type': 'object', 'properties': {...}, 'additionalProperties': false, and 'required': [...]. -Inside 'properties', use standard JSON Schema properties (type, description, enum, items for arrays, etc.). -Do not include any explanations, markdown formatting, or other text outside the JSON object. - -Valid Schema Examples: - -Example 1: -{ - "name": "reddit_post", - "description": "Fetches the reddit posts in the given subreddit", - "strict": true, - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "The title of the post" - }, - "content": { - "type": "string", - "description": "The content of the post" - } - }, - "additionalProperties": false, - "required": [ "title", "content" ] - } -} - -Example 2: -{ - "name": "get_weather", - "description": "Fetches the current weather for a specific location.", - "strict": true, - "schema": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "The city and state, e.g., San Francisco, CA" - }, - "unit": { - "type": "string", - "description": "Temperature unit", - "enum": ["celsius", "fahrenheit"] - } - }, - "additionalProperties": false, - "required": ["location", "unit"] - } -} - -Example 3 (Array Input): -{ - "name": "process_items", - "description": "Processes a list of items with specific IDs.", - "strict": true, - "schema": { - "type": "object", - "properties": { - "item_ids": { - "type": "array", - "description": "A list of unique item identifiers to process.", - "items": { - "type": "string", - "description": "An item ID" - } - }, - "processing_mode": { - "type": "string", - "description": "The mode for processing", - "enum": ["fast", "thorough"] - } - }, - "additionalProperties": false, - "required": ["item_ids", "processing_mode"] - } -} -`, - 'custom-tool-schema': `You are an expert programmer specializing in creating OpenAI function calling format JSON schemas for custom tools. -Generate ONLY the JSON schema based on the user's request. -The output MUST be a single, valid JSON object, starting with { and ending with }. -The JSON schema MUST follow this specific format: -1. Top-level property "type" must be set to "function" -2. A "function" object containing: - - "name": A concise, camelCase name for the function - - "description": A clear description of what the function does - - "parameters": A JSON Schema object describing the function's parameters with: - - "type": "object" - - "properties": An object containing parameter definitions - - "required": An array of required parameter names - -Do not include any explanations, markdown formatting, or other text outside the JSON object. - -Valid Schema Examples: - -Example 1: -{ - "type": "function", - "function": { - "name": "getWeather", - "description": "Fetches the current weather for a specific location.", - "parameters": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "The city and state, e.g., San Francisco, CA" - }, - "unit": { - "type": "string", - "description": "Temperature unit", - "enum": ["celsius", "fahrenheit"] - } - }, - "required": ["location"], - "additionalProperties": false - } - } -} - -Example 2: -{ - "type": "function", - "function": { - "name": "addItemToOrder", - "description": "Add one quantity of a food item to the order.", - "parameters": { - "type": "object", - "properties": { - "itemName": { - "type": "string", - "description": "The name of the food item to add to order" - }, - "quantity": { - "type": "integer", - "description": "The quantity of the item to add", - "default": 1 - } - }, - "required": ["itemName"], - "additionalProperties": false - } - } -} - -Example 3 (Array Input): -{ - "type": "function", - "function": { - "name": "processItems", - "description": "Processes a list of items with specific IDs.", - "parameters": { - "type": "object", - "properties": { - "itemIds": { - "type": "array", - "description": "A list of unique item identifiers to process.", - "items": { - "type": "string", - "description": "An item ID" - } - }, - "processingMode": { - "type": "string", - "description": "The mode for processing", - "enum": ["fast", "thorough"] - } - }, - "required": ["itemIds"], - "additionalProperties": false - } - } -} -`, - 'javascript-function-body': `You are an expert JavaScript programmer. -Generate ONLY the raw body of a JavaScript function based on the user's request. -The code should be executable within an 'async function(params, environmentVariables) {...}' context. -- 'params' (object): Contains input parameters derived from the JSON schema. Access these directly using the parameter name wrapped in angle brackets, e.g., ''. Do NOT use 'params.paramName'. -- 'environmentVariables' (object): Contains environment variables. Reference these using the double curly brace syntax: '{{ENV_VAR_NAME}}'. Do NOT use 'environmentVariables.VAR_NAME' or env. - -IMPORTANT FORMATTING RULES: -1. Reference Environment Variables: Use the exact syntax {{VARIABLE_NAME}}. Do NOT wrap it in quotes (e.g., use 'apiKey = {{SERVICE_API_KEY}}' not 'apiKey = "{{SERVICE_API_KEY}}"'). Our system replaces these placeholders before execution. -2. Reference Input Parameters/Workflow Variables: Use the exact syntax . Do NOT wrap it in quotes (e.g., use 'userId = ;' not 'userId = "";'). This includes parameters defined in the block's schema and outputs from previous blocks. -3. Function Body ONLY: Do NOT include the function signature (e.g., 'async function myFunction() {' or the surrounding '}'). -4. Imports: Do NOT include import/require statements unless they are standard Node.js built-in modules (e.g., 'crypto', 'fs'). External libraries are not supported in this context. -5. Output: Ensure the code returns a value if the function is expected to produce output. Use 'return'. -6. Clarity: Write clean, readable code. -7. No Explanations: Do NOT include markdown formatting, comments explaining the rules, or any text other than the raw JavaScript code for the function body. - -Example Scenario: -User Prompt: "Fetch user data from an API. Use the User ID passed in as 'userId' and an API Key stored as the 'SERVICE_API_KEY' environment variable." - -Generated Code: -const userId = ; // Correct: Accessing input parameter without quotes -const apiKey = {{SERVICE_API_KEY}}; // Correct: Accessing environment variable without quotes -const url = \`https://api.example.com/users/\${userId}\`; - -try { - const response = await fetch(url, { - method: 'GET', - headers: { - 'Authorization': \`Bearer \${apiKey}\`, - 'Content-Type': 'application/json' - } - }); - - if (!response.ok) { - // Throwing an error will mark the block execution as failed - throw new Error(\`API request failed with status \${response.status}: \${await response.text()}\`); - } - - const data = await response.json(); - console.log('User data fetched successfully.'); // Optional: logging for debugging - return data; // Return the fetched data which becomes the block's output -} catch (error) { - console.error(\`Error fetching user data: \${error.message}\`); - // Re-throwing the error ensures the workflow knows this step failed. - throw error; -}`, - 'typescript-function-body': `You are an expert TypeScript programmer. -Generate ONLY the body of a TypeScript function based on the user's request. -The code should be executable within an async context. You have access to a 'params' object (typed as Record) containing input parameters and an 'environmentVariables' object (typed as Record) for env vars. -Do not include the function signature (e.g., 'async function myFunction(): Promise {'). -Do not include import/require statements unless absolutely necessary and they are standard Node.js modules. -Do not include markdown formatting or explanations. -Output only the raw TypeScript code. Use modern TypeScript features where appropriate. Do not use semicolons. -Example: -const userId = as string -const apiKey = {{SERVICE_API_KEY}} -const response = await fetch(\`https://api.example.com/users/\${userId}\`, { headers: { Authorization: \`Bearer \${apiKey}\` } }) -if (!response.ok) { - throw new Error(\`Failed to fetch user data: \${response.statusText}\`) -} -const data: unknown = await response.json() -// Add type checking/assertion if necessary -return data // Ensure you return a value if expected`, - - 'json-object': `You are an expert JSON programmer. -Generate ONLY the raw JSON object based on the user's request. -The output MUST be a single, valid JSON object, starting with { and ending with }. - -Do not include any explanations, markdown formatting, or other text outside the JSON object. - -You have access to the following variables you can use to generate the JSON body: -- 'params' (object): Contains input parameters derived from the JSON schema. Access these directly using the parameter name wrapped in angle brackets, e.g., ''. Do NOT use 'params.paramName'. -- 'environmentVariables' (object): Contains environment variables. Reference these using the double curly brace syntax: '{{ENV_VAR_NAME}}'. Do NOT use 'environmentVariables.VAR_NAME' or env. - -Example: -{ - "name": "", - "age": , - "success": true -} -`, -} - -export async function POST(req: NextRequest) { - const requestId = crypto.randomUUID().slice(0, 8) - logger.info(`[${requestId}] Received code generation request`) - - if (!openai) { - logger.error(`[${requestId}] OpenAI client not initialized. Missing API key.`) - return NextResponse.json( - { success: false, error: 'Code generation service is not configured.' }, - { status: 503 } - ) - } - - try { - const body = (await req.json()) as RequestBody - noStore() - - // Destructure history along with other fields - const { prompt, generationType, context, stream = false, history = [] } = body - - if (!prompt || !generationType) { - logger.warn(`[${requestId}] Invalid request: Missing prompt or generationType.`) - return NextResponse.json( - { success: false, error: 'Missing required fields: prompt and generationType.' }, - { status: 400 } - ) - } - - if (!systemPrompts[generationType]) { - logger.warn(`[${requestId}] Invalid generationType: ${generationType}`) - return NextResponse.json( - { success: false, error: `Invalid generationType: ${generationType}` }, - { status: 400 } - ) - } - - const systemPrompt = systemPrompts[generationType] - - // Construct the user message, potentially including context - const currentUserMessageContent = context - ? `Prompt: ${prompt}\\n\\nExisting Content/Context:\\n${context}` - : `${prompt}` // Keep it simple for follow-ups, context is in history - - // Prepare messages for OpenAI API - // Start with the system prompt - const messages: ChatMessage[] = [{ role: 'system', content: systemPrompt }] - - // Add previous messages from history - // Filter out any potential system messages from history if we always prepend a fresh one - messages.push(...history.filter((msg) => msg.role !== 'system')) - - // Add the current user prompt - messages.push({ role: 'user', content: currentUserMessageContent }) - - logger.debug(`[${requestId}] Calling OpenAI API`, { - generationType, - stream, - historyLength: history.length, - }) - - // For streaming responses - if (stream) { - try { - const streamCompletion = await openai?.chat.completions.create({ - model: 'gpt-4o', - messages: messages, - temperature: 0.2, - max_tokens: 1500, - stream: true, - }) - - // Use ReadableStream for Edge runtime - return new Response( - new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder() - let fullContent = generationType === 'json-schema' ? '' : undefined - - // Process each chunk - for await (const chunk of streamCompletion) { - const content = chunk.choices[0]?.delta?.content || '' - if (content) { - // Only append if fullContent is defined (i.e., for json-schema) - if (fullContent !== undefined) { - fullContent += content - } - - // Send the chunk to the client - controller.enqueue( - encoder.encode( - `${JSON.stringify({ - chunk: content, - done: false, - })}\n` - ) - ) - } - } - - // Check JSON validity for json-schema type when streaming is complete - if (generationType === 'json-schema' && fullContent) { - try { - JSON.parse(fullContent) - } catch (parseError: any) { - logger.error(`[${requestId}] Generated JSON schema is invalid`, { - error: parseError.message, - content: fullContent, - }) - - // Send error to client - controller.enqueue( - encoder.encode( - `${JSON.stringify({ - error: 'Generated JSON schema was invalid.', - done: true, - })}\n` - ) - ) - controller.close() - return - } - } - - // Send the final done message - controller.enqueue( - encoder.encode( - `${JSON.stringify({ - done: true, - ...(fullContent !== undefined && { fullContent: fullContent }), - })}\n` - ) - ) - controller.close() - logger.info(`[${requestId}] Code generation streaming completed`, { generationType }) - }, - }), - { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - }, - } - ) - } catch (error: any) { - logger.error(`[${requestId}] Streaming error`, { - error: error.message || 'Unknown error', - stack: error.stack, - }) - - return NextResponse.json( - { success: false, error: 'An error occurred during code generation streaming.' }, - { status: 500 } - ) - } - } - - // For non-streaming responses (original implementation) - const completion = await openai?.chat.completions.create({ - // Use non-null assertion - model: 'gpt-4o', - // Pass the constructed messages array - messages: messages, - temperature: 0.2, - max_tokens: 1500, - response_format: generationType === 'json-schema' ? { type: 'json_object' } : undefined, - }) - - const generatedContent = completion.choices[0]?.message?.content?.trim() - - if (!generatedContent) { - logger.error(`[${requestId}] OpenAI response was empty or invalid.`) - return NextResponse.json( - { success: false, error: 'Failed to generate content. OpenAI response was empty.' }, - { status: 500 } - ) - } - - logger.info(`[${requestId}] Code generation successful`, { generationType }) - - if (generationType === 'json-schema') { - try { - JSON.parse(generatedContent) - return NextResponse.json({ success: true, generatedContent }) - } catch (parseError: any) { - logger.error(`[${requestId}] Generated JSON schema is invalid`, { - error: parseError.message, - content: generatedContent, - }) - return NextResponse.json( - { success: false, error: 'Generated JSON schema was invalid.' }, - { status: 500 } - ) - } - } else { - return NextResponse.json({ success: true, generatedContent }) - } - } catch (error: any) { - logger.error(`[${requestId}] Code generation failed`, { - error: error.message || 'Unknown error', - stack: error.stack, - }) - - let clientErrorMessage = 'Code generation failed. Please try again later.' - // Keep original message for server logging - let serverErrorMessage = error.message || 'Unknown error' - - let status = 500 - if (error instanceof OpenAI.APIError) { - status = error.status || 500 - serverErrorMessage = error.message // Use specific API error for server logs - logger.error(`[${requestId}] OpenAI API Error: ${status} - ${serverErrorMessage}`) - // Optionally, customize client message based on status, but keep it generic - if (status === 401) { - clientErrorMessage = 'Authentication failed. Please check your API key configuration.' - } else if (status === 429) { - clientErrorMessage = 'Rate limit exceeded. Please try again later.' - } else if (status >= 500) { - clientErrorMessage = - 'The code generation service is currently unavailable. Please try again later.' - } - } - - return NextResponse.json( - { - success: false, - error: clientErrorMessage, - }, - { status } - ) - } -} diff --git a/apps/sim/app/api/wand-generate/route.ts b/apps/sim/app/api/wand-generate/route.ts new file mode 100644 index 00000000000..d7eeba5be08 --- /dev/null +++ b/apps/sim/app/api/wand-generate/route.ts @@ -0,0 +1,194 @@ +import { unstable_noStore as noStore } from 'next/cache' +import { type NextRequest, NextResponse } from 'next/server' +import OpenAI from 'openai' +import { env } from '@/lib/env' +import { createLogger } from '@/lib/logs/console/logger' + +export const dynamic = 'force-dynamic' +export const runtime = 'edge' +export const maxDuration = 60 + +const logger = createLogger('WandGenerateAPI') + +const openai = env.OPENAI_API_KEY + ? new OpenAI({ + apiKey: env.OPENAI_API_KEY, + }) + : null + +if (!env.OPENAI_API_KEY) { + logger.warn('OPENAI_API_KEY not found. Wand generation API will not function.') +} + +interface ChatMessage { + role: 'user' | 'assistant' | 'system' + content: string +} + +interface RequestBody { + prompt: string + systemPrompt?: string + stream?: boolean + history?: ChatMessage[] +} + +// The endpoint is now generic - system prompts come from wand configs + +export async function POST(req: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + logger.info(`[${requestId}] Received wand generation request`) + + if (!openai) { + logger.error(`[${requestId}] OpenAI client not initialized. Missing API key.`) + return NextResponse.json( + { success: false, error: 'Wand generation service is not configured.' }, + { status: 503 } + ) + } + + try { + noStore() + const body = (await req.json()) as RequestBody + + const { prompt, systemPrompt, stream = false, history = [] } = body + + if (!prompt) { + logger.warn(`[${requestId}] Invalid request: Missing prompt.`) + return NextResponse.json( + { success: false, error: 'Missing required field: prompt.' }, + { status: 400 } + ) + } + + // Use provided system prompt or default + const finalSystemPrompt = + systemPrompt || + 'You are a helpful AI assistant. Generate content exactly as requested by the user.' + + // Prepare messages for OpenAI API + const messages: ChatMessage[] = [{ role: 'system', content: finalSystemPrompt }] + + // Add previous messages from history + messages.push(...history.filter((msg) => msg.role !== 'system')) + + // Add the current user prompt + messages.push({ role: 'user', content: prompt }) + + logger.debug(`[${requestId}] Calling OpenAI API for wand generation`, { + stream, + historyLength: history.length, + }) + + // For streaming responses + if (stream) { + try { + const streamCompletion = await openai?.chat.completions.create({ + model: 'gpt-4o', + messages: messages, + temperature: 0.3, + max_tokens: 10000, + stream: true, + }) + + return new Response( + new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + + try { + for await (const chunk of streamCompletion) { + const content = chunk.choices[0]?.delta?.content || '' + if (content) { + // Use the same format as codegen API for consistency + controller.enqueue( + encoder.encode(`${JSON.stringify({ chunk: content, done: false })}\n`) + ) + } + } + + // Send completion signal + controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: '', done: true })}\n`)) + controller.close() + logger.info(`[${requestId}] Wand generation streaming completed`) + } catch (streamError: any) { + logger.error(`[${requestId}] Streaming error`, { error: streamError.message }) + controller.enqueue( + encoder.encode(`${JSON.stringify({ error: 'Streaming failed', done: true })}\n`) + ) + controller.close() + } + }, + }), + { + headers: { + 'Content-Type': 'text/plain', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + } + ) + } catch (error: any) { + logger.error(`[${requestId}] Streaming error`, { + error: error.message || 'Unknown error', + stack: error.stack, + }) + + return NextResponse.json( + { success: false, error: 'An error occurred during wand generation streaming.' }, + { status: 500 } + ) + } + } + + // For non-streaming responses + const completion = await openai?.chat.completions.create({ + model: 'gpt-4o', + messages: messages, + temperature: 0.3, + max_tokens: 10000, + }) + + const generatedContent = completion.choices[0]?.message?.content?.trim() + + if (!generatedContent) { + logger.error(`[${requestId}] OpenAI response was empty or invalid.`) + return NextResponse.json( + { success: false, error: 'Failed to generate content. OpenAI response was empty.' }, + { status: 500 } + ) + } + + logger.info(`[${requestId}] Wand generation successful`) + return NextResponse.json({ success: true, content: generatedContent }) + } catch (error: any) { + logger.error(`[${requestId}] Wand generation failed`, { + error: error.message || 'Unknown error', + stack: error.stack, + }) + + let clientErrorMessage = 'Wand generation failed. Please try again later.' + let status = 500 + + if (error instanceof OpenAI.APIError) { + status = error.status || 500 + logger.error(`[${requestId}] OpenAI API Error: ${status} - ${error.message}`) + + if (status === 401) { + clientErrorMessage = 'Authentication failed. Please check your API key configuration.' + } else if (status === 429) { + clientErrorMessage = 'Rate limit exceeded. Please try again later.' + } else if (status >= 500) { + clientErrorMessage = + 'The wand generation service is currently unavailable. Please try again later.' + } + } + + return NextResponse.json( + { + success: false, + error: clientErrorMessage, + }, + { status } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts index 201496f21d8..2f5d8aeb52a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts @@ -1,10 +1,10 @@ -export { CodePromptBar } from './code-prompt-bar/code-prompt-bar' export { ControlBar } from './control-bar/control-bar' export { ErrorBoundary } from './error/index' export { LoopNodeComponent } from './loop-node/loop-node' export { Panel } from './panel/panel' export { ParallelNodeComponent } from './parallel-node/parallel-node' export { SkeletonLoading } from './skeleton-loading/skeleton-loading' +export { WandPromptBar } from './wand-prompt-bar/wand-prompt-bar' export { WorkflowBlock } from './workflow-block/workflow-block' export { WorkflowEdge } from './workflow-edge/workflow-edge' export { WorkflowTextEditorModal } from './workflow-text-editor/workflow-text-editor-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/code-prompt-bar/code-prompt-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar.tsx similarity index 97% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/code-prompt-bar/code-prompt-bar.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar.tsx index e10606c9d76..0c1134ec1af 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/code-prompt-bar/code-prompt-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' -interface CodePromptBarProps { +interface WandPromptBarProps { isVisible: boolean isLoading: boolean isStreaming: boolean @@ -16,7 +16,7 @@ interface CodePromptBarProps { className?: string } -export function CodePromptBar({ +export function WandPromptBar({ isVisible, isLoading, isStreaming, @@ -24,9 +24,9 @@ export function CodePromptBar({ onSubmit, onCancel, onChange, - placeholder = 'Describe the JavaScript code to generate...', + placeholder = 'Describe what you want to generate...', className, -}: CodePromptBarProps) { +}: WandPromptBarProps) { const promptBarRef = useRef(null) const [isExiting, setIsExiting] = useState(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/code.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/code.tsx index ea784349b87..e1b5d823ac6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/code.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/code.tsx @@ -10,9 +10,10 @@ import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-drop import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' -import { CodePromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/code-prompt-bar/code-prompt-bar' +import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value' -import { useCodeGeneration } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-code-generation' +import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand' +import type { GenerationType } from '@/blocks/types' import { useSubBlockStore } from '@/stores/workflows/subblock/store' const logger = createLogger('Code') @@ -23,12 +24,19 @@ interface CodeProps { isConnecting: boolean placeholder?: string language?: 'javascript' | 'json' - generationType?: 'javascript-function-body' | 'json-schema' | 'json-object' + generationType?: GenerationType value?: string isPreview?: boolean previewValue?: string | null disabled?: boolean onValidationChange?: (isValid: boolean) => void + wandConfig: { + enabled: boolean + prompt: string + generationType?: GenerationType + placeholder?: string + maintainHistory?: boolean + } } if (typeof document !== 'undefined') { @@ -61,6 +69,7 @@ export function Code({ previewValue, disabled = false, onValidationChange, + wandConfig, }: CodeProps) { // Determine the AI prompt placeholder based on language const aiPromptPlaceholder = useMemo(() => { @@ -124,25 +133,27 @@ export function Code({ const handleGeneratedContentRef = useRef<(generatedCode: string) => void>(() => {}) const handleStreamChunkRef = useRef<(chunk: string) => void>(() => {}) - // AI Code Generation Hook - const { - isLoading: isAiLoading, - isStreaming: isAiStreaming, - generate: generateCode, - generateStream: generateCodeStream, - cancelGeneration, - isPromptVisible, - showPromptInline, - hidePromptInline, - promptInputValue, - updatePromptValue, - } = useCodeGeneration({ - generationType: generationType, - initialContext: code, - onGeneratedContent: (content: string) => handleGeneratedContentRef.current?.(content), - onStreamChunk: (chunk: string) => handleStreamChunkRef.current?.(chunk), - onStreamStart: () => handleStreamStartRef.current?.(), - }) + // AI Code Generation Hook - use new wand system + const wandHook = wandConfig?.enabled + ? useWand({ + wandConfig, + currentValue: code, + onStreamStart: () => handleStreamStartRef.current?.(), + onStreamChunk: (chunk: string) => handleStreamChunkRef.current?.(chunk), + onGeneratedContent: (content: string) => handleGeneratedContentRef.current?.(content), + }) + : null + + // Extract values from wand hook + const isAiLoading = wandHook?.isLoading || false + const isAiStreaming = wandHook?.isStreaming || false + const generateCodeStream = wandHook?.generateStream || (() => {}) + const isPromptVisible = wandHook?.isPromptVisible || false + const showPromptInline = wandHook?.showPromptInline || (() => {}) + const hidePromptInline = wandHook?.hidePromptInline || (() => {}) + const promptInputValue = wandHook?.promptInputValue || '' + const updatePromptValue = wandHook?.updatePromptValue || (() => {}) + const cancelGeneration = wandHook?.cancelGeneration || (() => {}) // State management - useSubBlockValue with explicit streaming control const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId, false, { @@ -156,30 +167,32 @@ export function Code({ // Use preview value when in preview mode, otherwise use store value or prop value const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue - // Define the handlers now that we have access to setStoreValue - handleStreamStartRef.current = () => { - setCode('') - // Streaming state is now controlled by isAiStreaming - } - - handleGeneratedContentRef.current = (generatedCode: string) => { - setCode(generatedCode) - if (!isPreview && !disabled) { - setStoreValue(generatedCode) - // Final value will be persisted when isAiStreaming becomes false + // Define the handlers in useEffect to avoid setState during render + useEffect(() => { + handleStreamStartRef.current = () => { + setCode('') + // Streaming state is now controlled by isAiStreaming } - } - handleStreamChunkRef.current = (chunk: string) => { - setCode((currentCode) => { - const newCode = currentCode + chunk + handleGeneratedContentRef.current = (generatedCode: string) => { + setCode(generatedCode) if (!isPreview && !disabled) { - // Update the value - it won't be persisted until streaming ends - setStoreValue(newCode) + setStoreValue(generatedCode) + // Final value will be persisted when isAiStreaming becomes false } - return newCode - }) - } + } + + handleStreamChunkRef.current = (chunk: string) => { + setCode((currentCode) => { + const newCode = currentCode + chunk + if (!isPreview && !disabled) { + // Update the value - it won't be persisted until streaming ends + setStoreValue(newCode) + } + return newCode + }) + } + }, [isPreview, disabled, setStoreValue]) // Effects useEffect(() => { @@ -352,15 +365,15 @@ export function Code({ return ( <> - generateCodeStream({ prompt, context: code })} + onSubmit={(prompt: string) => generateCodeStream({ prompt })} onCancel={isAiStreaming ? cancelGeneration : hidePromptInline} onChange={updatePromptValue} - placeholder={aiPromptPlaceholder} + placeholder={wandConfig?.placeholder || aiPromptPlaceholder} />
('') + + // Wand functionality (only if wandConfig is enabled) - define early to get streaming state + const wandHook = config.wandConfig?.enabled + ? useWand({ + wandConfig: config.wandConfig, + currentValue: localContent, + onStreamStart: () => { + // Clear the content when streaming starts + setLocalContent('') + }, + onStreamChunk: (chunk) => { + // Update local content with each chunk as it arrives + setLocalContent((current) => current + chunk) + }, + onGeneratedContent: (content) => { + // Final content update (fallback) + setLocalContent(content) + }, + }) + : null + + // State management - useSubBlockValue with explicit streaming control + const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId, false, { + debounceMs: 150, + isStreaming: wandHook?.isStreaming || false, // Use wand streaming state + onStreamingEnd: () => { + logger.debug('Wand streaming ended, value persisted', { blockId, subBlockId }) + }, + }) + const [showEnvVars, setShowEnvVars] = useState(false) const [showTags, setShowTags] = useState(false) const [searchTerm, setSearchTerm] = useState('') @@ -55,7 +89,29 @@ export function LongInput({ const containerRef = useRef(null) // Use preview value when in preview mode, otherwise use store value or prop value - const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue + const baseValue = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue + + // During streaming, use local content; otherwise use base value + const value = wandHook?.isStreaming ? localContent : baseValue + + // Sync local content with base value when not streaming + useEffect(() => { + if (!wandHook?.isStreaming) { + const baseValueString = baseValue?.toString() ?? '' + if (baseValueString !== localContent) { + setLocalContent(baseValueString) + } + } + }, [baseValue, wandHook?.isStreaming]) // Removed localContent to prevent infinite loop + + // Update store value during streaming (but won't persist until streaming ends) + useEffect(() => { + if (wandHook?.isStreaming && localContent !== '') { + if (!isPreview && !disabled) { + setStoreValue(localContent) + } + } + }, [localContent, wandHook?.isStreaming, isPreview, disabled, setStoreValue]) // Calculate initial height based on rows prop with reasonable defaults const getInitialHeight = () => { @@ -83,12 +139,15 @@ export function LongInput({ // Handle input changes const handleChange = (e: React.ChangeEvent) => { - // Don't allow changes if disabled - if (disabled) return + // Don't allow changes if disabled or streaming + if (disabled || wandHook?.isStreaming) return const newValue = e.target.value const newCursorPosition = e.target.selectionStart ?? 0 + // Update local content immediately + setLocalContent(newValue) + if (onChange) { onChange(newValue) } else if (!isPreview) { @@ -190,7 +249,12 @@ export function LongInput({ // Update all state in a single batch Promise.resolve().then(() => { - if (!isPreview) { + // Update local content immediately + setLocalContent(newValue) + + if (onChange) { + onChange(newValue) + } else if (!isPreview) { setStoreValue(newValue) } setCursorPosition(dropPosition + 1) @@ -270,96 +334,130 @@ export function LongInput({ } return ( -
-