Стартап за выходные: AI-агент для БД, часть 2
- вторник, 29 июля 2025 г. в 00:00:05
 

Ну кто не мечтает запустить стартап за одни выходные?
 Давно хотел развеяться, и чутка отвлечься от рутины и работы.
 А ещё давно хотел пощупать Tauri v2, и новомодные фреймворки для построения AI-агентов (ai-sdk / mastra / llamaindex).
Идея простая: десктопное приложение, внутри ИИ-агент, который подключается к БД, получает данные о структуре таблиц/вьюшек. Справа сайдбар: интерфейс чата с агентом, а основное пространство - холст, на котором агент размещает что хочет сам. А именно - виджеты, которые делают запросы к БД, и выводят их в приятном глазу виде.
 Никакого удалённого бекенда, open-source, доступы к БД хранятся исключительно локально, всё секьюрно.
Так как весь код открытый, то процесс я буду логировать в репозитории: https://github.com/ElKornacio/qyp-mini
Флоу такой:
Я говорю агенту "добавь на дешборд плашку с количеством новых юзеров за последний месяц".
Он, используя знания о структуре БД, и возможность выполнять к ней запросы, придумывает корректный, соответствующий моей БД, SQL-запрос, который возвращает требуемую инфу
Он пишет React-компонент на Tailwind + shadcn/ui, который будет делать этот запрос и выводить ответ в виде симпатичной плашки
Под капотом, прямо в рантайме, react-компонент комплириуется (esbuild + postcss), и выводится на дешборд
В случае ошибок (компиляции или выполнения sql) - они автоматом летят обратно агенту, чтобы он чинил.
В общем, я хочу сделать приложение, в котором интерфейс будет писать сам ИИ агент, чтобы он сам решал, каким образом выводить информацию.
Интересно потыкать runtime компиляцию tailwind (это нетривиально, т.к. по дефолту tailwind генерирует css-классы на основе вашего кода), ещё runtime с esbuild под это всё упаковать, ну и я давно хотел Tauri v2 пощупать.
А ещё мне накидали в комменты в телеге новомодные AI-agent фреймворки для TypeScript, так что их тоже хочу пощупать и между собой сравнить, раньше я только на чистом LangChain писал.
Всего будет 5 частей:
Делаем скелет приложения (предыдущая часть)
Делаем runtime-компиляцию TSX-компонентов (эта часть)
Делаем AI-агента и сравниваем AI-фреймворки
Учим агента писать код и делать SQL-запросы
Собираем всё в кучу и причёсываем
Поехали!
Давайте ещё чуть-чуть порассуждаем об архитектуре. Изначально я планировал, что буквально каждый компонент выданный ИИ будет изолирован от остальных. Грубо говоря, если представить рантайм-среду для сборки как виртуальную файловую систему, то каждый компонент - это отдельный проект. Плюс такого подхода в изоляции компонентов и очень быстрой сборке - по сути, esbuild придется собрать буквально 1-2 небольших файла, в которых описана основная логика компонента. Минус - в той же изоляции, компоненты не смогут взаимодействовать друг с другом.
Со временем я задумался - а почему бы не дать ИИ единую среду, в которой он сможет встраивать одни компоненты в другие, создавать какие-нибудь utils-функции, которые будет переиспользовать, и так далее? Используя ту же аналогию с виртуальной файловой системой, в этом случае у нас есть один проект, а компоненты - это файлы в директории src/components.
 Плюс - компоненты можно соединять и строить более сложные интерфейсы. Минус - компилировать на каждое изменение надо будет сразу все компоненты.
Для первой версии приложения делать единую среду я не захотел. Надо будет решать чуть более сложные задачи по персистентности этой среды, корректной линковке компонентов, и так далее. Думаю, я к этому приду, но на старте давайте просто научимся выводить компилировать и рендерить простые компоненты в рантайме.
Напомню суть: мы хотим, чтобы ИИ выдавал нам код типа такого:
import { Button } from  '@/components/ui/button';
export default function MyComponent() {
	return (
		<Button>Click me!</Button>
	);
}
и мы хотим, чтобы компонент, описанный в этом коде, был выведен в интерфейс нашего приложения.
Напомню, речь о рантайме: то есть код выше нам надо самим программно собрать, а именно:
TSX: надо транспилировать jsx-синтаксис в React.createElement-стейтменты
TypeScript: надо транспилировать в JavaScript
Tailwind/PostCSS: сборщик Tailwind должен проанализировать исходники на предмет использования tailwind-классов, и сгенерировать для них css-код.
Бандлинг: надо собрать все импорты в единый файл, а те, которые мы подкидываем сами (типа тех же компонентов 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;
};
Итак, наш 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, и запустили его! Ну что за сказка.
Я боролся почти 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 частей:
Базовая компиляция css-файла (того самого, который @import 'tailwindcss')
Парсинг всех исходников проекта в поисках строк, которые выглядят как Tailwind utility-классы (типа md:text-xs в коде вашего компонента). Эти строки называются "кандидаты".
Далее, Tailwind фильтрует кандидатов, оставляя только валидные utility-классы. Он компилирует изначальный css + все utility-классы, которые он нашёл у вас в исходниках. На выходе получается intermediate css.
Далее, 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, то от него можно теперь смело избавляться.
Продолжим.
Чтож, перенос 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 версии при помощи разных фреймворков, и сравним их удобство между собой.
Детальнее про процесс разработки я рассказываю у себя в телеграм-канале. А ещё я там много пишут про разработку с ИИ, стартапы, обозреваю новости технологий, и всё такое. Велком!