Ну кто не мечтает запустить стартап за одни выходные?
Давно хотел развеяться, и чутка отвлечься от рутины и работы.
А ещё давно хотел пощупать Tauri v2, и новомодные фреймворки для построения AI-агентов (ai-sdk / mastra / llamaindex).

Идея простая: десктопное приложение, внутри ИИ-агент, который подключается к БД, получает данные о структуре таблиц/вьюшек. Справа сайдбар: интерфейс чата с агентом, а основное пространство - холст, на котором агент размещает что хочет сам. А именно - виджеты, которые делают запросы к БД, и выводят их в приятном глазу виде.
Никакого удалённого бекенда, open-source, доступы к БД хранятся исключительно локально, всё секьюрно.

Так как весь код открытый, то процесс я буду логировать в репозитории: https://github.com/ElKornacio/qyp-mini

Флоу такой:

  1. Я говорю агенту "добавь на дешборд плашку с количеством новых юзеров за последний месяц".

  2. Он, используя знания о структуре БД, и возможность выполнять к ней запросы, придумывает корректный, соответствующий моей БД, SQL-запрос, который возвращает требуемую инфу

  3. Он пишет React-компонент на Tailwind + shadcn/ui, который будет делать этот запрос и выводить ответ в виде симпатичной плашки

  4. Под капотом, прямо в рантайме, react-компонент комплириуется (esbuild + postcss), и выводится на дешборд

  5. В случае ошибок (компиляции или выполнения sql) - они автоматом летят обратно агенту, чтобы он чинил.

В общем, я хочу сделать приложение, в котором интерфейс будет писать сам ИИ агент, чтобы он сам решал, каким образом выводить информацию.

Интересно потыкать runtime компиляцию tailwind (это нетривиально, т.к. по дефолту tailwind генерирует css-классы на основе вашего кода), ещё runtime с esbuild под это всё упаковать, ну и я давно хотел Tauri v2 пощупать.

А ещё мне накидали в комменты в телеге новомодные AI-agent фреймворки для TypeScript, так что их тоже хочу пощупать и между собой сравнить, раньше я только на чистом LangChain писал.

Всего будет 5 частей:

  1. Делаем скелет приложения (предыдущая часть)

  2. Делаем runtime-компиляцию TSX-компонентов (эта часть)

  3. Делаем AI-агента и сравниваем AI-фреймворки

  4. Учим агента писать код и делать SQL-запросы

  5. Собираем всё в кучу и причёсываем

Поехали!

Немного про архитектуру рантайм-компонентов

Давайте ещё чуть-чуть порассуждаем об архитектуре. Изначально я планировал, что буквально каждый компонент выданный ИИ будет изолирован от остальных. Грубо говоря, если представить рантайм-среду для сборки как виртуальную файловую систему, то каждый компонент - это отдельный проект. Плюс такого подхода в изоляции компонентов и очень быстрой сборке - по сути, esbuild придется собрать буквально 1-2 небольших файла, в которых описана основная логика компонента. Минус - в той же изоляции, компоненты не смогут взаимодействовать друг с другом.

Со временем я задумался - а почему бы не дать ИИ единую среду, в которой он сможет встраивать одни компоненты в другие, создавать какие-нибудь utils-функции, которые будет переиспользовать, и так далее? Используя ту же аналогию с виртуальной файловой системой, в этом случае у нас есть один проект, а компоненты - это файлы в директории src/components.
Плюс - компоненты можно соединять и строить более сложные интерфейсы. Минус - компилировать на каждое изменение надо будет сразу все компоненты.

Для первой версии приложения делать единую среду я не захотел. Надо будет решать чуть более сложные задачи по персистентности этой среды, корректной линковке компонентов, и так далее. Думаю, я к этому приду, но на старте давайте просто научимся выводить компилировать и рендерить простые компоненты в рантайме.

TSX-компиляция в рантайме

Напомню суть: мы хотим, чтобы ИИ выдавал нам код типа такого:

import { Button } from  '@/components/ui/button';

export default function MyComponent() {
	return (
		<Button>Click me!</Button>
	);
}

