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

от автора

Ну кто не мечтает запустить стартап за одни выходные?

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

Поехали!

Tauri v2 — десктоп приложение

Tauri — это такой Electron на стероидах. Ребята почесали голову и спросили себя «зачем вместе с пользовательским приложением поставлять весь Chromium-браузер, если у каждого юзера на компе итак уже есть браузер? почему бы просто не поставлять html/css/js-бандл, который будет отображаться в стандартном WebView?»

Именно это и делает Tauri, в результате чего мы имеем на выходе приложение, которое весит не 400 мегабайт, а 5, и летает в плане быстродействия (под капотом — системный браузер для WebView, и Rust для самого локального «бекенда» Tauri).

Давно хотел что-нибудь с ним сделать. Поэтому, поехали:

Тыкаем сюда, и слепо следуем гайду.
Я юзаю pnpm, для других менеджеров команды аналогичные:

pnpm create tauri-app 

Везде выбирал Typescript/React.

Завелось с пол-пинка, далее:

pnpm tauri dev 

и перед нами работающее приложение.

В качестве сборщика для такого стека Tauri по дефолту использует Vite. Меня устраивает, я тоже часто использую его на своих проектах.

Далее, завозим Tailwind v4 и shadcn/ui:

pnpm i --save-dev tailwindcss @tailwindcss/vite 

не забываем добавить плагин в vite.config.ts:

