Fixes: - Refactor connection snapping when dragging and enable it also for non-main connection types - Fix propagation of errors from sub-nodes - Fix chat scrolling when sending/receiving messages - Prevent empty chat messages - Fix sub-node selected styles - Fix output names text overflow Usability improvements: - Auto-add manual chat trigger for agents & chain nodes - Various labels and description updates - Make the output parser input optional for Basic LLM Chain - Summarization Chain V2 with a simplified document loader & text chunking mode #### How to test the change: Example workflow showcasing different operation mode of the new summarization chain: [Summarization_V2.json](https://github.com/n8n-io/n8n/files/13599901/Summarization_V2.json) ## Issues fixed Include links to Github issue or Community forum post or **Linear ticket**: > Important in order to close automatically and provide context to reviewers - https://www.notion.so/n8n/David-Langchain-Posthog-notes-7a9294938420403095f4508f1a21d31d - https://linear.app/n8n/issue/N8N-7070/ux-fixes-batch - https://linear.app/n8n/issue/N8N-7071/ai-sub-node-bugs ## Review / Merge checklist - [x] PR title and summary are descriptive. **Remember, the title automatically goes into the changelog. Use `(no-changelog)` otherwise.** ([conventions](https://github.com/n8n-io/n8n/blob/master/.github/pull_request_title_conventions.md)) - [x] [Docs updated](https://github.com/n8n-io/n8n-docs) or follow-up ticket created. - [ ] Tests included. > A bug is not considered fixed, unless a test is added to prevent it from happening again. A feature is not complete without tests. > > *(internal)* You can use Slack commands to trigger [e2e tests](https://www.notion.so/n8n/How-to-use-Test-Instances-d65f49dfc51f441ea44367fb6f67eb0a?pvs=4#a39f9e5ba64a48b58a71d81c837e8227) or [deploy test instance](https://www.notion.so/n8n/How-to-use-Test-Instances-d65f49dfc51f441ea44367fb6f67eb0a?pvs=4#f6a177d32bde4b57ae2da0b8e454bfce) or [deploy early access version on Cloud](https://www.notion.so/n8n/Cloudbot-3dbe779836004972b7057bc989526998?pvs=4#fef2d36ab02247e1a0f65a74f6fb534e). --------- Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> Co-authored-by: Elias Meire <elias@meire.dev>
230 lines
5.8 KiB
TypeScript
230 lines
5.8 KiB
TypeScript
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
|
|
import { isObjectEmpty, NodeConnectionType } from 'n8n-workflow';
|
|
|
|
interface MemoryMessage {
|
|
lc: number;
|
|
type: string;
|
|
id: string[];
|
|
kwargs: {
|
|
content: unknown;
|
|
additional_kwargs: Record<string, unknown>;
|
|
};
|
|
}
|
|
interface LmGeneration {
|
|
text: string;
|
|
message: MemoryMessage;
|
|
}
|
|
|
|
type ExcludedKeys = NodeConnectionType.Main | NodeConnectionType.AiChain;
|
|
type AllowedEndpointType = Exclude<NodeConnectionType, ExcludedKeys>;
|
|
|
|
const fallbackParser = (execData: IDataObject) => ({
|
|
type: 'json' as 'json' | 'text' | 'markdown',
|
|
data: execData,
|
|
parsed: false,
|
|
});
|
|
|
|
const outputTypeParsers: {
|
|
[key in AllowedEndpointType]: (execData: IDataObject) => {
|
|
type: 'json' | 'text' | 'markdown';
|
|
data: unknown;
|
|
parsed: boolean;
|
|
};
|
|
} = {
|
|
[NodeConnectionType.AiLanguageModel](execData: IDataObject) {
|
|
const response = (execData.response as IDataObject) ?? execData;
|
|
if (!response) throw new Error('No response from Language Model');
|
|
|
|
// Simple LLM output — single string message item
|
|
if (
|
|
Array.isArray(response?.messages) &&
|
|
response?.messages.length === 1 &&
|
|
typeof response?.messages[0] === 'string'
|
|
) {
|
|
return {
|
|
type: 'text',
|
|
data: response.messages[0],
|
|
parsed: true,
|
|
};
|
|
}
|
|
// Use the memory parser if the response is a memory-like(chat) object
|
|
if (response.messages && Array.isArray(response.messages)) {
|
|
return outputTypeParsers[NodeConnectionType.AiMemory](execData);
|
|
}
|
|
if (response.generations) {
|
|
const generations = response.generations as LmGeneration[];
|
|
|
|
const content = generations.map((generation) => {
|
|
if (generation?.text) return generation.text;
|
|
|
|
if (Array.isArray(generation)) {
|
|
return generation
|
|
.map((item: LmGeneration) => item.text ?? item)
|
|
.join('\n\n')
|
|
.trim();
|
|
}
|
|
|
|
return generation;
|
|
});
|
|
|
|
return {
|
|
type: 'json',
|
|
data: content,
|
|
parsed: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: 'json',
|
|
data: response,
|
|
parsed: true,
|
|
};
|
|
},
|
|
[NodeConnectionType.AiTool]: fallbackParser,
|
|
[NodeConnectionType.AiAgent]: fallbackParser,
|
|
[NodeConnectionType.AiMemory](execData: IDataObject) {
|
|
const chatHistory =
|
|
execData.chatHistory ?? execData.messages ?? execData?.response?.chat_history;
|
|
if (Array.isArray(chatHistory)) {
|
|
const responseText = chatHistory
|
|
.map((content: MemoryMessage) => {
|
|
if (
|
|
content.type === 'constructor' &&
|
|
content.id?.includes('messages') &&
|
|
content.kwargs
|
|
) {
|
|
interface MessageContent {
|
|
type: string;
|
|
image_url?: {
|
|
url: string;
|
|
};
|
|
}
|
|
let message = content.kwargs.content;
|
|
if (Array.isArray(message)) {
|
|
const messageContent = message[0] as {
|
|
type?: string;
|
|
image_url?: { url: string };
|
|
};
|
|
if (messageContent?.type === 'image_url') {
|
|
message = ``;
|
|
}
|
|
message = message as MessageContent[];
|
|
}
|
|
if (Object.keys(content.kwargs.additional_kwargs).length) {
|
|
message += ` (${JSON.stringify(content.kwargs.additional_kwargs)})`;
|
|
}
|
|
if (content.id.includes('HumanMessage')) {
|
|
message = `**Human:** ${message.trim()}`;
|
|
} else if (content.id.includes('AIMessage')) {
|
|
message = `**AI:** ${message}`;
|
|
} else if (content.id.includes('SystemMessage')) {
|
|
message = `**System Message:** ${message}`;
|
|
}
|
|
if (execData.action && execData.action !== 'getMessages') {
|
|
message = `## Action: ${execData.action}\n\n${message}`;
|
|
}
|
|
|
|
return message;
|
|
}
|
|
return '';
|
|
})
|
|
.join('\n\n');
|
|
|
|
return {
|
|
type: 'markdown',
|
|
data: responseText,
|
|
parsed: true,
|
|
};
|
|
}
|
|
|
|
return fallbackParser(execData);
|
|
},
|
|
[NodeConnectionType.AiOutputParser]: fallbackParser,
|
|
[NodeConnectionType.AiRetriever]: fallbackParser,
|
|
[NodeConnectionType.AiVectorStore](execData: IDataObject) {
|
|
if (execData.documents) {
|
|
return {
|
|
type: 'json',
|
|
data: execData.documents,
|
|
parsed: true,
|
|
};
|
|
}
|
|
|
|
return fallbackParser(execData);
|
|
},
|
|
[NodeConnectionType.AiEmbedding](execData: IDataObject) {
|
|
if (execData.documents) {
|
|
return {
|
|
type: 'json',
|
|
data: execData.documents,
|
|
parsed: true,
|
|
};
|
|
}
|
|
|
|
return fallbackParser(execData);
|
|
},
|
|
[NodeConnectionType.AiDocument](execData: IDataObject) {
|
|
if (execData.documents) {
|
|
return {
|
|
type: 'json',
|
|
data: execData.documents,
|
|
parsed: true,
|
|
};
|
|
}
|
|
|
|
return fallbackParser(execData);
|
|
},
|
|
[NodeConnectionType.AiTextSplitter](execData: IDataObject) {
|
|
const arrayData = Array.isArray(execData.response)
|
|
? execData.response
|
|
: [execData.textSplitter];
|
|
return {
|
|
type: 'text',
|
|
data: arrayData.join('\n\n'),
|
|
parsed: true,
|
|
};
|
|
},
|
|
};
|
|
export type ParsedAiContent = Array<{
|
|
raw: IDataObject | IDataObject[];
|
|
parsedContent: {
|
|
type: 'json' | 'text' | 'markdown';
|
|
data: unknown;
|
|
parsed: boolean;
|
|
} | null;
|
|
}>;
|
|
|
|
export const useAiContentParsers = () => {
|
|
const parseAiRunData = (
|
|
executionData: INodeExecutionData[],
|
|
endpointType: NodeConnectionType,
|
|
): ParsedAiContent => {
|
|
if ([NodeConnectionType.AiChain, NodeConnectionType.Main].includes(endpointType)) {
|
|
return executionData.map((data) => ({ raw: data.json, parsedContent: null }));
|
|
}
|
|
|
|
const contentJson = executionData.map((node) => {
|
|
const hasBinarData = !isObjectEmpty(node.binary);
|
|
return hasBinarData ? node.binary : node.json;
|
|
});
|
|
|
|
const parser = outputTypeParsers[endpointType as AllowedEndpointType];
|
|
if (!parser)
|
|
return [
|
|
{
|
|
raw: contentJson.filter((item): item is IDataObject => item !== undefined),
|
|
parsedContent: null,
|
|
},
|
|
];
|
|
|
|
const parsedOutput = contentJson
|
|
.filter((c): c is IDataObject => c !== undefined)
|
|
.map((c) => ({ raw: c, parsedContent: parser(c) }));
|
|
return parsedOutput;
|
|
};
|
|
|
|
return {
|
|
parseAiRunData,
|
|
};
|
|
};
|