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

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

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

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

Комментарии (0)