mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Comment instead of ff
This commit is contained in:
@@ -1291,6 +1291,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
{renderDebugModeToggle()}
|
||||
<ImportControls disabled={!userPermissions.canEdit} />
|
||||
<ExportControls disabled={!userPermissions.canRead} />
|
||||
{/* <WorkflowTextEditorModal disabled={!userPermissions.canEdit} /> */}
|
||||
{/* {renderPublishButton()} */}
|
||||
{renderDeployButton()}
|
||||
{renderRunButton()}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { Chat } from './components/chat/chat'
|
||||
import { ChatModal } from './components/chat/components/chat-modal/chat-modal'
|
||||
import { Console } from './components/console/console'
|
||||
import { Copilot } from './components/copilot/copilot'
|
||||
import { Variables } from './components/variables/variables'
|
||||
|
||||
export function Panel() {
|
||||
@@ -120,7 +119,7 @@ export function Panel() {
|
||||
>
|
||||
Variables
|
||||
</button>
|
||||
<button
|
||||
{/* <button
|
||||
onClick={() => setActiveTab('copilot')}
|
||||
className={`rounded-md px-3 py-1 text-sm transition-colors ${
|
||||
activeTab === 'copilot'
|
||||
@@ -129,19 +128,20 @@ export function Panel() {
|
||||
}`}
|
||||
>
|
||||
Copilot
|
||||
</button>
|
||||
</button> */}
|
||||
</div>
|
||||
|
||||
{(activeTab === 'console' || activeTab === 'chat' || activeTab === 'copilot') && (
|
||||
{(activeTab === 'console' || activeTab === 'chat' /* || activeTab === 'copilot' */) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeTab === 'console') {
|
||||
clearConsole(activeWorkflowId)
|
||||
} else if (activeTab === 'chat') {
|
||||
clearChat(activeWorkflowId)
|
||||
} else if (activeTab === 'copilot') {
|
||||
copilotRef.current?.clearMessages()
|
||||
}
|
||||
// else if (activeTab === 'copilot') {
|
||||
// copilotRef.current?.clearMessages()
|
||||
// }
|
||||
}}
|
||||
className='rounded-md px-3 py-1 text-muted-foreground text-sm transition-colors hover:bg-accent/50 hover:text-foreground'
|
||||
>
|
||||
@@ -156,7 +156,8 @@ export function Panel() {
|
||||
<Chat panelWidth={width} chatMessage={chatMessage} setChatMessage={setChatMessage} />
|
||||
) : activeTab === 'console' ? (
|
||||
<Console panelWidth={width} />
|
||||
) : activeTab === 'copilot' ? (
|
||||
) : (
|
||||
/* activeTab === 'copilot' ? (
|
||||
<Copilot
|
||||
ref={copilotRef}
|
||||
panelWidth={width}
|
||||
@@ -165,8 +166,7 @@ export function Panel() {
|
||||
fullscreenInput={copilotMessage}
|
||||
onFullscreenInputChange={setCopilotMessage}
|
||||
/>
|
||||
) : (
|
||||
<Variables panelWidth={width} />
|
||||
) : */ <Variables panelWidth={width} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -200,7 +200,7 @@ export function Panel() {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{activeTab === 'copilot' && (
|
||||
{/* activeTab === 'copilot' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -213,7 +213,7 @@ export function Panel() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='left'>Expand Copilot</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
) */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { importWorkflowFromYaml } from '@/stores/workflows/yaml/importer'
|
||||
import type { EditorFormat } from './workflow-text-editor'
|
||||
|
||||
const logger = createLogger('WorkflowApplier')
|
||||
|
||||
export interface ApplyResult {
|
||||
success: boolean
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
appliedOperations: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply workflow changes by using the existing importer for YAML
|
||||
* or direct state replacement for JSON
|
||||
*/
|
||||
export async function applyWorkflowDiff(
|
||||
content: string,
|
||||
format: EditorFormat
|
||||
): Promise<ApplyResult> {
|
||||
try {
|
||||
const { activeWorkflowId } = useWorkflowRegistry.getState()
|
||||
|
||||
if (!activeWorkflowId) {
|
||||
return {
|
||||
success: false,
|
||||
errors: ['No active workflow found'],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
if (format === 'yaml') {
|
||||
// Use the existing YAML importer which handles ID mapping and complete state replacement
|
||||
const workflowActions = {
|
||||
addBlock: () => {}, // Not used in this path
|
||||
addEdge: () => {}, // Not used in this path
|
||||
applyAutoLayout: () => {
|
||||
// Trigger auto layout after import
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('trigger-auto-layout'))
|
||||
}, 100)
|
||||
},
|
||||
setSubBlockValue: () => {}, // Not used in this path
|
||||
getExistingBlocks: () => useWorkflowStore.getState().blocks,
|
||||
}
|
||||
|
||||
const result = await importWorkflowFromYaml(content, workflowActions)
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
errors: result.errors,
|
||||
warnings: result.warnings,
|
||||
appliedOperations: result.success ? 1 : 0, // One complete import operation
|
||||
}
|
||||
}
|
||||
// Handle JSON format - complete state replacement
|
||||
let parsedData: any
|
||||
try {
|
||||
parsedData = JSON.parse(content)
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [`Invalid JSON: ${error instanceof Error ? error.message : 'Parse error'}`],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate JSON structure
|
||||
if (!parsedData.state || !parsedData.state.blocks) {
|
||||
return {
|
||||
success: false,
|
||||
errors: ['Invalid JSON structure: missing state.blocks'],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Extract workflow state and subblock values
|
||||
const newWorkflowState = {
|
||||
blocks: parsedData.state.blocks,
|
||||
edges: parsedData.state.edges || [],
|
||||
loops: parsedData.state.loops || {},
|
||||
parallels: parsedData.state.parallels || {},
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: parsedData.state.isDeployed || false,
|
||||
deployedAt: parsedData.state.deployedAt,
|
||||
deploymentStatuses: parsedData.state.deploymentStatuses || {},
|
||||
hasActiveSchedule: parsedData.state.hasActiveSchedule || false,
|
||||
hasActiveWebhook: parsedData.state.hasActiveWebhook || false,
|
||||
}
|
||||
|
||||
// Update local workflow state
|
||||
useWorkflowStore.setState(newWorkflowState)
|
||||
|
||||
// Update subblock values if provided
|
||||
if (parsedData.subBlockValues) {
|
||||
useSubBlockStore.setState((state: any) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[activeWorkflowId]: parsedData.subBlockValues,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// Update workflow metadata if provided
|
||||
if (parsedData.workflow) {
|
||||
const { updateWorkflow } = useWorkflowRegistry.getState()
|
||||
const metadata = parsedData.workflow
|
||||
|
||||
updateWorkflow(activeWorkflowId, {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
color: metadata.color,
|
||||
})
|
||||
}
|
||||
|
||||
// Save to database
|
||||
try {
|
||||
const response = await fetch(`/api/workflows/${activeWorkflowId}/state`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(newWorkflowState),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Failed to save workflow state:', errorData.error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [`Database save failed: ${errorData.error || 'Unknown error'}`],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to save workflow state:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
`Failed to save workflow state: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger auto layout
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('trigger-auto-layout'))
|
||||
}, 100)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
appliedOperations: 1, // One complete state replacement
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to apply workflow changes:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [`Apply failed: ${error instanceof Error ? error.message : 'Unknown error'}`],
|
||||
warnings: [],
|
||||
appliedOperations: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview what changes would be applied (simplified for the new approach)
|
||||
*/
|
||||
export function previewWorkflowDiff(
|
||||
content: string,
|
||||
format: EditorFormat
|
||||
): {
|
||||
summary: string
|
||||
operations: Array<{
|
||||
type: string
|
||||
description: string
|
||||
}>
|
||||
} {
|
||||
try {
|
||||
if (format === 'yaml') {
|
||||
// For YAML, we would do a complete import
|
||||
return {
|
||||
summary: 'Complete workflow replacement from YAML',
|
||||
operations: [
|
||||
{
|
||||
type: 'complete_replacement',
|
||||
description: 'Replace entire workflow with YAML content',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
// For JSON, we would do a complete state replacement
|
||||
let parsedData: any
|
||||
try {
|
||||
parsedData = JSON.parse(content)
|
||||
} catch (error) {
|
||||
return {
|
||||
summary: 'Invalid JSON format',
|
||||
operations: [],
|
||||
}
|
||||
}
|
||||
|
||||
const operations = []
|
||||
|
||||
if (parsedData.state?.blocks) {
|
||||
const blockCount = Object.keys(parsedData.state.blocks).length
|
||||
operations.push({
|
||||
type: 'replace_blocks',
|
||||
description: `Replace workflow with ${blockCount} blocks`,
|
||||
})
|
||||
}
|
||||
|
||||
if (parsedData.state?.edges) {
|
||||
const edgeCount = parsedData.state.edges.length
|
||||
operations.push({
|
||||
type: 'replace_edges',
|
||||
description: `Replace connections with ${edgeCount} edges`,
|
||||
})
|
||||
}
|
||||
|
||||
if (parsedData.subBlockValues) {
|
||||
operations.push({
|
||||
type: 'replace_values',
|
||||
description: 'Replace all input values',
|
||||
})
|
||||
}
|
||||
|
||||
if (parsedData.workflow) {
|
||||
operations.push({
|
||||
type: 'update_metadata',
|
||||
description: 'Update workflow metadata',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
summary: 'Complete workflow state replacement from JSON',
|
||||
operations,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
summary: 'Error analyzing changes',
|
||||
operations: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { dump as yamlDump } from 'js-yaml'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { EditorFormat } from './workflow-text-editor'
|
||||
|
||||
const logger = createLogger('WorkflowExporter')
|
||||
|
||||
/**
|
||||
* Get subblock values organized by block for the exporter
|
||||
*/
|
||||
function getSubBlockValues() {
|
||||
const workflowState = useWorkflowStore.getState()
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
const subBlockValues: Record<string, Record<string, any>> = {}
|
||||
Object.entries(workflowState.blocks).forEach(([blockId]) => {
|
||||
subBlockValues[blockId] = {}
|
||||
// Get all subblock values for this block
|
||||
Object.keys(workflowState.blocks[blockId].subBlocks || {}).forEach((subBlockId) => {
|
||||
const value = subBlockStore.getValue(blockId, subBlockId)
|
||||
if (value !== undefined) {
|
||||
subBlockValues[blockId][subBlockId] = value
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return subBlockValues
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate full workflow data including metadata and state
|
||||
*/
|
||||
export function generateFullWorkflowData() {
|
||||
const workflowState = useWorkflowStore.getState()
|
||||
const { workflows, activeWorkflowId } = useWorkflowRegistry.getState()
|
||||
|
||||
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
|
||||
|
||||
if (!currentWorkflow || !activeWorkflowId) {
|
||||
throw new Error('No active workflow found')
|
||||
}
|
||||
|
||||
const subBlockValues = getSubBlockValues()
|
||||
|
||||
return {
|
||||
workflow: {
|
||||
id: activeWorkflowId,
|
||||
name: currentWorkflow.name,
|
||||
description: currentWorkflow.description,
|
||||
color: currentWorkflow.color,
|
||||
workspaceId: currentWorkflow.workspaceId,
|
||||
folderId: currentWorkflow.folderId,
|
||||
},
|
||||
state: {
|
||||
blocks: workflowState.blocks,
|
||||
edges: workflowState.edges,
|
||||
loops: workflowState.loops,
|
||||
parallels: workflowState.parallels,
|
||||
},
|
||||
subBlockValues,
|
||||
exportedAt: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export workflow in the specified format
|
||||
*/
|
||||
export function exportWorkflow(format: EditorFormat): string {
|
||||
try {
|
||||
if (format === 'yaml') {
|
||||
// Use the existing YAML generator for condensed format
|
||||
const workflowState = useWorkflowStore.getState()
|
||||
const subBlockValues = getSubBlockValues()
|
||||
return generateWorkflowYaml(workflowState, subBlockValues)
|
||||
}
|
||||
// Generate full JSON format
|
||||
const fullData = generateFullWorkflowData()
|
||||
return JSON.stringify(fullData, null, 2)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to export workflow as ${format}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse workflow content based on format
|
||||
*/
|
||||
export function parseWorkflowContent(content: string, format: EditorFormat): any {
|
||||
if (format === 'yaml') {
|
||||
const { load: yamlParse } = require('js-yaml')
|
||||
return yamlParse(content)
|
||||
}
|
||||
return JSON.parse(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert between YAML and JSON formats
|
||||
*/
|
||||
export function convertBetweenFormats(
|
||||
content: string,
|
||||
fromFormat: EditorFormat,
|
||||
toFormat: EditorFormat
|
||||
): string {
|
||||
if (fromFormat === toFormat) return content
|
||||
|
||||
try {
|
||||
const parsed = parseWorkflowContent(content, fromFormat)
|
||||
|
||||
if (toFormat === 'yaml') {
|
||||
return yamlDump(parsed, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
sortKeys: false,
|
||||
})
|
||||
}
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to convert from ${fromFormat} to ${toFormat}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { FileCode } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { applyWorkflowDiff } from './workflow-applier'
|
||||
import { exportWorkflow } from './workflow-exporter'
|
||||
import { type EditorFormat, WorkflowTextEditor } from './workflow-text-editor'
|
||||
|
||||
const logger = createLogger('WorkflowTextEditorModal')
|
||||
|
||||
interface WorkflowTextEditorModalProps {
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function WorkflowTextEditorModal({
|
||||
disabled = false,
|
||||
className,
|
||||
}: WorkflowTextEditorModalProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [format, setFormat] = useState<EditorFormat>('yaml')
|
||||
const [initialContent, setInitialContent] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const workflowState = useWorkflowStore()
|
||||
|
||||
// Load initial content when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && activeWorkflowId) {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const content = exportWorkflow(format)
|
||||
setInitialContent(content)
|
||||
} catch (error) {
|
||||
logger.error('Failed to export workflow:', error)
|
||||
setInitialContent('# Error loading workflow content')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [isOpen, format, activeWorkflowId])
|
||||
|
||||
// Handle format changes
|
||||
const handleFormatChange = useCallback((newFormat: EditorFormat) => {
|
||||
setFormat(newFormat)
|
||||
}, [])
|
||||
|
||||
// Handle save operation
|
||||
const handleSave = useCallback(
|
||||
async (content: string, contentFormat: EditorFormat) => {
|
||||
if (!activeWorkflowId) {
|
||||
return { success: false, errors: ['No active workflow'] }
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('Applying workflow changes from text editor', { format: contentFormat })
|
||||
|
||||
// Apply changes using the simplified approach
|
||||
const applyResult = await applyWorkflowDiff(content, contentFormat)
|
||||
|
||||
if (applyResult.success) {
|
||||
logger.info('Successfully applied workflow changes', {
|
||||
appliedOperations: applyResult.appliedOperations,
|
||||
})
|
||||
|
||||
// Update initial content to reflect current state
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const updatedContent = exportWorkflow(contentFormat)
|
||||
setInitialContent(updatedContent)
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh content after save:', error)
|
||||
}
|
||||
}, 500) // Give more time for the workflow state to update
|
||||
}
|
||||
|
||||
return {
|
||||
success: applyResult.success,
|
||||
errors: applyResult.errors,
|
||||
warnings: applyResult.warnings,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to save workflow changes:', error)
|
||||
return {
|
||||
success: false,
|
||||
errors: [error instanceof Error ? error.message : 'Unknown error'],
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeWorkflowId]
|
||||
)
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
setIsOpen(open)
|
||||
if (!open) {
|
||||
// Reset state when closing
|
||||
setInitialContent('')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isDisabled = disabled || !activeWorkflowId
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
{isDisabled ? (
|
||||
<div className='inline-flex h-10 w-10 cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
|
||||
<FileCode className='h-5 w-5' />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className={cn('hover:text-foreground', className)}
|
||||
>
|
||||
<FileCode className='h-5 w-5' />
|
||||
<span className='sr-only'>Edit as Text</span>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isDisabled
|
||||
? disabled
|
||||
? 'Text editor not available'
|
||||
: 'No active workflow'
|
||||
: 'Edit as Text'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DialogContent className='flex h-[85vh] w-[90vw] max-w-6xl flex-col p-0'>
|
||||
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
|
||||
<DialogTitle>Workflow Text Editor</DialogTitle>
|
||||
<DialogDescription>
|
||||
Edit your workflow as YAML or JSON. Changes will completely replace the current workflow
|
||||
when you save.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='flex-1 overflow-hidden'>
|
||||
{isLoading ? (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<div className='text-center'>
|
||||
<div className='mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-primary border-b-2' />
|
||||
<p className='text-muted-foreground'>Loading workflow content...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<WorkflowTextEditor
|
||||
initialValue={initialContent}
|
||||
format={format}
|
||||
onSave={handleSave}
|
||||
onFormatChange={handleFormatChange}
|
||||
disabled={isDisabled}
|
||||
className='h-full rounded-none border-0'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { dump as yamlDump, load as yamlParse } from 'js-yaml'
|
||||
import { AlertCircle, Check, FileCode, Save } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CodeEditor } from '../workflow-block/components/sub-block/components/tool-input/components/code-editor/code-editor'
|
||||
|
||||
const logger = createLogger('WorkflowTextEditor')
|
||||
|
||||
export type EditorFormat = 'yaml' | 'json'
|
||||
|
||||
interface ValidationError {
|
||||
line?: number
|
||||
column?: number
|
||||
message: string
|
||||
}
|
||||
|
||||
interface WorkflowTextEditorProps {
|
||||
initialValue: string
|
||||
format: EditorFormat
|
||||
onSave: (
|
||||
content: string,
|
||||
format: EditorFormat
|
||||
) => Promise<{ success: boolean; errors?: string[]; warnings?: string[] }>
|
||||
onFormatChange?: (format: EditorFormat) => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function WorkflowTextEditor({
|
||||
initialValue,
|
||||
format,
|
||||
onSave,
|
||||
onFormatChange,
|
||||
className,
|
||||
disabled = false,
|
||||
}: WorkflowTextEditorProps) {
|
||||
const [content, setContent] = useState(initialValue)
|
||||
const [currentFormat, setCurrentFormat] = useState<EditorFormat>(format)
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([])
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [saveResult, setSaveResult] = useState<{
|
||||
success: boolean
|
||||
errors?: string[]
|
||||
warnings?: string[]
|
||||
} | null>(null)
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||
|
||||
// Validate content based on format
|
||||
const validateContent = useCallback((text: string, fmt: EditorFormat): ValidationError[] => {
|
||||
const errors: ValidationError[] = []
|
||||
|
||||
if (!text.trim()) {
|
||||
return errors // Empty content is valid
|
||||
}
|
||||
|
||||
try {
|
||||
if (fmt === 'yaml') {
|
||||
yamlParse(text)
|
||||
} else if (fmt === 'json') {
|
||||
JSON.parse(text)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Parse error'
|
||||
|
||||
// Extract line/column info if available
|
||||
const lineMatch = errorMessage.match(/line (\d+)/i)
|
||||
const columnMatch = errorMessage.match(/column (\d+)/i)
|
||||
|
||||
errors.push({
|
||||
line: lineMatch ? Number.parseInt(lineMatch[1], 10) : undefined,
|
||||
column: columnMatch ? Number.parseInt(columnMatch[1], 10) : undefined,
|
||||
message: errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
return errors
|
||||
}, [])
|
||||
|
||||
// Convert between formats
|
||||
const convertFormat = useCallback(
|
||||
(text: string, fromFormat: EditorFormat, toFormat: EditorFormat): string => {
|
||||
if (fromFormat === toFormat || !text.trim()) {
|
||||
return text
|
||||
}
|
||||
|
||||
try {
|
||||
let parsed: any
|
||||
|
||||
if (fromFormat === 'yaml') {
|
||||
parsed = yamlParse(text)
|
||||
} else {
|
||||
parsed = JSON.parse(text)
|
||||
}
|
||||
|
||||
if (toFormat === 'yaml') {
|
||||
return yamlDump(parsed, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
})
|
||||
}
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to convert from ${fromFormat} to ${toFormat}:`, error)
|
||||
return text // Return original if conversion fails
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Handle content changes
|
||||
const handleContentChange = useCallback(
|
||||
(newContent: string) => {
|
||||
setContent(newContent)
|
||||
setHasUnsavedChanges(newContent !== initialValue)
|
||||
|
||||
// Validate on change
|
||||
const errors = validateContent(newContent, currentFormat)
|
||||
setValidationErrors(errors)
|
||||
|
||||
// Clear save result when editing
|
||||
setSaveResult(null)
|
||||
},
|
||||
[initialValue, currentFormat, validateContent]
|
||||
)
|
||||
|
||||
// Handle format changes
|
||||
const handleFormatChange = useCallback(
|
||||
(newFormat: EditorFormat) => {
|
||||
if (newFormat === currentFormat) return
|
||||
|
||||
// Convert content to new format
|
||||
const convertedContent = convertFormat(content, currentFormat, newFormat)
|
||||
|
||||
setCurrentFormat(newFormat)
|
||||
setContent(convertedContent)
|
||||
|
||||
// Validate converted content
|
||||
const errors = validateContent(convertedContent, newFormat)
|
||||
setValidationErrors(errors)
|
||||
|
||||
// Notify parent
|
||||
onFormatChange?.(newFormat)
|
||||
},
|
||||
[content, currentFormat, convertFormat, validateContent, onFormatChange]
|
||||
)
|
||||
|
||||
// Handle save
|
||||
const handleSave = useCallback(async () => {
|
||||
if (validationErrors.length > 0) {
|
||||
logger.warn('Cannot save with validation errors')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
setSaveResult(null)
|
||||
|
||||
try {
|
||||
const result = await onSave(content, currentFormat)
|
||||
setSaveResult(result)
|
||||
|
||||
if (result.success) {
|
||||
setHasUnsavedChanges(false)
|
||||
logger.info('Workflow successfully updated from text editor')
|
||||
} else {
|
||||
logger.error('Failed to save workflow:', result.errors)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Save failed with exception:', error)
|
||||
setSaveResult({
|
||||
success: false,
|
||||
errors: [error instanceof Error ? error.message : 'Unknown error'],
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [content, currentFormat, validationErrors, onSave])
|
||||
|
||||
// Update content when initialValue changes
|
||||
useEffect(() => {
|
||||
setContent(initialValue)
|
||||
setHasUnsavedChanges(false)
|
||||
setSaveResult(null)
|
||||
}, [initialValue])
|
||||
|
||||
// Validation status
|
||||
const isValid = validationErrors.length === 0
|
||||
const canSave = isValid && hasUnsavedChanges && !disabled
|
||||
|
||||
// Get editor language for syntax highlighting
|
||||
const editorLanguage = currentFormat === 'yaml' ? 'javascript' : 'json' // yaml highlighting not available, use js
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col bg-background', className)}>
|
||||
{/* Header with controls */}
|
||||
<div className='flex-shrink-0 border-b bg-background px-6 py-4'>
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FileCode className='h-5 w-5' />
|
||||
<span className='font-semibold'>Workflow Text Editor</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Tabs
|
||||
value={currentFormat}
|
||||
onValueChange={(value) => handleFormatChange(value as EditorFormat)}
|
||||
>
|
||||
<TabsList className='grid w-fit grid-cols-2'>
|
||||
<TabsTrigger value='yaml' disabled={disabled}>
|
||||
YAML
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='json' disabled={disabled}>
|
||||
JSON
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!canSave || isSaving}
|
||||
size='sm'
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<Save className='h-4 w-4' />
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{!isValid
|
||||
? 'Fix validation errors to save'
|
||||
: !hasUnsavedChanges
|
||||
? 'No changes to save'
|
||||
: disabled
|
||||
? 'Editor is disabled'
|
||||
: 'Save changes to workflow'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status indicators */}
|
||||
<div className='flex items-center gap-2 text-sm'>
|
||||
{isValid ? (
|
||||
<div className='flex items-center gap-1 text-green-600'>
|
||||
<Check className='h-4 w-4' />
|
||||
Valid {currentFormat.toUpperCase()}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-1 text-red-600'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
{validationErrors.length} validation error{validationErrors.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasUnsavedChanges && <div className='text-orange-600'>• Unsaved changes</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts section - fixed height, scrollable if needed */}
|
||||
{(validationErrors.length > 0 || saveResult) && (
|
||||
<div className='scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent max-h-32 flex-shrink-0 overflow-y-auto border-b bg-muted/20'>
|
||||
<div className='space-y-2 p-4'>
|
||||
{/* Validation errors */}
|
||||
{validationErrors.length > 0 && (
|
||||
<>
|
||||
{validationErrors.map((error, index) => (
|
||||
<Alert key={index} variant='destructive' className='py-2'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription className='text-sm'>
|
||||
{error.line && error.column
|
||||
? `Line ${error.line}, Column ${error.column}: ${error.message}`
|
||||
: error.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save result */}
|
||||
{saveResult && (
|
||||
<Alert variant={saveResult.success ? 'default' : 'destructive'} className='py-2'>
|
||||
{saveResult.success ? (
|
||||
<Check className='h-4 w-4' />
|
||||
) : (
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
)}
|
||||
<AlertDescription className='text-sm'>
|
||||
{saveResult.success ? (
|
||||
<>
|
||||
Workflow updated successfully!
|
||||
{saveResult.warnings && saveResult.warnings.length > 0 && (
|
||||
<div className='mt-2'>
|
||||
<strong>Warnings:</strong>
|
||||
<ul className='mt-1 list-inside list-disc text-xs'>
|
||||
{saveResult.warnings.map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Failed to update workflow:
|
||||
{saveResult.errors && (
|
||||
<ul className='mt-1 list-inside list-disc text-xs'>
|
||||
{saveResult.errors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code editor - takes remaining space */}
|
||||
<div className='min-h-0 flex-1 overflow-hidden'>
|
||||
<div className='h-full p-4'>
|
||||
<CodeEditor
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
language={editorLanguage}
|
||||
placeholder={`Enter ${currentFormat.toUpperCase()} workflow definition...`}
|
||||
className={cn(
|
||||
'h-full w-full overflow-auto rounded-md border',
|
||||
!isValid && 'border-red-500',
|
||||
hasUnsavedChanges && 'border-orange-500'
|
||||
)}
|
||||
minHeight='calc(100vh - 300px)'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user