import { defineConfig } from 'vite' import tailwindcss from '@tailwindcss/vite'  export default defineConfig({ plugins: [ // ... tailwindcss(), // ... ], }) 

и импортировать Tailwind в css (src/index.css):

@import "tailwindcss"; 

далее, проинициализировать shadcn/ui проще всего поставив какой-нибудь его компонент, к примеру, кнопку:

pnpm dlx shadcn@latest add button 

Вуаля, базовый сетап готов. Теперь разберёмся со средой для runtime-компиляции.

Компилируем React-компоненты в рантайме

Представим, что ИИ нам выдал такой код:

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?)

В общем, большую часть мы сможем решить при помощи esbuild — TSX/TypeScript/бандлинг он прекрасно возьмёт на себя. Более того, у него есть версия для браузерного-рантайма — esbuild-wasm.

А вот с Tailwind/PostCSS всё гораздо сложнее. С пол-пинка в браузерной среде оно не заводится. Если долго пинать, то в целом можно, у самого Tailwind есть play.tailwindcss.com, на котором как раз можно поиграться с компиляцией Tailwind прямо в браузере. Но вот беда — этот проект раньше был open-source, а потом ребята передумали.
Но, к счастью, интернет всё помнит, и найти устаревшие исходники Tailwind Play не составляет большого труда.

Если хорошенько их покопать, то видно, что там нет поддержки 4 версии, и работает оно очень грязно — с заглушками всяких Node.js-модулей, «виртуальной» файловой системой, кучей хаков и так далее.
Идти по этому пути не хотелось совершенно.

Поэтому, я принял решение использовать для компиляции TSX+Tailwind райтайм Node.js. Оставался вопрос — как завести его в Tauri.

Заводим Node.js в Tauri

В общем, в Tauri есть механизм sidecars, который позволяет упаковывать внешние бинарники в единый бандл с приложением.
А для Node.js есть pkg — утилита, которая умеет превращать Node.js скрипт-бандл в бинарник не требующий внешних зависимостей.
И у Tauri даже есть официальный гайд о том, как завести Node.js-приложение как sidecar для Tauri-приложения.

Так как мы планируем обмениваться с Node.js большими объёмами информации, я хочу завести под это простенький stdin-stdout протокол, который будет передавать JSON-пакеты упакованные в base64.

Помимо этого, Node.js код я тоже хочу держать как TypeScript, и упаковывать перед фактической передачей в pkg.
Делаем pnpm init в src-node директории, бахаем src-node/src/index.ts с дефолтной заглушкой, после чего настраиваем скрипты для компиляции в src-node/package.json:

{ ... "scripts": { "build-code": "tsc", "build-binary": "pnpm run build-code && pkg dist/index.js --output qyp-mini-node-backend", "package-binary": "node scripts/build.js qyp-mini-node-backend", "build": "pnpm run build-binary && pnpm run package-binary" }, "devDependencies": { "@yao-pkg/pkg": "^5.15.0" }, "bin": { "qyp-mini-node-backend": "dist/index.js" }, "pkg": { "targets": [ "latest-macos" ], "scripts": [ "dist/index.js" ] }, ... } 

Как можно увидеть, я пока что завёл только MacOS — для простоты, остальные платформы будем заводить потом.
Теперь давайте сделаем небольшой скрипт в src-node/scripts/build.js для переноса скомпилированного pkg бинарника в Tauri (это нужно, чтобы у бинарников было корректное имя файла — Tauri по имени определяет платформу, под которую бинарник скомпилирован):

import { execSync } from 'child_process'; import fs from 'fs';  const ext = process.platform === 'win32' ? '.exe' : '';  const appName = process.argv[2];  const rustInfo = execSync('rustc -vV'); const targetTriple = /host: (\S+)/g.exec(rustInfo)[1]; if (!targetTriple) { console.error('Failed to determine platform target triple'); } fs.renameSync(`${appName}${ext}`, `../src-tauri/binaries/${appName}-${targetTriple}${ext}`);  

И докинем это всё великолепие в package.json основного проекта:

"scripts": { "dev": "cd src-node && pnpm run build && cd .. && vite", "build": "cd src-node && pnpm run build && cd .. && tsc && vite build", "preview": "vite preview", "tauri": "tauri" }, 

Теперь при запуске команды pnpm tauri dev он автоматом дёрнет сборку через pkg свежего Node.js бинарника, и сразу будет использовать её при работе приложения.

Помимо этого, чтобы работала запись в stdin, да и вообще вызов sidecar, важно не забыть сконфигурировать файл src-tauri/capabilities/default.json:

{ ... "permissions": [ "core:default", "opener:default", { "identifier": "shell:allow-spawn", "allow": [ { "name": "binaries/qyp-mini-node-backend", "cmd": "binaries/qyp-mini-node-backend", "args": true, // не забудьте про sidecar: true, иначе будет scoped command not found "sidecar": true } ] }, "shell:allow-stdin-write", // а это позволит писать в stdin "shell:default" ] } 

Пилим простенький JSON-протокол для общения с Node.js

Как я уже говорил, упаковываем JSON в utf-8 строчку, а её упаковываем в base64. В качестве символа-реминатора будем использовать перенос строки (\n).

На стороне Node.js:

// Читаем строку из stdin const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false, });  rl.on('line', async line => { try { // Декодируем base64 const jsonString = SmartBuffer.ofBase64String(line.trim()).toUTF8String(); // Парсим JSON const request = JSON.parse(jsonString);  // Обрабатываем запрос const response = await processRequest(request);  // Отправляем ответ sendResponse(response);  // Завершаем процесс после обработки process.exit(0); } catch (error) { console.error('Ошибка обработки запроса:', error); sendResponse({ status: 'error', message: `Ошибка декодирования запроса: ${error instanceof Error ? error.message : String(error)}`, }); process.exit(1); } }); 

На стороне фронтенда:

import { SmartBuffer } from '@tiny-utils/bytes';  export class SidecarEncoder { static encodeRequest(request: SidecarRequest): string { const jsonString = JSON.stringify(request); const base64String = SmartBuffer.ofUTF8String(jsonString).toBase64String(); return base64String + '\n'; }  static decodeResponse(base64Response: string): any { try { const jsonString = SmartBuffer.ofBase64String(base64Response.trim()).toUTF8String(); return JSON.parse(jsonString); } catch (error) { throw new Error(`Ошибка декодирования ответа от sidecar: ${error}`); } } }  

И executor:

import { Command, TerminatedPayload } from '@tauri-apps/plugin-shell'; import { SidecarEncoder, SidecarRequest, SidecarResponse } from './SidecarEncoder';  export class SidecarExecutor { private static readonly SIDECAR_NAME = 'binaries/qyp-mini-node-backend';  static async execute<T extends SidecarResponse>(params: SidecarExecutionParams): Promise<T> { const command = Command.sidecar(this.SIDECAR_NAME, params.args);  let stdout = ''; let stderr = '';  command.stdout.on('data', data => { stdout += data; }); command.stderr.on('data', data => { stderr += data; });  const child = await command.spawn();  const encodedRequest = SidecarEncoder.encodeRequest(params.request);  const output = await new Promise<TerminatedPayload>(resolve => { command.on('close', out => resolve(out));  // Отправляем закодированный запрос child.write(encodedRequest); });  if (output.code !== 0) { throw new Error(`Sidecar завершился с кодом: ${output.code}. Stderr: ${stderr}`); }  return SidecarEncoder.decodeResponse(stdout); } } 

Вуаля, протокол готов. Запускаем (pnpm tauri dev, тестируем, и радуемся что всё работает).

Заключение

Это первая часть, в которой мы просто собирали скелет приложения. В итоге, на базе стека:

  • TypeScript

  • Tauri v2

  • Tailwind v4 / shadcn/ui

  • Vite 6 / React 18

  • Node.js + pkg

У нас получилось desktop-приложение, с фронтом на React, Node.js мини-беком для будущих задач компиляции TSX-кода в рантайме, которое весит в 5 раз меньше Electron сборки, и гораздо шустрее.
Посмотреть полный код можно в репозитории: https://github.com/ElKornacio/qyp-mini

Более детально про процесс разработки я пишу у себя в телеграм-канале (да и в целом я там много всего пишу про ИИ, разработку с ИИ, стартапы и прочее).

Спасибо за внимание, следующая часть завтра!


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


Комментарии

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

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