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