Feature: Implement comprehensive workflow management system (selection, execution panel, and API wrappers)
This commit is contained in:
22
src/workflows/WorkflowConfirmationDialog.jsx
Normal file
22
src/workflows/WorkflowConfirmationDialog.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorkflowConfirmationDialog({ confirmation, onRespond }) {
|
||||
if (!confirmation) return null
|
||||
const args = confirmation.payload?.arguments || {}
|
||||
return (
|
||||
<div className="workflow-confirmation-overlay" role="dialog" aria-modal="true">
|
||||
<div className="workflow-confirmation-dialog">
|
||||
<h3>Confirm workflow action</h3>
|
||||
<p>This workflow wants to run <code>{confirmation.payload?.tool}</code>.</p>
|
||||
{args.library_slug && <p><strong>Destination:</strong> {args.library_slug}</p>}
|
||||
{args.title && <p><strong>Title:</strong> {args.title}</p>}
|
||||
{args.message_id && <p><strong>Message:</strong> {args.message_id}</p>}
|
||||
{args.url && <p><strong>URL:</strong> {args.url}</p>}
|
||||
<div className="workflow-confirmation-actions">
|
||||
<button className="button ghost" type="button" onClick={() => onRespond(false)}>Reject</button>
|
||||
<button className="button" type="button" onClick={() => onRespond(true)}>Approve</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/workflows/WorkflowExecutionPanel.jsx
Normal file
37
src/workflows/WorkflowExecutionPanel.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
|
||||
const ACTIVE_TYPES = new Set(['node_started', 'tool_started', 'model_token', 'confirmation_required'])
|
||||
|
||||
export default function WorkflowExecutionPanel({ execution }) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
if (!execution) return null
|
||||
const events = execution.events || []
|
||||
const current = [...events].reverse().find(event => ACTIVE_TYPES.has(event.type))
|
||||
const duration = execution.startedAt
|
||||
? Math.max(0, ((execution.finishedAt || Date.now()) - execution.startedAt) / 1000).toFixed(1)
|
||||
: '0.0'
|
||||
return (
|
||||
<div className={`workflow-execution ${execution.status || ''}`}>
|
||||
<button type="button" className="workflow-execution-summary" onClick={() => setOpen(value => !value)}>
|
||||
<span>{open ? '▾' : '▸'}</span>
|
||||
<strong>{execution.workflowName || 'Workflow'}</strong>
|
||||
<span>{execution.status || 'running'}</span>
|
||||
<span>{duration}s</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="workflow-execution-body">
|
||||
{current && <div className="workflow-current-node">Current: {current.node_id || current.payload?.tool || current.type}</div>}
|
||||
<div className="workflow-event-list">
|
||||
{events.filter(event => event.type !== 'model_token').slice(-40).map(event => (
|
||||
<div key={event.sequence} className={`workflow-event ${event.type}`}>
|
||||
<span>{event.type.replaceAll('_', ' ')}</span>
|
||||
<code>{event.node_id || event.payload?.tool || ''}</code>
|
||||
{event.payload?.error && <small>{String(event.payload.error)}</small>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
src/workflows/WorkflowSelector.jsx
Normal file
25
src/workflows/WorkflowSelector.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorkflowSelector({ disabled, onChange, selection, workflows }) {
|
||||
const value = selection.mode === 'explicit' ? `workflow:${selection.workflowId}` : selection.mode
|
||||
return (
|
||||
<label className="workflow-selector" title="Choose how this chat prompt is executed">
|
||||
<span>Flow</span>
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value
|
||||
if (next === 'direct' || next === 'automatic') onChange({ mode: next, workflowId: null })
|
||||
else onChange({ mode: 'explicit', workflowId: next.replace(/^workflow:/, '') })
|
||||
}}
|
||||
>
|
||||
<option value="direct">Direct</option>
|
||||
<option value="automatic">Automatic</option>
|
||||
{workflows.filter(item => item.enabled && item.slug !== 'input-output').map(item => (
|
||||
<option key={item.id} value={`workflow:${item.id}`}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
86
src/workflows/workflowApi.js
Normal file
86
src/workflows/workflowApi.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { expectBackendJson, readBackendErrorText } from '../backendApi'
|
||||
|
||||
export async function fetchTools(apiBase) {
|
||||
return expectBackendJson(await fetch(`${apiBase}/tools`))
|
||||
}
|
||||
|
||||
export async function fetchWorkflows(apiBase) {
|
||||
return expectBackendJson(await fetch(`${apiBase}/workflows`))
|
||||
}
|
||||
|
||||
export async function fetchWorkflow(apiBase, workflowId) {
|
||||
return expectBackendJson(await fetch(`${apiBase}/workflows/${workflowId}`))
|
||||
}
|
||||
|
||||
export async function createWorkflow(apiBase, payload) {
|
||||
return expectBackendJson(await fetch(`${apiBase}/workflows`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function updateWorkflow(apiBase, workflowId, payload) {
|
||||
return expectBackendJson(await fetch(`${apiBase}/workflows/${workflowId}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function duplicateWorkflow(apiBase, workflowId) {
|
||||
return expectBackendJson(await fetch(`${apiBase}/workflows/${workflowId}/duplicate`, { method: 'POST' }))
|
||||
}
|
||||
|
||||
export async function saveWorkflowRevision(apiBase, workflowId, graph) {
|
||||
return expectBackendJson(await fetch(`${apiBase}/workflows/${workflowId}/revisions`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ graph }),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function validateWorkflow(apiBase, graph) {
|
||||
return expectBackendJson(await fetch(`${apiBase}/workflows/validate`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ graph }),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function deleteWorkflow(apiBase, workflowId) {
|
||||
return expectBackendJson(await fetch(`${apiBase}/workflows/${workflowId}`, { method: 'DELETE' }))
|
||||
}
|
||||
|
||||
export async function createWorkflowRun(apiBase, payload, signal) {
|
||||
return expectBackendJson(await fetch(`${apiBase}/workflow-runs`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getWorkflowRun(apiBase, runId) {
|
||||
return expectBackendJson(await fetch(`${apiBase}/workflow-runs/${runId}`))
|
||||
}
|
||||
|
||||
export async function cancelWorkflowRun(apiBase, runId) {
|
||||
return expectBackendJson(await fetch(`${apiBase}/workflow-runs/${runId}/cancel`, { method: 'POST' }))
|
||||
}
|
||||
|
||||
export async function respondToConfirmation(apiBase, runId, confirmationId, approved) {
|
||||
return expectBackendJson(await fetch(`${apiBase}/workflow-runs/${runId}/confirmations/${confirmationId}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ approved }),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function streamWorkflowEvents(apiBase, runId, { signal, onEvent }) {
|
||||
const response = await fetch(`${apiBase}/workflow-runs/${runId}/events?follow=true`, { signal })
|
||||
if (!response.ok) throw new Error(await readBackendErrorText(response))
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error('Workflow event stream is unavailable.')
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
buffer += decoder.decode(value || new Uint8Array(), { stream: !done })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
onEvent(JSON.parse(line))
|
||||
}
|
||||
if (done) break
|
||||
}
|
||||
if (buffer.trim()) onEvent(JSON.parse(buffer))
|
||||
}
|
||||
Reference in New Issue
Block a user