и мы хотим, чтобы компонент, описанный в этом коде, был выведен в интерфейс нашего приложения.

Напомню, речь о рантайме: то есть код выше нам надо самим программно собрать, а именно:

  1. TSX: надо транспилировать jsx-синтаксис в React.createElement-стейтменты

  2. TypeScript: надо транспилировать в JavaScript

  3. Tailwind/PostCSS: сборщик Tailwind должен проанализировать исходники на предмет использования tailwind-классов, и сгенерировать для них css-код.

  4. Бандлинг: надо собрать все импорты в единый файл, а те, которые мы подкидываем сами (типа тех же компонентов shadcn/ui) - их надо корректно пробросить в рантайм компонента (имплементировать свой require?)

Как вы, уже увидели в предыдущей статье - от идеи компилировать это дело в браузерной среде я отказался, т.к. сборка Tailwind очень туго в браузер затаскивается. Поэтому в предыдущей части мы подключили Node.js бекенд, и именно он за сборку и будет отвечать. Чтобы это дело шло быстро - мы постараемся в рантайме собирать самый минимум кода, а практически все внешние зависимости подкидывать из среды самого приложения.

Давайте простым языком, вот код выше:

import { Button } from  '@/components/ui/button';

в классической схеме, esbuild бы перешёл в папку src/components/ui/button и закинул код компонента в результирующий бандл. Но это не только увеличивает время сборки (а у нас таких компонентов будут десятки, если не сотни), но и дублирует код: компонент @/components/ui/button будет определён дважды - первый раз в коде самого приложения, второй - в рантайм-бандле.

Я чутка причесал в проекте коммуникацию между Node.js и фронтом, вытащил парсинг запросов и подобное в отдельные модули в Node.js.

Далее, стал собирать часть ответственную за TSX сборку. По плану, я хочу, чтобы в нашем виртуальном проекте была структура типа такой:

src/                   # Корневая директория
├── components/
│   ├── ui/            # Базовые UI-компоненты, readonly (shadcn)
├── widget/
│   ├── index.tsx      # Главный файл, которые будет генерировать ИИ - React-компонент с виджетом
│   ├── query.sql.ts   # Файл с sql-запросом в базу, экспортирует одну async-функцию, делающую запрос. Тоже генерирует ИИ
├── lib/
│   ├── utils.ts       # readonly, здесь будут утилитарные функции аля `cn`

И как-то эти данные надо будет пересылать между фронтом и Node.js. Более того, в идеале не хотелось бы пересылать те файлы, которые не нужны будут для компиляции, а именно - содержимое папки src/components и src/lib, т.к. модули оттуда мы будем подкидывать в рантайме сами.

Я изначально хотел сделать упаковку в zip, его в base64, и передавать в Node.js, но мне показалось, что это будет очень громоздко и неудобно с точки зрения производительности. Плюс, не очень понятно, как простым способом прикреплять к файлам/папкам метадату. В итоге я решил сделать свою простенькую наивную реализацию виртуальной in-memory файловой системы. Получилось лаконично и удобно, код здесь приводить не буду, он доступен в репе: https://github.com/ElKornacio/qyp-mini/blob/main/src-node/src/virtual-fs/VirtualFS.ts

Этот VirtualFS класс позволяет мне быстро сериализовать все файлы в один JSON, и даже передать функцию-фильтр, чтобы какие-то ненужные файлы на лету выкидывать (а именно - src/components, src/lib). На стороне Node.js я этот JSON десериализую, и готов передавать его в esbuild.

Сборка файлов в эту виртуальную среду будет выглядеть примерно так:

