Стартап за выходные: AI-агент для БД, часть 2

от автора

Ну кто не мечтает запустить стартап за одни выходные?
Давно хотел развеяться, и чутка отвлечься от рутины и работы.
А ещё давно хотел пощупать 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 версии при помощи разных фреймворков, и сравним их удобство между собой.

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


ссылка на оригинал статьи https://habr.com/ru/articles/931518/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *