diff --git a/src/appConfig.js b/src/appConfig.js
index 83d9d5e..812d129 100644
--- a/src/appConfig.js
+++ b/src/appConfig.js
@@ -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
diff --git a/src/useChatWorkflowSelection.js b/src/useChatWorkflowSelection.js
new file mode 100644
index 0000000..76c1fba
--- /dev/null
+++ b/src/useChatWorkflowSelection.js
@@ -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,
+ }
+}
diff --git a/src/workflows/WorkflowConfirmationDialog.jsx b/src/workflows/WorkflowConfirmationDialog.jsx
new file mode 100644
index 0000000..1d53039
--- /dev/null
+++ b/src/workflows/WorkflowConfirmationDialog.jsx
@@ -0,0 +1,22 @@
+import React from 'react'
+
+export default function WorkflowConfirmationDialog({ confirmation, onRespond }) {
+ if (!confirmation) return null
+ const args = confirmation.payload?.arguments || {}
+ return (
+
+
+
Confirm workflow action
+
This workflow wants to run {confirmation.payload?.tool}.
+ {args.library_slug &&
Destination: {args.library_slug}
}
+ {args.title &&
Title: {args.title}
}
+ {args.message_id &&
Message: {args.message_id}
}
+ {args.url &&
URL: {args.url}
}
+
+
+
+
+
+
+ )
+}
diff --git a/src/workflows/WorkflowExecutionPanel.jsx b/src/workflows/WorkflowExecutionPanel.jsx
new file mode 100644
index 0000000..26f9e8b
--- /dev/null
+++ b/src/workflows/WorkflowExecutionPanel.jsx
@@ -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 (
+
+
+ {open && (
+
+ {current &&
Current: {current.node_id || current.payload?.tool || current.type}
}
+
+ {events.filter(event => event.type !== 'model_token').slice(-40).map(event => (
+
+ {event.type.replaceAll('_', ' ')}
+ {event.node_id || event.payload?.tool || ''}
+ {event.payload?.error && {String(event.payload.error)}}
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/src/workflows/WorkflowSelector.jsx b/src/workflows/WorkflowSelector.jsx
new file mode 100644
index 0000000..6e84f84
--- /dev/null
+++ b/src/workflows/WorkflowSelector.jsx
@@ -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 (
+
+ )
+}
diff --git a/src/workflows/workflowApi.js b/src/workflows/workflowApi.js
new file mode 100644
index 0000000..5161f3d
--- /dev/null
+++ b/src/workflows/workflowApi.js
@@ -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))
+}