export const buildDefaultFS = async (
	indexTsxContent: string = getDefaultWidgetIndexTsxContent(),
	querySqlTsContent: string = getDefaultWidgetQuerySqlTsContent(),
): Promise<VirtualFS> => {
	const vfs = new VirtualFS();

	vfs.makeDirectory('/src');

	// помечаем всю директорию как readonly, чтобы в будущем агент не мог писать в неё
	vfs.makeDirectory('/src/components', { readonly: true });
	vfs.makeDirectory('/src/components/ui');

	vfs.writeFile('/src/components/ui/button.tsx', `// nothing here for now`, {
		externalized:  true, // помечаем этот файл как external, чтобы esbuild его не бандлил
	});

	vfs.makeDirectory('/src/widget');
	vfs.writeFile('/src/widget/index.tsx', indexTsxContent); // подкидываем контент в файл
	vfs.writeFile('/src/widget/query.sql.ts', querySqlTsContent); // подкидываем контент в файл

	vfs.makeDirectory('/src/lib', { readonly: true });
	vfs.writeFile('/src/lib/utils.ts', `// nothing here for now`, {
		externalized: true, // помечаем этот файл как external, чтобы esbuild его не бандлил
	});

	return vfs;
};

Расчехляем esbuild

Итак, наш Node.js получил все файлы, и теперь самое время их скомпилировать.

Давайте сразу создадим кастомный плагин под ESBuild, который будет соединять ESBuild с нашей виртуальной файловой системой:

import path from 'path';
import { PluginBuild, Loader, OnLoadArgs, OnResolveArgs } from 'esbuild';

import { createError } from '../utils';
import { VirtualFS } from '../virtual-fs/VirtualFS';

export class ESBuildVFS {
	name = 'virtual-files';

	constructor(private vfs: VirtualFS) {}

	get() {
		return {
			name: this.name,
			setup: this.setup,
		};
	}

	private setup = (build: PluginBuild) => {
		// Резолвим импорты виртуальных файлов
		build.onResolve({ filter: /.*/ }, this.handleResolve);
		// Загружаем содержимое виртуальных файлов
		build.onLoad({ filter: /.*/, namespace: 'virtual' }, this.handleLoad);
	};

	private handleResolve = (args: OnResolveArgs) => {
		// Пропускаем внешние модули (node_modules)
		if (!args.path.startsWith('.') && !args.path.startsWith('/') && !args.path.startsWith('@')) {
			return { external: true };
		}

		const resolvedPath = args.path.startsWith('@')
			? args.path.replace('@/', '/src/')
			: this.resolveVirtualPath(args.path, args.importer);

		let foundPath: string | undefined = undefined;

		if (this.vfs.fileExists(resolvedPath)) {
			foundPath = resolvedPath; // для кейсов import * from './file.tsx', с указанным расширением
		} else if (this.vfs.fileExists(resolvedPath + '.tsx')) {
			// для кейсов import * from './file', когда расширение было опущено
			foundPath = resolvedPath + '.tsx';
		} else if (this.vfs.fileExists(resolvedPath + '.ts')) {
			// для кейсов import * from './file', когда расширение было опущено
			foundPath = resolvedPath + '.ts';
		}

		if (foundPath) {
			const meta = this.vfs.readFileMetadata(foundPath);

			// то самое волшебное место, в котором мы помечаем файлы как внешние для esbuild
			if (meta.externalized) {
				return { external: true };
			} else {
				return {
					path: foundPath,
					namespace: 'virtual',
				};
			}
		} else {
			return undefined;
		}
	};

	private handleLoad = (args: OnLoadArgs) => {
		try {
			const file = this.vfs.readFile(args.path);
			return {
				contents: file.content,
				loader: this.getLoader(args.path),
			};
		} catch (error) {
			throw createError(`Ошибка загрузки виртуального файла ${args.path}`, error);
		}
	};

	/**
	 * Резолвит путь в виртуальной файловой системе
	 */
	private resolveVirtualPath(importPath: string, importer?: string): string {
		if (path.isAbsolute(importPath)) {
			// Если путь абсолютный, возвращаем как есть
			return path.resolve(importPath);
		} else
		if (importer) {
			// Если есть импортер, резолвим относительно него
			const importerDir = path.dirname(importer);
			return path.resolve(importerDir, importPath);
		} else {
			// Иначе резолвим относительно корня
			return path.resolve('/', importPath);
		}
	}

