diff --git a/skyvern-frontend/src/components/WorkflowBlockInputTextarea.tsx b/skyvern-frontend/src/components/WorkflowBlockInputTextarea.tsx index 3cb41524..cd4561c6 100644 --- a/skyvern-frontend/src/components/WorkflowBlockInputTextarea.tsx +++ b/skyvern-frontend/src/components/WorkflowBlockInputTextarea.tsx @@ -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(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 = () => { diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index c0e3fd16..8f2700cf 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -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(null); useAutoPan(editorElementRef, nodes); + useAutoGenerateWorkflowTitle(nodes, edges); useEffect(() => { doLayout(nodes, edges); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx index b6fad87e..f83d0518 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ActionNode/ActionNode.tsx @@ -127,7 +127,6 @@ function ActionNode({ id, data, type }: NodeProps) { { update({ url: value }); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx index 084a6f14..63106188 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/FileDownloadNode/FileDownloadNode.tsx @@ -121,7 +121,6 @@ function FileDownloadNode({ id, data }: NodeProps) { ) : null} { update({ url: value }); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpRequestNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpRequestNode.tsx index f61f87f8..e58b9859 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpRequestNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/HttpRequestNode/HttpRequestNode.tsx @@ -233,7 +233,6 @@ function HttpRequestNode({ id, data, type }: NodeProps) { ) : null} { update({ url: value }); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginNode.tsx index 9ef6bc64..5b429277 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/LoginNode/LoginNode.tsx @@ -116,7 +116,6 @@ function LoginNode({ id, data, type }: NodeProps) { update({ url: value })} value={data.url} diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx index d8b0a349..98c37956 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NavigationNode/NavigationNode.tsx @@ -122,7 +122,6 @@ function NavigationNode({ id, data, type }: NodeProps) { { update({ url: value }); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx index 5ed3d6b1..574823e2 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNode.tsx @@ -125,7 +125,6 @@ function TaskNode({ id, data, type }: NodeProps) { ) : null} { update({ url: value }); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx index 4ceafb49..5c106e9a 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/Taskv2Node/Taskv2Node.tsx @@ -88,7 +88,6 @@ function Taskv2Node({ id, data, type }: NodeProps) {
{ update({ url: value }); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/URLNode/URLNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/URLNode/URLNode.tsx index c3dd2feb..de368bbe 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/URLNode/URLNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/URLNode/URLNode.tsx @@ -80,7 +80,6 @@ function URLNode({ id, data, type }: NodeProps) { ) : null}
{ update({ url: value }); diff --git a/skyvern-frontend/src/routes/workflows/hooks/useAutoGenerateWorkflowTitle.ts b/skyvern-frontend/src/routes/workflows/hooks/useAutoGenerateWorkflowTitle.ts new file mode 100644 index 00000000..2f0854fb --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useAutoGenerateWorkflowTitle.ts @@ -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(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 }; diff --git a/skyvern-frontend/src/store/WorkflowTitleStore.ts b/skyvern-frontend/src/store/WorkflowTitleStore.ts index aa1ebe03..16bdd2fc 100644 --- a/skyvern-frontend/src/store/WorkflowTitleStore.ts +++ b/skyvern-frontend/src/store/WorkflowTitleStore.ts @@ -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((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 }); }, }; }); diff --git a/skyvern/forge/prompts/skyvern/generate-workflow-title.j2 b/skyvern/forge/prompts/skyvern/generate-workflow-title.j2 new file mode 100644 index 00000000..38d9ab28 --- /dev/null +++ b/skyvern/forge/prompts/skyvern/generate-workflow-title.j2 @@ -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"} diff --git a/skyvern/forge/sdk/routes/prompts.py b/skyvern/forge/sdk/routes/prompts.py index 6894e385..d047520f 100644 --- a/skyvern/forge/sdk/routes/prompts.py +++ b/skyvern/forge/sdk/routes/prompts.py @@ -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)}", + ) diff --git a/skyvern/forge/sdk/schemas/prompts.py b/skyvern/forge/sdk/schemas/prompts.py index cea20f51..fcaf87af 100644 --- a/skyvern/forge/sdk/schemas/prompts.py +++ b/skyvern/forge/sdk/schemas/prompts.py @@ -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") diff --git a/skyvern/forge/sdk/workflow/service.py b/skyvern/forge/sdk/workflow/service.py index e205f95d..bc799127 100644 --- a/skyvern/forge/sdk/workflow/service.py +++ b/skyvern/forge/sdk/workflow/service.py @@ -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