
Ну кто не мечтает запустить стартап за одни выходные?
Давно хотел развеяться, и чутка отвлечься от рутины и работы.
А ещё давно хотел пощупать 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
.
Плюс - компоненты можно соединять и строить более сложные интерфейсы. Минус - компилировать на каждое изменение надо будет сразу все компоненты.
Для первой версии приложения делать единую среду я не захотел. Надо будет решать чуть более сложные задачи по персистентности этой среды, корректной линковке компонентов, и так далее. Думаю, я к этому приду, но на старте давайте просто научимся выводить компилировать и рендерить простые компоненты в рантайме.
TSX-компиляция в рантайме
Напомню суть: мы хотим, чтобы ИИ выдавал нам код типа такого:
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;
};
Расчехляем 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 частей:
Базовая компиляция 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, то от него можно теперь смело избавляться.
Продолжим.
Возвращаемся к 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 версии при помощи разных фреймворков, и сравним их удобство между собой.
Детальнее про процесс разработки я рассказываю у себя в телеграм-канале. А ещё я там много пишут про разработку с ИИ, стартапы, обозреваю новости технологий, и всё такое. Велком!
n0isy
Новомодный движок найден исключительно в тегах, как пасхалка.