	/**
	 * Определяет загрузчик для файла по расширению
	 */
	private getLoader(filePath: string): Loader {
		if (filePath.endsWith('.tsx')) return 'tsx';
		if (filePath.endsWith('.ts')) return 'ts';
		if (filePath.endsWith('.jsx')) return 'jsx';
		if (filePath.endsWith('.js')) return 'js';
		if (filePath.endsWith('.css')) return 'css';
		if (filePath.endsWith('.json')) return 'json';

		return 'js'; // fallback
	}
}

Обратите внимание на этот блок:

if (foundPath) {
	const meta = this.vfs.readFileMetadata(foundPath);

	if (meta.externalized) {
		return { external: true };
	} else {
		return {
			path: foundPath,
			namespace: 'virtual',
		};
	}
}

Как раз здесь мы получаем из нашей файловой системы информацию о том, что данный файл не должен присутствовать в финальном бандле, и ESBuild должен воспринимать его как "внешний".

Теперь закидываем этот плагин в ESBuild, и сетапим дефолтный конфиг для нашей среды:

const vfsPlugin = new ESBuildVFS(vfs);

const result = await esbuild.build({
	entryPoints: [entryPoint],
	bundle: true,
	write: false,
	format: 'cjs',
	target: 'es2020',
	jsx: 'automatic',
	minify: options.minify || false,
	sourcemap: false,
	// Наш плагин для работы с виртуальными файлами
	plugins: [vfsPlugin.get()],
});

if (result.errors.length > 0) {
	const errorMessages = result.errors.map(err => err.text).join('\n');
	throw createError(`Ошибки компиляции ESBuild:\n${errorMessages}`);
}

const outputFile = result.outputFiles?.[0];
if (!outputFile) {
	throw createError('ESBuild не создал выходной файл');
}

return {
	code: outputFile.text,
};

Соединяем отдельные кусочки воедино, пробрасываем функцию для вызова "компиляции" на фронт:

async function compileCodeViaNodejsSidecar(indexTsxContent: string): Promise<string> {
	const vfs = await buildDefaultFS(indexTsxContent, getDefaultWidgetQuerySqlTsContent());
	const serialized = vfs.serialize();
	const result = await QypSidecar.compile(serialized, '/src/widget/index.tsx');
	return result.jsBundle;
}

Запускаем, тестируем, и, вуаля:

Рендерим компонент в рантайме

(Да-да, я помню про Tailwind. Давайте отрендерим, а потом доделаем стили)

Чтож, мы получили текст с кодом нашего компонента. Надо теперь этот код запустить, и не забыть пробросить внешние зависимости.

Начнём с простенькой обёртки, которая будет брать на вход готовый код, выполнять его, и получать React-компонент (да, через eval, пока что сойдёт). Пока что на require повесим заглушку:

async function compileBundleToComponent(code: string) {
	const wrappedIIFE = `(function(module, require) { ${code} })(__module__, __require__)`;
	const executeModule = new Function('__module__', '__require__', wrappedIIFE);

	const customModule: any = { exports: {} };
	const customRequire = (path: string) => {
		console.log('received require call: ', path);
		return {};
	};

	executeModule(customModule, customRequire);

	return customModule.exports.default;
}

И любуемся результатом:

Давайте теперь замокаем модули и попробуем отрендерить это чудо:

// tryToMockGlobalModule.tsx:
import * as ReactRuntime from 'react';
import * as ReactJSXRuntime from 'react/jsx-runtime';

export const tryToMockGlobalModule = (context: any, path: string) => {
	if (path === 'react') {
		return ReactRuntime;
	} else if (path === 'react/jsx-runtime') {
		return ReactJSXRuntime;
	}

	return null;
};

// tryToMockShadcnUiModules.tsx:

import * as ButtonModule from '@/components/ui/button';

export const tryToMockShadcnUiModules = (context: any, path: string) => {
	if (path === '@/components/ui/button') {
		return ButtonModule;
	}

	return null;
};

// tryToMockUtilsModule.tsx:

export const tryToMockUtilsModule = (context: any, path: string) => {
	if (path === '@/lib/utils') {
		return { runSql: async () => [{ count: 10 }] };
	}

	return null;
};

И обновим функцию для резолва:

