Added notifications and check notification history

This commit is contained in:
Emir Karabeg
2025-01-17 16:09:46 -08:00
parent 5c0ea3f6d8
commit 4c939880cc
23 changed files with 408 additions and 83 deletions
+54 -74
View File
@@ -26,6 +26,8 @@ import { initializeStateLogger } from '@/stores/workflow/state-logger'
import { Serializer } from '@/serializer'
import { Executor } from '@/executor'
import { BlockState, SubBlockState } from '@/stores/workflow/types'
import { NotificationList } from '@/app/w/components/notifications/notifications'
import { useNotificationStore } from '@/stores/notifications/notifications-store'
/**
* Represents the data structure for a workflow node
@@ -104,6 +106,7 @@ function WorkflowCanvas() {
} = useWorkflowStore()
const [isExecuting, setIsExecuting] = useState(false)
const [executionResult, setExecutionResult] = useState<any>(null)
const { addNotification } = useNotificationStore()
// Convert blocks to ReactFlow nodes
const nodes = Object.values(blocks).map((block) => ({
@@ -240,24 +243,26 @@ function WorkflowCanvas() {
* @throws {Error} If no initial block is found or block configuration is invalid
*/
const getInitialNode = () => {
const initialBlockId = Object.values(blocks).find(block =>
!edges.some(edge => edge.target === block.id)
)?.id;
const initialBlockId = Object.values(blocks).find(
(block) => !edges.some((edge) => edge.target === block.id)
)?.id
if (!initialBlockId) {
throw new Error('Could not determine the initial block in the workflow');
throw new Error('Could not determine the initial block in the workflow')
}
const blockConfig = getBlock(blocks[initialBlockId].type);
const blockConfig = getBlock(blocks[initialBlockId].type)
if (!blockConfig) {
throw new Error(`Block configuration not found for type: ${blocks[initialBlockId].type}`);
throw new Error(
`Block configuration not found for type: ${blocks[initialBlockId].type}`
)
}
return {
block: blocks[initialBlockId],
config: blockConfig
};
};
config: blockConfig,
}
}
/**
* Determines the initial input parameters based on the block type
@@ -272,18 +277,18 @@ function WorkflowCanvas() {
systemPrompt: block.subBlocks?.['systemPrompt']?.value,
temperature: block.subBlocks?.['temperature']?.value,
apiKey: block.subBlocks?.['apiKey']?.value,
prompt: block.subBlocks?.['systemPrompt']?.value
};
prompt: block.subBlocks?.['systemPrompt']?.value,
}
} else if (block.type === 'api') {
return {
url: block.subBlocks?.['url']?.value,
method: block.subBlocks?.['method']?.value,
headers: block.subBlocks?.['headers']?.value,
body: block.subBlocks?.['body']?.value
};
body: block.subBlocks?.['body']?.value,
}
}
return {};
};
return {}
}
/**
* Serializes a block into the format expected by the executor
@@ -292,23 +297,23 @@ function WorkflowCanvas() {
* @throws {Error} If block configuration or tools are not properly defined
*/
const serializeBlock = (block: BlockState) => {
const blockConfig = getBlock(block.type);
const blockConfig = getBlock(block.type)
if (!blockConfig) {
throw new Error(`Block configuration not found for type: ${block.type}`);
throw new Error(`Block configuration not found for type: ${block.type}`)
}
const tools = blockConfig.workflow.tools;
const tools = blockConfig.workflow.tools
if (!tools || !tools.access || tools.access.length === 0) {
throw new Error(`No tools specified for block type: ${block.type}`);
throw new Error(`No tools specified for block type: ${block.type}`)
}
// Get the values from subBlocks
const params: Record<string, any> = {};
const params: Record<string, any> = {}
Object.entries(block.subBlocks || {}).forEach(([id, subBlock]) => {
if (subBlock) {
params[id] = subBlock.value;
params[id] = subBlock.value
}
});
})
return {
id: block.id,
@@ -320,15 +325,15 @@ function WorkflowCanvas() {
interface: {
inputs: block.type === 'agent' ? { prompt: 'string' } : {},
outputs: {
[block.type === 'agent' ? 'response' : 'output']:
typeof blockConfig.workflow.outputType === 'string'
? blockConfig.workflow.outputType
: blockConfig.workflow.outputType.default
}
}
[block.type === 'agent' ? 'response' : 'output']:
typeof blockConfig.workflow.outputType === 'string'
? blockConfig.workflow.outputType
: blockConfig.workflow.outputType.default,
},
},
},
};
};
}
}
/**
* Handles the execution of the workflow
@@ -340,19 +345,20 @@ function WorkflowCanvas() {
setExecutionResult(null)
// 1. Get initial node
const { block: initialBlock, config: initialBlockConfig } = getInitialNode();
const { block: initialBlock, config: initialBlockConfig } =
getInitialNode()
// 2. Serialize the workflow
const serializer = new Serializer()
const serializedWorkflow = serializer.serializeWorkflow(
Object.values(blocks).map(serializeBlock),
edges
);
)
// 3. Create executor and run workflow
const executor = new Executor(serializedWorkflow)
const initialInput = getInitialInput(initialBlock, initialBlockConfig);
const initialInput = getInitialInput(initialBlock, initialBlockConfig)
const result = await executor.execute(
window.location.pathname.split('/').pop() || 'workflow',
initialInput
@@ -360,63 +366,37 @@ function WorkflowCanvas() {
// 4. Handle result
setExecutionResult(result)
if (result.success) {
console.log('Workflow executed successfully:', result.data)
addNotification('console', 'Workflow completed successfully')
} else {
console.error('Workflow execution failed:', result.error)
addNotification('error', `Failed to execute workflow: ${result.error}`)
}
} catch (error) {
} catch (error: any) {
console.error('Error executing workflow:', error)
setExecutionResult({
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
error:
error instanceof Error ? error.message : 'Unknown error occurred',
})
addNotification('error', `Failed to execute workflow: ${error.message}`)
} finally {
setIsExecuting(false)
}
};
}
const testNotification = () => {
addNotification('console', 'Response: hey')
}
return (
<div className="relative w-full h-[calc(100vh-56px)]">
<NotificationList />
<style>{keyframeStyles}</style>
{/* Run Button */}
<div className="absolute top-4 right-4 z-10">
<button
onClick={handleRunWorkflow}
disabled={isExecuting || Object.keys(blocks).length === 0}
className={`px-4 py-2 rounded-md text-white ${
isExecuting
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600'
}`}
>
{isExecuting ? 'Running...' : 'Run Workflow'}
</button>
</div>
{/* Execution Result */}
{executionResult && (
<div className={`absolute top-16 right-4 z-10 p-4 rounded-md ${
executionResult.success ? 'bg-green-100' : 'bg-red-100'
}`}>
{executionResult.success ? (
<div>
<h3 className="font-bold text-green-800">Success</h3>
<pre className="mt-2 text-sm">
{JSON.stringify(executionResult.data, null, 2)}
</pre>
</div>
) : (
<div>
<h3 className="font-bold text-red-800">Error</h3>
<p className="mt-2 text-sm text-red-600">{executionResult.error}</p>
</div>
)}
</div>
)}
<button onClick={testNotification}>Test Notification</button>
<ReactFlow
nodes={nodes}
edges={edges}
@@ -0,0 +1,59 @@
import { formatDistanceToNow } from 'date-fns'
import { Terminal, AlertCircle } from 'lucide-react'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import {
NotificationType,
useNotificationStore,
} from '@/stores/notifications/notifications-store'
import { cn } from '@/lib/utils'
import { ErrorIcon } from '@/components/icons'
interface NotificationDropdownItemProps {
id: string
type: NotificationType
message: string
timestamp: number
}
const NotificationIcon = {
error: ErrorIcon,
console: Terminal,
}
const NotificationColors = {
error: 'text-destructive',
console: 'text-foreground',
}
export function NotificationDropdownItem({
id,
type,
message,
timestamp,
}: NotificationDropdownItemProps) {
const { showNotification } = useNotificationStore()
const Icon = NotificationIcon[type]
const timeAgo = formatDistanceToNow(timestamp, { addSuffix: true })
// Truncate message if it's too long
const truncatedMessage =
message.length > 50 ? `${message.slice(0, 50)}...` : message
return (
<DropdownMenuItem
className="flex items-start gap-2 p-3 cursor-pointer"
onClick={() => showNotification(id)}
>
<Icon className={cn('h-4 w-4', NotificationColors[type])} />
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">
{type === 'error' ? 'Error' : 'Console'}
</span>
<span className="text-xs text-muted-foreground">{timeAgo}</span>
</div>
<p className="text-sm text-foreground">{truncatedMessage}</p>
</div>
</DropdownMenuItem>
)
}
+28 -3
View File
@@ -1,3 +1,5 @@
'use client'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -6,8 +8,12 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { History, Bell, Play } from 'lucide-react'
import { useNotificationStore } from '@/stores/notifications/notifications-store'
import { NotificationDropdownItem } from './components/notification-dropdown-item'
export function ControlBar() {
const { notifications } = useNotificationStore()
return (
<div className="flex h-16 w-full items-center justify-between bg-background px-6 border-b">
{/* Left Section - Workflow Info */}
@@ -41,9 +47,28 @@ export function ControlBar() {
<span className="sr-only">Notifications</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>No new notifications</DropdownMenuItem>
</DropdownMenuContent>
{notifications.length === 0 ? (
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem className="text-sm text-muted-foreground">
No new notifications
</DropdownMenuItem>
</DropdownMenuContent>
) : (
<DropdownMenuContent align="end" className="w-60">
{[...notifications]
.sort((a, b) => b.timestamp - a.timestamp)
.map((notification) => (
<NotificationDropdownItem
id={notification.id}
key={notification.id}
type={notification.type}
message={notification.message}
timestamp={notification.timestamp}
/>
))}
</DropdownMenuContent>
)}
</DropdownMenu>
<Button className="gap-2 bg-[#7F2FFF] hover:bg-[#7F2FFF]/90">
@@ -0,0 +1,100 @@
import { useEffect, useState } from 'react'
import { AlertCircle, Terminal } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { useNotificationStore } from '@/stores/notifications/notifications-store'
import { cn } from '@/lib/utils'
import { ErrorIcon } from '@/components/icons'
const NOTIFICATION_TIMEOUT = 3000
const FADE_DURATION = 300
const NotificationIcon = {
error: ErrorIcon,
console: Terminal,
}
const NotificationColors = {
error:
'border-red-500 bg-red-50 text-destructive dark:border-border dark:text-foreground dark:bg-background',
console:
'border-border bg-background text-foreground dark:border-border dark:text-foreground dark:bg-background',
}
export function NotificationList() {
const { notifications, hideNotification } = useNotificationStore()
const [fadingNotifications, setFadingNotifications] = useState<Set<string>>(
new Set()
)
// Only show visible notifications in the display
const visibleNotifications = notifications.filter((n) => n.isVisible)
useEffect(() => {
notifications.forEach((notification) => {
if (!notification.isVisible) return
// Start fade out
const fadeTimer = setTimeout(() => {
setFadingNotifications((prev) => new Set([...prev, notification.id]))
}, NOTIFICATION_TIMEOUT)
// Hide notification after fade
const hideTimer = setTimeout(() => {
hideNotification(notification.id)
setFadingNotifications((prev) => {
const next = new Set(prev)
next.delete(notification.id)
return next
})
}, NOTIFICATION_TIMEOUT + FADE_DURATION)
return () => {
clearTimeout(fadeTimer)
clearTimeout(hideTimer)
}
})
}, [notifications, hideNotification])
if (visibleNotifications.length === 0) return null
return (
<div
className="absolute left-1/2 z-50 space-y-2 max-w-md w-full"
style={{
top: '30px',
transform: 'translateX(-50%)',
}}
>
{visibleNotifications.map((notification) => {
const Icon = NotificationIcon[notification.type]
const isFading = fadingNotifications.has(notification.id)
return (
<Alert
key={notification.id}
className={cn(
'transition-all duration-300 ease-in-out opacity-0 translate-y-[-100%]',
isFading
? 'animate-notification-fade-out'
: 'animate-notification-slide',
NotificationColors[notification.type]
)}
>
<Icon
className={cn('h-4 w-4', {
'!text-red-500': notification.type === 'error',
'text-foreground': notification.type === 'console',
})}
/>
<AlertTitle className="ml-2">
{notification.type === 'error' ? 'Error' : 'Console'}
</AlertTitle>
<AlertDescription className="ml-2">
{notification.message}
</AlertDescription>
</Alert>
)
})}
</div>
)
}
+1 -1
View File
@@ -1,5 +1,5 @@
import Link from 'next/link'
import { NavItem } from './nav-item'
import { NavItem } from './components/nav-item'
import { Settings, Plus } from 'lucide-react'
import {
Tooltip,
+1 -1
View File
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { ToolbarTabs } from './toolbar-tabs'
import { ToolbarTabs } from './components/toolbar-tabs'
import { ToolbarBlock } from '../toolbar-block/toolbar-block'
import { getBlocksByCategory, BlockCategory } from '../../../../blocks'
@@ -1,4 +1,4 @@
import { SubBlockConfig } from '../../../../../blocks/types/block'
import { SubBlockConfig } from '../../../../../../blocks/types/block'
import { Label } from '@/components/ui/label'
import { ShortInput } from './components/short-input'
import { LongInput } from './components/long-input'
@@ -1,9 +1,9 @@
import { Card } from '@/components/ui/card'
import { BlockConfig, SubBlockConfig } from '../../../../blocks/types/block'
import { SubBlock } from './sub-block/sub-block'
import { SubBlock } from './components/sub-block/sub-block'
import { Handle, Position } from 'reactflow'
import { cn } from '@/lib/utils'
import { ActionBar } from './action-block/action-block'
import { ActionBar } from './components/action-block/action-block'
interface WorkflowBlockProps {
id: string
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
+11
View File
@@ -20,6 +20,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"lucide-react": "^0.469.0",
"next": "15.1.3",
"react": "^18.2.0",
@@ -4671,6 +4672,16 @@
"node": ">=12"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
+1
View File
@@ -23,6 +23,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"lucide-react": "^0.469.0",
"next": "15.1.3",
"react": "^18.2.0",
@@ -0,0 +1,59 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
export type NotificationType = 'error' | 'console'
export interface Notification {
id: string
type: NotificationType
message: string
timestamp: number
isVisible: boolean
}
interface NotificationStore {
notifications: Notification[]
addNotification: (type: NotificationType, message: string) => void
hideNotification: (id: string) => void
showNotification: (id: string) => void
removeNotification: (id: string) => void
clearNotifications: () => void
}
export const useNotificationStore = create<NotificationStore>()(
devtools(
(set) => ({
notifications: [],
addNotification: (type, message) => {
const notification: Notification = {
id: crypto.randomUUID(),
type,
message,
timestamp: Date.now(),
isVisible: true,
}
set((state) => ({
notifications: [...state.notifications, notification],
}))
},
hideNotification: (id) =>
set((state) => ({
notifications: state.notifications.map((n) =>
n.id === id ? { ...n, isVisible: false } : n
),
})),
showNotification: (id) =>
set((state) => ({
notifications: state.notifications.map((n) =>
n.id === id ? { ...n, isVisible: true } : n
),
})),
removeNotification: (id) =>
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
})),
clearNotifications: () => set({ notifications: [] }),
}),
{ name: 'notification-store' }
)
)
+32 -1
View File
@@ -55,7 +55,38 @@ export default {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
},
keyframes: {
'slide-down': {
'0%': { transform: 'translate(-50%, -100%)', opacity: '0' },
'100%': { transform: 'translate(-50%, 0)', opacity: '1' },
},
'notification-slide': {
'0%': {
opacity: '0',
transform: 'translateY(-100%)'
},
'100%': {
opacity: '1',
transform: 'translateY(0)'
},
},
'notification-fade-out': {
'0%': {
opacity: '1',
transform: 'translateY(0)'
},
'100%': {
opacity: '0',
transform: 'translateY(0)'
},
}
},
animation: {
'slide-down': 'slide-down 0.3s ease-out',
'notification-slide': 'notification-slide 0.3s ease-out forwards',
'notification-fade-out': 'notification-fade-out 0.2s ease-out forwards'
},
}
},
plugins: [require("tailwindcss-animate")],