Files
Automata/packages/editor-ui/src/components/RunDataAi/useAiContentParsers.ts
oleg dcf12867b3 feat: AI nodes usability fixes + Summarization Chain V2 (#7949)
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>
2023-12-08 13:42:32 +01:00

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 = `![Input image](${messageContent.image_url?.url})`;
}
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,
};
};