const customRequire = (path: string) => {
	let resolvedModule: any;
	if ((resolvedModule = tryToMockGlobalModule(context, path))) {
		return resolvedModule;
	} else if ((resolvedModule = tryToMockShadcnUiModules(context, path))) {
		return resolvedModule;
	} else if ((resolvedModule = tryToMockUtilsModule(context, path))) {
		return resolvedModule;
	}

	throw new Error(`Module ${path} not found`);
};

Запускаем, проверяем, и...

Просто идеально. Наш sql-мок сработал, проброшенные в рантайм модули React.js и shadcn/ui сработали, и всё корректно отрендерилось. Мы прямо в рантайме собрали TSX код React-компонента в JS, и запустили его! Ну что за сказка.

Возвращаемся к Tailwind (всё пошло не так)

Я боролся почти 6 часов, но с Tailwind-сборкой в Node.js всё пошло не так. Я уже поныл об этом у себя в телеграм-канале, дам тут более развернутое описание.
Казалось бы, остаётся ведь всего лишь генерить tailwind-стили?

Дело в том, что Tailwind v4 использует module resolution без указания main в package.json, из-за чего сборка Node.js-скриптов в бинарник через pkg ломается. Дело в том, что pkg переживает тяжелые времена - Vercel его бросили, его взял под крыло Daniel Sorridi - https://github.com/yao-pkg/pkg, который поддерживает его работоспособность для последних версий Node.js. Вот только беда в том, что его ресурсов хватает исключительно на поддержку - внедрение новых функций, к примеру, поддержку Node.js modules (import-стейтменты), туда не завезли.
Именно поэтому импортирование tailwindcss@4 ломает pkg-сборку. Можно было бы упороться, и сделать свой бандлер на базе esbuild, но я решил, что это слишком сложный путь.

Поэтому, решил завести Tailwind v4 в браузерной среде. С этим мне помогал этот прекрасный блог-пост.
Сборка Tailwind состоит из 4 частей:

  1. Базовая компиляция css-файла (того самого, который @import 'tailwindcss')

  2. Парсинг всех исходников проекта в поисках строк, которые выглядят как Tailwind utility-классы (типа md:text-xs в коде вашего компонента). Эти строки называются "кандидаты".

  3. Далее, Tailwind фильтрует кандидатов, оставляя только валидные utility-классы. Он компилирует изначальный css + все utility-классы, которые он нашёл у вас в исходниках. На выходе получается intermediate css.

  4. Далее, Tailwind швыряет intermediate css в lightningcss, и тот уже превращает его в финальный css файл.

Так вот, пункт 2 делается через @tailwind/oxide - Rust-тула, который очень быстро сканирует код вашего проекта. И этот тул не только не open-source, но и не имеет wasm-версии для браузерной среды.

Пункт 4 делается через lightningcss - тоже Rust-based тула, но у него, к счастью, есть wasm-версия.
В целом, пункт 2 можно заменить на utility classes extractor из tailwind v3, и оно будет работать.

Изначально, мне показалось это лютой грязью, и я захотел перейти на Tailwind v3.
Но вот беда - shadcn/ui перешёл на Tailwind v4 довольно плотно, и legacy-доки никто не обновляет, и написана там дичь. Да и установить shadcn-компоненты для Tailwind v3 - задачка довольно нетривиальная.

В общем, я решил, что надо всё таки завести Tailwind v4 с extractor'ом от Tailwind v3 в браузер.

Но тут возникает вопрос... а зачем тогда мне вообще нужен Node.js?
Если его единственная задача была в компиляции TSX+Tailwind, то от него можно теперь смело избавляться.

Продолжим.

Возвращаемся к Tailwind (теперь всё так)

Чтож, перенос ESBuild в браузер прошёл абсолютно гладко - я просто заменил esbuild на esbuild-wasm. Главное, не забыть сделать так, чтобы инициализировать WASM-модуль:

import * as esbuild from 'esbuild-wasm';
import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url';

const esbuildPromise = esbuild.initialize({ wasmURL: esbuildWasmUrl });

