
Ну кто не мечтает запустить стартап за одни выходные?
Давно хотел развеяться, и чутка отвлечься от рутины и работы.
А ещё давно хотел пощупать 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
Более детально про процесс разработки я пишу у себя в телеграм-канале (да и в целом я там много всего пишу про ИИ, разработку с ИИ, стартапы и прочее).
Спасибо за внимание, следующая часть завтра!
ссылка на оригинал статьи https://habr.com/ru/articles/931390/
Добавить комментарий