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

Ну кто не мечтает запустить стартап за одни выходные?
Давно хотел развеяться, и чутка отвлечься от рутины и работы.
 А ещё давно хотел пощупать 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-запросы
Собираем всё в кучу и причёсываем
Поехали!
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-компиляции.
Представим, что ИИ нам выдал такой код:
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?)
В общем, большую часть мы сможем решить при помощи esbuild - TSX/TypeScript/бандлинг он прекрасно возьмёт на себя. Более того, у него есть версия для браузерного-рантайма - esbuild-wasm.
А вот с Tailwind/PostCSS всё гораздо сложнее. С пол-пинка в браузерной среде оно не заводится. Если долго пинать, то в целом можно, у самого Tailwind есть play.tailwindcss.com, на котором как раз можно поиграться с компиляцией Tailwind прямо в браузере. Но вот беда - этот проект раньше был open-source, а потом ребята передумали.
 Но, к счастью, интернет всё помнит, и найти устаревшие исходники Tailwind Play не составляет большого труда.
Если хорошенько их покопать, то видно, что там нет поддержки 4 версии, и работает оно очень грязно - с заглушками всяких Node.js-модулей, "виртуальной" файловой системой, кучей хаков и так далее.
 Идти по этому пути не хотелось совершенно.
Поэтому, я принял решение использовать для компиляции TSX+Tailwind райтайм 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 в 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
Более детально про процесс разработки я пишу у себя в телеграм-канале (да и в целом я там много всего пишу про ИИ, разработку с ИИ, стартапы и прочее).
Спасибо за внимание, следующая часть завтра!