mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-30 21:44:50 +02:00
[eric] workflows: SchedulingView + NL parse-schedule endpoint (Image #49)
This commit is contained in:
@@ -405,6 +405,83 @@ async def delete_workflow(workflow_id: str):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@workflows.router.post("/{workflow_id}/parse-schedule")
|
||||
async def parse_schedule(workflow_id: str, body: dict):
|
||||
"""Aux-LLM-parse natural language into a ScheduleConfig.
|
||||
|
||||
Frontend SchedulingView (Image #49) hits this on submit; the parsed
|
||||
config rides back to the user for explicit "Schedule it" confirmation
|
||||
before any persistence. Returns the parsed config under {"schedule": ...}.
|
||||
"""
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
text = (body or {}).get("text", "").strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="Missing text")
|
||||
try:
|
||||
from backend.apps.agents.providers.registry import resolve_aux_model, get_anthropic_client_for_model
|
||||
from backend.apps.settings.settings import load_settings as _ls
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Aux model unavailable")
|
||||
settings = _ls()
|
||||
try:
|
||||
aux_model, _ = await resolve_aux_model(settings, preferred_tier="haiku")
|
||||
client = get_anthropic_client_for_model(settings, aux_model)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Aux model unavailable")
|
||||
import json, re
|
||||
prompt = (
|
||||
"Parse the following natural-language schedule into STRICT JSON. "
|
||||
"No prose, no fence, no comments. Schema:\n"
|
||||
' {"repeat_unit": "day"|"week"|"month", '
|
||||
'"repeat_every": int>=1, '
|
||||
'"on_days": [int 0..6, Sunday=0], '
|
||||
'"hour": int 0..23, "minute": int 0..59, '
|
||||
'"timezone": IANA tz string (default to local)}\n\n'
|
||||
"Rules:\n"
|
||||
"- If user says weekdays, on_days=[1,2,3,4,5], repeat_unit=week.\n"
|
||||
"- If user says weekends, on_days=[0,6], repeat_unit=week.\n"
|
||||
"- If user names a single day (e.g. \"Mondays\"), on_days=[1], repeat_unit=week.\n"
|
||||
"- If user says daily/everyday, repeat_unit=day, on_days=[].\n"
|
||||
"- If no AM/PM, assume PM for 1-7 and AM for 8-12.\n"
|
||||
"- timezone: assume system local if not given.\n\n"
|
||||
f"Input: {text}"
|
||||
)
|
||||
try:
|
||||
resp = await client.messages.create(
|
||||
model=aux_model,
|
||||
max_tokens=180,
|
||||
messages=[
|
||||
{"role": "user", "content": prompt},
|
||||
{"role": "assistant", "content": "{"},
|
||||
],
|
||||
)
|
||||
out = ""
|
||||
if isinstance(resp.content, list):
|
||||
for block in resp.content:
|
||||
if getattr(block, "type", None) == "text":
|
||||
out += getattr(block, "text", "")
|
||||
raw = "{" + out.strip() if not out.strip().startswith("{") else out.strip()
|
||||
m = re.search(r"\{[^{}]*\}", raw, flags=re.DOTALL)
|
||||
if m:
|
||||
raw = m.group(0)
|
||||
data = json.loads(raw)
|
||||
except Exception as e:
|
||||
logger.warning("parse-schedule: aux LLM failed: %s", e)
|
||||
raise HTTPException(status_code=400, detail="Couldn't parse schedule")
|
||||
cfg = wf.schedule.model_copy(update={
|
||||
"enabled": True,
|
||||
"repeat_unit": str(data.get("repeat_unit") or "week"),
|
||||
"repeat_every": int(data.get("repeat_every") or 1),
|
||||
"on_days": [int(d) for d in (data.get("on_days") or [])],
|
||||
"hour": int(data.get("hour") or 9),
|
||||
"minute": int(data.get("minute") or 0),
|
||||
"timezone": str(data.get("timezone") or wf.schedule.timezone or "UTC"),
|
||||
})
|
||||
return {"schedule": cfg.model_dump(mode="json")}
|
||||
|
||||
|
||||
@workflows.router.post("/{workflow_id}/run")
|
||||
async def run_workflow_now(workflow_id: str):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// Image #49: natural-language schedule composer.
|
||||
// Header morphs into `[trash] Cancel task scheduling`. Body shows a soft
|
||||
// frame around the read-only step list, an agent reply bubble asking for
|
||||
// the cadence, and a chat-style composer at the bottom. On submit we
|
||||
// hit /workflows/{id}/parse-schedule (aux LLM), surface the parsed
|
||||
// ScheduleConfig in a confirmation modal (the "always ask permission"
|
||||
// stand-in for the schedule_workflow tool call), and PATCH on confirm.
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import TextareaAutosize from '@mui/material/TextareaAutosize';
|
||||
import DeleteOutlineRounded from '@mui/icons-material/DeleteOutlineRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { updateWorkflow, updateWorkflowCard, type ScheduleConfig, type Workflow } from '@/shared/state/workflowsSlice';
|
||||
import StepList from './StepList';
|
||||
import { API_BASE, getAuthToken } from '@/shared/config';
|
||||
|
||||
interface Props {
|
||||
workflow: Workflow;
|
||||
steps: Workflow['steps'];
|
||||
}
|
||||
|
||||
export default function SchedulingView({ workflow, steps }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const [draft, setDraft] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState<ScheduleConfig | null>(null);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }));
|
||||
}, [dispatch, workflow.id]);
|
||||
|
||||
const onSubmit = useCallback(async () => {
|
||||
if (!draft.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
|
||||
const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/parse-schedule`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) },
|
||||
body: JSON.stringify({ text: draft.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(`Couldn't parse that. Try "every Wednesday at 1pm" or "Mondays at 3pm".`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const cfg = data?.schedule as ScheduleConfig | undefined;
|
||||
if (!cfg) {
|
||||
setError(`Couldn't read a schedule out of that. Try being more specific.`);
|
||||
return;
|
||||
}
|
||||
setPending(cfg);
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message || 'Network error.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [draft, busy, workflow.id]);
|
||||
|
||||
const onConfirm = useCallback(async () => {
|
||||
if (!pending) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await dispatch(updateWorkflow({
|
||||
id: workflow.id,
|
||||
patch: { schedule: { ...pending, enabled: true } as Workflow['schedule'] },
|
||||
ifMatch: workflow.updated_at || null,
|
||||
}));
|
||||
dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setPending(null);
|
||||
}
|
||||
}, [pending, dispatch, workflow.id, workflow.updated_at]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minHeight: '100%' }}>
|
||||
{/* Inline header replacement. The default History/Run row is hidden
|
||||
for 'scheduling' view; this Cancel button sits in its place. */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Box
|
||||
onClick={onCancel}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.45,
|
||||
fontSize: '0.82rem', fontWeight: 600,
|
||||
color: c.text.secondary, cursor: 'pointer',
|
||||
px: 0.75, py: 0.5,
|
||||
'&:hover': { color: c.status.error },
|
||||
}}>
|
||||
<DeleteOutlineRounded sx={{ fontSize: 16 }} />
|
||||
Cancel task scheduling
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{
|
||||
p: 1.5, borderRadius: `${c.radius.lg}px`,
|
||||
border: `1px solid ${c.border.subtle}`, bgcolor: c.bg.elevated,
|
||||
}}>
|
||||
<StepList steps={steps} />
|
||||
</Box>
|
||||
<Box sx={{
|
||||
p: 1.5, borderRadius: `${c.radius.lg}px`,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.92rem', color: c.text.primary, lineHeight: 1.45 }}>
|
||||
When should this workflow run (e.g. every Wednesday at 1pm)
|
||||
</Typography>
|
||||
</Box>
|
||||
{error && (
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.status.error }}>{error}</Typography>
|
||||
)}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box sx={{
|
||||
p: 1, borderRadius: `${c.radius.lg}px`,
|
||||
border: `1px solid ${c.border.subtle}`, bgcolor: c.bg.surface,
|
||||
display: 'flex', flexDirection: 'column', gap: 0.5,
|
||||
}}>
|
||||
<TextareaAutosize
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void onSubmit();
|
||||
}
|
||||
}}
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
placeholder="Agent, @ for context, / for commands"
|
||||
style={{
|
||||
width: '100%', resize: 'none', boxSizing: 'border-box',
|
||||
fontFamily: 'inherit', fontSize: '0.92rem', color: c.text.primary,
|
||||
border: 'none', outline: 'none', background: 'transparent',
|
||||
padding: '6px 4px', lineHeight: 1.45,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Pill label="Agent" />
|
||||
<Pill label="Claude Opus 4.6" />
|
||||
<Pill label="High" />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Box
|
||||
onClick={onSubmit}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.78rem', fontWeight: 700,
|
||||
color: '#fff', bgcolor: c.accent.primary,
|
||||
px: 1.2, py: 0.4, borderRadius: 999,
|
||||
cursor: busy || !draft.trim() ? 'not-allowed' : 'pointer',
|
||||
opacity: busy || !draft.trim() ? 0.5 : 1,
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
{busy ? 'Working…' : 'Send'}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Dialog open={!!pending} onClose={() => setPending(null)}>
|
||||
<Box sx={{ p: 2.5, minWidth: 360, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography sx={{ fontSize: '1rem', fontWeight: 700, color: c.text.primary }}>
|
||||
Schedule this workflow?
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.9rem', color: c.text.secondary, lineHeight: 1.5 }}>
|
||||
The agent wants to set <b>{workflow.title}</b> to run <b>{pending && describe(pending)}</b>. You can change or cancel this anytime.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 0.5 }}>
|
||||
<Box
|
||||
onClick={() => setPending(null)}
|
||||
role="button"
|
||||
sx={{ fontSize: '0.86rem', color: c.text.secondary, px: 1, py: 0.6, cursor: 'pointer', '&:hover': { color: c.text.primary } }}>
|
||||
Cancel
|
||||
</Box>
|
||||
<Box
|
||||
onClick={onConfirm}
|
||||
role="button"
|
||||
sx={{
|
||||
fontSize: '0.86rem', fontWeight: 700,
|
||||
color: '#fff', bgcolor: c.accent.primary,
|
||||
px: 1.4, py: 0.55, borderRadius: 999, cursor: 'pointer',
|
||||
'&:hover': { filter: 'brightness(1.05)' },
|
||||
}}>
|
||||
{busy ? 'Applying…' : 'Schedule it'}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill({ label }: { label: string }) {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<Box sx={{
|
||||
fontSize: '0.74rem', fontWeight: 600, color: c.text.secondary,
|
||||
px: 0.8, py: 0.25, borderRadius: 999,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
}}>{label}</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function describe(s: ScheduleConfig): string {
|
||||
const h12 = ((s.hour + 11) % 12) + 1;
|
||||
const ampm = s.hour < 12 ? 'am' : 'pm';
|
||||
const time = s.minute === 0 ? `${h12}${ampm}` : `${h12}:${String(s.minute).padStart(2, '0')}${ampm}`;
|
||||
if (s.repeat_unit === 'day') return s.repeat_every === 1 ? `every day at ${time}` : `every ${s.repeat_every} days at ${time}`;
|
||||
if (s.repeat_unit === 'month') return s.repeat_every === 1 ? `every month at ${time}` : `every ${s.repeat_every} months at ${time}`;
|
||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
if (s.on_days.length === 5 && [1,2,3,4,5].every((d) => s.on_days.includes(d))) return `weekdays at ${time}`;
|
||||
if (s.on_days.length === 2 && [0,6].every((d) => s.on_days.includes(d))) return `weekends at ${time}`;
|
||||
if (s.on_days.length === 1) return `${labels[s.on_days[0]]}s at ${time}`;
|
||||
return `weekly at ${time}`;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { fetchSession } from '@/shared/state/agentsSlice';
|
||||
import WorkflowEditViews from './WorkflowEditViews';
|
||||
import { HistoryDetail, HistoryList, PreviewView, SavedView } from './WorkflowCardSubviews';
|
||||
import { CompletedView, FailedView, RunningView } from './WorkflowCardLiveViews';
|
||||
import SchedulingView from './SchedulingView';
|
||||
import StopRounded from '@mui/icons-material/StopRounded';
|
||||
import PauseRounded from '@mui/icons-material/PauseRounded';
|
||||
import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun } from './workflowVisuals';
|
||||
@@ -607,10 +608,13 @@ const WorkflowCard: React.FC<Props> = ({
|
||||
{card.view === 'failed' && workflow && (
|
||||
<FailedView workflow={workflow} steps={steps} runs={runs} mode={card.sidecarKind === 'viewing-error' ? 'sidecar-linked' : 'card'} />
|
||||
)}
|
||||
{(card.view === 'edit_agent' || card.view === 'fix_agent' || card.view === 'scheduling') && workflow && (
|
||||
{card.view === 'scheduling' && workflow && (
|
||||
<SchedulingView workflow={workflow} steps={steps} />
|
||||
)}
|
||||
{(card.view === 'edit_agent' || card.view === 'fix_agent') && workflow && (
|
||||
<WorkflowEditViews
|
||||
workflow={workflow}
|
||||
facet={card.view === 'scheduling' ? 'Schedule' : (card.editFacet || 'General')}
|
||||
facet={card.editFacet || 'General'}
|
||||
onChangeFacet={(f) => dispatch(updateWorkflowCard({ workflowId, patch: { editFacet: f } }))}
|
||||
onDirtyChange={setEditDirty}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user