mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 12:47:42 +02:00
[aidan] feat/workflows: generate title, description, step labels at convert time
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user