mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-10 03:37:44 +02:00
[eric] events: universal sources (agent-check any condition, custom push via /api/events/ingest)
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
// One trigger row inside the Event Triggers panel: folder watch, page watch,
|
||||
// agent check (any natural-language condition), or custom push (ingest API).
|
||||
// Text fields commit onBlur so typing doesn't PATCH per keystroke.
|
||||
|
||||
import React from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import type { EventTriggerConfig, Workflow } from '@/shared/state/workflowsSlice';
|
||||
import { useWC, track, knob } from './uiKit';
|
||||
|
||||
const WEB_POLL_CHOICES: Array<[number, string]> = [[60, 'every minute'], [300, 'every 5 min'], [900, 'every 15 min'], [3600, 'hourly']];
|
||||
const AGENT_POLL_CHOICES: Array<[number, string]> = [[300, 'every 5 min'], [900, 'every 15 min'], [3600, 'hourly'], [21600, 'every 6 hours'], [86400, 'daily']];
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
file: 'Folder / file watch',
|
||||
web: 'Web page watch',
|
||||
agent: 'Agent check',
|
||||
custom: 'Custom (push)',
|
||||
};
|
||||
|
||||
interface RowProps {
|
||||
workflow: Workflow;
|
||||
trigger: EventTriggerConfig;
|
||||
onMutate: (mut: (t: EventTriggerConfig) => EventTriggerConfig) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const EventTriggerRow: React.FC<RowProps> = ({ workflow, trigger, onMutate, onRemove }) => {
|
||||
const WC = useWC();
|
||||
const t = trigger;
|
||||
const src = t.source;
|
||||
|
||||
const fieldStyle: CSSProperties = {
|
||||
width: '100%', boxSizing: 'border-box', height: 30, background: WC.raised,
|
||||
border: `1px solid rgba(${WC.inkRGB},0.12)`, borderRadius: 8, padding: '0 9px',
|
||||
fontSize: 12.5, color: WC.ink,
|
||||
};
|
||||
const labelStyle: CSSProperties = { fontSize: 11.5, color: WC.muted, marginBottom: 4, display: 'block' };
|
||||
const ghostBtn: CSSProperties = {
|
||||
height: 26, padding: '0 8px', borderRadius: 7, border: `1px solid rgba(${WC.inkRGB},0.12)`,
|
||||
cursor: 'pointer', fontSize: 11.5, fontWeight: 600, background: WC.raised, color: WC.ink3,
|
||||
};
|
||||
|
||||
const pollSelect = (value: number, choices: Array<[number, string]>, onChange: (v: number) => void) => (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||
style={{ ...fieldStyle, cursor: 'pointer', padding: '0 6px' }}
|
||||
>
|
||||
{choices.map(([v, label]) => <option key={v} value={v}>{label}</option>)}
|
||||
</select>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ border: `1px solid ${WC.line}`, borderRadius: 10, padding: '10px 11px', marginBottom: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 9 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: WC.ink3 }}>{KIND_LABELS[src.kind] ?? src.kind}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
onClick={() => onMutate((x) => ({ ...x, enabled: !x.enabled }))}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
|
||||
>
|
||||
<span style={{ fontSize: 11.5, fontWeight: 600, color: t.enabled ? WC.accent : WC.muted }}>{t.enabled ? 'On' : 'Off'}</span>
|
||||
<div style={track(t.enabled, WC)}><div style={knob(t.enabled)} /></div>
|
||||
</div>
|
||||
<button aria-label="Remove trigger" onClick={onRemove} style={{ ...ghostBtn, padding: '0 8px' }}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{src.kind === 'file' && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span style={labelStyle}>Watch this folder or file</span>
|
||||
<input
|
||||
style={fieldStyle}
|
||||
defaultValue={src.path}
|
||||
placeholder="~/Downloads"
|
||||
onBlur={(e) => {
|
||||
const path = e.target.value.trim();
|
||||
if (path !== src.path) onMutate((x) => ({ ...x, source: { ...src, path } }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{src.kind === 'web' && (
|
||||
<>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span style={labelStyle}>Page URL</span>
|
||||
<input
|
||||
style={fieldStyle}
|
||||
defaultValue={src.url}
|
||||
placeholder="https://example.com/reservations"
|
||||
onBlur={(e) => {
|
||||
const url = e.target.value.trim();
|
||||
if (url !== src.url) onMutate((x) => ({ ...x, source: { ...src, url } }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<span style={labelStyle}>Watching for</span>
|
||||
<input
|
||||
style={fieldStyle}
|
||||
defaultValue={src.watch_for}
|
||||
placeholder="a reservation slot opening"
|
||||
onBlur={(e) => {
|
||||
const watchFor = e.target.value.trim();
|
||||
if (watchFor !== src.watch_for) onMutate((x) => ({ ...x, source: { ...src, watch_for: watchFor } }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 118, flex: 'none' }}>
|
||||
<span style={labelStyle}>Check</span>
|
||||
{pollSelect(src.poll_seconds, WEB_POLL_CHOICES, (v) => onMutate((x) => ({ ...x, source: { ...src, poll_seconds: v } })))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{src.kind === 'agent' && (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<span style={labelStyle}>What counts as the event? An agent checks with its tools.</span>
|
||||
<input
|
||||
style={fieldStyle}
|
||||
defaultValue={src.check}
|
||||
placeholder="a new episode of my favorite podcast is out"
|
||||
onBlur={(e) => {
|
||||
const check = e.target.value.trim();
|
||||
if (check !== src.check) onMutate((x) => ({ ...x, source: { ...src, check } }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 130, flex: 'none', alignSelf: 'flex-end' }}>
|
||||
{pollSelect(src.poll_seconds, AGENT_POLL_CHOICES, (v) => onMutate((x) => ({ ...x, source: { ...src, poll_seconds: v } })))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{src.kind === 'custom' && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span style={labelStyle}>Anything can push events here (scripts, webhooks, Shortcuts):</span>
|
||||
<pre style={{
|
||||
margin: 0, padding: '8px 10px', background: WC.inset, border: `1px solid ${WC.line}`,
|
||||
borderRadius: 8, fontSize: 10.5, color: WC.ink3, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||||
fontFamily: "'JetBrains Mono',monospace", userSelect: 'text',
|
||||
}}>
|
||||
{`POST ${API_BASE}/events/ingest\n{"workflow_id": "${workflow.id}", "trigger_id": "${t.id}", "summary": "what happened", "dedup_key": "optional-id"}`}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span style={labelStyle}>Only when (optional, plain English)</span>
|
||||
<input
|
||||
style={fieldStyle}
|
||||
defaultValue={t.predicate}
|
||||
placeholder={src.kind === 'file' ? 'a new CSV export shows up' : 'it matters enough to act on'}
|
||||
onBlur={(e) => {
|
||||
const predicate = e.target.value.trim();
|
||||
if (predicate !== t.predicate) onMutate((x) => ({ ...x, predicate }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTriggerRow;
|
||||
@@ -1,24 +1,38 @@
|
||||
// "When something happens" panel beside the Schedule card: watch a folder/file
|
||||
// or a web page, optionally filter with a plain-English condition, and see the
|
||||
// trigger's recent activity ("saw X, skipped because Y") so a quiet trigger is
|
||||
// debuggable instead of mysterious.
|
||||
// "When something happens" panel beside the Schedule card. Four source kinds
|
||||
// cover the universe: folder/file watch, web page watch, agent check (any
|
||||
// natural-language condition), and custom push (anything can POST an event).
|
||||
// The Recent-activity feed makes a quiet trigger debuggable instead of
|
||||
// mysterious ("saw X, skipped because Y").
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import type { EventTriggerConfig, Workflow, WorkflowEventLogEntry } from '@/shared/state/workflowsSlice';
|
||||
import { useWC, FONT_SERIF, track, knob } from './uiKit';
|
||||
import type { EventSourceConfig, EventTriggerConfig, Workflow, WorkflowEventLogEntry } from '@/shared/state/workflowsSlice';
|
||||
import EventTriggerRow from './EventTriggerRow';
|
||||
import { useWC, FONT_SERIF } from './uiKit';
|
||||
import { useWorkflowPatch } from './useWorkflowPatch';
|
||||
|
||||
const WEB_POLL_CHOICES: Array<[number, string]> = [[60, 'every minute'], [300, 'every 5 min'], [900, 'every 15 min'], [3600, 'hourly']];
|
||||
type TriggerKind = 'file' | 'web' | 'agent' | 'custom';
|
||||
|
||||
function newTrigger(kind: 'file' | 'web'): EventTriggerConfig {
|
||||
const ADD_CHOICES: Array<[TriggerKind, string]> = [
|
||||
['file', '+ Folder'],
|
||||
['web', '+ Page'],
|
||||
['agent', '+ Agent check'],
|
||||
['custom', '+ Custom'],
|
||||
];
|
||||
|
||||
function newSource(kind: TriggerKind): EventSourceConfig {
|
||||
if (kind === 'file') return { kind: 'file', path: '', poll_seconds: 15 };
|
||||
if (kind === 'web') return { kind: 'web', url: '', watch_for: '', poll_seconds: 300 };
|
||||
if (kind === 'agent') return { kind: 'agent', check: '', model: '', poll_seconds: 900 };
|
||||
return { kind: 'custom' };
|
||||
}
|
||||
|
||||
function newTrigger(kind: TriggerKind): EventTriggerConfig {
|
||||
return {
|
||||
id: crypto.randomUUID().replace(/-/g, ''),
|
||||
enabled: true,
|
||||
source: kind === 'file'
|
||||
? { kind: 'file', path: '', poll_seconds: 15 }
|
||||
: { kind: 'web', url: '', watch_for: '', poll_seconds: 300 },
|
||||
source: newSource(kind),
|
||||
predicate: '',
|
||||
coalesce_seconds: kind === 'file' ? 30 : 0,
|
||||
max_fires_per_hour: 6,
|
||||
@@ -35,10 +49,6 @@ const EventTriggersCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
|
||||
patch(workflow, { event_triggers: next });
|
||||
}, [patch, workflow]);
|
||||
|
||||
const updateTrigger = useCallback((id: string, mut: (t: EventTriggerConfig) => EventTriggerConfig) => {
|
||||
patchTriggers(triggers.map((t) => (t.id === id ? mut(t) : t)));
|
||||
}, [patchTriggers, triggers]);
|
||||
|
||||
// Activity poll while the panel is mounted; local endpoint, cheap.
|
||||
useEffect(() => {
|
||||
if (triggers.length === 0) return;
|
||||
@@ -56,15 +66,10 @@ const EventTriggersCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
|
||||
}, [workflow.id, triggers.length]);
|
||||
|
||||
const ghostBtn: CSSProperties = {
|
||||
height: 26, padding: '0 10px', borderRadius: 7, border: `1px solid rgba(${WC.inkRGB},0.12)`,
|
||||
cursor: 'pointer', fontSize: 11.5, fontWeight: 600, background: WC.raised, color: WC.ink3,
|
||||
height: 26, padding: '0 8px', borderRadius: 7, border: `1px solid rgba(${WC.inkRGB},0.12)`,
|
||||
cursor: 'pointer', fontSize: 11, fontWeight: 600, background: WC.raised, color: WC.ink3, whiteSpace: 'nowrap',
|
||||
};
|
||||
const fieldStyle: CSSProperties = {
|
||||
width: '100%', boxSizing: 'border-box', height: 30, background: WC.raised,
|
||||
border: `1px solid rgba(${WC.inkRGB},0.12)`, borderRadius: 8, padding: '0 9px',
|
||||
fontSize: 12.5, color: WC.ink,
|
||||
};
|
||||
const labelStyle: CSSProperties = { fontSize: 11.5, color: WC.muted, marginBottom: 4, display: 'block' };
|
||||
const labelStyle: CSSProperties = { fontSize: 11.5, color: WC.muted, marginBottom: 7, display: 'block' };
|
||||
|
||||
const dotColor = (kind: WorkflowEventLogEntry['kind']): string => {
|
||||
if (kind === 'fired') return WC.accent;
|
||||
@@ -74,122 +79,34 @@ const EventTriggersCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => {
|
||||
|
||||
return (
|
||||
<div style={{ background: WC.paper, border: `1px solid rgba(${WC.inkRGB},0.08)`, borderRadius: WC.radius.lg, padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<span style={{ fontFamily: FONT_SERIF, fontSize: 16, fontWeight: 500, color: WC.ink }}>Event triggers</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button style={ghostBtn} onClick={() => patchTriggers([...triggers, newTrigger('file')])}>+ Folder</button>
|
||||
<button style={ghostBtn} onClick={() => patchTriggers([...triggers, newTrigger('web')])}>+ Web page</button>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span style={{ fontFamily: FONT_SERIF, fontSize: 16, fontWeight: 500, color: WC.ink, display: 'block', marginBottom: 8 }}>Event triggers</span>
|
||||
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
|
||||
{ADD_CHOICES.map(([kind, label]) => (
|
||||
<button key={kind} style={ghostBtn} onClick={() => patchTriggers([...triggers, newTrigger(kind)])}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{triggers.length === 0 && (
|
||||
<span style={{ fontSize: 12.5, color: WC.ink4, lineHeight: 1.5, display: 'block' }}>
|
||||
Run this workflow when something happens: a file lands in a folder, or a page you care about changes.
|
||||
Run this workflow when something happens: a file lands, a page changes, an agent spots any condition you describe, or anything pushes an event in.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{triggers.map((t) => {
|
||||
const src = t.source;
|
||||
return (
|
||||
<div key={t.id} style={{ border: `1px solid ${WC.line}`, borderRadius: 10, padding: '10px 11px', marginBottom: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 9 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: WC.ink3 }}>
|
||||
{src.kind === 'file' ? 'Folder / file watch' : 'Web page watch'}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
onClick={() => updateTrigger(t.id, (x) => ({ ...x, enabled: !x.enabled }))}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
|
||||
>
|
||||
<span style={{ fontSize: 11.5, fontWeight: 600, color: t.enabled ? WC.accent : WC.muted }}>{t.enabled ? 'On' : 'Off'}</span>
|
||||
<div style={track(t.enabled, WC)}><div style={knob(t.enabled)} /></div>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Remove trigger"
|
||||
onClick={() => patchTriggers(triggers.filter((x) => x.id !== t.id))}
|
||||
style={{ ...ghostBtn, padding: '0 8px' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{src.kind === 'file' ? (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span style={labelStyle}>Watch this folder or file</span>
|
||||
<input
|
||||
style={fieldStyle}
|
||||
defaultValue={src.path}
|
||||
placeholder="~/Downloads"
|
||||
onBlur={(e) => {
|
||||
const path = e.target.value.trim();
|
||||
if (path !== src.path) updateTrigger(t.id, (x) => ({ ...x, source: { ...src, path } }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span style={labelStyle}>Page URL</span>
|
||||
<input
|
||||
style={fieldStyle}
|
||||
defaultValue={src.url}
|
||||
placeholder="https://example.com/reservations"
|
||||
onBlur={(e) => {
|
||||
const url = e.target.value.trim();
|
||||
if (url !== src.url) updateTrigger(t.id, (x) => ({ ...x, source: { ...src, url } }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<span style={labelStyle}>Watching for</span>
|
||||
<input
|
||||
style={fieldStyle}
|
||||
defaultValue={src.watch_for}
|
||||
placeholder="a reservation slot opening"
|
||||
onBlur={(e) => {
|
||||
const watchFor = e.target.value.trim();
|
||||
if (watchFor !== src.watch_for) updateTrigger(t.id, (x) => ({ ...x, source: { ...src, watch_for: watchFor } }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 118, flex: 'none' }}>
|
||||
<span style={labelStyle}>Check</span>
|
||||
<select
|
||||
value={src.poll_seconds}
|
||||
onChange={(e) => {
|
||||
const pollSeconds = parseInt(e.target.value, 10);
|
||||
updateTrigger(t.id, (x) => ({ ...x, source: { ...src, poll_seconds: pollSeconds } }));
|
||||
}}
|
||||
style={{ ...fieldStyle, cursor: 'pointer', padding: '0 6px' }}
|
||||
>
|
||||
{WEB_POLL_CHOICES.map(([v, label]) => <option key={v} value={v}>{label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span style={labelStyle}>Only when (optional, plain English)</span>
|
||||
<input
|
||||
style={fieldStyle}
|
||||
defaultValue={t.predicate}
|
||||
placeholder={src.kind === 'file' ? 'a new CSV export shows up' : 'the change mentions Friday or Saturday'}
|
||||
onBlur={(e) => {
|
||||
const predicate = e.target.value.trim();
|
||||
if (predicate !== t.predicate) updateTrigger(t.id, (x) => ({ ...x, predicate }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{triggers.map((t) => (
|
||||
<EventTriggerRow
|
||||
key={t.id}
|
||||
workflow={workflow}
|
||||
trigger={t}
|
||||
onMutate={(mut) => patchTriggers(triggers.map((x) => (x.id === t.id ? mut(x) : x)))}
|
||||
onRemove={() => patchTriggers(triggers.filter((x) => x.id !== t.id))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{triggers.length > 0 && log.length > 0 && (
|
||||
<div style={{ marginTop: 4, paddingTop: 11, borderTop: `1px solid ${WC.line}` }}>
|
||||
<span style={{ ...labelStyle, marginBottom: 7 }}>Recent activity</span>
|
||||
<span style={labelStyle}>Recent activity</span>
|
||||
{log.slice(0, 6).map((e, i) => (
|
||||
<div key={`${e.ts}-${i}`} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ width: 14, display: 'flex', justifyContent: 'center', flex: 'none', paddingTop: 5 }}>
|
||||
|
||||
@@ -42,7 +42,21 @@ export interface WebWatchSource {
|
||||
poll_seconds: number;
|
||||
}
|
||||
|
||||
export type EventSourceConfig = FileWatchSource | WebWatchSource;
|
||||
export interface AgentCheckSource {
|
||||
kind: 'agent';
|
||||
/** Any natural-language condition; a real agent verifies it each poll with its full tool surface. */
|
||||
check: string;
|
||||
/** Empty = the app's default model. */
|
||||
model: string;
|
||||
poll_seconds: number;
|
||||
}
|
||||
|
||||
export interface CustomEventSource {
|
||||
/** Push-only: events arrive via POST /api/events/ingest from any script/webhook/Shortcut. */
|
||||
kind: 'custom';
|
||||
}
|
||||
|
||||
export type EventSourceConfig = FileWatchSource | WebWatchSource | AgentCheckSource | CustomEventSource;
|
||||
|
||||
export interface EventTriggerConfig {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user