diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py index 93b01d0f..4814e583 100644 --- a/backend/apps/workflows/models.py +++ b/backend/apps/workflows/models.py @@ -216,6 +216,20 @@ class WorkflowCreate(BaseModel): provider: Optional[str] = None cost_cap_usd_monthly: Optional[float] = None tested_signature: Optional[str] = None + # The FE already named + described + labeled this at preview time; skip the + # backend aux call so we don't double-spend or change the title under the user. + metadata_generated: bool = False + + +class GenerateMetadataRequest(BaseModel): + steps: list[WorkflowStep] = Field(default_factory=list) + model: Optional[str] = None + + +class GenerateMetadataResponse(BaseModel): + title: str = "" + description: str = "" + step_labels: list[str] = Field(default_factory=list) class WorkflowUpdate(BaseModel): diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 87f13df9..64cf7d3d 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -15,6 +15,8 @@ from backend.apps.workflows.models import ( WorkflowStep, DraftCommitBody, MissedRunAction, + GenerateMetadataRequest, + GenerateMetadataResponse, ) from backend.apps.workflows import storage, scheduler, executor, audit, escalation @@ -265,27 +267,39 @@ async def create_workflow(body: WorkflowCreate): # leaving stale session names ("Inbox check") as titles. Step labels # are the 3-6 word at-a-glance headlines surfaced in StepList; without # them the UI falls back to truncated raw prompts. - try: - title, description, labels = await _generate_workflow_metadata(wf) - # Respect a user-supplied title (auto_named=False); only auto-fill the - # name + description while the workflow is still auto-named. Labels are - # always safe to fill since they don't override a user's title. - if wf.auto_named: - if title: - wf.title = title - if description: - wf.description = description - if labels and len(labels) == len(wf.steps): - for i, lab in enumerate(labels): - if lab: - wf.steps[i].label = lab - except Exception: - pass + # When the FE already generated metadata at preview time it ships the title, + # description, and per-step labels on the body, so we skip the aux call here. + if not body.metadata_generated: + try: + title, description, labels = await _generate_workflow_metadata(wf) + # Respect a user-supplied title (auto_named=False); only auto-fill the + # name + description while the workflow is still auto-named. Labels are + # always safe to fill since they don't override a user's title. + if wf.auto_named: + if title: + wf.title = title + if description: + wf.description = description + if labels and len(labels) == len(wf.steps): + for i, lab in enumerate(labels): + if lab: + wf.steps[i].label = lab + except Exception: + pass storage.save_workflow(wf) scheduler.kick() return _enriched(wf) +@workflows.router.post("/generate-metadata") +async def generate_workflow_metadata(body: GenerateMetadataRequest) -> GenerateMetadataResponse: + # Preview-time naming for the convert-to-workflow draft. Generates without + # persisting so the card can show a real title before the user saves. + wf = Workflow(steps=body.steps, model=body.model or "sonnet") + title, description, labels = await _generate_workflow_metadata(wf) + return GenerateMetadataResponse(title=title, description=description, step_labels=labels) + + async def _generate_workflow_metadata(wf: Workflow) -> tuple[str, str, list[str]]: """Single aux-model call returning (title, description, step_labels). diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index b649d093..f025c5f6 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -42,7 +42,7 @@ import { useDashboardActive } from '@/shared/hooks/useDashboardActive'; import { useOverlayScrollPassthrough } from '../hooks/interaction/useOverlayScrollPassthrough'; import { useStreamingMessage } from '@/shared/state/streamingSlice'; import { isCanvasInteractionActive, onCanvasInteractionEnd } from '@/shared/canvasInteractionState'; -import { openWorkflowCard, setCardSidecar, type Workflow } from '@/shared/state/workflowsSlice'; +import { openWorkflowCard, updateWorkflowCard, generateWorkflowMetadata, applyGeneratedMetadata, setCardSidecar, type Workflow } from '@/shared/state/workflowsSlice'; import { addWorkflowCard, setWorkflowCardPosition, setWorkflowCardSize } from '@/shared/state/dashboardLayoutSlice'; import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined'; import { getAgentWorkTime, fmtSeconds } from '@/shared/agentWorkTime'; @@ -330,8 +330,11 @@ const AgentCard: React.FC = ({ workflowId: draftId, sourceSessionId: session.id, view: 'preview', + metaLoading: true, draft: { - title: session.name || 'New workflow', + // Empty so the header shows its calm "New workflow" placeholder while + // naming runs, instead of flashing the stale chat name. + title: '', description: '', steps, source_session_id: session.id, @@ -340,6 +343,15 @@ const AgentCard: React.FC = ({ mode: defaultMode || session.mode, } as Partial, })); + const genModel = defaultModel || session.model; + dispatch(generateWorkflowMetadata({ steps: steps.map((s) => ({ id: s.id, text: s.text })), model: genModel })) + .then((r) => { + if (generateWorkflowMetadata.fulfilled.match(r)) { + dispatch(applyGeneratedMetadata({ workflowId: draftId, meta: r.payload })); + } else { + dispatch(updateWorkflowCard({ workflowId: draftId, patch: { metaLoading: false } })); + } + }); }, [converting, session, dispatch, expandedSessionIds, cardX, cardY, cardWidth, cardHeight, defaultModel, defaultMode]); // Curated picker label with a tidy fallback for unknowns. const friendlyModelLabel = useMemo(() => { diff --git a/frontend/src/app/pages/Workflows/WorkflowCard.tsx b/frontend/src/app/pages/Workflows/WorkflowCard.tsx index 6f63c3cc..fe2c65e1 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCard.tsx @@ -390,7 +390,8 @@ const WorkflowCard: React.FC = ({ const result = await dispatch(createWorkflow({ title: (d.title as string) || 'New workflow', description: (d.description as string) || '', - steps: draftSteps.map((s) => ({ id: s.id, text: s.text })), + steps: draftSteps.map((s) => ({ id: s.id, text: s.text, label: s.label })), + metadata_generated: card?.metaGenerated === true, source_session_id: (d.source_session_id as string | undefined) || card?.sourceSessionId || null, use_synced_prompt: true, model: defaultModel || (d.model as string), @@ -558,12 +559,14 @@ const WorkflowCard: React.FC = ({ data-no-drag onPointerDown={(e) => e.stopPropagation()} value={(card?.draft?.title as string) || ''} - placeholder="New workflow" + placeholder={card?.metaLoading ? 'Naming workflow' : 'New workflow'} onChange={(e) => dispatch(updateWorkflowCard({ workflowId, patch: { draft: { ...(card?.draft || {}), title: e.target.value } } }))} sx={{ flex: 1, fontWeight: 600, fontSize: '0.95rem', color: c.text.primary, letterSpacing: '-0.005em', '& input::placeholder': { color: c.text.muted, opacity: 1 }, + animation: card?.metaLoading ? 'wfTitlePulse 1.2s ease-in-out infinite' : 'none', + '@keyframes wfTitlePulse': { '0%, 100%': { opacity: 0.55 }, '50%': { opacity: 1 } }, }} /> ) : ( diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index 01ae2147..d69ec5bd 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -169,7 +169,8 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, const result = await dispatch(createWorkflow({ title, description, - steps: steps.map((s) => ({ id: s.id, text: s.text })), + steps: steps.map((s) => ({ id: s.id, text: s.text, label: s.label })), + metadata_generated: card?.metaGenerated === true, source_session_id: sourceSessionId, use_synced_prompt: true, // The user's configured default wins over whatever model the source chat @@ -184,7 +185,7 @@ export function PreviewView({ workflowId, steps, sourceSessionId, initialDraft, const wf = result.payload as Workflow; if (wf?.id) return wf; return null; - }, [canSave, dispatch, title, description, steps, sourceSessionId, liveDraft, defaultModel, defaultMode]); + }, [canSave, dispatch, title, description, steps, sourceSessionId, liveDraft, defaultModel, defaultMode, card]); const onIgnore = useCallback(async () => { if (busy) return; diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index 1f3126b7..307ec496 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -159,6 +159,12 @@ export interface OpenCard { /** Pre-seed message for the Fix-with-Agent flow so the EditAgent composer * knows which failure context to lead with. Cleared once consumed. */ fixSeed?: { runId: string; stepIdx: number; stepLabel: string; error: string } | null; + /** True while the preview-time aux naming call is in flight; drives the + * header's subtle pulse on a just-converted draft. */ + metaLoading?: boolean; + /** True once preview-time naming filled a real title, so save trusts the + * draft's metadata instead of regenerating it server-side. */ + metaGenerated?: boolean; } export interface RunningToast { @@ -260,7 +266,7 @@ export const fetchWorkflows = createAsyncThunk( export const createWorkflow = createAsyncThunk( 'workflows/create', - async (body: Partial) => { + async (body: Partial & { metadata_generated?: boolean }) => { const res = await fetch(`${API}/create`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -271,6 +277,54 @@ export const createWorkflow = createAsyncThunk( }, ); +export interface GeneratedMetadata { + title: string; + description: string; + step_labels: string[]; +} + +export const generateWorkflowMetadata = createAsyncThunk( + 'workflows/generateMetadata', + async (arg: { steps: Array<{ id: string; text: string }>; model?: string }) => { + const res = await fetch(`${API}/generate-metadata`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(arg), + }); + if (!res.ok) throw new Error(`metadata failed ${res.status}`); + return (await res.json()) as GeneratedMetadata; + }, +); + +// Merge preview-time generated metadata into a draft card, filling only the +// fields the user hasn't typed into so a rename mid-flight survives. +export const applyGeneratedMetadata = createAsyncThunk( + 'workflows/applyGeneratedMetadata', + async (arg: { workflowId: string; meta: GeneratedMetadata }, { getState, dispatch }) => { + const state = getState() as { workflows: State }; + const card = state.workflows.openCards[arg.workflowId]; + if (!card) return; + const draft = (card.draft || {}) as Partial; + const { meta } = arg; + const steps = draft.steps || []; + const nextDraft: Partial = { ...draft }; + let changed = false; + if (meta.step_labels && meta.step_labels.length === steps.length) { + nextDraft.steps = steps.map((s, i) => (meta.step_labels[i] ? { ...s, label: meta.step_labels[i] } : s)); + changed = true; + } + const hasTitle = Boolean(meta.title && meta.title.trim()); + if (hasTitle && !(draft.title || '').trim()) { nextDraft.title = meta.title; changed = true; } + if (meta.description && meta.description.trim() && !(draft.description || '').trim()) { + nextDraft.description = meta.description; + changed = true; + } + const patch: Partial = { metaLoading: false, metaGenerated: hasTitle }; + if (changed) patch.draft = nextDraft; + dispatch(updateWorkflowCard({ workflowId: arg.workflowId, patch })); + }, +); + // Optimistic concurrency via If-Match: server 409s on stale writes; rejectWithValue lets FE distinguish. export const updateWorkflow = createAsyncThunk< Workflow,