[aidan] feat/workflows: generate title, description, step labels at convert time

This commit is contained in:
abccodes
2026-06-19 19:39:38 -07:00
parent ed43937a5e
commit c163dfed77
6 changed files with 121 additions and 23 deletions
+14
View File
@@ -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):
+30 -16
View File
@@ -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).
@@ -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<Props> = ({
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<Props> = ({
mode: defaultMode || session.mode,
} as Partial<Workflow>,
}));
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(() => {
@@ -390,7 +390,8 @@ const WorkflowCard: React.FC<Props> = ({
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<Props> = ({
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 } },
}}
/>
) : (
@@ -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;
+55 -1
View File
@@ -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<Workflow>) => {
async (body: Partial<Workflow> & { 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<Workflow>;
const { meta } = arg;
const steps = draft.steps || [];
const nextDraft: Partial<Workflow> = { ...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<OpenCard> = { 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,