first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# @n8n/chat-hub
|
||||
|
||||
Common utility functions for n8n Chat Hub.
|
||||
|
||||
## Purpose
|
||||
|
||||
This package provides shared utility functions and types for the Chat Hub feature across both frontend and backend implementations.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { parseArtifactCreateCommands, collectChatArtifacts } from '@n8n/chat-hub';
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build
|
||||
pnpm build
|
||||
|
||||
# Watch mode
|
||||
pnpm dev
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
||||
# Type checking
|
||||
pnpm typecheck
|
||||
|
||||
# Linting
|
||||
pnpm lint
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import { baseConfig } from '@n8n/eslint-config/base';
|
||||
|
||||
export default defineConfig(baseConfig, {
|
||||
rules: {
|
||||
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = require('../../../jest.config');
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@n8n/chat-hub",
|
||||
"version": "1.4.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome ci .",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"watch": "tsc -p tsconfig.build.json --watch",
|
||||
"test": "jest",
|
||||
"test:unit": "jest",
|
||||
"test:dev": "jest --watch"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "src/index.ts",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@n8n/typescript-config": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@n8n/api-types": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { ChatMessageContentChunk } from '@n8n/api-types';
|
||||
|
||||
import { collectChatArtifacts } from './artifact';
|
||||
|
||||
describe(collectChatArtifacts, () => {
|
||||
it('should create a single artifact', () => {
|
||||
const items: ChatMessageContentChunk[] = [
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command>...</command>',
|
||||
command: {
|
||||
title: 'My Document',
|
||||
type: 'md',
|
||||
content: '# Hello World',
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
];
|
||||
|
||||
const result = collectChatArtifacts(items);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'My Document',
|
||||
type: 'md',
|
||||
content: '# Hello World',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create multiple artifacts', () => {
|
||||
const items: ChatMessageContentChunk[] = [
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command>...</command>',
|
||||
command: {
|
||||
title: 'Document 1',
|
||||
type: 'md',
|
||||
content: 'Content 1',
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command>...</command>',
|
||||
command: {
|
||||
title: 'Document 2',
|
||||
type: 'html',
|
||||
content: '<h1>Content 2</h1>',
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
];
|
||||
|
||||
const result = collectChatArtifacts(items);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Document 1',
|
||||
type: 'md',
|
||||
content: 'Content 1',
|
||||
},
|
||||
{
|
||||
title: 'Document 2',
|
||||
type: 'html',
|
||||
content: '<h1>Content 2</h1>',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip artifact-create without title', () => {
|
||||
const items: ChatMessageContentChunk[] = [
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command>...</command>',
|
||||
command: {
|
||||
title: '',
|
||||
type: 'md',
|
||||
content: 'Content',
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command>...</command>',
|
||||
command: {
|
||||
title: 'Valid Document',
|
||||
type: 'md',
|
||||
content: 'Valid Content',
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
];
|
||||
|
||||
const result = collectChatArtifacts(items);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Valid Document',
|
||||
type: 'md',
|
||||
content: 'Valid Content',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should edit an artifact (single occurrence)', () => {
|
||||
const items: ChatMessageContentChunk[] = [
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command>...</command>',
|
||||
command: {
|
||||
title: 'My Document',
|
||||
type: 'md',
|
||||
content: 'Hello world, hello universe',
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
{
|
||||
type: 'artifact-edit',
|
||||
content: '<command>...</command>',
|
||||
command: {
|
||||
title: 'My Document',
|
||||
oldString: 'hello',
|
||||
newString: 'goodbye',
|
||||
replaceAll: false,
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
];
|
||||
|
||||
const result = collectChatArtifacts(items);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'My Document',
|
||||
type: 'md',
|
||||
content: 'Hello world, goodbye universe',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should edit an artifact (replaceAll)', () => {
|
||||
const items: ChatMessageContentChunk[] = [
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command>...</command>',
|
||||
command: {
|
||||
title: 'My Document',
|
||||
type: 'md',
|
||||
content: 'hello world, hello universe',
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
{
|
||||
type: 'artifact-edit',
|
||||
content: '<command>...</command>',
|
||||
command: {
|
||||
title: 'My Document',
|
||||
oldString: 'hello',
|
||||
newString: 'goodbye',
|
||||
replaceAll: true,
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
];
|
||||
|
||||
const result = collectChatArtifacts(items);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'My Document',
|
||||
type: 'md',
|
||||
content: 'goodbye world, goodbye universe',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle multiple edits to the same artifact', () => {
|
||||
const items: ChatMessageContentChunk[] = [
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command>...</command>',
|
||||
command: {
|
||||
title: 'My Document',
|
||||
type: 'md',
|
||||
content: 'Step 1',
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
{
|
||||
type: 'artifact-edit',
|
||||
content: '<command>...</command>',
|
||||
command: {
|
||||
title: 'My Document',
|
||||
oldString: 'Step 1',
|
||||
newString: 'Step 2',
|
||||
replaceAll: false,
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
{
|
||||
type: 'artifact-edit',
|
||||
content: '<command>...</command>',
|
||||
command: {
|
||||
title: 'My Document',
|
||||
oldString: 'Step 2',
|
||||
newString: 'Step 3',
|
||||
replaceAll: false,
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
];
|
||||
|
||||
const result = collectChatArtifacts(items);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'My Document',
|
||||
type: 'md',
|
||||
content: 'Step 3',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore artifact-edit when target artifact does not exist', () => {
|
||||
const items: ChatMessageContentChunk[] = [
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command>...</command>',
|
||||
command: {
|
||||
title: 'Document A',
|
||||
type: 'md',
|
||||
content: 'Content A',
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
{
|
||||
type: 'artifact-edit',
|
||||
content: '<command>...</command>',
|
||||
command: {
|
||||
title: 'Document B',
|
||||
oldString: 'old',
|
||||
newString: 'new',
|
||||
replaceAll: false,
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
];
|
||||
|
||||
const result = collectChatArtifacts(items);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Document A',
|
||||
type: 'md',
|
||||
content: 'Content A',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore text and hidden items', () => {
|
||||
const items: ChatMessageContentChunk[] = [
|
||||
{ type: 'text', content: 'Some text before' },
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command>...</command>',
|
||||
command: {
|
||||
title: 'My Document',
|
||||
type: 'md',
|
||||
content: 'Content',
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
{ type: 'text', content: 'Some text after' },
|
||||
{ type: 'hidden', content: '<com' },
|
||||
];
|
||||
|
||||
const result = collectChatArtifacts(items);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'My Document',
|
||||
type: 'md',
|
||||
content: 'Content',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle incomplete artifacts', () => {
|
||||
const items: ChatMessageContentChunk[] = [
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command:artifact-create>\n<title>Test',
|
||||
command: {
|
||||
title: 'Test',
|
||||
type: 'md',
|
||||
content: 'Partial content',
|
||||
},
|
||||
isIncomplete: true,
|
||||
},
|
||||
];
|
||||
|
||||
const result = collectChatArtifacts(items);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Test',
|
||||
type: 'md',
|
||||
content: 'Partial content',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ChatArtifact, ChatMessageContentChunk } from '@n8n/api-types';
|
||||
|
||||
export function collectChatArtifacts(items: ChatMessageContentChunk[]): ChatArtifact[] {
|
||||
const artifacts: ChatArtifact[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === 'artifact-create') {
|
||||
if (!item.command.title) {
|
||||
continue;
|
||||
}
|
||||
|
||||
artifacts.push({
|
||||
title: item.command.title,
|
||||
type: item.command.type,
|
||||
content: item.command.content,
|
||||
});
|
||||
} else if (item.type === 'artifact-edit') {
|
||||
// Find document by title
|
||||
const targetDoc = artifacts.find((doc) => doc.title === item.command.title);
|
||||
|
||||
if (targetDoc) {
|
||||
if (item.command.replaceAll) {
|
||||
targetDoc.content = targetDoc.content
|
||||
.split(item.command.oldString)
|
||||
.join(item.command.newString);
|
||||
} else {
|
||||
targetDoc.content = targetDoc.content.replace(
|
||||
item.command.oldString,
|
||||
item.command.newString,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './parser';
|
||||
export * from './artifact';
|
||||
@@ -0,0 +1,522 @@
|
||||
/* eslint-disable n8n-local-rules/no-json-parse-json-stringify */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import type { ChatMessageContentChunk } from '@n8n/api-types';
|
||||
import assert from 'assert';
|
||||
|
||||
import { parseMessage, appendChunkToParsedMessageItems } from './parser';
|
||||
|
||||
describe(parseMessage, () => {
|
||||
it('should parse text', () => {
|
||||
expect(parseMessage({ type: 'ai', content: 'hello' })).toEqual([
|
||||
{ type: 'text', content: 'hello' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse non-ai message as text', () => {
|
||||
expect(parseMessage({ type: 'human', content: '<command:artifact-create>' })).toEqual([
|
||||
{ type: 'text', content: '<command:artifact-create>' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse AI message containing with-buttons JSON', () => {
|
||||
const json = JSON.stringify({
|
||||
type: 'with-buttons',
|
||||
text: 'Do you want to proceed?',
|
||||
blockUserInput: true,
|
||||
buttons: [
|
||||
{ text: 'Yes', link: 'https://example.com/yes', type: 'primary' },
|
||||
{ text: 'No', link: 'https://example.com/no', type: 'secondary' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = parseMessage({ type: 'ai', content: json });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: 'with-buttons',
|
||||
content: 'Do you want to proceed?',
|
||||
blockUserInput: true,
|
||||
buttons: [
|
||||
{ text: 'Yes', link: 'https://example.com/yes', type: 'primary' },
|
||||
{ text: 'No', link: 'https://example.com/no', type: 'secondary' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return human message content as text even if it looks like with-buttons JSON', () => {
|
||||
const json = JSON.stringify({
|
||||
type: 'with-buttons',
|
||||
text: 'User sent this',
|
||||
blockUserInput: true,
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
const result = parseMessage({ type: 'human', content: json });
|
||||
|
||||
expect(result).toEqual([{ type: 'text', content: json }]);
|
||||
});
|
||||
|
||||
it('should parse artifact-create command', () => {
|
||||
const content = `Here is a document:
|
||||
|
||||
<command:artifact-create>
|
||||
<title>My Document</title>
|
||||
<type>md</type>
|
||||
<content>
|
||||
# Hello World
|
||||
This is a test.
|
||||
</content>
|
||||
</command:artifact-create>
|
||||
|
||||
Done!`;
|
||||
|
||||
const result = parseMessage({ type: 'ai', content });
|
||||
expect(result).toEqual([
|
||||
{ type: 'text', content: 'Here is a document:\n\n' },
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: `<command:artifact-create>
|
||||
<title>My Document</title>
|
||||
<type>md</type>
|
||||
<content>
|
||||
# Hello World
|
||||
This is a test.
|
||||
</content>
|
||||
</command:artifact-create>`,
|
||||
command: {
|
||||
title: 'My Document',
|
||||
type: 'md',
|
||||
content: '\n# Hello World\nThis is a test.\n',
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
{ type: 'text', content: '\n\nDone!' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse artifact-edit command', () => {
|
||||
const content = `<command:artifact-edit>
|
||||
<title>My Document</title>
|
||||
<oldString>old text</oldString>
|
||||
<newString>new text</newString>
|
||||
<replaceAll>true</replaceAll>
|
||||
</command:artifact-edit>`;
|
||||
|
||||
const result = parseMessage({ type: 'ai', content });
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: 'artifact-edit',
|
||||
content,
|
||||
command: {
|
||||
title: 'My Document',
|
||||
oldString: 'old text',
|
||||
newString: 'new text',
|
||||
replaceAll: true,
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(appendChunkToParsedMessageItems, () => {
|
||||
it('should append text chunk to empty items', () => {
|
||||
const result = appendChunkToParsedMessageItems([], 'hello');
|
||||
expect(result).toEqual([{ type: 'text', content: 'hello' }]);
|
||||
});
|
||||
|
||||
it('should append text chunk to existing text', () => {
|
||||
const items: ChatMessageContentChunk[] = [{ type: 'text', content: 'hello' }];
|
||||
const result = appendChunkToParsedMessageItems(items, ' world');
|
||||
expect(result).toEqual([{ type: 'text', content: 'hello world' }]);
|
||||
});
|
||||
|
||||
it('should preserve whitespace-only chunks like newlines', () => {
|
||||
let items: ChatMessageContentChunk[] = [];
|
||||
items = appendChunkToParsedMessageItems(items, '## Hi');
|
||||
items = appendChunkToParsedMessageItems(items, '\n\n');
|
||||
items = appendChunkToParsedMessageItems(items, 'Thanks');
|
||||
expect(items).toEqual([{ type: 'text', content: '## Hi\n\nThanks' }]);
|
||||
});
|
||||
|
||||
describe('with-buttons JSON parsing', () => {
|
||||
it('should parse valid with-buttons JSON', () => {
|
||||
const json = JSON.stringify({
|
||||
type: 'with-buttons',
|
||||
text: 'Please approve this action',
|
||||
blockUserInput: true,
|
||||
buttons: [
|
||||
{ text: 'Approve', link: 'https://example.com/approve', type: 'primary' },
|
||||
{ text: 'Reject', link: 'https://example.com/reject', type: 'secondary' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = appendChunkToParsedMessageItems([], json);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'with-buttons',
|
||||
content: 'Please approve this action',
|
||||
blockUserInput: true,
|
||||
buttons: [
|
||||
{ text: 'Approve', link: 'https://example.com/approve', type: 'primary' },
|
||||
{ text: 'Reject', link: 'https://example.com/reject', type: 'secondary' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not parse with-buttons JSON with empty buttons array', () => {
|
||||
const json = JSON.stringify({
|
||||
type: 'with-buttons',
|
||||
text: 'No buttons here',
|
||||
blockUserInput: false,
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
const result = appendChunkToParsedMessageItems([], json);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('text');
|
||||
});
|
||||
|
||||
it('should parse with-buttons JSON with blockUserInput false', () => {
|
||||
const json = JSON.stringify({
|
||||
type: 'with-buttons',
|
||||
text: 'You can still type',
|
||||
blockUserInput: false,
|
||||
buttons: [{ text: 'Click me', link: 'https://example.com', type: 'primary' }],
|
||||
});
|
||||
|
||||
const result = appendChunkToParsedMessageItems([], json);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: 'with-buttons',
|
||||
content: 'You can still type',
|
||||
blockUserInput: false,
|
||||
buttons: [{ text: 'Click me', link: 'https://example.com', type: 'primary' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not parse invalid JSON as with-buttons', () => {
|
||||
const invalidJson = '{ invalid json }';
|
||||
|
||||
const result = appendChunkToParsedMessageItems([], invalidJson);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('text');
|
||||
});
|
||||
|
||||
it('should not parse JSON with wrong type field', () => {
|
||||
const json = JSON.stringify({
|
||||
type: 'something-else',
|
||||
text: 'Not buttons',
|
||||
blockUserInput: true,
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
const result = appendChunkToParsedMessageItems([], json);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('text');
|
||||
});
|
||||
|
||||
it('should not parse JSON missing required fields', () => {
|
||||
const json = JSON.stringify({
|
||||
type: 'with-buttons',
|
||||
text: 'Missing blockUserInput and buttons',
|
||||
});
|
||||
|
||||
const result = appendChunkToParsedMessageItems([], json);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('text');
|
||||
});
|
||||
|
||||
it('should not parse text that starts with { but is not valid JSON', () => {
|
||||
const text = '{this is not json}';
|
||||
|
||||
const result = appendChunkToParsedMessageItems([], text);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('text');
|
||||
});
|
||||
|
||||
it('should not try to parse text that does not start with {', () => {
|
||||
const text = 'Regular text message';
|
||||
|
||||
const result = appendChunkToParsedMessageItems([], text);
|
||||
|
||||
expect(result).toEqual([{ type: 'text', content: 'Regular text message' }]);
|
||||
});
|
||||
|
||||
it('should not parse JSON with invalid button type', () => {
|
||||
const json = JSON.stringify({
|
||||
type: 'with-buttons',
|
||||
text: 'Invalid button type',
|
||||
blockUserInput: true,
|
||||
buttons: [{ text: 'Bad', link: 'https://example.com', type: 'invalid' }],
|
||||
});
|
||||
|
||||
const result = appendChunkToParsedMessageItems([], json);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore potential prefix of command', () => {
|
||||
const result = appendChunkToParsedMessageItems([], 'here: <com');
|
||||
expect(result).toEqual([
|
||||
{ type: 'text', content: 'here: ' },
|
||||
{ type: 'hidden', content: '<com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle artifact-create command divided into multiple chunks', () => {
|
||||
const result1 = appendChunkToParsedMessageItems([], '<comman');
|
||||
const result2 = appendChunkToParsedMessageItems(result1, 'd:artifact-create>\n<title>Test');
|
||||
|
||||
expect(result2).toEqual([
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command:artifact-create>\n<title>Test',
|
||||
command: { title: 'Test', type: '', content: '' },
|
||||
isIncomplete: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle incomplete artifact-create command', () => {
|
||||
const result = appendChunkToParsedMessageItems([], '<command:artifact-create>\n<title>Test');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command:artifact-create>\n<title>Test',
|
||||
command: { title: 'Test', type: '', content: '' },
|
||||
isIncomplete: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle incomplete artifact-create command with incomplete closing tag', () => {
|
||||
const result = appendChunkToParsedMessageItems([], '<command:artifact-create>\n<title>Test</t');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command:artifact-create>\n<title>Test</t',
|
||||
command: { title: 'Test', type: '', content: '' },
|
||||
isIncomplete: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle incomplete artifact-create command starting in the middle of chunk', () => {
|
||||
const result = appendChunkToParsedMessageItems(
|
||||
[],
|
||||
'here: <command:artifact-create>\n<title>Test',
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{ type: 'text', content: 'here: ' },
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command:artifact-create>\n<title>Test',
|
||||
command: { title: 'Test', type: '', content: '' },
|
||||
isIncomplete: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should complete an incomplete artifact-create command', () => {
|
||||
const items: ChatMessageContentChunk[] = [
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command:artifact-create>\n<title>Test',
|
||||
command: { title: 'Test', type: '', content: '' },
|
||||
isIncomplete: true,
|
||||
},
|
||||
];
|
||||
|
||||
const result = appendChunkToParsedMessageItems(
|
||||
items,
|
||||
'</title>\n<type>md</type>\n<content>Content here</content>\n</command:artifact-create>',
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: `<command:artifact-create>
|
||||
<title>Test</title>
|
||||
<type>md</type>
|
||||
<content>Content here</content>
|
||||
</command:artifact-create>`,
|
||||
command: {
|
||||
title: 'Test',
|
||||
type: 'md',
|
||||
content: 'Content here',
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle incomplete artifact-edit command', () => {
|
||||
const result = appendChunkToParsedMessageItems(
|
||||
[],
|
||||
'<command:artifact-edit>\n<title>My Doc</title>\n<oldString>',
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: 'artifact-edit',
|
||||
content: '<command:artifact-edit>\n<title>My Doc</title>\n<oldString>',
|
||||
command: { title: 'My Doc', oldString: '', newString: '', replaceAll: false },
|
||||
isIncomplete: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should complete an incomplete artifact-edit command', () => {
|
||||
const items: ChatMessageContentChunk[] = [
|
||||
{
|
||||
type: 'artifact-edit',
|
||||
content: '<command:artifact-edit>\n<title>Doc</title>\n<oldString>old',
|
||||
command: { title: 'Doc', oldString: 'old', newString: '', replaceAll: false },
|
||||
isIncomplete: true,
|
||||
},
|
||||
];
|
||||
|
||||
const result = appendChunkToParsedMessageItems(
|
||||
items,
|
||||
'</oldString>\n<newString>new</newString>\n<replaceAll>false</replaceAll>\n</command:artifact-edit>',
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: 'artifact-edit',
|
||||
content: `<command:artifact-edit>
|
||||
<title>Doc</title>
|
||||
<oldString>old</oldString>
|
||||
<newString>new</newString>
|
||||
<replaceAll>false</replaceAll>
|
||||
</command:artifact-edit>`,
|
||||
command: {
|
||||
title: 'Doc',
|
||||
oldString: 'old',
|
||||
newString: 'new',
|
||||
replaceAll: false,
|
||||
},
|
||||
isIncomplete: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle multiple commands in sequence', () => {
|
||||
const content = `<command:artifact-create>
|
||||
<title>Doc1</title>
|
||||
<type>md</type>
|
||||
<content>Content 1</content>
|
||||
</command:artifact-create>
|
||||
<command:artifact-edit>
|
||||
<title>Doc1</title>
|
||||
<oldString>Content 1</oldString>
|
||||
<newString>Updated Content</newString>
|
||||
<replaceAll>false</replaceAll>
|
||||
</command:artifact-edit>`;
|
||||
|
||||
const result = appendChunkToParsedMessageItems([], content);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].type).toBe('artifact-create');
|
||||
expect(result[1]).toEqual({ type: 'text', content: '\n' });
|
||||
expect(result[2].type).toBe('artifact-edit');
|
||||
});
|
||||
|
||||
it('should handle streaming scenario with text before command', () => {
|
||||
let items: ChatMessageContentChunk[] = [];
|
||||
|
||||
// Chunk 1: Text before command
|
||||
items = appendChunkToParsedMessageItems(items, 'Here is a document:\n\n');
|
||||
expect(items).toEqual([{ type: 'text', content: 'Here is a document:\n\n' }]);
|
||||
|
||||
// Chunk 2: Start of command
|
||||
items = appendChunkToParsedMessageItems(items, '<command:artifact-create>\n<title>');
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[1].type).toBe('artifact-create');
|
||||
assert(items[1].type === 'artifact-create');
|
||||
expect(items[1].isIncomplete).toBe(true);
|
||||
|
||||
// Chunk 3: Complete the command
|
||||
items = appendChunkToParsedMessageItems(
|
||||
items,
|
||||
'Test</title>\n<type>md</type>\n<content>Hello</content>\n</command:artifact-create>',
|
||||
);
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[1].type).toBe('artifact-create');
|
||||
assert(items[1].type === 'artifact-create');
|
||||
expect(items[1].isIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
describe('immutability (pure function behavior)', () => {
|
||||
it('should not mutate input items array with incomplete commands', () => {
|
||||
const originalItems: ChatMessageContentChunk[] = [
|
||||
{
|
||||
type: 'artifact-create',
|
||||
content: '<command:artifact-create>\n<title>Test',
|
||||
command: { title: 'Test', type: '', content: '' },
|
||||
isIncomplete: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Deep clone to compare later
|
||||
const clonedOriginal = JSON.parse(JSON.stringify(originalItems));
|
||||
|
||||
const result = appendChunkToParsedMessageItems(
|
||||
originalItems,
|
||||
'</title>\n<type>md</type>\n<content>Content</content>\n</command:artifact-create>',
|
||||
);
|
||||
|
||||
// Original should remain unchanged
|
||||
expect(originalItems).toEqual(clonedOriginal);
|
||||
|
||||
// Result should have the updated item
|
||||
expect(result).toHaveLength(1);
|
||||
assert(result[0].type === 'artifact-create');
|
||||
expect(result[0].isIncomplete).toBe(false);
|
||||
expect(result[0].command.type).toBe('md');
|
||||
});
|
||||
|
||||
it('should not mutate hidden items', () => {
|
||||
const originalItems: ChatMessageContentChunk[] = [
|
||||
{ type: 'text', content: 'hello' },
|
||||
{ type: 'hidden', content: '<com' },
|
||||
];
|
||||
|
||||
const clonedOriginal = JSON.parse(JSON.stringify(originalItems));
|
||||
|
||||
const result = appendChunkToParsedMessageItems(originalItems, 'mand:artifact-create>');
|
||||
|
||||
// Original should remain unchanged
|
||||
expect(originalItems).toEqual(clonedOriginal);
|
||||
|
||||
// Result should have parsed the command
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].type).toBe('text');
|
||||
expect(result[1].type).toBe('artifact-create');
|
||||
});
|
||||
|
||||
it('should not mutate text items when appending more text', () => {
|
||||
const originalItems: ChatMessageContentChunk[] = [{ type: 'text', content: 'hello' }];
|
||||
|
||||
const clonedOriginal = JSON.parse(JSON.stringify(originalItems));
|
||||
|
||||
const result = appendChunkToParsedMessageItems(originalItems, ' world');
|
||||
|
||||
// Original should remain unchanged
|
||||
expect(originalItems).toEqual(clonedOriginal);
|
||||
expect(originalItems[0].content).toBe('hello');
|
||||
|
||||
// Result should have concatenated text
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('text');
|
||||
expect(result[0].content).toBe('hello world');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
chatHubMessageWithButtonsSchema,
|
||||
type ChatHubMessageType,
|
||||
type ChatMessageContentChunk,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
export interface MessageWithContent {
|
||||
type: ChatHubMessageType;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function appendChunkToParsedMessageItems(
|
||||
items: ChatMessageContentChunk[],
|
||||
chunk: string,
|
||||
): ChatMessageContentChunk[] {
|
||||
const result = [...items];
|
||||
let remaining = chunk;
|
||||
|
||||
// If the last item is incomplete, append to it and re-parse
|
||||
if (result.length > 0) {
|
||||
const lastItem = result[result.length - 1];
|
||||
if (lastItem.type === 'hidden') {
|
||||
// Hidden item might be a command prefix, combine with new chunk and re-parse
|
||||
remaining = lastItem.content + chunk;
|
||||
result.pop(); // Remove it so we can re-parse
|
||||
} else if (
|
||||
(lastItem.type === 'artifact-create' || lastItem.type === 'artifact-edit') &&
|
||||
lastItem.isIncomplete
|
||||
) {
|
||||
// Incomplete command - append chunk and re-parse
|
||||
// Don't mutate the original item, create new content string
|
||||
remaining = lastItem.content + chunk;
|
||||
result.pop(); // Remove it so we can re-parse
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the chunk is button JSON (arrives as complete JSON in one chunk)
|
||||
const buttonChunk = tryParseButtonsJson(remaining);
|
||||
if (buttonChunk) {
|
||||
result.push(buttonChunk);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Parse the remaining content
|
||||
let currentPos = 0;
|
||||
const createCommandRegex = /<command:artifact-create>/g;
|
||||
const editCommandRegex = /<command:artifact-edit>/g;
|
||||
|
||||
while (currentPos < remaining.length) {
|
||||
// Find the next command
|
||||
createCommandRegex.lastIndex = currentPos;
|
||||
editCommandRegex.lastIndex = currentPos;
|
||||
|
||||
const createMatch = createCommandRegex.exec(remaining);
|
||||
const editMatch = editCommandRegex.exec(remaining);
|
||||
|
||||
let nextMatch: RegExpExecArray | null = null;
|
||||
let commandType: 'create' | 'edit' | null = null;
|
||||
|
||||
if (createMatch && editMatch) {
|
||||
// Both found, use the earlier one
|
||||
if (createMatch.index < editMatch.index) {
|
||||
nextMatch = createMatch;
|
||||
commandType = 'create';
|
||||
} else {
|
||||
nextMatch = editMatch;
|
||||
commandType = 'edit';
|
||||
}
|
||||
} else if (createMatch) {
|
||||
nextMatch = createMatch;
|
||||
commandType = 'create';
|
||||
} else if (editMatch) {
|
||||
nextMatch = editMatch;
|
||||
commandType = 'edit';
|
||||
}
|
||||
|
||||
if (!nextMatch || !commandType) {
|
||||
// No more commands, rest is text
|
||||
const textContent = remaining.slice(currentPos);
|
||||
if (textContent) {
|
||||
// Split text and potential command prefix
|
||||
const { text, hiddenPrefix } = splitPotentialCommandPrefix(textContent);
|
||||
if (text) {
|
||||
addTextToResult(result, text);
|
||||
}
|
||||
if (hiddenPrefix) {
|
||||
result.push({ type: 'hidden', content: hiddenPrefix });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Add text before the command
|
||||
if (nextMatch.index > currentPos) {
|
||||
const textContent = remaining.slice(currentPos, nextMatch.index);
|
||||
addTextToResult(result, textContent);
|
||||
}
|
||||
|
||||
// Parse the command
|
||||
const commandStart = nextMatch.index;
|
||||
const commandContent = remaining.slice(commandStart);
|
||||
|
||||
if (commandType === 'create') {
|
||||
const parsed = parseArtifactCreateCommand(commandContent);
|
||||
result.push(parsed.item);
|
||||
currentPos = commandStart + parsed.consumed;
|
||||
} else {
|
||||
const parsed = parseArtifactEditCommand(commandContent);
|
||||
result.push(parsed.item);
|
||||
currentPos = commandStart + parsed.consumed;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function addTextToResult(result: ChatMessageContentChunk[], textContent: string): void {
|
||||
// Skip empty text (but preserve whitespace like newlines, which are meaningful in markdown)
|
||||
if (textContent === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.length > 0) {
|
||||
const lastItem = result[result.length - 1];
|
||||
if (lastItem.type === 'text') {
|
||||
// Don't mutate the original item, create a new one
|
||||
result[result.length - 1] = { type: 'text', content: lastItem.content + textContent };
|
||||
return;
|
||||
}
|
||||
}
|
||||
result.push({ type: 'text', content: textContent });
|
||||
}
|
||||
|
||||
function splitPotentialCommandPrefix(text: string): {
|
||||
text: string;
|
||||
hiddenPrefix: string;
|
||||
} {
|
||||
const commandTags = ['<command:artifact-create>', '<command:artifact-edit>'];
|
||||
|
||||
// Check if the end of text matches any prefix of a command tag
|
||||
for (let len = 1; len <= Math.min(text.length, 30); len++) {
|
||||
const suffix = text.slice(-len);
|
||||
|
||||
// Check if this suffix is a prefix of any command tag
|
||||
for (const tag of commandTags) {
|
||||
if (tag.startsWith(suffix)) {
|
||||
// Found a potential command prefix, split it
|
||||
return {
|
||||
text: text.slice(0, -len),
|
||||
hiddenPrefix: suffix,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { text, hiddenPrefix: '' };
|
||||
}
|
||||
|
||||
function parseArtifactCreateCommand(content: string): {
|
||||
item: ChatMessageContentChunk;
|
||||
consumed: number;
|
||||
} {
|
||||
const closingTag = '</command:artifact-create>';
|
||||
const closingIndex = content.indexOf(closingTag);
|
||||
|
||||
const isIncomplete = closingIndex === -1;
|
||||
const commandContent = isIncomplete
|
||||
? content
|
||||
: content.slice(0, closingIndex + closingTag.length);
|
||||
|
||||
// Extract fields even if incomplete
|
||||
const title = extractTagContent(commandContent, 'title') ?? '';
|
||||
const type = extractTagContent(commandContent, 'type') ?? '';
|
||||
const contentField = extractTagContent(commandContent, 'content') ?? '';
|
||||
|
||||
return {
|
||||
item: {
|
||||
type: 'artifact-create',
|
||||
content: commandContent,
|
||||
command: { title, type, content: contentField },
|
||||
isIncomplete,
|
||||
},
|
||||
consumed: commandContent.length,
|
||||
};
|
||||
}
|
||||
|
||||
function parseArtifactEditCommand(content: string): {
|
||||
item: ChatMessageContentChunk;
|
||||
consumed: number;
|
||||
} {
|
||||
const closingTag = '</command:artifact-edit>';
|
||||
const closingIndex = content.indexOf(closingTag);
|
||||
|
||||
const isIncomplete = closingIndex === -1;
|
||||
const commandContent = isIncomplete
|
||||
? content
|
||||
: content.slice(0, closingIndex + closingTag.length);
|
||||
|
||||
// Extract fields even if incomplete
|
||||
const title = extractTagContent(commandContent, 'title') ?? '';
|
||||
const oldString = extractTagContent(commandContent, 'oldString') ?? '';
|
||||
const newString = extractTagContent(commandContent, 'newString') ?? '';
|
||||
const replaceAllStr = extractTagContent(commandContent, 'replaceAll') ?? 'false';
|
||||
const replaceAll = replaceAllStr.toLowerCase() === 'true';
|
||||
|
||||
return {
|
||||
item: {
|
||||
type: 'artifact-edit',
|
||||
content: commandContent,
|
||||
command: { title, oldString, newString, replaceAll },
|
||||
isIncomplete,
|
||||
},
|
||||
consumed: commandContent.length,
|
||||
};
|
||||
}
|
||||
|
||||
function extractTagContent(xml: string, tagName: string): string | null {
|
||||
const openTag = `<${tagName}>`;
|
||||
const closeTag = `</${tagName}>`;
|
||||
|
||||
const startIndex = xml.indexOf(openTag);
|
||||
if (startIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentStart = startIndex + openTag.length;
|
||||
const endIndex = xml.indexOf(closeTag, contentStart);
|
||||
|
||||
// If closing tag not found, return content from open tag to end of string
|
||||
if (endIndex === -1) {
|
||||
let content = xml.slice(contentStart);
|
||||
|
||||
// Check if content ends with a partial closing tag and exclude it
|
||||
// A partial closing tag looks like: </, </t, </ti, </tit, etc.
|
||||
for (let len = 1; len < closeTag.length; len++) {
|
||||
const partialCloseTag = closeTag.slice(0, len);
|
||||
if (content.endsWith(partialCloseTag)) {
|
||||
content = content.slice(0, -len);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only return if there's actual content after the opening tag
|
||||
return content.length > 0 ? content : null;
|
||||
}
|
||||
|
||||
return xml.slice(contentStart, endIndex);
|
||||
}
|
||||
|
||||
function tryParseButtonsJson(content: string): ChatMessageContentChunk | null {
|
||||
if (!content.startsWith('{')) return null;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
const result = chatHubMessageWithButtonsSchema.safeParse(parsed);
|
||||
if (result.success) {
|
||||
return {
|
||||
type: 'with-buttons',
|
||||
content: result.data.text,
|
||||
buttons: result.data.buttons,
|
||||
blockUserInput: result.data.blockUserInput,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a message and extract all content (text and commands)
|
||||
* Returns an array of parsed items in order, including text segments
|
||||
* Incomplete commands (without closing tags) are marked as isComplete: false
|
||||
*/
|
||||
export function parseMessage(message: MessageWithContent): ChatMessageContentChunk[] {
|
||||
if (message.type !== 'ai') {
|
||||
return [{ type: 'text' as const, content: message.content }];
|
||||
}
|
||||
|
||||
return appendChunkToParsedMessageItems([], message.content);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@n8n/typescript-config/tsconfig.common.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"types": ["node", "jest"],
|
||||
"baseUrl": "src",
|
||||
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../api-types/tsconfig.build.json" }]
|
||||
}
|
||||
Reference in New Issue
Block a user