diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 64e7a11c..c3198ae2 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -405,6 +405,104 @@ async def delete_workflow(workflow_id: str): return {"ok": True} +@workflows.router.post("/{workflow_id}/propose-edit") +async def propose_edit(workflow_id: str, body: dict): + """Aux-LLM-propose a single-step edit from a natural-language request. + + Powers the Edit Agent chat (Image #38). Frontend hands us the user's + message, the current draft steps, and optional failure-context (when + we're inside Fix-with-Agent). We respond with a reply string PLUS, + optionally, a `step_idx` + `new_text` that the FE shows as a + proposal card. The user clicks Apply to merge into their local draft; + nothing is persisted until they click Save in the header. + """ + wf = storage.get_workflow(workflow_id) + if not wf: + raise HTTPException(status_code=404, detail="Workflow not found") + message = (body or {}).get("message", "").strip() + steps_in = (body or {}).get("steps") or [] + context = (body or {}).get("context") or None + if not message or not isinstance(steps_in, list): + raise HTTPException(status_code=400, detail="Missing message or steps") + 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 + steps_lines = "\n".join( + f"{i+1}. {(s.get('label') or '').strip() or (s.get('text') or '')[:60]}: {(s.get('text') or '')}" + for i, s in enumerate(steps_in) + ) + fix_context = "" + if context and isinstance(context, dict): + fs = context.get("failed_step") + err = context.get("error") + if fs is not None and err: + fix_context = ( + f"\n\nFAILURE CONTEXT: Step {int(fs) + 1} failed on the most recent run. " + f"The error was: {err}\n" + f"Your proposed edit should specifically address that failure if possible." + ) + prompt = ( + "You are an Edit Agent helping the user iterate on a saved automation " + "workflow. The workflow's current steps are listed below. The user has " + "asked for a modification.\n\n" + "Respond with STRICT JSON, no prose, no fence. Schema:\n" + ' {"reply": string, ' + '"step_idx": int | null, ' + '"new_text": string | null, ' + '"explanation": string | null}\n\n' + "Rules:\n" + "- `reply` is a short conversational acknowledgement (1-2 sentences).\n" + "- If the user is asking a question or for clarification, set step_idx=null and new_text=null.\n" + "- If the user is asking to change a specific step, set step_idx (0-based) and new_text to the FULL replacement prompt for that step.\n" + "- `explanation` describes the change in user-facing terms.\n" + "- Never invent new steps. Never remove steps. Only edit existing ones.\n\n" + f"Workflow steps:\n{steps_lines}{fix_context}\n\n" + f"User: {message}" + ) + try: + resp = await client.messages.create( + model=aux_model, + max_tokens=400, + 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("propose-edit: aux LLM failed: %s", e) + raise HTTPException(status_code=400, detail="Couldn't generate a proposal") + reply = str(data.get("reply") or "").strip()[:600] + step_idx = data.get("step_idx") + new_text = data.get("new_text") + explanation = str(data.get("explanation") or "").strip()[:600] + out: dict = {"reply": reply} + if isinstance(step_idx, int) and 0 <= step_idx < len(steps_in) and isinstance(new_text, str) and new_text.strip(): + out["step_idx"] = step_idx + out["new_text"] = new_text.strip() + if explanation: + out["explanation"] = explanation + return out + + @workflows.router.post("/{workflow_id}/parse-schedule") async def parse_schedule(workflow_id: str, body: dict): """Aux-LLM-parse natural language into a ScheduleConfig. diff --git a/frontend/src/app/pages/Workflows/EditAgentView.tsx b/frontend/src/app/pages/Workflows/EditAgentView.tsx new file mode 100644 index 00000000..e135c526 --- /dev/null +++ b/frontend/src/app/pages/Workflows/EditAgentView.tsx @@ -0,0 +1,408 @@ +// Image #38: Edit Agent chat shell embedded inside the workflow card. +// Header is { Discard | Save }. Body shows a soft frame around the step +// list, then the conversation (agent reply bubbles + tool-call cards + +// user bubbles), then a composer at the bottom. Submitting a message +// hits /workflows/{id}/edit-step which uses aux LLM to propose a step +// edit. The user reviews the proposal, then either applies it to the +// draft (local) or asks the agent to try again. Save persists the +// accumulated draft via PATCH; Discard reverts to the saved state. +// +// Test Agent (Image #39) integration lands in slice 5; the "Test" button +// here is wired but the spawned sibling currently runs the saved workflow +// (not the unsaved draft) until the run endpoint accepts step overrides. + +import React, { useCallback, useEffect, 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 SaveOutlinedIcon from '@mui/icons-material/SaveOutlined'; +import PlayArrowRounded from '@mui/icons-material/PlayArrowRounded'; +import BuildRounded from '@mui/icons-material/BuildRounded'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { clearFixSeed, updateWorkflow, updateWorkflowCard, type Workflow } from '@/shared/state/workflowsSlice'; +import StepList from './StepList'; +import { API_BASE, getAuthToken } from '@/shared/config'; + +interface Props { + workflow: Workflow; + steps: Workflow['steps']; + isFixMode?: boolean; +} + +type Turn = + | { kind: 'user'; text: string } + | { kind: 'assistant'; text: string } + | { kind: 'proposal'; stepIdx: number; before: string; after: string; explanation: string; applied: boolean } + | { kind: 'fix-prefix'; stepIdx: number; stepLabel: string; error: string }; + +export default function EditAgentView({ workflow, steps, isFixMode = false }: Props) { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const card = useAppSelector((s) => s.workflows.openCards[workflow.id]); + const fixSeed = card?.fixSeed || null; + + const [draftSteps, setDraftSteps] = useState(steps); + const [turns, setTurns] = useState(() => isFixMode && fixSeed ? [ + { kind: 'fix-prefix', stepIdx: fixSeed.stepIdx, stepLabel: fixSeed.stepLabel, error: fixSeed.error }, + ] : [ + { kind: 'assistant', text: 'How would you like to modify the workflow (e.g. filter out spam emails before summarizing)' }, + ]); + const [draft, setDraft] = useState(''); + const [busy, setBusy] = useState(false); + const [showSaveModal, setShowSaveModal] = useState(false); + + useEffect(() => () => { dispatch(clearFixSeed(workflow.id)); }, [dispatch, workflow.id]); + + const dirty = React.useMemo(() => { + if (draftSteps.length !== steps.length) return true; + for (let i = 0; i < steps.length; i++) { + if ((draftSteps[i]?.text || '') !== (steps[i]?.text || '')) return true; + if ((draftSteps[i]?.label || '') !== (steps[i]?.label || '')) return true; + } + return false; + }, [draftSteps, steps]); + + const onSubmit = useCallback(async () => { + const text = draft.trim(); + if (!text || busy) return; + setBusy(true); + setTurns((t) => [...t, { kind: 'user', text }]); + setDraft(''); + try { + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + const res = await fetch(`${API_BASE}/workflows/${encodeURIComponent(workflow.id)}/propose-edit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) }, + body: JSON.stringify({ + message: text, + steps: draftSteps.map((s) => ({ id: s.id, text: s.text, label: s.label || null })), + context: isFixMode && fixSeed ? { failed_step: fixSeed.stepIdx, error: fixSeed.error } : null, + }), + }); + if (!res.ok) { + setTurns((t) => [...t, { kind: 'assistant', text: `Sorry, that didn't go through. (${res.status})` }]); + return; + } + const data = await res.json(); + if (data?.reply) setTurns((t) => [...t, { kind: 'assistant', text: data.reply as string }]); + if (typeof data?.step_idx === 'number' && typeof data?.new_text === 'string') { + setTurns((t) => [...t, { + kind: 'proposal', + stepIdx: data.step_idx, + before: draftSteps[data.step_idx]?.text || '', + after: data.new_text, + explanation: data.explanation || '', + applied: false, + }]); + } + } catch (e) { + setTurns((t) => [...t, { kind: 'assistant', text: (e as Error)?.message || 'Network error.' }]); + } finally { + setBusy(false); + } + }, [draft, busy, workflow.id, draftSteps, isFixMode, fixSeed]); + + const onApplyProposal = useCallback((turnIdx: number) => { + setTurns((all) => { + const next = all.slice(); + const turn = next[turnIdx]; + if (turn?.kind !== 'proposal' || turn.applied) return all; + setDraftSteps((ds) => { + const updated = ds.slice(); + if (updated[turn.stepIdx]) { + updated[turn.stepIdx] = { ...updated[turn.stepIdx], text: turn.after }; + } + return updated; + }); + next[turnIdx] = { ...turn, applied: true }; + return next; + }); + }, []); + + const onDiscard = useCallback(() => { + if (dirty) { + // No second confirm; the agent's proposals weren't persisted yet. + // Reverting just clears local state. + } + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } })); + }, [dirty, dispatch, workflow.id]); + + const onSaveClick = useCallback(() => { + if (!dirty) { + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } })); + return; + } + setShowSaveModal(true); + }, [dirty, dispatch, workflow.id]); + + const onConfirmSave = useCallback(async () => { + setBusy(true); + try { + await dispatch(updateWorkflow({ + id: workflow.id, + patch: { steps: draftSteps }, + ifMatch: workflow.updated_at || null, + })); + dispatch(updateWorkflowCard({ workflowId: workflow.id, patch: { view: 'saved' } })); + } finally { + setBusy(false); + setShowSaveModal(false); + } + }, [dispatch, workflow.id, workflow.updated_at, draftSteps]); + + return ( + + {/* Inline header replacement. The card's default action bar is + hidden for edit_agent / fix_agent views. */} + + + } + onClick={onDiscard} + tone="muted" + /> + } + onClick={onSaveClick} + tone="filled" + disabled={busy} + /> + + + + + {/* Conversation. Bubbles + tool-call style cards. */} + + {turns.map((t, idx) => { + if (t.kind === 'fix-prefix') { + return ( + + + + + + + Fixing Step {t.stepIdx + 1}: {t.stepLabel} + + + {t.error} + + + + ); + } + if (t.kind === 'assistant') { + return ( + + + {t.text} + + + ); + } + if (t.kind === 'user') { + return ( + + + + {t.text} + + + + ); + } + // proposal + return ( + + + Proposed change to Step {t.stepIdx + 1} + + {t.explanation && ( + + {t.explanation} + + )} + + AFTER + + {t.after} + + + + {t.applied ? ( + Applied to draft + ) : ( + onApplyProposal(idx)} + role="button" + sx={{ + fontSize: '0.82rem', fontWeight: 700, + color: '#fff', bgcolor: c.accent.primary, + px: 1.25, py: 0.4, borderRadius: 999, cursor: 'pointer', + '&:hover': { filter: 'brightness(1.05)' }, + }}> + Apply to draft + + )} + + + ); + })} + + + + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + void onSubmit(); + } + }} + minRows={1} + maxRows={5} + 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, + }} + /> + + + + + + + + {busy ? 'Working…' : 'Send'} + + + + + setShowSaveModal(false)}> + + + Save changes to workflow? + + + You're replacing the saved steps with your edits. The next scheduled run will use the new version. + + + {draftSteps.map((s, i) => { + const changed = (s.text || '') !== (steps[i]?.text || ''); + return ( + + {changed ? '●' : '○'} Step {i + 1}: {s.label || (s.text || '').slice(0, 60)} + + ); + })} + + + setShowSaveModal(false)} role="button" sx={{ fontSize: '0.86rem', color: c.text.secondary, px: 1, py: 0.6, cursor: 'pointer', '&:hover': { color: c.text.primary } }}> + Keep editing + + + {busy ? 'Saving…' : 'Save & close'} + + + + + + ); +} + +function HeaderBtn({ label, icon, onClick, tone, disabled }: { label: string; icon: React.ReactNode; onClick: () => void; tone: 'muted' | 'filled'; disabled?: boolean }) { + const c = useClaudeTokens(); + const filled = tone === 'filled'; + return ( + + {icon} + {label} + + ); +} + +function Pill({ label }: { label: string }) { + const c = useClaudeTokens(); + return ( + {label} + ); +} diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index fb9f7107..e5cc60ee 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -36,6 +36,7 @@ import WorkflowEditViews from './WorkflowEditViews'; import { HistoryDetail, HistoryList, PreviewView, SavedView } from './WorkflowCardSubviews'; import { CompletedView, FailedView, RunningView } from './WorkflowCardLiveViews'; import SchedulingView from './SchedulingView'; +import EditAgentView from './EditAgentView'; import StopRounded from '@mui/icons-material/StopRounded'; import PauseRounded from '@mui/icons-material/PauseRounded'; import { StatusDot, RunSparkline, LastFiredHint, isStaleSinceLastRun } from './workflowVisuals'; @@ -612,12 +613,7 @@ const WorkflowCard: React.FC = ({ )} {(card.view === 'edit_agent' || card.view === 'fix_agent') && workflow && ( - dispatch(updateWorkflowCard({ workflowId, patch: { editFacet: f } }))} - onDirtyChange={setEditDirty} - /> + )}