Auto-generate meaningful workflow titles via debounced LLM (#SKY-7287) (#4652)

This commit is contained in:
Celal Zamanoglu
2026-02-06 16:43:48 +03:00
committed by GitHub
parent 32444429fd
commit a6af22fa20
16 changed files with 321 additions and 94 deletions

View File

@@ -3,7 +3,6 @@ import { cn } from "@/util/utils";
import { AutoResizingTextarea } from "./AutoResizingTextarea/AutoResizingTextarea";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { WorkflowBlockParameterSelect } from "@/routes/workflows/editor/nodes/WorkflowBlockParameterSelect";
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
import { useEffect, useRef, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
@@ -19,20 +18,12 @@ type Props = Omit<
"onChange"
> & {
aiImprove?: AiImprove;
canWriteTitle?: boolean;
onChange: (value: string) => void;
nodeId: string;
};
function WorkflowBlockInputTextarea(props: Props) {
const { maybeAcceptTitle, maybeWriteTitle } = useWorkflowTitleStore();
const {
aiImprove,
nodeId,
onChange,
canWriteTitle = false,
...textAreaProps
} = props;
const { aiImprove, nodeId, onChange, ...textAreaProps } = props;
const [internalValue, setInternalValue] = useState(props.value ?? "");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [cursorPosition, setCursorPosition] = useState<{
@@ -46,11 +37,6 @@ function WorkflowBlockInputTextarea(props: Props) {
const doOnChange = useDebouncedCallback((value: string) => {
onChange(value);
if (canWriteTitle) {
maybeWriteTitle(value);
maybeAcceptTitle();
}
}, 300);
const handleTextareaSelect = () => {

View File

@@ -96,6 +96,7 @@ import {
import { getWorkflowErrors } from "./workflowEditorUtils";
import { toast } from "@/components/ui/use-toast";
import { useAutoPan } from "./useAutoPan";
import { useAutoGenerateWorkflowTitle } from "../hooks/useAutoGenerateWorkflowTitle";
function convertToParametersYAML(
parameters: ParametersState,
@@ -740,6 +741,7 @@ function FlowRenderer({
const editorElementRef = useRef<HTMLDivElement>(null);
useAutoPan(editorElementRef, nodes);
useAutoGenerateWorkflowTitle(nodes, edges);
useEffect(() => {
doLayout(nodes, edges);

View File

@@ -127,7 +127,6 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
</div>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
update({ url: value });

View File

@@ -121,7 +121,6 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
) : null}
</div>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
update({ url: value });

View File

@@ -233,7 +233,6 @@ function HttpRequestNode({ id, data, type }: NodeProps<HttpRequestNodeType>) {
) : null}
</div>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
update({ url: value });

View File

@@ -116,7 +116,6 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
</div>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => update({ url: value })}
value={data.url}

View File

@@ -122,7 +122,6 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
</div>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
update({ url: value });

View File

@@ -125,7 +125,6 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
) : null}
</div>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
update({ url: value });

View File

@@ -88,7 +88,6 @@ function Taskv2Node({ id, data, type }: NodeProps<Taskv2Node>) {
<div className="space-y-2">
<Label className="text-xs text-slate-300">URL</Label>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
update({ url: value });

View File

@@ -80,7 +80,6 @@ function URLNode({ id, data, type }: NodeProps<URLNode>) {
) : null}
</div>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
update({ url: value });

View File

@@ -0,0 +1,134 @@
import { useEffect, useMemo, useRef } from "react";
import { useDebouncedCallback } from "use-debounce";
import type { Edge } from "@xyflow/react";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
import { getWorkflowBlocks } from "../editor/workflowEditorUtils";
import type { AppNode } from "../editor/nodes";
import type { BlockYAML } from "../types/workflowYamlTypes";
type BlockInfo = {
block_type: string;
url?: string;
goal?: string;
};
function extractBlockInfo(block: BlockYAML): BlockInfo {
const info: BlockInfo = { block_type: block.block_type };
if ("url" in block && block.url) {
info.url = block.url;
}
if ("navigation_goal" in block && block.navigation_goal) {
info.goal =
block.navigation_goal.length > 150
? block.navigation_goal.slice(0, 150)
: block.navigation_goal;
} else if ("data_extraction_goal" in block && block.data_extraction_goal) {
info.goal =
block.data_extraction_goal.length > 150
? block.data_extraction_goal.slice(0, 150)
: block.data_extraction_goal;
} else if ("prompt" in block && block.prompt) {
const prompt = block.prompt;
info.goal = prompt.length > 150 ? prompt.slice(0, 150) : prompt;
}
return info;
}
function hasMeaningfulContent(blocksInfo: BlockInfo[]): boolean {
return blocksInfo.some((b) => b.url || b.goal);
}
const TITLE_GENERATION_DEBOUNCE_MS = 4000;
function useAutoGenerateWorkflowTitle(nodes: AppNode[], edges: Edge[]): void {
const credentialGetter = useCredentialGetter();
const abortControllerRef = useRef<AbortController | null>(null);
// Derive a stable content fingerprint so we only react to actual block
// content changes, not to layout/dimension/position updates on nodes.
const contentFingerprint = useMemo(() => {
const blocks = getWorkflowBlocks(nodes, edges);
const info = blocks.slice(0, 5).map(extractBlockInfo);
return JSON.stringify(info);
}, [nodes, edges]);
// useDebouncedCallback returns a stable reference (uses useMemo internally
// with static deps), so it's safe to call in effects without listing it as
// a dependency.
const debouncedGenerate = useDebouncedCallback(
async (blocksInfo: BlockInfo[]) => {
// Re-check title state right before making the API call
const state = useWorkflowTitleStore.getState();
if (!state.isNewTitle() || state.titleHasBeenGenerated) {
return;
}
// Cancel any previous in-flight request
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
try {
const client = await getClient(credentialGetter, "sans-api-v1");
const response = await client.post<
{ blocks: BlockInfo[] },
{ data: { title: string | null } }
>(
"/prompts/generate-workflow-title",
{ blocks: blocksInfo },
{ signal: controller.signal },
);
// Re-check after async call - user may have edited title during the request
const currentState = useWorkflowTitleStore.getState();
if (
currentState.isNewTitle() &&
!currentState.titleHasBeenGenerated &&
response.data.title
) {
currentState.setTitleFromGeneration(response.data.title);
}
} catch {
// Silently ignore - abort errors, network errors, etc.
// The first-save fallback in create_workflow_from_request still works.
}
},
TITLE_GENERATION_DEBOUNCE_MS,
);
useEffect(() => {
const state = useWorkflowTitleStore.getState();
// Only auto-generate for new, untouched workflows
if (!state.isNewTitle() || state.titleHasBeenGenerated) {
debouncedGenerate.cancel();
return;
}
const blocksInfo: BlockInfo[] = JSON.parse(contentFingerprint);
if (!hasMeaningfulContent(blocksInfo)) {
debouncedGenerate.cancel();
return;
}
debouncedGenerate(blocksInfo);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contentFingerprint]);
// Cleanup on unmount
useEffect(() => {
return () => {
debouncedGenerate.cancel();
abortControllerRef.current?.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
export { useAutoGenerateWorkflowTitle };

View File

@@ -1,88 +1,38 @@
/**
* Context: new workflows begin with a default title. If the user edits a URL
* field in a workflow block, and the title is deemed "new", we want to
* automagically update the title to the text of the URL. That way, they don't
* have to manually update the title themselves, if they deem the automagic
* title to be appropriate.
*/
import { create } from "zustand";
const DEFAULT_WORKFLOW_TITLE = "New Workflow" as const;
const DELIMITER_OPEN = "[[";
const DELIMITER_CLOSE = "]]";
type WorkflowTitleStore = {
title: string;
/**
* If the title is deemed to be new, accept it, and prevent further
* `maybeWriteTitle` updates.
*/
maybeAcceptTitle: () => void;
/**
* Maybe update the title - if it's empty, or deemed to be new and unedited.
*/
maybeWriteTitle: (title: string) => void;
titleHasBeenGenerated: boolean;
isNewTitle: () => boolean;
setTitle: (title: string) => void;
setTitleFromGeneration: (title: string) => void;
initializeTitle: (title: string) => void;
resetTitle: () => void;
};
/**
* If the title appears to be a URL, let's trim it down to the domain and path.
*/
const formatURL = (url: string) => {
try {
const urlObj = new URL(url);
return urlObj.hostname + urlObj.pathname;
} catch {
return url;
}
};
/**
* If the title begins and ends with squackets, remove them.
*/
const formatAcceptedTitle = (title: string) => {
if (title.startsWith(DELIMITER_OPEN) && title.endsWith(DELIMITER_CLOSE)) {
const trimmed = title.slice(DELIMITER_OPEN.length, -DELIMITER_CLOSE.length);
return formatURL(trimmed);
}
return title;
};
const formatNewTitle = (title: string) =>
title.trim().length
? `${DELIMITER_OPEN}${title}${DELIMITER_CLOSE}`
: DEFAULT_WORKFLOW_TITLE;
const isNewTitle = (title: string) =>
title === DEFAULT_WORKFLOW_TITLE ||
(title.startsWith(DELIMITER_OPEN) && title.endsWith(DELIMITER_CLOSE));
const useWorkflowTitleStore = create<WorkflowTitleStore>((set, get) => {
return {
title: "",
maybeAcceptTitle: () => {
const { title: currentTitle } = get();
if (isNewTitle(currentTitle)) {
set({ title: formatAcceptedTitle(currentTitle) });
}
},
maybeWriteTitle: (title: string) => {
const { title: currentTitle } = get();
if (isNewTitle(currentTitle)) {
set({ title: formatNewTitle(title.trim()) });
}
titleHasBeenGenerated: false,
isNewTitle: () => {
return get().title === DEFAULT_WORKFLOW_TITLE;
},
setTitle: (title: string) => {
set({ title: title.trim() });
set({ title: title.trim(), titleHasBeenGenerated: true });
},
setTitleFromGeneration: (title: string) => {
set({ title: title.trim(), titleHasBeenGenerated: true });
},
initializeTitle: (title: string) => {
set({ title: title.trim() });
set({
title: title.trim(),
titleHasBeenGenerated: title.trim() !== DEFAULT_WORKFLOW_TITLE,
});
},
resetTitle: () => {
set({ title: "" });
set({ title: "", titleHasBeenGenerated: false });
},
};
});

View File

@@ -0,0 +1,15 @@
Generate a brief, descriptive title for a browser automation workflow.
Rules:
- Maximum 5 words
- Start with an action verb when possible
- Be specific about what the workflow does
- Examples: "Scrape LinkedIn job listings", "Extract Amazon product prices", "Submit insurance form"
Workflow blocks ({{ blocks|length }} total):
{% for block in blocks %}
- {{ block.block_type }}{% if block.url %} on {{ block.url }}{% endif %}{% if block.goal %}: {{ block.goal }}{% endif %}
{% endfor %}
Respond with JSON only:
{"title": "generated title here"}

View File

@@ -10,8 +10,14 @@ from skyvern.forge.prompts import prompt_engine
from skyvern.forge.sdk.api.llm.exceptions import LLMProviderError
from skyvern.forge.sdk.routes.routers import base_router
from skyvern.forge.sdk.schemas.organizations import Organization
from skyvern.forge.sdk.schemas.prompts import ImprovePromptRequest, ImprovePromptResponse
from skyvern.forge.sdk.schemas.prompts import (
GenerateWorkflowTitleRequest,
GenerateWorkflowTitleResponse,
ImprovePromptRequest,
ImprovePromptResponse,
)
from skyvern.forge.sdk.services import org_auth_service
from skyvern.forge.sdk.workflow.service import generate_title_from_blocks_info
LOG = structlog.get_logger()
@@ -124,3 +130,42 @@ async def improve_prompt(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to improve prompt: {str(e)}",
)
@base_router.post(
"/prompts/generate-workflow-title",
tags=["Prompts"],
description="Generate a meaningful workflow title from block content",
summary="Generate workflow title",
include_in_schema=False,
)
async def generate_workflow_title(
request: GenerateWorkflowTitleRequest,
current_org: Organization = Depends(org_auth_service.get_current_org),
) -> GenerateWorkflowTitleResponse:
"""Generate a meaningful workflow title based on block content using LLM."""
LOG.info(
"Generating workflow title",
organization_id=current_org.organization_id,
num_blocks=len(request.blocks),
)
try:
blocks_info = [block.model_dump(exclude_none=True) for block in request.blocks]
title = await generate_title_from_blocks_info(
organization_id=current_org.organization_id,
blocks_info=blocks_info,
)
return GenerateWorkflowTitleResponse(title=title)
except LLMProviderError:
LOG.error("Failed to generate workflow title", exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to generate title. Please try again later.",
)
except Exception as e:
LOG.error("Unexpected error generating workflow title", error=str(e), exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to generate title: {str(e)}",
)

View File

@@ -30,3 +30,20 @@ class ImprovePromptResponse(BaseModel):
error: str | None = Field(None, description="Error message if prompt improvement failed")
improved: str = Field(..., description="The improved version of the prompt")
original: str = Field(..., description="The original prompt provided for improvement")
class BlockInfoForTitle(BaseModel):
block_type: str = Field(..., description="The type of the workflow block")
url: str | None = Field(None, description="URL associated with the block")
goal: str | None = Field(None, description="Goal or prompt text for the block")
class GenerateWorkflowTitleRequest(BaseModel):
blocks: list[BlockInfoForTitle] = Field(
...,
description="List of block info objects for title generation",
)
class GenerateWorkflowTitleResponse(BaseModel):
title: str | None = Field(None, description="The generated workflow title")

View File

@@ -134,6 +134,76 @@ BLOCK_TYPES_THAT_SHOULD_BE_CACHED = {
}
def _extract_blocks_info(blocks: list[BLOCK_YAML_TYPES]) -> list[dict[str, str]]:
"""Extract lightweight info from blocks for title generation (limit to first 5)."""
blocks_info: list[dict[str, str]] = []
for block in blocks[:5]:
info: dict[str, str] = {"block_type": block.block_type.value}
# Extract URL if present
if hasattr(block, "url") and block.url:
info["url"] = block.url
# Extract goal/prompt
goal = None
if hasattr(block, "navigation_goal") and block.navigation_goal:
goal = block.navigation_goal
elif hasattr(block, "data_extraction_goal") and block.data_extraction_goal:
goal = block.data_extraction_goal
elif hasattr(block, "prompt") and block.prompt:
goal = block.prompt
if goal:
# Truncate long goals
info["goal"] = goal[:150] if len(goal) > 150 else goal
blocks_info.append(info)
return blocks_info
async def generate_title_from_blocks_info(
organization_id: str,
blocks_info: list[dict[str, Any]],
) -> str | None:
"""Call LLM to generate a workflow title from pre-extracted block info."""
if not blocks_info:
return None
try:
llm_prompt = prompt_engine.load_prompt(
"generate-workflow-title",
blocks=blocks_info,
)
response = await app.SECONDARY_LLM_API_HANDLER(
prompt=llm_prompt,
prompt_name="generate-workflow-title",
organization_id=organization_id,
)
if isinstance(response, dict) and "title" in response:
title = str(response["title"]).strip()
if title and len(title) <= 100: # Sanity check on length
return title
return None
except Exception:
LOG.exception("Failed to generate workflow title")
return None
async def generate_workflow_title(
organization_id: str,
blocks: list[BLOCK_YAML_TYPES],
) -> str | None:
"""Generate a meaningful workflow title based on block content using LLM."""
if not blocks:
return None
blocks_info = _extract_blocks_info(blocks)
return await generate_title_from_blocks_info(organization_id, blocks_info)
@dataclass
class CacheInvalidationPlan:
reason: CacheInvalidationReason | None = None
@@ -3210,10 +3280,26 @@ class WorkflowService:
delete_code_cache_is_ok: bool = True,
) -> Workflow:
organization_id = organization.organization_id
# Generate meaningful title if using default and has blocks
title = request.title
if title == DEFAULT_WORKFLOW_TITLE and request.workflow_definition.blocks:
generated_title = await generate_workflow_title(
organization_id=organization_id,
blocks=request.workflow_definition.blocks,
)
if generated_title:
title = generated_title
LOG.info(
"Generated workflow title",
organization_id=organization_id,
generated_title=title,
)
LOG.info(
"Creating workflow from request",
organization_id=organization_id,
title=request.title,
title=title,
)
new_workflow_id: str | None = None
@@ -3233,7 +3319,7 @@ class WorkflowService:
# NOTE: it's only potential, as it may be immediately deleted!
potential_workflow = await self.create_workflow(
title=request.title,
title=title,
workflow_definition=WorkflowDefinition(parameters=[], blocks=[]),
description=request.description,
organization_id=organization_id,
@@ -3259,7 +3345,7 @@ class WorkflowService:
else:
# NOTE: it's only potential, as it may be immediately deleted!
potential_workflow = await self.create_workflow(
title=request.title,
title=title,
workflow_definition=WorkflowDefinition(parameters=[], blocks=[]),
description=request.description,
organization_id=organization_id,
@@ -3291,7 +3377,7 @@ class WorkflowService:
updated_workflow = await self.update_workflow_definition(
workflow_id=potential_workflow.workflow_id,
organization_id=organization_id,
title=request.title,
title=title,
description=request.description,
workflow_definition=workflow_definition,
)
@@ -3313,7 +3399,7 @@ class WorkflowService:
)
await self.delete_workflow_by_id(workflow_id=new_workflow_id, organization_id=organization_id)
else:
LOG.exception(f"Failed to create workflow from request, title: {request.title}")
LOG.exception(f"Failed to create workflow from request, title: {title}")
raise e
@staticmethod