Auto-generate meaningful workflow titles via debounced LLM (#SKY-7287) (#4652)
This commit is contained in:
@@ -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 = () => {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user