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