diff --git a/.config/config.json b/.config/config.json new file mode 100644 index 0000000..9a63b79 --- /dev/null +++ b/.config/config.json @@ -0,0 +1,36 @@ +{ + "providers": [ + { + "id": "openai", + "name": "OpenAI", + "enabled": true, + "models": [ + { + "id": "gpt-5", + "name": "GPT-5", + "reasoningEfforts": ["minimal", "low", "medium", "high"] + }, + { + "id": "gpt-5-mini", + "name": "GPT-5 Mini", + "reasoningEfforts": ["minimal", "low", "medium", "high"] + }, + { + "id": "gpt-5-nano", + "name": "GPT-5 Nano", + "reasoningEfforts": ["minimal", "low", "medium", "high"] + }, + { + "id": "gpt-5.1", + "name": "GPT-5.1", + "reasoningEfforts": ["none", "low", "medium", "high"] + }, + { + "id": "gpt-5.2", + "name": "GPT-5.2", + "reasoningEfforts": ["none", "low", "medium", "high", "xhigh"] + } + ] + } + ] +} diff --git a/.gitignore b/.gitignore index cc81089..3977282 100644 --- a/.gitignore +++ b/.gitignore @@ -18,13 +18,16 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -# Env files -.env -.env.local -.env.*.local - # OS .DS_Store # Scripts setup.sh + +# Local config +.config/default-workflow.json + +# Environment files (safety net — never commit secrets) +.env +.env.local +.env.*.local diff --git a/AGENTS.md b/AGENTS.md index 59ccf64..512ff5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,687 +1,48 @@ -# CodeSignal Design System - Agent Reference Guide - -This document provides comprehensive information for agentic code generators to effectively use the CodeSignal Design System. - -## Table of Contents - -1. [System Overview](#system-overview) -2. [Installation & Setup](#installation--setup) -3. [Design Tokens](#design-tokens) -4. [Components](#components) -5. [Usage Patterns](#usage-patterns) -6. [Best Practices](#best-practices) -7. [File Structure](#file-structure) - ---- - -## System Overview - -The CodeSignal Design System is a CSS-based design system organized into **Foundations** (design tokens) and **Components** (reusable UI elements). All components are built using CSS custom properties (CSS variables) for theming and consistency. - -### Key Principles - -- **Semantic over Primitive**: Always prefer semantic tokens (e.g., `--Colors-Text-Body-Default`) over base scale tokens -- **Dark Mode Support**: All components automatically adapt to dark mode via `@media (prefers-color-scheme: dark)` -- **CSS-First**: Components are primarily CSS-based with minimal JavaScript (only Dropdown requires JS) -- **Accessibility**: Components follow WCAG guidelines and support keyboard navigation - ---- - -## Installation & Setup - -### Required CSS Files (Load in Order) - -```html - - - - - - - - - - - - - - - - - -``` - -### Alternative: CSS Import - -```css -@import url('/design-system/colors/colors.css'); -@import url('/design-system/spacing/spacing.css'); -@import url('/design-system/typography/typography.css'); -@import url('/design-system/components/button/button.css'); -``` - -### JavaScript (Only for Dropdown) - -```html - -``` - ---- - -## Design Tokens - -### Colors - -#### Base Scales (Primitive Tokens) -**Avoid using these directly** - use semantic tokens instead for theming support. - -Pattern: `--Colors-Base-[Family]-[Step]` - -**Families:** -- `Primary`: Brand blue colors (20-1400 scale) -- `Neutral`: Grays, white, black (00-1400 scale) -- `Accent-Green`: Success states -- `Accent-Sky-Blue`: Info states -- `Accent-Yellow`: Warning states -- `Accent-Orange`: Warning states -- `Accent-Red`: Error/Danger states - -**Example:** -```css ---Colors-Base-Primary-700: #1062FB; ---Colors-Base-Neutral-600: #ACB4C7; ---Colors-Base-Accent-Green-600: #10B981; -``` - -#### Semantic Tokens (Preferred) -**Always use these** for automatic dark mode support and consistency. - -**Categories:** - -1. **Primary Colors** - - `--Colors-Primary-Default` - - `--Colors-Primary-Medium` - - `--Colors-Primary-Strong` - -2. **Backgrounds** - - `--Colors-Backgrounds-Main-Default` - - `--Colors-Backgrounds-Main-Top` - - `--Colors-Backgrounds-Main-Medium` - - `--Colors-Backgrounds-Main-Strong` - -3. **Text Colors** - - `--Colors-Text-Body-Default` - - `--Colors-Text-Body-Secondary` - - `--Colors-Text-Body-Medium` - - `--Colors-Text-Body-Strong` - - `--Colors-Text-Body-Strongest` - -4. **Icon Colors** - - `--Colors-Icon-Default` - - `--Colors-Icon-Primary` - - `--Colors-Icon-Secondary` - -5. **Stroke/Border Colors** - - `--Colors-Stroke-Default` - - `--Colors-Stroke-Strong` - - `--Colors-Stroke-Strongest` - -6. **Alert Colors** - - `--Colors-Alert-Success-Default`, `--Colors-Alert-Success-Medium` - - `--Colors-Alert-Error-Default`, `--Colors-Alert-Error-Medium` - - `--Colors-Alert-Warning-Default`, `--Colors-Alert-Warning-Medium` - - `--Colors-Alert-Info-Default`, `--Colors-Alert-Info-Medium` - -### Spacing - -Pattern: `--UI-Spacing-spacing-[size]` - -**Available Sizes:** -- `none`: 0 -- `min`: 2px -- `xxs`: 4px -- `xs`: 6px -- `s`: 8px -- `mxs`: 12px -- `ms`: 16px -- `m`: 18px -- `ml`: 20px -- `mxl`: 24px -- `l`: 28px -- `xl`: 32px -- `xxl`: 36px -- `xxxl`: 48px -- `4xl`: 60px -- `max`: 90px - -**Usage:** -```css -padding: var(--UI-Spacing-spacing-m); -margin: var(--UI-Spacing-spacing-s); -gap: var(--UI-Spacing-spacing-mxl); -``` - -### Border Radius - -Pattern: `--UI-Radius-radius-[size]` - -**Available Sizes:** -- `none`: 0 -- `min`: 2px -- `xxs`: 4px -- `xs`: 6px -- `s`: 8px -- `m`: 12px -- `ml`: 16px -- `mxl`: 20px -- `l`: 24px -- `xl`: 32px - -**Usage:** -```css -border-radius: var(--UI-Radius-radius-m); -``` - -### Input Heights - -Pattern: `--UI-Input-[size]` - -**Available Sizes:** -- `min`: 26px -- `xs`: 32px -- `sm`: 40px -- `md`: 48px (default) -- `lg`: 60px - -**Usage:** -```css -height: var(--UI-Input-md); -``` - -### Typography - -#### Font Families -- **Body & Labels**: `Work Sans` (sans-serif) - Must be loaded from Google Fonts -- **Headings**: `Founders Grotesk` (sans-serif) - Included via `@font-face` -- **Code**: `JetBrains Mono` (monospace) - Included via `@font-face` - -#### Typography Classes - -**Body Text** (Work Sans): -- `.body-xxsmall` (13px) -- `.body-xsmall` (14px) -- `.body-small` (15px) -- `.body-medium` (16px) -- `.body-large` (17px) -- `.body-xlarge` (19px) -- `.body-xxlarge` (21px) -- `.body-xxxlarge` (24px) - -**Body Elegant** (Founders Grotesk): -- `.body-elegant-xxsmall` (22px) -- `.body-elegant-xsmall` (26px) -- `.body-elegant-small` (32px) -- `.body-elegant-medium` (38px) - -**Headings** (Founders Grotesk, 500 weight): -- `.heading-xxxsmall` (16px) -- `.heading-xxsmall` (22px) -- `.heading-xsmall` (22px) -- `.heading-small` (24px) -- `.heading-medium` (32px) -- `.heading-large` (38px) -- `.heading-xlarge` (48px) -- `.heading-xxlarge` (64px) - -**Labels** (Work Sans, 600 weight, uppercase): -- `.label-small` (10px) -- `.label-medium` (11px) -- `.label-large` (14px) - -**Label Numbers** (Work Sans, 500 weight): -- `.label-number-xsmall` (11px) -- `.label-number-small` (12px) -- `.label-number-medium` (14px) -- `.label-number-large` (15px) - ---- - -## Components - -### Button - -**Base Class:** `.button` (required) - -**Variants:** -- `.button-primary`: Primary action (Brand Blue background) -- `.button-secondary`: Secondary action (Outlined style) -- `.button-tertiary`: Tertiary/Ghost (Subtle background) -- `.button-danger`: Destructive action (Red) -- `.button-success`: Positive action (Green) -- `.button-text`: Text button (Neutral text, no background) -- `.button-text-primary`: Primary text button (Brand color text) - -**Sizes:** -- `.button-xsmall`: 32px height -- `.button-small`: 40px height -- Default: 48px height (medium) -- `.button-large`: 60px height - -**States:** -- Standard pseudo-classes: `:hover`, `:focus`, `:active`, `:disabled` -- Utility classes: `.hover`, `.focus`, `.active`, `.disabled` - -**Example:** -```html - - - -``` - -**Dependencies:** colors.css, spacing.css, typography.css - ---- - -### Box - -**Base Class:** `.box` (required) - -**Variants:** -- `.box.selected`: Selected state (Primary border) -- `.box.emphasized`: Emphasized state (Neutral border) -- `.box.shadowed`: Soft shadow -- `.box.card`: Card-style shadow - -**States:** -- Standard pseudo-classes: `:hover`, `:focus`, `:active` -- Utility classes: `.hover`, `.focus`, `.selected` - -**Example:** -```html -
Default content
-
Selected content
-
Card content
-``` - -**Dependencies:** colors.css, spacing.css - ---- - -### Input - -**Base Class:** `.input` (required) - -**Input Types:** -- `type="text"`: Standard text input (default) -- `type="number"`: Numeric input with styled spinner buttons - -**States:** -- Standard pseudo-classes: `:hover`, `:focus`, `:disabled` -- Utility classes: `.hover`, `.focus` - -**Features:** -- Automatic focus ring (primary color with reduced opacity) -- Styled number input spinners -- Dark mode support - -**Example:** -```html - - - -``` - -**Dependencies:** colors.css, spacing.css, typography.css - ---- - -### Tag - -**Base Class:** `.tag` or `.tag.default` (required) - -**Variants:** -- `.tag` / `.tag.default`: Primary tag (Brand Blue background) -- `.tag.secondary`: Secondary tag (Neutral gray background) -- `.tag.outline`: Outline tag (Transparent with border) -- `.tag.success`: Success tag (Green background) -- `.tag.error`: Error tag (Red background) -- `.tag.warning`: Warning tag (Yellow background) -- `.tag.info`: Info tag (Sky Blue background) - -**States:** -- Standard pseudo-classes: `:hover`, `:focus`, `:active` -- Utility classes: `.hover`, `.focus`, `.active` - -**Example:** -```html -
Default
-
Completed
-
Failed
-
Filter
-``` - -**Dependencies:** colors.css, spacing.css, typography.css - ---- - -### Icon - -**Base Class:** `.icon` (required) - -**Icon Names:** -Use `.icon-[name]` where `[name]` is derived from SVG filename (e.g., `Icon=Academy.svg` → `.icon-academy`) - -**Available Icons** (80+ icons): -- `.icon-academy` -- `.icon-assessment` -- `.icon-interview` -- `.icon-jobs` -- `.icon-course` -- ... (see `icons.css` for full list) - -**Sizes:** -- `.icon-small`: 16px -- `.icon-medium`: 24px (default) -- `.icon-large`: 32px -- `.icon-xlarge`: 48px - -**Colors:** -- Default: Uses `currentColor` (inherits text color) -- `.icon-primary`: Primary brand color -- `.icon-secondary`: Secondary neutral color -- `.icon-success`: Success green color -- `.icon-danger`: Danger red color -- `.icon-warning`: Warning yellow color - -**Implementation Note:** -Icons use `mask-image` with `background-color` for color control. SVGs in data URIs use black fills (black = visible in mask). - -**Example:** -```html - - - -``` - -**Dependencies:** colors.css, spacing.css - ---- - -### Dropdown (JavaScript Component) - -**Import:** -```javascript -import Dropdown from '/design-system/components/dropdown/dropdown.js'; -``` - -**Initialization:** -```javascript -const dropdown = new Dropdown(selector, options); -``` - -**Configuration Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `items` | Array | `[]` | Array of `{value, label}` objects | -| `placeholder` | String | `'Select option'` | Placeholder text | -| `selectedValue` | String | `null` | Initial selected value | -| `width` | String/Number | `'auto'` | Fixed width (ignored if `growToFit` is true) | -| `growToFit` | Boolean | `false` | Auto-resize to fit content | -| `onSelect` | Function | `null` | Callback `(value, item)` on selection | - -**API Methods:** -- `getValue()`: Returns current selected value -- `setValue(value)`: Sets selected value programmatically -- `open()`: Opens dropdown menu -- `close()`: Closes dropdown menu -- `toggleOpen()`: Toggles open state -- `destroy()`: Removes event listeners and clears container - -**Example:** -```javascript -const dropdown = new Dropdown('#my-dropdown', { - placeholder: 'Choose an option', - items: [ - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' } - ], - onSelect: (value, item) => { - console.log('Selected:', value, item); - } -}); - -// Later... -dropdown.setValue('2'); -const currentValue = dropdown.getValue(); -``` - -**Dependencies:** colors.css, spacing.css, typography.css - ---- - -## Usage Patterns - -### Component Composition - -Components can be combined and nested: - -```html - - - - -
- - Completed -
- - -
- - -
-``` - -### Custom Styling - -You can extend components using CSS custom properties: - -```css -.my-custom-button { - /* Inherit button styles */ - composes: button button-primary; - - /* Override with custom properties */ - --Colors-Base-Primary-700: #custom-color; -} -``` - -### Responsive Design - -Use standard CSS media queries with design tokens: - -```css -@media (max-width: 768px) { - .responsive-box { - padding: var(--UI-Spacing-spacing-s); - } -} -``` - ---- - -## Best Practices - -### 1. Token Usage - -✅ **DO:** -```css -color: var(--Colors-Text-Body-Default); -padding: var(--UI-Spacing-spacing-m); -border-radius: var(--UI-Radius-radius-m); -``` - -❌ **DON'T:** -```css -color: #333; -padding: 16px; -border-radius: 8px; -``` - -### 2. Component Classes - -✅ **DO:** -```html - -``` - -❌ **DON'T:** -```html - -``` - -### 3. Dark Mode - -✅ **DO:** Use semantic tokens (automatic dark mode) -```css -background: var(--Colors-Backgrounds-Main-Default); -``` - -❌ **DON'T:** Use hardcoded colors -```css -background: #ffffff; -``` - -### 4. File Loading Order - -✅ **DO:** Load foundations before components -```html - - - - - - - -``` - -### 5. Icon Usage - -✅ **DO:** -```html - -``` - -❌ **DON'T:** Use inline SVG or img tags for icons -```html -icon -``` - -### 6. Accessibility - -- Always include proper `alt` text for images -- Use semantic HTML (` - +
+
+ + Zoom +
+ + 100% + +
@@ -61,7 +61,6 @@

Nodes

-
- - diff --git a/apps/web/src/app/workflow-editor.ts b/apps/web/src/app/workflow-editor.ts index 39032b4..51db2cc 100644 --- a/apps/web/src/app/workflow-editor.ts +++ b/apps/web/src/app/workflow-editor.ts @@ -2,12 +2,12 @@ // Bespoke Agent Builder - Client Logic import type { WorkflowGraph } from '@agentic/types'; -import { runWorkflow, resumeWorkflow } from '../services/api'; +import { runWorkflowStream, resumeWorkflow, fetchConfig } from '../services/api'; const EXPANDED_NODE_WIDTH = 420; const DEFAULT_NODE_WIDTH = 150; // Fallback if DOM not ready -const MODEL_OPTIONS = ['gpt-5', 'gpt-5-mini', 'gpt-5.1']; -const MODEL_EFFORTS = { +const DEFAULT_MODEL_OPTIONS = ['gpt-5', 'gpt-5-mini', 'gpt-5.1']; +const DEFAULT_MODEL_EFFORTS: Record = { 'gpt-5': ['low', 'medium', 'high'], 'gpt-5-mini': ['low', 'medium', 'high'], 'gpt-5.1': ['none', 'low', 'medium', 'high'] @@ -15,6 +15,8 @@ const MODEL_EFFORTS = { export class WorkflowEditor { constructor() { + this.modelOptions = [...DEFAULT_MODEL_OPTIONS]; + this.modelEfforts = { ...DEFAULT_MODEL_EFFORTS }; this.nodes = []; this.connections = []; this.nextNodeId = 1; @@ -39,24 +41,25 @@ export class WorkflowEditor { this.chatMessages = document.getElementById('chat-messages'); this.initialPrompt = document.getElementById('initial-prompt'); this.runButton = document.getElementById('btn-run'); + this.cancelRunButton = document.getElementById('btn-cancel-run'); + this.zoomValue = document.getElementById('zoom-value'); this.workflowState = 'idle'; // 'idle' | 'running' | 'paused' this.rightPanel = document.getElementById('right-panel'); - this.rightResizer = document.getElementById('right-resizer'); this.pendingAgentMessage = null; this.currentPrompt = ''; this.pendingApprovalRequest = null; - this.confirmModal = document.getElementById('confirm-modal'); - this.confirmTitle = document.getElementById('confirm-modal-title'); - this.confirmMessage = document.getElementById('confirm-modal-message'); - this.confirmConfirmBtn = document.getElementById('confirm-modal-confirm'); - this.confirmCancelBtn = document.getElementById('confirm-modal-cancel'); - this.confirmBackdrop = this.confirmModal ? this.confirmModal.querySelector('.modal-backdrop') : null; + this.activeRunController = null; + this.lastLlmResponseContent = null; + + this.splitPanelCtorPromise = null; + this.dropdownCtorPromise = null; + this.modalCtorPromise = null; + this.initSplitPanelLayout(); // Bindings this.initDragAndDrop(); this.initCanvasInteractions(); this.initButtons(); - this.initPanelControls(); // WebSocket for Logs this.initWebSocket(); @@ -65,8 +68,7 @@ export class WorkflowEditor { this.updateRunButton(); this.addDefaultStartNode(); this.upgradeLegacyNodes(true); - - this.dropdownCtorPromise = null; + this.loadConfig().then(() => this.loadDefaultWorkflow()); } async getDropdownCtor() { @@ -78,6 +80,60 @@ export class WorkflowEditor { return this.dropdownCtorPromise; } + async getSplitPanelCtor() { + if (!this.splitPanelCtorPromise) { + const origin = window.location.origin; + const splitPanelModulePath = `${origin}/design-system/components/split-panel/split-panel.js`; + this.splitPanelCtorPromise = import(/* @vite-ignore */ splitPanelModulePath).then((mod) => mod.default); + } + return this.splitPanelCtorPromise; + } + + async getModalCtor() { + if (!this.modalCtorPromise) { + const origin = window.location.origin; + const modalModulePath = `${origin}/design-system/components/modal/modal.js`; + this.modalCtorPromise = import(/* @vite-ignore */ modalModulePath).then((mod) => mod.default); + } + return this.modalCtorPromise; + } + + async initSplitPanelLayout() { + const mainLayout = document.querySelector('.main-layout'); + if (!mainLayout || !this.canvas || !this.rightPanel) return; + + const rightWidthVar = getComputedStyle(document.documentElement) + .getPropertyValue('--right-sidebar-width') + .trim(); + const rightWidth = Number.parseFloat(rightWidthVar) || 320; + const containerWidth = mainLayout.getBoundingClientRect().width || window.innerWidth || 1280; + const initialSplit = ((containerWidth - rightWidth) / containerWidth) * 100; + const clampedSplit = Math.max(40, Math.min(80, initialSplit)); + + try { + const SplitPanelCtor = await this.getSplitPanelCtor(); + this.splitPanel = new SplitPanelCtor(mainLayout, { + initialSplit: clampedSplit, + minLeft: 40, + minRight: 20 + }); + this.splitPanel.getLeftPanel().appendChild(this.canvas); + this.splitPanel.getRightPanel().appendChild(this.rightPanel); + + // Canvas now has correct dimensions — reposition the Start node if it's + // still the only node (i.e. no default-workflow.json was loaded yet or at all) + const startNode = this.nodes.find(n => n.type === 'start'); + if (startNode && this.nodes.length === 1) { + const pos = this.getDefaultStartPosition(); + startNode.x = pos.x; + startNode.y = pos.y; + this.render(); + } + } catch (error) { + console.warn('Failed to initialize split panel layout', error); + } + } + async setupDropdown(container, items, selectedValue, placeholder, onSelect) { const DropdownCtor = await this.getDropdownCtor(); const dropdown = new DropdownCtor(container, { @@ -94,6 +150,12 @@ export class WorkflowEditor { if (this.canvasStage) { this.canvasStage.style.transform = `translate(${this.viewport.x}px, ${this.viewport.y}px) scale(${this.viewport.scale})`; } + this.updateZoomValue(); + } + + updateZoomValue() { + if (!this.zoomValue) return; + this.zoomValue.textContent = `${Math.round(this.viewport.scale * 100)}%`; } screenToWorld(clientX, clientY) { @@ -135,22 +197,132 @@ export class WorkflowEditor { this.updateRunButton(); } + setRunButtonHint(reason) { + if (!this.runButton) return; + if (reason) { + this.runButton.title = reason; + this.runButton.setAttribute('data-disabled-hint', reason); + } else { + this.runButton.removeAttribute('title'); + this.runButton.removeAttribute('data-disabled-hint'); + } + } + + isAbortError(error) { + if (!error) return false; + if (error.name === 'AbortError') return true; + const message = typeof error.message === 'string' ? error.message : ''; + return message.toLowerCase().includes('aborted'); + } + + cancelRunningWorkflow() { + if (this.activeRunController) { + this.activeRunController.abort(); + this.activeRunController = null; + } + if (this.workflowState === 'running') { + this.hideAgentSpinner(); + this.clearApprovalMessage(); + this.appendStatusMessage('Cancelled'); + this.currentRunId = null; + this.setWorkflowState('idle'); + } + } + + getRunDisableReason() { + const startNodes = this.nodes.filter(node => node.type === 'start'); + if (startNodes.length === 0) { + return 'Add a Start node to run the workflow.'; + } + if (startNodes.length > 1) { + return 'Use only one Start node before running.'; + } + + const nodeIdSet = new Set(this.nodes.map(node => node.id)); + const hasBrokenConnection = this.connections.some( + (conn) => !nodeIdSet.has(conn.source) || !nodeIdSet.has(conn.target) + ); + if (hasBrokenConnection) { + return 'Fix broken connections before running.'; + } + + const startNode = startNodes[0]; + const adjacency = new Map(); + this.connections.forEach((conn) => { + if (!adjacency.has(conn.source)) adjacency.set(conn.source, []); + adjacency.get(conn.source).push(conn); + }); + + const startConnections = adjacency.get(startNode.id) || []; + if (startConnections.length === 0) { + return 'Connect Start to another node before running.'; + } + + const reachable = new Set([startNode.id]); + const queue = [startNode.id]; + while (queue.length > 0) { + const nodeId = queue.shift(); + const next = adjacency.get(nodeId) || []; + next.forEach((conn) => { + if (!reachable.has(conn.target)) { + reachable.add(conn.target); + queue.push(conn.target); + } + }); + } + + if (reachable.size <= 1) { + return 'Add and connect at least one node after Start.'; + } + + for (const node of this.nodes) { + if (!reachable.has(node.id)) continue; + if (node.type === 'if') { + const outgoing = adjacency.get(node.id) || []; + const hasTrue = outgoing.some((conn) => conn.sourceHandle === 'true'); + const hasFalse = outgoing.some((conn) => conn.sourceHandle === 'false'); + if (!hasTrue && !hasFalse) { + return 'Connect at least one branch for each If / Else node.'; + } + } + if (node.type === 'approval' || node.type === 'input') { + const outgoing = adjacency.get(node.id) || []; + const hasApprove = outgoing.some((conn) => conn.sourceHandle === 'approve'); + const hasReject = outgoing.some((conn) => conn.sourceHandle === 'reject'); + if (!hasApprove && !hasReject) { + return 'Connect at least one branch for each approval node.'; + } + } + } + + return null; + } + updateRunButton() { if (!this.runButton) return; + if (this.cancelRunButton) { + const showCancel = this.workflowState === 'running'; + this.cancelRunButton.style.display = showCancel ? 'inline-flex' : 'none'; + this.cancelRunButton.disabled = !showCancel; + } switch (this.workflowState) { case 'running': this.runButton.textContent = 'Running...'; this.runButton.disabled = true; + this.setRunButtonHint('Workflow is currently running.'); break; case 'paused': this.runButton.textContent = 'Paused'; this.runButton.disabled = true; + this.setRunButtonHint('Workflow is paused waiting for approval.'); break; case 'idle': default: - this.runButton.textContent = 'Run Workflow'; - this.runButton.disabled = false; + const disabledReason = this.getRunDisableReason(); + this.runButton.innerHTML = 'Run Workflow '; + this.runButton.disabled = Boolean(disabledReason); + this.setRunButtonHint(disabledReason); break; } } @@ -170,21 +342,21 @@ export class WorkflowEditor { this.runHistory.push({ role: 'user', content: text }); } - showAgentSpinner() { + showAgentSpinner(name?: string) { if (!this.chatMessages) return; this.hideAgentSpinner(); - const name = this.getPrimaryAgentName(); + const resolvedName = name || this.getPrimaryAgentName(); const spinner = document.createElement('div'); spinner.className = 'chat-message agent spinner'; const label = document.createElement('span'); label.className = 'chat-message-label'; - label.textContent = `${name} agent`; + label.textContent = resolvedName; spinner.appendChild(label); const body = document.createElement('div'); body.className = 'chat-spinner-row'; const text = document.createElement('span'); text.className = 'chat-spinner-text'; - text.textContent = `${name} is working`; + text.textContent = `${resolvedName} is working`; const dots = document.createElement('span'); dots.className = 'chat-spinner'; dots.innerHTML = ''; @@ -206,7 +378,7 @@ export class WorkflowEditor { renderEffortSelect(node) { const select = document.createElement('select'); select.className = 'input ds-select'; - const options = MODEL_EFFORTS[node.data.model] || MODEL_EFFORTS['gpt-5']; + const options = this.modelEfforts[node.data.model] || this.modelEfforts[this.modelOptions[0]] || []; if (!options.includes(node.data.reasoningEffort)) { node.data.reasoningEffort = options[0]; } @@ -223,9 +395,12 @@ export class WorkflowEditor { return select; } - zoomCanvas(factor) { + zoomCanvas(stepPercent) { if (!this.canvas) return; - const newScale = Math.min(2, Math.max(0.5, this.viewport.scale * factor)); + const snappedScale = Math.round(this.viewport.scale * 10) / 10; + const delta = stepPercent / 100; + const newScale = Math.min(2, Math.max(0.5, snappedScale + delta)); + if (newScale === this.viewport.scale) return; const rect = this.canvas.getBoundingClientRect(); const screenX = rect.width / 2; const screenY = rect.height / 2; @@ -237,11 +412,6 @@ export class WorkflowEditor { this.applyViewport(); } - resetViewport() { - this.viewport = { x: 0, y: 0, scale: 1 }; - this.applyViewport(); - } - // --- INITIALIZATION --- initDragAndDrop() { @@ -341,6 +511,10 @@ export class WorkflowEditor { initButtons() { document.getElementById('btn-run').addEventListener('click', () => this.runWorkflow()); + const cancelRunBtn = document.getElementById('btn-cancel-run'); + if (cancelRunBtn) { + cancelRunBtn.addEventListener('click', () => this.cancelRunningWorkflow()); + } document.getElementById('btn-clear').addEventListener('click', async () => { const confirmed = await this.openConfirmModal({ title: 'Clear Canvas', @@ -370,38 +544,8 @@ export class WorkflowEditor { const zoomInBtn = document.getElementById('btn-zoom-in'); const zoomOutBtn = document.getElementById('btn-zoom-out'); - const zoomResetBtn = document.getElementById('btn-zoom-reset'); - if (zoomInBtn) zoomInBtn.addEventListener('click', () => this.zoomCanvas(1.2)); - if (zoomOutBtn) zoomOutBtn.addEventListener('click', () => this.zoomCanvas(0.8)); - if (zoomResetBtn) zoomResetBtn.addEventListener('click', () => this.resetViewport()); - } - - initPanelControls() { - if (this.rightResizer && this.rightPanel) { - let isDragging = false; - - const onMouseMove = (e) => { - if (!isDragging) return; - const newWidth = Math.min(600, Math.max(240, window.innerWidth - e.clientX)); - document.documentElement.style.setProperty('--right-sidebar-width', `${newWidth}px`); - }; - - const onMouseUp = () => { - if (!isDragging) return; - isDragging = false; - this.rightResizer.classList.remove('dragging'); - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - }; - - this.rightResizer.addEventListener('mousedown', (e) => { - e.preventDefault(); - isDragging = true; - this.rightResizer.classList.add('dragging'); - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }); - } + if (zoomInBtn) zoomInBtn.addEventListener('click', () => this.zoomCanvas(10)); + if (zoomOutBtn) zoomOutBtn.addEventListener('click', () => this.zoomCanvas(-10)); } initWebSocket() { @@ -412,7 +556,7 @@ export class WorkflowEditor { }; } - openConfirmModal(options = {}) { + async openConfirmModal(options = {}) { const { title = 'Confirm', message = 'Are you sure?', @@ -420,48 +564,45 @@ export class WorkflowEditor { cancelLabel = 'Cancel' } = options; - if (!this.confirmModal || !this.confirmConfirmBtn || !this.confirmCancelBtn) { - return Promise.resolve(window.confirm(message)); - } - - if (this.confirmTitle) this.confirmTitle.textContent = title; - if (this.confirmMessage) this.confirmMessage.textContent = message; - this.confirmConfirmBtn.textContent = confirmLabel; - this.confirmCancelBtn.textContent = cancelLabel; - - return new Promise((resolve) => { - const cleanup = () => { - this.confirmModal.style.display = 'none'; - this.confirmConfirmBtn.removeEventListener('click', onConfirm); - this.confirmCancelBtn.removeEventListener('click', onCancel); - if (this.confirmBackdrop) { - this.confirmBackdrop.removeEventListener('click', onCancel); - } - document.removeEventListener('keydown', onKeydown); - }; - - const onConfirm = () => { - cleanup(); - resolve(true); - }; - - const onCancel = () => { - cleanup(); - resolve(false); - }; - - const onKeydown = (event) => { - if (event.key === 'Escape') onCancel(); - }; + try { + const ModalCtor = await this.getModalCtor(); + const content = document.createElement('p'); + content.textContent = message; + + return await new Promise((resolve) => { + let confirmed = false; + + const modal = new ModalCtor({ + size: 'small', + title, + content, + footerButtons: [ + { + label: cancelLabel, + type: 'secondary', + onClick: (_event, instance) => instance.close() + }, + { + label: confirmLabel, + type: 'primary', + onClick: (_event, instance) => { + confirmed = true; + instance.close(); + } + } + ], + onClose: () => { + modal.destroy(); + resolve(confirmed); + } + }); - this.confirmModal.style.display = 'flex'; - document.addEventListener('keydown', onKeydown); - this.confirmConfirmBtn.addEventListener('click', onConfirm); - this.confirmCancelBtn.addEventListener('click', onCancel); - if (this.confirmBackdrop) { - this.confirmBackdrop.addEventListener('click', onCancel); - } - }); + modal.open(); + }); + } catch (error) { + console.warn('Failed to initialize DS confirm modal', error); + return window.confirm(message); + } } // --- NODE MANAGEMENT --- @@ -477,6 +618,7 @@ export class WorkflowEditor { }; this.nodes.push(node); this.renderNode(node); + this.updateRunButton(); } upgradeLegacyNodes(shouldRender = false) { @@ -492,9 +634,54 @@ export class WorkflowEditor { }); if (updated && shouldRender) { this.render(); + } else if (updated) { + this.updateRunButton(); } } + async loadConfig() { + try { + const cfg = await fetchConfig(); + const enabledProviders = (cfg.providers ?? []).filter(p => p.enabled); + if (enabledProviders.length === 0) return; + const options: string[] = []; + const efforts: Record = {}; + for (const provider of enabledProviders) { + for (const model of provider.models) { + options.push(model.id); + efforts[model.id] = model.reasoningEfforts; + } + } + this.modelOptions = options; + this.modelEfforts = efforts; + } catch { + // keep hardcoded defaults + } + } + + async loadDefaultWorkflow() { + try { + const res = await fetch('/api/default-workflow'); + if (!res.ok) return; + const graph = await res.json(); + this.loadWorkflow(graph); + } catch { + // keep the default start node already rendered synchronously + } + } + + loadWorkflow(graph) { + this.nodes = graph.nodes ?? []; + this.connections = graph.connections ?? []; + const maxId = this.nodes.reduce((max, n) => { + const num = parseInt(n.id.replace('node_', ''), 10); + return isNaN(num) ? max : Math.max(max, num); + }, 0); + this.nextNodeId = maxId + 1; + this.upgradeLegacyNodes(); + this.render(); + } + addDefaultStartNode() { const startExists = this.nodes.some(n => n.type === 'start'); if (startExists) return; @@ -504,7 +691,7 @@ export class WorkflowEditor { getDefaultStartPosition() { const container = this.canvas; - const fallback = { x: 200, y: 200 }; + const fallback = { x: 370, y: 310 }; if (!container) return fallback; const rect = container.getBoundingClientRect(); if (!rect.width || !rect.height) return fallback; @@ -523,7 +710,7 @@ export class WorkflowEditor { return { agentName: 'Agent', systemPrompt: 'You are a helpful assistant.', - userPrompt: '', + userPrompt: '{{PREVIOUS_OUTPUT}}', model: 'gpt-5', reasoningEffort: 'low', tools: { web_search: false }, @@ -550,6 +737,7 @@ export class WorkflowEditor { this.nodes = this.nodes.filter(n => n.id !== id); this.connections = this.connections.filter(c => c.source !== id && c.target !== id); this.render(); + this.updateRunButton(); } // --- RENDERING --- @@ -559,6 +747,7 @@ export class WorkflowEditor { this.connectionsLayer.innerHTML = ''; this.nodes.forEach(n => this.renderNode(n)); this.renderConnections(); + this.updateRunButton(); } renderNode(node) { @@ -615,7 +804,7 @@ export class WorkflowEditor { delBtn = document.createElement('button'); delBtn.type = 'button'; delBtn.className = 'button button-tertiary button-small icon-btn delete'; - delBtn.innerHTML = ''; + delBtn.innerHTML = ''; delBtn.title = 'Delete Node'; delBtn.addEventListener('mousedown', async (e) => { e.stopPropagation(); @@ -695,7 +884,7 @@ export class WorkflowEditor { getNodeLabel(node) { if (node.type === 'agent') { const name = (node.data.agentName || 'Agent').trim() || 'Agent'; - return `${name}`; + return `${name}`; } if (node.type === 'start') return 'Start'; if (node.type === 'end') return 'End'; @@ -746,6 +935,7 @@ export class WorkflowEditor { container.appendChild(buildLabel('System Prompt')); const sysInput = document.createElement('textarea'); sysInput.className = 'input textarea-input'; + sysInput.placeholder = 'Define the agent\'s role, persona, or instructions.'; sysInput.value = node.data.systemPrompt || ''; sysInput.addEventListener('input', (e) => { node.data.systemPrompt = e.target.value; @@ -753,12 +943,12 @@ export class WorkflowEditor { }); container.appendChild(sysInput); - // User Prompt Override - container.appendChild(buildLabel('User Prompt Override (optional)')); + // Input + container.appendChild(buildLabel('Input')); const userInput = document.createElement('textarea'); userInput.className = 'input textarea-input'; - userInput.placeholder = 'If left empty, uses previous node output.'; - userInput.value = node.data.userPrompt || ''; + userInput.placeholder = 'Use {{PREVIOUS_OUTPUT}} to include the previous node\'s output.'; + userInput.value = node.data.userPrompt ?? '{{PREVIOUS_OUTPUT}}'; userInput.addEventListener('input', (e) => { node.data.userPrompt = e.target.value; }); @@ -771,8 +961,8 @@ export class WorkflowEditor { container.appendChild(modelDropdown); this.setupDropdown( modelDropdown, - MODEL_OPTIONS.map(m => ({ value: m, label: m.toUpperCase() })), - node.data.model || MODEL_OPTIONS[0], + this.modelOptions.map(m => ({ value: m, label: m.toUpperCase() })), + node.data.model || this.modelOptions[0], 'Select model', (value) => { node.data.model = value; @@ -786,7 +976,7 @@ export class WorkflowEditor { const effortDropdown = document.createElement('div'); effortDropdown.className = 'ds-dropdown'; container.appendChild(effortDropdown); - const effortOptions = (MODEL_EFFORTS[node.data.model] || MODEL_EFFORTS['gpt-5']).map(optValue => ({ + const effortOptions = (this.modelEfforts[node.data.model] || this.modelEfforts[this.modelOptions[0]] || []).map(optValue => ({ value: optValue, label: optValue.charAt(0).toUpperCase() + optValue.slice(1) })); @@ -808,24 +998,36 @@ export class WorkflowEditor { toolsList.className = 'tool-list'; const toolItems = [ - { key: 'web_search', label: 'Web Search' } + { key: 'web_search', label: 'Web Search', iconClass: 'icon-globe' } ]; toolItems.forEach(tool => { - const row = document.createElement('label'); - row.className = 'tool-item'; - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.className = 'tool-checkbox'; - checkbox.checked = node.data.tools?.[tool.key] || false; - checkbox.addEventListener('change', (e) => { - if (!node.data.tools) node.data.tools = {}; - node.data.tools[tool.key] = e.target.checked; - }); - row.appendChild(checkbox); + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'tool-item tool-tag tag secondary'; + + if (tool.iconClass) { + const icon = document.createElement('span'); + icon.className = `icon ${tool.iconClass} icon-small tool-item-icon`; + row.appendChild(icon); + } const labelText = document.createElement('span'); labelText.textContent = tool.label; row.appendChild(labelText); + + const setSelected = (selected) => { + if (!node.data.tools) node.data.tools = {}; + node.data.tools[tool.key] = selected; + row.classList.toggle('selected', selected); + row.classList.toggle('secondary', !selected); + row.setAttribute('aria-pressed', String(selected)); + }; + + setSelected(Boolean(node.data.tools?.[tool.key])); + row.addEventListener('click', () => { + setSelected(!Boolean(node.data.tools?.[tool.key])); + }); + toolsList.appendChild(row); }); @@ -950,6 +1152,7 @@ export class WorkflowEditor { if(this.tempConnection) this.tempConnection.remove(); this.connectionStart = null; this.tempConnection = null; + this.updateRunButton(); } else if (this.reconnectingConnection !== null) { // Released without connecting to anything - connection already deleted, just clean up this.reconnectingConnection = null; @@ -957,6 +1160,7 @@ export class WorkflowEditor { if(this.tempConnection) this.tempConnection.remove(); this.connectionStart = null; this.tempConnection = null; + this.updateRunButton(); } } @@ -990,6 +1194,7 @@ export class WorkflowEditor { // Remove the original connection temporarily this.connections.splice(connIndex, 1); this.renderConnections(); + this.updateRunButton(); // Create temp connection for dragging this.tempConnection = document.createElementNS('http://www.w3.org/2000/svg', 'path'); @@ -1151,35 +1356,37 @@ export class WorkflowEditor { // --- CHAT PANEL HELPERS --- - appendChatMessage(text, role = 'system') { + appendChatMessage(text, role = 'system', agentName?: string) { if (!this.chatMessages) return; const message = document.createElement('div'); message.className = `chat-message ${role}`; + const normalizedText = + role === 'error' && !String(text).trim().toLowerCase().startsWith('error:') + ? `Error: ${text}` + : text; if (role === 'agent') { const label = document.createElement('span'); label.className = 'chat-message-label'; - label.textContent = this.getPrimaryAgentName(); + label.textContent = agentName || this.getPrimaryAgentName(); message.appendChild(label); } const body = document.createElement('div'); - body.textContent = text; + body.textContent = normalizedText; message.appendChild(body); this.chatMessages.appendChild(message); this.chatMessages.scrollTop = this.chatMessages.scrollHeight; } - startChatSession(promptText) { + startChatSession(_promptText) { if (!this.chatMessages) return; this.chatMessages.innerHTML = ''; - if (promptText && promptText.trim().length > 0) { - this.logManualUserMessage(promptText.trim()); - } this.showAgentSpinner(); } mapLogEntryToRole(entry) { const type = entry.type || ''; if (type.includes('llm_response')) return 'agent'; + if (type.includes('llm_error') || type === 'error') return 'error'; if (type.includes('input_received') || type.includes('start_prompt')) return 'user'; return null; } @@ -1189,22 +1396,56 @@ export class WorkflowEditor { return typeof content === 'string' ? content : ''; } + getAgentNameForNode(nodeId: string): string { + const node = this.nodes.find(n => n.id === nodeId); + return (node?.data?.agentName || '').trim() || 'Agent'; + } + + onLogEntry(entry) { + const type = entry.type || ''; + if (type === 'step_start') { + const node = this.nodes.find(n => n.id === entry.nodeId); + if (node?.type === 'agent') { + this.showAgentSpinner(this.getAgentNameForNode(entry.nodeId)); + } + } else if (type === 'start_prompt') { + // Only show the user's initial input (before any agent has responded) + if (entry.content && this.lastLlmResponseContent === null) { + this.hideAgentSpinner(); + this.appendChatMessage(entry.content, 'user'); + this.showAgentSpinner(this.getAgentNameForNode(entry.nodeId)); + } + } else if (type === 'llm_response') { + this.hideAgentSpinner(); + this.lastLlmResponseContent = entry.content ?? null; + this.appendChatMessage(entry.content || '', 'agent', this.getAgentNameForNode(entry.nodeId)); + } else if (type === 'llm_error' || type === 'error') { + this.hideAgentSpinner(); + this.appendChatMessage(entry.content || '', 'error'); + } + } + renderChatFromLogs(logs = []) { if (!this.chatMessages) return; this.chatMessages.innerHTML = ''; - let agentMessageShown = false; + this.lastLlmResponseContent = null; + let messageShown = false; logs.forEach(entry => { const role = this.mapLogEntryToRole(entry); if (!role) return; - if (role === 'agent' && !agentMessageShown) { + // Only show the user's initial input (before any agent has responded) + if (entry.type === 'start_prompt' && this.lastLlmResponseContent !== null) return; + if (entry.type === 'llm_response') this.lastLlmResponseContent = entry.content ?? null; + if ((role === 'agent' || role === 'error') && !messageShown) { this.hideAgentSpinner(); - agentMessageShown = true; + messageShown = true; } const text = this.formatLogContent(entry); if (!text) return; - this.appendChatMessage(text, role); + const agentName = role === 'agent' ? this.getAgentNameForNode(entry.nodeId) : undefined; + this.appendChatMessage(text, role, agentName); }); - if (!agentMessageShown) { + if (!messageShown) { this.showAgentSpinner(); } } @@ -1224,6 +1465,7 @@ export class WorkflowEditor { this.currentPrompt = this.initialPrompt.value || ''; this.startChatSession(this.currentPrompt); + this.lastLlmResponseContent = null; // Update Start Node with initial input startNode.data.initialInput = this.currentPrompt; @@ -1232,23 +1474,37 @@ export class WorkflowEditor { nodes: this.nodes, connections: this.connections }; + const controller = new AbortController(); + this.activeRunController = controller; try { - const result = await runWorkflow(graph); - this.handleRunResult(result); + const result = await runWorkflowStream( + graph, + (entry) => this.onLogEntry(entry), + { signal: controller.signal } + ); + this.handleRunResult(result, true); } catch (e) { - this.appendChatMessage('Error: ' + e.message, 'error'); + if (this.isAbortError(e)) return; + this.appendChatMessage(e.message, 'error'); this.appendStatusMessage('Failed', 'failed'); this.hideAgentSpinner(); this.setWorkflowState('idle'); + } finally { + if (this.activeRunController === controller) { + this.activeRunController = null; + } } } - handleRunResult(result) { - if (result.logs) { + handleRunResult(result, fromStream = false) { + if (!fromStream && result.logs) { this.renderChatFromLogs(result.logs); } + const hasLlmError = Array.isArray(result.logs) + ? result.logs.some(entry => (entry?.type || '').includes('llm_error')) + : false; if (result.status === 'paused' && result.waitingForInput) { this.currentRunId = result.runId; @@ -1257,7 +1513,11 @@ export class WorkflowEditor { this.setWorkflowState('paused'); } else if (result.status === 'completed') { this.clearApprovalMessage(); - this.appendStatusMessage('Completed', 'completed'); + if (hasLlmError) { + this.appendStatusMessage('Failed', 'failed'); + } else { + this.appendStatusMessage('Completed', 'completed'); + } this.hideAgentSpinner(); this.setWorkflowState('idle'); this.currentRunId = null; @@ -1283,15 +1543,22 @@ export class WorkflowEditor { this.replaceApprovalWithResult(decision, note); this.setWorkflowState('running'); this.showAgentSpinner(); + const controller = new AbortController(); + this.activeRunController = controller; try { - const result = await resumeWorkflow(this.currentRunId, { decision, note }); + const result = await resumeWorkflow(this.currentRunId, { decision, note }, { signal: controller.signal }); this.handleRunResult(result); } catch (e) { + if (this.isAbortError(e)) return; this.appendChatMessage(e.message, 'error'); this.appendStatusMessage('Failed', 'failed'); this.hideAgentSpinner(); this.setWorkflowState('idle'); + } finally { + if (this.activeRunController === controller) { + this.activeRunController = null; + } } } } diff --git a/apps/web/src/components/help-modal.ts b/apps/web/src/components/help-modal.ts deleted file mode 100644 index 0679a49..0000000 --- a/apps/web/src/components/help-modal.ts +++ /dev/null @@ -1,111 +0,0 @@ -export interface HelpModalOptions { - triggerSelector?: string; - content?: string; - theme?: 'auto' | 'light' | 'dark'; -} - -export class HelpModal { - private options: Required; - - private isOpen = false; - - private modal: HTMLDivElement; - - private trigger: HTMLElement | null = null; - - constructor(options: HelpModalOptions = {}) { - this.options = { - triggerSelector: '#btn-help', - content: '', - theme: 'auto', - ...options - }; - - this.modal = this.createModal(); - this.bindEvents(); - } - - static init(options: HelpModalOptions) { - return new HelpModal(options); - } - - open() { - if (this.isOpen) return; - this.isOpen = true; - this.modal.style.display = 'flex'; - document.body.style.overflow = 'hidden'; - const closeBtn = this.modal.querySelector('.modal-close'); - closeBtn?.focus(); - this.trigger?.dispatchEvent(new CustomEvent('helpModal:open', { detail: this })); - } - - close() { - if (!this.isOpen) return; - this.isOpen = false; - this.modal.style.display = 'none'; - document.body.style.overflow = ''; - this.trigger?.focus(); - this.trigger?.dispatchEvent(new CustomEvent('helpModal:close', { detail: this })); - } - - updateContent(content: string) { - const body = this.modal.querySelector('.modal-body'); - if (body) { - body.innerHTML = content; - } - } - - private createModal(): HTMLDivElement { - const modal = document.createElement('div'); - modal.className = 'modal'; - modal.style.display = 'none'; - modal.innerHTML = ` - - - `; - document.body.appendChild(modal); - return modal; - } - - private bindEvents() { - this.trigger = document.querySelector(this.options.triggerSelector); - if (!this.trigger) { - console.warn(`Help trigger "${this.options.triggerSelector}" not found`); - return; - } - - this.trigger.addEventListener('click', (event) => { - event.preventDefault(); - this.open(); - }); - - const closeBtn = this.modal.querySelector('.modal-close'); - closeBtn?.addEventListener('click', () => this.close()); - - const backdrop = this.modal.querySelector('.modal-backdrop'); - backdrop?.addEventListener('click', () => this.close()); - - document.addEventListener('keydown', (event) => { - if (event.key === 'Escape' && this.isOpen) { - this.close(); - } - }); - - this.modal.addEventListener('click', (event) => { - const target = event.target as HTMLElement; - if (target.matches('a[href^="#"]')) { - event.preventDefault(); - const id = target.getAttribute('href')?.substring(1); - const section = id ? this.modal.querySelector(`#${id}`) : null; - section?.scrollIntoView({ behavior: 'smooth' }); - } - }); - } -} - diff --git a/apps/web/src/data/help-content.ts b/apps/web/src/data/help-content.ts index 707ba09..0b14136 100644 --- a/apps/web/src/data/help-content.ts +++ b/apps/web/src/data/help-content.ts @@ -51,14 +51,5 @@ export const helpContent = ` Why does a workflow pause?

Approval nodes intentionally pause execution until you make a decision in the Run Console.

-
- Where are run logs stored? -

Each execution is saved under data/runs/run_*.json for later auditing.

-
-
- Do I need an OpenAI key? -

No. Without OPENAI_API_KEY, the engine returns mock responses. Provide the key for live model calls.

-
`; - diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index b879f7e..d9d4a54 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -1,7 +1,6 @@ import './workflow-editor.css'; import WorkflowEditor from './app/workflow-editor'; -import { HelpModal } from './components/help-modal'; import { helpContent } from './data/help-content'; declare global { @@ -12,5 +11,25 @@ declare global { document.addEventListener('DOMContentLoaded', () => { window.editor = new WorkflowEditor(); - HelpModal.init({ content: helpContent }); + const helpTrigger = document.getElementById('btn-help'); + if (!helpTrigger) return; + + const origin = window.location.origin; + const modalModulePath = `${origin}/design-system/components/modal/modal.js`; + + import(/* @vite-ignore */ modalModulePath) + .then(({ default: Modal }) => { + const helpModal = Modal.createHelpModal({ + title: 'Help / User Guide', + content: helpContent + }); + + helpTrigger.addEventListener('click', (event) => { + event.preventDefault(); + helpModal.open(); + }); + }) + .catch((error) => { + console.warn('Failed to initialize DS help modal', error); + }); }); diff --git a/apps/web/src/services/api.ts b/apps/web/src/services/api.ts index eb4cf2c..d64e4bf 100644 --- a/apps/web/src/services/api.ts +++ b/apps/web/src/services/api.ts @@ -1,25 +1,135 @@ -import type { ApprovalInput, WorkflowGraph, WorkflowRunResult } from '@agentic/types'; +import type { ApprovalInput, WorkflowGraph, WorkflowLogEntry, WorkflowRunResult } from '@agentic/types'; -async function request(url: string, body: unknown): Promise { +type RequestOptions = { + signal?: AbortSignal; +}; + +async function request(url: string, body: unknown, options: RequestOptions = {}): Promise { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) + body: JSON.stringify(body), + signal: options.signal }); if (!res.ok) { + const contentType = res.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + let payload: { error?: string; details?: string; message?: string } | null = null; + try { + payload = (await res.json()) as { error?: string; details?: string; message?: string }; + } catch { + // Fall through to generic message + } + const message = payload?.error || payload?.details || payload?.message; + throw new Error(message || 'Request failed'); + } + const text = await res.text(); - throw new Error(text || 'Request failed'); + if (text) { + let parsed: { error?: string; details?: string; message?: string } | undefined; + try { + parsed = JSON.parse(text) as { error?: string; details?: string; message?: string }; + } catch { /* not JSON */ } + const message = parsed?.error || parsed?.details || parsed?.message; + throw new Error(message || text.trim()); + } + + throw new Error('Request failed'); } return res.json() as Promise; } -export function runWorkflow(graph: WorkflowGraph): Promise { - return request('/api/run', { graph }); +export function runWorkflow(graph: WorkflowGraph, options: RequestOptions = {}): Promise { + return request('/api/run', { graph }, options); +} + +export async function runWorkflowStream( + graph: WorkflowGraph, + onLog: (entry: WorkflowLogEntry) => void, + options: RequestOptions = {} +): Promise { + const res = await fetch('/api/run-stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ graph }), + signal: options.signal + }); + + if (!res.ok) { + const text = await res.text(); + let parsed: { error?: string; details?: string } | undefined; + try { + parsed = JSON.parse(text) as { error?: string; details?: string }; + } catch { /* not JSON */ } + throw new Error(parsed?.error || parsed?.details || text.trim() || 'Request failed'); + } + + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let result: WorkflowRunResult | null = null; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop()!; + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const parsed = JSON.parse(line.slice(6)) as { + type: string; + entry?: WorkflowLogEntry; + result?: WorkflowRunResult; + message?: string; + }; + if (parsed.type === 'log' && parsed.entry) { + onLog(parsed.entry); + } else if (parsed.type === 'done' && parsed.result) { + result = parsed.result; + } else if (parsed.type === 'error') { + throw new Error(parsed.message || 'Workflow execution failed'); + } + } + } + } finally { + reader.releaseLock(); + } + + if (!result) throw new Error('Workflow stream ended without a result'); + return result; } -export function resumeWorkflow(runId: string, input: ApprovalInput): Promise { - return request('/api/resume', { runId, input }); +export type ProviderModel = { + id: string; + name: string; + reasoningEfforts: string[]; +}; + +export type Provider = { + id: string; + name: string; + enabled: boolean; + models: ProviderModel[]; +}; + +export type AppConfig = { + providers?: Provider[]; +}; + +export async function fetchConfig(): Promise { + const res = await fetch('/api/config'); + if (!res.ok) return {}; + return res.json() as Promise; } +export function resumeWorkflow( + runId: string, + input: ApprovalInput, + options: RequestOptions = {} +): Promise { + return request('/api/resume', { runId, input }, options); +} diff --git a/apps/web/src/workflow-editor.css b/apps/web/src/workflow-editor.css index fb82443..6e617e4 100644 --- a/apps/web/src/workflow-editor.css +++ b/apps/web/src/workflow-editor.css @@ -2,7 +2,6 @@ --node-width-min: 120px; --node-width-max: 280px; --node-width-expanded: 420px; - --sidebar-width: 260px; --right-sidebar-width: 320px; } @@ -25,10 +24,8 @@ body { } -/* Main Layout Override for 2-column structure */ +/* Split panel host */ .main-layout { - display: grid; - grid-template-columns: 1fr 6px var(--right-sidebar-width); height: 100vh; overflow: hidden; } @@ -40,12 +37,22 @@ body { display: flex; flex-direction: column; gap: var(--UI-Spacing-spacing-mxs); - overflow-y: auto; - width: var(--right-sidebar-width); - min-width: var(--right-sidebar-width); + height: 100%; + min-height: 0; + overflow: hidden; box-sizing: border-box; } +.split-panel-container { + height: 100vh; +} + +.split-panel-container .split-panel-left, +.split-panel-container .split-panel-right { + overflow: hidden; + min-height: 0; +} + .palette-floating { position: absolute; top: var(--UI-Spacing-spacing-ms); @@ -65,33 +72,6 @@ body { font-size: var(--Fonts-Body-Default-md); } -.panel-resizer { - width: 6px; - cursor: col-resize; - background: transparent; - position: relative; - z-index: 4; - height: 100%; -} - -.panel-resizer::after { - content: ''; - position: absolute; - left: 50%; - top: 0; - bottom: 0; - width: 2px; - background: var(--Colors-Stroke-Default); - transform: translateX(-50%); - opacity: 0; - transition: opacity 0.2s ease; -} - -.panel-resizer:hover::after, -.panel-resizer.dragging::after { - opacity: 1; -} - /* Palette Items */ .draggable-node { padding: var(--UI-Spacing-spacing-mxs); @@ -115,6 +95,9 @@ body { /* Canvas Area */ #canvas-container { position: relative; + width: 100%; + height: 100%; + min-height: 100%; overflow: hidden; background-image: radial-gradient(var(--Colors-Stroke-Default) 1px, transparent 1px); background-size: 20px 20px; @@ -122,6 +105,16 @@ body { cursor: grab; } +@media (prefers-color-scheme: dark) { + #canvas-container { + background-image: radial-gradient(color-mix(in srgb, var(--Colors-Stroke-Default) 10%, transparent) 1px, transparent 1px); + } + + .tool-list .tool-item.selected { + background: color-mix(in srgb, var(--Colors-Tags-Primary) 35%, var(--Colors-Tags-Secondary)); + } +} + #canvas-container.panning { cursor: grabbing; } @@ -154,7 +147,6 @@ body { right: var(--UI-Spacing-spacing-ms); z-index: 5; display: flex; - gap: var(--UI-Spacing-spacing-xs); } .canvas-clear { @@ -170,28 +162,57 @@ body { opacity: 0.9; } -.canvas-controls button { - width: 48px; +.zoom-control { height: 44px; border-radius: var(--UI-Radius-radius-mxl); - border: none; - font-size: var(--Fonts-Body-Default-xl); - color: var(--Colors-Text-Body-Strong); - background: var(--Colors-Backgrounds-Main-Top); + padding: 0 var(--UI-Spacing-spacing-mxs); + background: color-mix(in srgb, var(--Colors-Backgrounds-Main-Strong) 82%, transparent); box-shadow: 0 3px 2px 0 var(--Colors-Shadow-Card); display: flex; align-items: center; - justify-content: center; - padding: 0; + gap: var(--UI-Spacing-spacing-s); + border: 1px solid var(--Colors-Stroke-Default); } -.canvas-controls button:hover { - background: var(--Colors-Backgrounds-Main-Medium); +.zoom-label { + display: inline-flex; + align-items: center; + gap: var(--UI-Spacing-spacing-xs); + color: var(--Colors-Text-Body-Default); + padding: 0 var(--UI-Spacing-spacing-xxs); + font-size: var(--Fonts-Body-Default-md); + font-weight: 600; +} + +.zoom-label .icon { + color: var(--Colors-Text-Body-Secondary); } -.canvas-controls button span { +.zoom-stepper { + width: 28px; + height: 28px; + min-width: 28px; + border-radius: 50%; + font-size: var(--Fonts-Body-Default-xl); line-height: 1; + padding: 0; +} + +.zoom-value { + font-size: var(--Fonts-Body-Default-md); font-weight: 600; + letter-spacing: 0.01em; + width: 54px; + min-width: 54px; + text-align: center; + padding: 0; + font-variant-numeric: tabular-nums; +} + +.zoom-value:focus-visible, +.zoom-stepper:focus-visible { + outline: 2px solid var(--Colors-Primary-Medium); + outline-offset: 1px; } /* SVG Connections */ @@ -292,6 +313,8 @@ path.connection-line.active { } .node-header > span:first-child { + display: inline-flex; + align-items: center; flex: 1; min-width: 0; overflow: hidden; @@ -480,57 +503,30 @@ path.connection-line.active { color: var(--Colors-Text-Body-Default); } -.tool-list .tool-item:hover { - color: var(--Colors-Text-Body-Strong); -} - -/* Custom checkbox styling */ -.tool-list .tool-checkbox { +.tool-list .tool-tag { + width: fit-content; + border: none; + cursor: pointer; appearance: none; -webkit-appearance: none; - width: 18px; - height: 18px; - border: 2px solid var(--Colors-Stroke-Strong); - border-radius: var(--UI-Radius-radius-xxs); - background: var(--Colors-Backgrounds-Main-Top); - cursor: pointer; - position: relative; - flex-shrink: 0; - transition: all 0.15s ease; -} - -.tool-list .tool-checkbox:hover { - border-color: var(--Colors-Primary-Medium); } -.tool-list .tool-checkbox:checked { - background: var(--Colors-Primary-Default); - border-color: var(--Colors-Primary-Default); -} - -.tool-list .tool-checkbox:checked::after { - content: ''; - position: absolute; - left: 5px; - top: 2px; - width: 4px; - height: 8px; - border: solid var(--Colors-Text-Body-White); - border-width: 0 2px 2px 0; - transform: rotate(45deg); +.tool-list .tool-item:hover { + color: var(--Colors-Text-Body-Strong); } -.tool-list .tool-checkbox:focus { - outline: none; - box-shadow: 0 0 0 3px var(--Colors-Primary-Lightest); +.tool-list .tool-item.selected .tool-item-icon { + color: var(--Colors-Primary-Medium); } /* Right Sidebar - Chat Panel */ .chat-panel { - height: calc(100vh - var(--UI-Spacing-spacing-ms)); + height: 100%; + min-height: 0; display: flex; flex-direction: column; padding: var(--UI-Spacing-spacing-mxs); + box-sizing: border-box; } .chat-header { @@ -545,6 +541,7 @@ path.connection-line.active { } .chat-messages { flex: 1; + min-height: 0; background: var(--Colors-Backgrounds-Main-Light); border: 1px solid var(--Colors-Stroke-Default); border-radius: var(--UI-Radius-radius-s); @@ -560,6 +557,8 @@ path.connection-line.active { padding: var(--UI-Spacing-spacing-mxs) var(--UI-Spacing-spacing-ms); line-height: 1.4; font-size: var(--Fonts-Body-Default-xs); + max-width: 100%; + min-width: 0; } .chat-message.user { @@ -622,6 +621,7 @@ path.connection-line.active { .chat-message > div:last-child { white-space: pre-wrap; + overflow-wrap: anywhere; } .chat-message .chat-message-label { @@ -783,98 +783,68 @@ path.connection-line.active { box-sizing: border-box; } -.run-button { - width: 100%; +.run-actions { margin-top: var(--UI-Spacing-spacing-mxs); -} - -/* Confirmation Modal Adjustments */ -#confirm-modal .modal-content { - max-width: 420px; - width: min(420px, calc(100% - 2rem)); -} - -#confirm-modal .modal-header, -#confirm-modal .modal-body, -#confirm-modal .modal-footer { - padding: var(--UI-Spacing-spacing-ms); -} - -#confirm-modal .modal-body { - font-size: var(--Fonts-Body-Default-md); - color: var(--Colors-Text-Body-Strong); -} - -#confirm-modal .modal-footer { - border-top: 1px solid var(--Colors-Stroke-Default); display: flex; - justify-content: flex-end; + align-items: center; gap: var(--UI-Spacing-spacing-s); } -/* Modal Base Styles */ -.modal { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 1000; - display: flex; +.run-button { + flex: 1; + width: auto; + display: inline-flex; align-items: center; justify-content: center; -} - -.modal-backdrop { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: var(--Colors-Base-Neutral-Alphas-1400-50); - backdrop-filter: blur(2px); -} - -.modal-content { + gap: var(--UI-Spacing-spacing-s); position: relative; - background: var(--Colors-Backgrounds-Main-Top); - border-radius: var(--UI-Radius-radius-m); - box-shadow: 0 12px 38px 0 var(--Colors-Shadow-Float, rgba(22, 44, 96, 0.12)); - max-width: 600px; - width: min(600px, calc(100% - 2rem)); - max-height: 90vh; - overflow-y: auto; + overflow: visible; } -.modal-header { - display: flex; - justify-content: space-between; +.cancel-run-button { + width: var(--UI-Input-md); + min-width: var(--UI-Input-md); + height: var(--UI-Input-md); + padding: 0; + display: none; align-items: center; - padding: var(--UI-Spacing-spacing-ms); - border-bottom: 1px solid var(--Colors-Stroke-Default); -} - -.modal-header h2 { - margin: 0; - font-size: var(--Fonts-Headlines-xsm); + justify-content: center; } -.modal-close { - background: none; - border: none; - font-size: var(--Fonts-Body-Default-xxl); - cursor: pointer; - color: var(--Colors-Text-Body-Medium); - padding: var(--UI-Spacing-spacing-xxs); - line-height: 1; +.run-button:disabled { + pointer-events: auto; + cursor: not-allowed; } -.modal-close:hover { +.run-button:disabled[data-disabled-hint]:hover::after { + content: attr(data-disabled-hint); + position: absolute; + left: 50%; + bottom: calc(100% + var(--UI-Spacing-spacing-s)); + transform: translateX(-50%); + width: max-content; + max-width: min(320px, calc(100vw - 32px)); + padding: var(--UI-Spacing-spacing-s) var(--UI-Spacing-spacing-ms); + border-radius: var(--UI-Radius-radius-xs); + border: 1px solid var(--Colors-Stroke-Strong); + background: var(--Colors-Backgrounds-Main-Top); color: var(--Colors-Text-Body-Strong); + font-size: var(--Fonts-Body-Default-xxs); + line-height: 1.4; + white-space: normal; + text-align: left; + z-index: 30; + box-shadow: 0 3px 2px 0 var(--Colors-Shadow-Card); } -.modal-body { - padding: var(--UI-Spacing-spacing-ms); - max-height: 60vh; - overflow-y: auto; +.run-button:disabled[data-disabled-hint]:hover::before { + content: ''; + position: absolute; + left: 50%; + bottom: calc(100% + var(--UI-Spacing-spacing-xxs)); + transform: translateX(-50%); + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid var(--Colors-Backgrounds-Main-Top); + z-index: 31; } diff --git a/design-system b/design-system index 30ab87b..450e146 160000 --- a/design-system +++ b/design-system @@ -1 +1 @@ -Subproject commit 30ab87bae891f0b621bcb4081e8d53b19557653c +Subproject commit 450e146b6ce81363abb1a5fdab26868e51b7ab72 diff --git a/package-lock.json b/package-lock.json index 952c51f..f0acb14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,6 @@ "@agentic/types": "file:../../packages/types", "@agentic/workflow-engine": "file:../../packages/workflow-engine", "cors": "^2.8.5", - "dotenv": "^17.2.3", "express": "^4.19.2", "openai": "^6.9.1" }, @@ -1280,6 +1279,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -1611,6 +1611,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2063,18 +2064,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2225,6 +2214,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4129,6 +4119,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4187,6 +4178,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/packages/workflow-engine/src/index.js b/packages/workflow-engine/src/index.js index f14ea3d..e6fa305 100644 --- a/packages/workflow-engine/src/index.js +++ b/packages/workflow-engine/src/index.js @@ -2,10 +2,9 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.WorkflowEngine = void 0; const DEFAULT_REASONING = 'low'; -class MockLLM { - async respond(input) { - const toolSuffix = input.tools?.web_search ? ' (web search enabled)' : ''; - return `Mock response for "${input.userContent || 'empty prompt'}" using ${input.model}${toolSuffix}.`; +class MissingLLM { + async respond() { + throw new Error('No LLM service configured. Set OPENAI_API_KEY in the environment.'); } } class WorkflowEngine { @@ -17,7 +16,7 @@ class WorkflowEngine { this.waitingForInput = false; this.graph = this.normalizeGraph(graph); this.runId = options.runId ?? Date.now().toString(); - this.llm = options.llm ?? new MockLLM(); + this.llm = options.llm ?? new MissingLLM(); this.timestampFn = options.timestampFn ?? (() => new Date().toISOString()); this.onLog = options.onLog; } @@ -73,10 +72,10 @@ class WorkflowEngine { const restored = this.state.pre_approval_output; if (restored !== undefined) { if (typeof restored === 'string') { - this.state.last_output = restored; + this.state.previous_output = restored; } else { - this.state.last_output = JSON.stringify(restored); + this.state.previous_output = JSON.stringify(restored); } } delete this.state.pre_approval_output; @@ -84,7 +83,7 @@ class WorkflowEngine { } else { this.log(currentNode.id, 'input_received', JSON.stringify(input)); - this.state.last_output = input ?? ''; + this.state.previous_output = input ?? ''; connection = this.graph.connections.find((c) => c.source === currentNode.id); } if (connection) { @@ -151,7 +150,7 @@ class WorkflowEngine { break; } case 'approval': - this.state.pre_approval_output = this.state.last_output; + this.state.pre_approval_output = this.state.previous_output; this.status = 'paused'; this.waitingForInput = true; this.log(node.id, 'wait_input', 'Waiting for user approval'); @@ -162,7 +161,7 @@ class WorkflowEngine { default: this.log(node.id, 'warn', `Unknown node type "${node.type}" skipped`); } - this.state.last_output = output; + this.state.previous_output = output; this.state[node.id] = output; const nextConnection = this.graph.connections.find((c) => c.source === node.id); if (nextConnection) { @@ -180,7 +179,14 @@ class WorkflowEngine { } catch (error) { const message = error instanceof Error ? error.message : String(error); - this.log(node.id, 'error', message); + const lastLog = this.logs[this.logs.length - 1]; + const isDuplicateLlmError = lastLog && + lastLog.nodeId === node.id && + lastLog.type === 'llm_error' && + lastLog.content === message; + if (!isDuplicateLlmError) { + this.log(node.id, 'error', message); + } this.status = 'failed'; } } @@ -204,7 +210,7 @@ class WorkflowEngine { } evaluateIfNode(node) { const condition = node.data?.condition || ''; - const input = JSON.stringify(this.state.last_output || ''); + const input = JSON.stringify(this.state.previous_output || ''); const match = input.toLowerCase().includes(condition.toLowerCase()); this.log(node.id, 'logic_check', `Condition "${condition}" evaluated as ${match ? 'true' : 'false'}`); const trueConn = this.graph.connections.find((c) => c.source === node.id && c.sourceHandle === 'true'); @@ -216,23 +222,27 @@ class WorkflowEngine { return null; } async executeAgentNode(node) { - const previousOutput = this.state.last_output; - let userContent = ''; - if (node.data?.userPrompt && typeof node.data.userPrompt === 'string' && node.data.userPrompt.trim()) { - userContent = node.data.userPrompt; - } - else if (typeof previousOutput === 'string') { - userContent = previousOutput; + const previousOutput = this.state.previous_output; + let lastOutputStr = ''; + if (typeof previousOutput === 'string') { + lastOutputStr = previousOutput; } else if (previousOutput !== undefined && previousOutput !== null) { - userContent = JSON.stringify(previousOutput); + lastOutputStr = JSON.stringify(previousOutput); } if (previousOutput && typeof previousOutput === 'object' && ('decision' in previousOutput || 'note' in previousOutput)) { - const safe = this.findLastNonApprovalOutput(); - userContent = safe || ''; + lastOutputStr = this.findLastNonApprovalOutput() || ''; + } + const userPrompt = node.data?.userPrompt; + let userContent; + if (userPrompt && typeof userPrompt === 'string' && userPrompt.trim()) { + userContent = userPrompt.replace(/\{\{PREVIOUS_OUTPUT\}\}/g, lastOutputStr); + } + else { + userContent = lastOutputStr; } const invocation = { systemPrompt: node.data?.systemPrompt || 'You are a helpful assistant.', @@ -250,14 +260,14 @@ class WorkflowEngine { catch (error) { const message = error instanceof Error ? error.message : String(error); this.log(node.id, 'llm_error', message); - return `LLM error: ${message}`; + throw error instanceof Error ? error : new Error(message); } } findLastNonApprovalOutput() { const entries = Object.entries(this.state); for (let i = entries.length - 1; i >= 0; i -= 1) { const [key, value] = entries[i]; - if (key.includes('_approval') || key === 'last_output' || key === 'pre_approval_output') { + if (key.includes('_approval') || key === 'previous_output' || key === 'pre_approval_output') { continue; } if (typeof value === 'string') { diff --git a/packages/workflow-engine/src/index.ts b/packages/workflow-engine/src/index.ts index b8ea0e1..907be48 100644 --- a/packages/workflow-engine/src/index.ts +++ b/packages/workflow-engine/src/index.ts @@ -33,10 +33,9 @@ export interface WorkflowEngineInitOptions { const DEFAULT_REASONING = 'low'; -class MockLLM implements WorkflowLLM { - async respond(input: AgentInvocation): Promise { - const toolSuffix = input.tools?.web_search ? ' (web search enabled)' : ''; - return `Mock response for "${input.userContent || 'empty prompt'}" using ${input.model}${toolSuffix}.`; +class MissingLLM implements WorkflowLLM { + async respond(): Promise { + throw new Error('No LLM service configured. Set OPENAI_API_KEY in the environment.'); } } @@ -64,7 +63,7 @@ export class WorkflowEngine { constructor(graph: WorkflowGraph, options: WorkflowEngineInitOptions = {}) { this.graph = this.normalizeGraph(graph); this.runId = options.runId ?? Date.now().toString(); - this.llm = options.llm ?? new MockLLM(); + this.llm = options.llm ?? new MissingLLM(); this.timestampFn = options.timestampFn ?? (() => new Date().toISOString()); this.onLog = options.onLog; } @@ -136,9 +135,9 @@ export class WorkflowEngine { const restored = this.state.pre_approval_output; if (restored !== undefined) { if (typeof restored === 'string') { - this.state.last_output = restored; + this.state.previous_output = restored; } else { - this.state.last_output = JSON.stringify(restored); + this.state.previous_output = JSON.stringify(restored); } } delete this.state.pre_approval_output; @@ -147,7 +146,7 @@ export class WorkflowEngine { ); } else { this.log(currentNode.id, 'input_received', JSON.stringify(input)); - this.state.last_output = input ?? ''; + this.state.previous_output = input ?? ''; connection = this.graph.connections.find((c) => c.source === currentNode.id); } @@ -219,7 +218,7 @@ export class WorkflowEngine { break; } case 'approval': - this.state.pre_approval_output = this.state.last_output; + this.state.pre_approval_output = this.state.previous_output; this.status = 'paused'; this.waitingForInput = true; this.log(node.id, 'wait_input', 'Waiting for user approval'); @@ -231,7 +230,7 @@ export class WorkflowEngine { this.log(node.id, 'warn', `Unknown node type "${node.type}" skipped`); } - this.state.last_output = output; + this.state.previous_output = output; this.state[node.id] = output; const nextConnection = this.graph.connections.find((c) => c.source === node.id); @@ -247,7 +246,15 @@ export class WorkflowEngine { } } catch (error) { const message = error instanceof Error ? error.message : String(error); - this.log(node.id, 'error', message); + const lastLog = this.logs[this.logs.length - 1]; + const isDuplicateLlmError = + lastLog && + lastLog.nodeId === node.id && + lastLog.type === 'llm_error' && + lastLog.content === message; + if (!isDuplicateLlmError) { + this.log(node.id, 'error', message); + } this.status = 'failed'; } } @@ -273,7 +280,7 @@ export class WorkflowEngine { private evaluateIfNode(node: WorkflowNode): string | null { const condition = (node.data?.condition as string) || ''; - const input = JSON.stringify(this.state.last_output || ''); + const input = JSON.stringify(this.state.previous_output || ''); const match = input.toLowerCase().includes(condition.toLowerCase()); this.log( node.id, @@ -292,25 +299,33 @@ export class WorkflowEngine { } private async executeAgentNode(node: WorkflowNode): Promise { - const previousOutput = this.state.last_output; - let userContent = ''; + const previousOutput = this.state.previous_output; - if (node.data?.userPrompt && typeof node.data.userPrompt === 'string' && node.data.userPrompt.trim()) { - userContent = node.data.userPrompt; - } else if (typeof previousOutput === 'string') { - userContent = previousOutput; + // Resolve previousOutput to a string for template substitution + let lastOutputStr = ''; + if (typeof previousOutput === 'string') { + lastOutputStr = previousOutput; } else if (previousOutput !== undefined && previousOutput !== null) { - userContent = JSON.stringify(previousOutput); + lastOutputStr = JSON.stringify(previousOutput); } + // If the previous output was an approval object, use the last safe non-approval output if ( previousOutput && typeof previousOutput === 'object' && ('decision' in (previousOutput as Record) || 'note' in (previousOutput as Record)) ) { - const safe = this.findLastNonApprovalOutput(); - userContent = safe || ''; + lastOutputStr = this.findLastNonApprovalOutput() || ''; + } + + const userPrompt = node.data?.userPrompt; + let userContent: string; + if (userPrompt && typeof userPrompt === 'string' && userPrompt.trim()) { + userContent = userPrompt.replace(/\{\{PREVIOUS_OUTPUT\}\}/g, lastOutputStr); + } else { + // Backwards compatibility: empty userPrompt falls back to last output directly + userContent = lastOutputStr; } const invocation: AgentInvocation = { @@ -331,7 +346,7 @@ export class WorkflowEngine { } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log(node.id, 'llm_error', message); - return `LLM error: ${message}`; + throw error instanceof Error ? error : new Error(message); } } @@ -339,7 +354,7 @@ export class WorkflowEngine { const entries = Object.entries(this.state); for (let i = entries.length - 1; i >= 0; i -= 1) { const [key, value] = entries[i]; - if (key.includes('_approval') || key === 'last_output' || key === 'pre_approval_output') { + if (key.includes('_approval') || key === 'previous_output' || key === 'pre_approval_output') { continue; } if (typeof value === 'string') { @@ -380,4 +395,3 @@ export class WorkflowEngine { } export default WorkflowEngine; -