Feature: Implement comprehensive workflow management system (selection, execution panel, and API wrappers)

This commit is contained in:
2026-06-15 15:14:40 +02:00
parent 8207b1d2e1
commit 920888b336
6 changed files with 237 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ export const COLOR_SCHEME_KEY = 'colorScheme'
export const WEBSEARCH_URL_KEY = 'websearch.searxUrl'
export const WEBSEARCH_ENGINES_KEY = 'websearch.engines'
export const CHAT_LIBRARY_MAP_KEY = 'chat.libraryBySession'
export const CHAT_WORKFLOW_MAP_KEY = 'chat.workflowBySession'
export const DEFAULT_SEARX_URL = 'http://127.0.0.1:8888'
export const DEFAULT_BACKEND_API_URL = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:8000'
export const MAX_IMAGE_ATTACHMENTS = 6

View File

@@ -0,0 +1,66 @@
import { useEffect, useMemo, useState } from 'react'
import { CHAT_WORKFLOW_MAP_KEY } from './appConfig'
const DIRECT_SELECTION = Object.freeze({ mode: 'direct', workflowId: null })
function loadMap() {
try {
return JSON.parse(localStorage.getItem(CHAT_WORKFLOW_MAP_KEY) || '{}')
} catch {
return {}
}
}
export function useChatWorkflowSelection({ activeSessionId, workflows }) {
const [selectionBySession, setSelectionBySession] = useState(loadMap)
useEffect(() => {
try {
localStorage.setItem(CHAT_WORKFLOW_MAP_KEY, JSON.stringify(selectionBySession))
} catch {}
}, [selectionBySession])
useEffect(() => {
const validIds = new Set(workflows.filter(item => item.enabled).map(item => item.id))
setSelectionBySession(previous => {
let changed = false
const next = { ...previous }
Object.entries(next).forEach(([sessionId, selection]) => {
if (selection?.mode === 'explicit' && !validIds.has(selection.workflowId)) {
next[sessionId] = DIRECT_SELECTION
changed = true
}
})
return changed ? next : previous
})
}, [workflows])
const selection = activeSessionId
? (selectionBySession[activeSessionId] || DIRECT_SELECTION)
: DIRECT_SELECTION
const selectedWorkflow = useMemo(() => {
if (selection.mode === 'direct') return workflows.find(item => item.slug === 'input-output') || null
if (selection.mode === 'automatic') return null
return workflows.find(item => item.id === selection.workflowId) || null
}, [selection, workflows])
function getSelectionForSession(sessionId) {
return selectionBySession[sessionId] || DIRECT_SELECTION
}
function setSelectionForSession(sessionId, nextSelection) {
if (!sessionId) return
setSelectionBySession(previous => ({
...previous,
[sessionId]: nextSelection?.mode ? nextSelection : DIRECT_SELECTION,
}))
}
return {
getSelectionForSession,
selectedWorkflow,
selection,
setSelectionForSession,
}
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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))
}