Auto-generate meaningful workflow titles via debounced LLM (#SKY-7287) (#4652)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -127,7 +127,6 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
|
||||
</div>
|
||||
|
||||
<WorkflowBlockInputTextarea
|
||||
canWriteTitle={true}
|
||||
nodeId={id}
|
||||
onChange={(value) => {
|
||||
update({ url: value });
|
||||
|
||||
@@ -121,7 +121,6 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
|
||||
) : null}
|
||||
</div>
|
||||
<WorkflowBlockInputTextarea
|
||||
canWriteTitle={true}
|
||||
nodeId={id}
|
||||
onChange={(value) => {
|
||||
update({ url: value });
|
||||
|
||||
@@ -233,7 +233,6 @@ function HttpRequestNode({ id, data, type }: NodeProps<HttpRequestNodeType>) {
|
||||
) : null}
|
||||
</div>
|
||||
<WorkflowBlockInputTextarea
|
||||
canWriteTitle={true}
|
||||
nodeId={id}
|
||||
onChange={(value) => {
|
||||
update({ url: value });
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -122,7 +122,6 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
|
||||
</div>
|
||||
|
||||
<WorkflowBlockInputTextarea
|
||||
canWriteTitle={true}
|
||||
nodeId={id}
|
||||
onChange={(value) => {
|
||||
update({ url: value });
|
||||
|
||||
@@ -125,7 +125,6 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
|
||||
) : null}
|
||||
</div>
|
||||
<WorkflowBlockInputTextarea
|
||||
canWriteTitle={true}
|
||||
nodeId={id}
|
||||
onChange={(value) => {
|
||||
update({ url: value });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -80,7 +80,6 @@ function URLNode({ id, data, type }: NodeProps<URLNode>) {
|
||||
) : null}
|
||||
</div>
|
||||
<WorkflowBlockInputTextarea
|
||||
canWriteTitle={true}
|
||||
nodeId={id}
|
||||
onChange={(value) => {
|
||||
update({ url: value });
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user