Теперь вернёмся к Tailwind. Во первых, чтобы в одном проекте иметь сразу две версии одной библиотеки, надо использовать механизм алиасов, который поддерживает и npm, и pnpm:

pnpm i --save tailwindcss-v3@npm:tailwindcss@3

Теперь мы сможем сделать так:

import { compile } from  'tailwindcss';
import { defaultExtractor  as  createDefaultExtractor } from  'tailwindcss-v3/lib/lib/defaultExtractor';

И обращаться к Tailwind v4 через tailwindcss, и к Tailwind v3 через tailwindcss-v3.

Первое, что нам нужно сделать, базово собрать основные стили Tailwind:

import  tailwindcssFile  from  'tailwindcss/index.css?raw';

async compile(vfs: VirtualFS) {
	const result = await compile(`@import 'tailwindcss';`, {
		loadStylesheet: async url => {
			if (url === 'tailwindcss') {
				// пробрасываем главный стиль Tailwind
				return {
					path: url,
					base: url,
					content: tailwindcssFile,
				};
			} else {
				throw new Error(`Unknown stylesheet: ${url}`);
			}
		},
	});
}

Теперь, давайте научимся собирать кандидатов на utility-классы при помощи extractor'а из Tailwind v3:

/**
 * Проходит по всем файлам в виртуальной файловой системе,
 * извлекает utility-class кандидатов из файлов, которые не отмечены как externalized
 * @returns массив уникальных utility-class кандидатов
 */
buildCandidates(vfs: VirtualFS): string[] {
	const candidatesSet = new Set<string>();
	// Проходим по всем файлам в VFS
	for (const [_filePath, fileNode] of vfs.filesNodes) {
		// Пропускаем файлы, отмеченные как externalized
		if (fileNode.metadata.externalized === true) {
			continue;
		}
		// Извлекаем кандидатов из содержимого файла
		const fileCandidates = this.defaultExtractor(fileNode.content);
		// Добавляем всех кандидатов в глобальный Set
		fileCandidates.forEach(candidate => candidatesSet.add(candidate));
	}
	// Возвращаем массив уникальных кандидатов
	return Array.from(candidatesSet);
}

Отлично. Мы уже близко, собираем intermediate css:

async compile(vfs: VirtualFS, baseCss: string = this.getBaseCss()) {
	const result = await compile(...);
	
	const intermediateCss = await result.build(this.buildCandidates(vfs));
	// ...
}

Теперь подключим lightningcss - используем lightningcss-wasm, и инициализируем его аналогично esbuild-wasm:

import initLightningCssModule, * as lightningcss from 'lightningcss-wasm';
import lightningcssWasmModule from 'lightningcss-wasm/lightningcss_node.wasm?url';

const lightningcssModuleLoaded = initLightningCssModule(lightningcssWasmModule);

Наконец, мы можем дописать функцию compile:

const intermediateCss = await result.build(this.buildCandidates(vfs));

await lightningcssModuleLoaded;

const resultCss = new TextDecoder().decode(
	lightningcss.transform({
		filename: 'input.css',
		code: new TextEncoder().encode(intermediateCss),
		drafts: {
			customMedia: true,
		},
		nonStandard: {
			deepSelectorCombinator: true,
		},
		include: lightningcss.Features.Nesting,
		exclude: lightningcss.Features.LogicalProperties,
		targets: {
			safari: (16 << 16) | (4 << 8),
		},
		errorRecovery: true,
	}).code,
);

return resultCss;

Вуаля, весь процесс собран, и работает. Папку src-node и настройки sidecar из проекта я выкинул, за ненадобностью.

Заключение

Не без приключений, но мы полностью научились собирать TSX React-компоненты с shadcn/ui и Tailwind в рантайме, и отображать их в том же интерфейсе.

В следующей части мы слегка причешем среду, и начнём реализацию AI-агента - сделаем 3 версии при помощи разных фреймворков, и сравним их удобство между собой.

Детальнее про процесс разработки я рассказываю у себя в телеграм-канале. А ещё я там много пишут про разработку с ИИ, стартапы, обозреваю новости технологий, и всё такое. Велком!

Комментарии (0)