feat: Add workflow name generation and save after initial generation (no-changelog) (#18348)

This commit is contained in:
Eugene
2025-08-15 15:38:48 +02:00
committed by GitHub
parent abf7b11e09
commit 1ddb10c3c8
13 changed files with 1101 additions and 24 deletions

View File

@@ -0,0 +1,33 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { PromptTemplate } from '@langchain/core/prompts';
import z from 'zod';
const workflowNamingPromptTemplate = PromptTemplate.fromTemplate(
`Based on the initial user prompt, please generate a name for the workflow that captures its essence and purpose.
<initial_prompt>
{initialPrompt}
</initial_prompt>
This name should be concise, descriptive, and suitable for a workflow that automates tasks related to the given prompt. The name should be in a format that is easy to read and understand.
`,
);
export async function workflowNameChain(llm: BaseChatModel, initialPrompt: string) {
// Use structured output for the workflow name to ensure it meets the required format and length
const nameSchema = z.object({
name: z.string().min(10).max(128).describe('Name of the workflow based on the prompt'),
});
const modelWithStructure = llm.withStructuredOutput(nameSchema);
const prompt = await workflowNamingPromptTemplate.invoke({
initialPrompt,
});
const structuredOutput = (await modelWithStructure.invoke(prompt)) as z.infer<typeof nameSchema>;
return {
name: structuredOutput.name,
};
}

View File

@@ -3,7 +3,7 @@ import type { IWorkflowBase, INode, IConnections } from 'n8n-workflow';
/**
* Simplified workflow representation containing only nodes and connections
*/
export type SimpleWorkflow = Pick<IWorkflowBase, 'nodes' | 'connections'>;
export type SimpleWorkflow = Pick<IWorkflowBase, 'name' | 'nodes' | 'connections'>;
/**
* Workflow operation types that can be applied to the workflow state
@@ -14,4 +14,5 @@ export type WorkflowOperation =
| { type: 'addNodes'; nodes: INode[] }
| { type: 'updateNode'; nodeId: string; updates: Partial<INode> }
| { type: 'setConnections'; connections: IConnections }
| { type: 'mergeConnections'; connections: IConnections };
| { type: 'mergeConnections'; connections: IConnections }
| { type: 'setName'; name: string };

View File

@@ -15,13 +15,14 @@ export function applyOperations(
let result: SimpleWorkflow = {
nodes: [...workflow.nodes],
connections: { ...workflow.connections },
name: workflow.name || '',
};
// Apply each operation in sequence
for (const operation of operations) {
switch (operation.type) {
case 'clear':
result = { nodes: [], connections: {} };
result = { nodes: [], connections: {}, name: '' };
break;
case 'removeNode': {

View File

@@ -18,6 +18,7 @@ describe('operations-processor', () => {
node3 = createNode({ id: 'node3', name: 'Node 3', position: [500, 100] });
baseWorkflow = {
name: 'Test Workflow',
nodes: [node1, node2, node3],
connections: {
node1: {

View File

@@ -12,6 +12,7 @@ import type {
NodeExecutionSchema,
} from 'n8n-workflow';
import { workflowNameChain } from '@/chains/workflow-name';
import { DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS, MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants';
import { conversationCompactChain } from './chains/conversation-compact';
@@ -122,7 +123,7 @@ export class WorkflowBuilderAgent {
};
const shouldModifyState = (state: typeof WorkflowState.State) => {
const { messages } = state;
const { messages, workflowContext } = state;
const lastHumanMessage = messages.findLast((m) => m instanceof HumanMessage)!; // There always should be at least one human message in the array
if (lastHumanMessage.content === '/compact') {
@@ -133,6 +134,12 @@ export class WorkflowBuilderAgent {
return 'delete_messages';
}
// If the workflow is empty (no nodes),
// we consider it initial generation request and auto-generate a name for the workflow.
if (workflowContext?.currentWorkflow?.nodes?.length === 0 && messages.length === 1) {
return 'create_workflow_name';
}
if (shouldAutoCompact(state)) {
return 'auto_compact_messages';
}
@@ -162,6 +169,7 @@ export class WorkflowBuilderAgent {
workflowJSON: {
nodes: [],
connections: {},
name: '',
},
};
@@ -180,7 +188,7 @@ export class WorkflowBuilderAgent {
}
const { messages, previousSummary } = state;
const lastHumanMessage = messages[messages.length - 1] as HumanMessage;
const lastHumanMessage = messages[messages.length - 1] satisfies HumanMessage;
const isAutoCompact = lastHumanMessage.content !== '/compact';
this.logger?.debug('Compacting conversation history', {
@@ -209,6 +217,40 @@ export class WorkflowBuilderAgent {
};
};
/**
* Creates a workflow name based on the initial user message.
*/
const createWorkflowName = async (state: typeof WorkflowState.State) => {
if (!this.llmSimpleTask) {
throw new LLMServiceError('LLM not setup');
}
const { workflowJSON, messages } = state;
if (messages.length === 1 && messages[0] instanceof HumanMessage) {
const initialMessage = messages[0] satisfies HumanMessage;
if (typeof initialMessage.content !== 'string') {
this.logger?.debug(
'Initial message content is not a string, skipping workflow name generation',
);
return {};
}
this.logger?.debug('Generating workflow name');
const { name } = await workflowNameChain(this.llmSimpleTask, initialMessage.content);
return {
workflowJSON: {
...workflowJSON,
name,
},
};
}
return {};
};
const workflow = new StateGraph(WorkflowState)
.addNode('agent', callModel)
.addNode('tools', customToolExecutor)
@@ -216,10 +258,12 @@ export class WorkflowBuilderAgent {
.addNode('delete_messages', deleteMessages)
.addNode('compact_messages', compactSession)
.addNode('auto_compact_messages', compactSession)
.addNode('create_workflow_name', createWorkflowName)
.addConditionalEdges('__start__', shouldModifyState)
.addEdge('tools', 'process_operations')
.addEdge('process_operations', 'agent')
.addEdge('auto_compact_messages', 'agent')
.addEdge('create_workflow_name', 'agent')
.addEdge('delete_messages', END)
.addEdge('compact_messages', END)
.addConditionalEdges('agent', shouldContinue);

View File

@@ -68,7 +68,7 @@ export const WorkflowState = Annotation.Root({
// Now a simple field without custom reducer - all updates go through operations
workflowJSON: Annotation<SimpleWorkflow>({
reducer: (x, y) => y ?? x,
default: () => ({ nodes: [], connections: {} }),
default: () => ({ nodes: [], connections: {}, name: '' }),
}),
// Operations to apply to the workflow - processed by a separate node
workflowOperations: Annotation<WorkflowOperation[] | null>({

View File

@@ -44,7 +44,7 @@ export const createNode = (overrides: Partial<INode> = {}): INode => ({
// Simple workflow builder
export const createWorkflow = (nodes: INode[] = []): SimpleWorkflow => {
const workflow: SimpleWorkflow = { nodes, connections: {} };
const workflow: SimpleWorkflow = { nodes, connections: {}, name: 'Test workflow' };
return workflow;
};