UspAIChat: свой AI-чат на пяти провайдерах — с биллингом, мобильным клиентом и умным роутером

Кейс: self-hosted чат к Claude, GPT, Gemini, DeepSeek и Kimi. Один Node-процесс и SQLite вместо тяжёлой инфраструктуры, кредитный биллинг и роутер, который отправляет простые запросы в дешёвые модели.

Иметь один интерфейс ко всем моделям сразу — идея не новая. Непонятно другое: зачем ради неё тащить Postgres, Redis и оркестрацию контейнеров, если пользователей будет не миллион, а несколько сотен.

Этот кейс — про то, как выглядит AI-чат, собранный по обратному принципу: один Node-процесс и файл SQLite, никаких внешних сервисов в рантайме кроме самих API моделей. При этом внутри — пять провайдеров, четыре способа входа, кредитный биллинг с поштучным списанием за токены и мобильное приложение на Flutter.

Экран входа UspAIChat

Задача

Хотелось общаться с разными моделями — Claude, GPT, Gemini, DeepSeek, Kimi — не отдавая переписку в чужое облако: свои данные, свой сервер, своя биллинговая логика. Плюс раздать доступ команде и близким, контролируя расходы на токены.

Готовые open-source решения не подошли по двум причинам. Одни тянули за собой тяжёлую инфраструктуру, другие не умели нужного: четыре способа входа, кредитный биллинг и автовыбор модели. Отсюда — свой стек, максимально простой в эксплуатации.

Мотив здесь тот же, что и у любого self-hosted-решения: контроль над данными и предсказуемая стоимость. Где эта логика оправдана, а где превращается в дорогую паранойю, я разбирал в посте про локальные AI-модели — там же критерии, когда своё окупается.

Результат: ~17 000 строк кода — Express-бэкенд (3,5K), React-фронтенд (6,4K), Flutter-приложение (7,2K), задеплоенное на VPS и работающее в продакшене.

Стек

Слой Технологии
Backend Node.js 20, Express, better-sqlite3 (WAL), JWT + refresh-токены
Web React 18, TypeScript, Vite, Tailwind CSS, Zustand, framer-motion
Mobile Flutter 3.29, Riverpod, GoRouter, Dio (SSE-стриминг)
AI Anthropic SDK, OpenAI SDK (+ DeepSeek/Kimi через кастомный baseURL), Google Generative AI
Auth Google OAuth, Apple Sign-In, Telegram-бот, email + пароль
Инфраструктура VPS, Nginx, PM2, Certbot

Архитектура

Классическая трёхзвенка с одним важным решением: SQLite вместо клиент-серверной СУБД. Для чата с сотнями пользователей better-sqlite3 в WAL-режиме даёт синхронный API без пула соединений, атомарные транзакции для биллинга и полнотекстовый поиск FTS5 — бесплатно, в одном файле.

flowchart LR
    subgraph Clients["Клиенты"]
        WEB["React SPA<br/>(Vite + Zustand)"]
        MOB["Flutter App<br/>(Android / iOS)"]
    end

    subgraph Server["VPS · Nginx + PM2"]
        NGINX["Nginx<br/>SSL, статика frontend/dist"]
        API["Express API<br/>:3088"]
        subgraph Services["Сервисы"]
            ROUTER["autoRouter.js<br/>Smart Router"]
            BILLING["billing.js<br/>кредиты, цены"]
            TG["telegram.js<br/>бот-авторизация"]
        end
        DB[("SQLite (WAL)<br/>users · conversations ·<br/>messages · FTS5 · transactions")]
    end

    subgraph AI["AI-провайдеры"]
        ANT["Anthropic"]
        OAI["OpenAI"]
        GEM["Google Gemini"]
        DS["DeepSeek"]
        KIMI["Kimi"]
    end

    WEB -->|"HTTPS / SSE"| NGINX --> API
    MOB -->|"HTTPS / SSE"| NGINX
    API --> ROUTER & BILLING & TG
    API --> DB
    ROUTER --> ANT & OAI & GEM & DS & KIMI
    TG <-->|"polling"| TGAPI["Telegram Bot API"]

Главный экран веб-клиента — тёмная тема, закреплённые чаты, папки, горячие клавиши:

Главный экран

Схема данных

Ядро — четыре таблицы (пользователи, беседы, сообщения, транзакции), вокруг которых наросли фичи: шаринг, шаблоны промптов, платежи, промокоды, рефералка.

erDiagram
    users ||--o{ conversations : "ведёт"
    users ||--o{ refresh_tokens : "сессии"
    users ||--o{ transactions : "биллинг"
    users ||--o{ api_keys : "личные ключи"
    users ||--o{ folders : "организация"
    conversations ||--o{ messages : "содержит"
    conversations ||--o| shared_conversations : "публичная ссылка"
    messages ||--|| messages_fts : "FTS5-индекс"
    users ||--o{ payments : "ЮKassa"
    users ||--o{ promo_uses : "промокоды"

    users {
        text id PK "UUID"
        text email UK "nullable для OAuth"
        text google_id UK
        text apple_id UK
        integer telegram_id UK
        text role "admin | user"
        real balance "кредиты"
    }
    conversations {
        text id PK
        text provider
        text model
        text system_prompt
        integer is_pinned
    }
    messages {
        text id PK
        text role
        text content
        integer token_count
        text files "JSON-вложения"
    }
    transactions {
        text type "topup | charge"
        real amount
        real balance_after
        integer tokens
    }

Первый зарегистрировавшийся пользователь автоматически становится админом — удобно для self-hosted: развернул, вошёл, владеешь.

Четыре способа входа

Регистрация — только через OAuth (Google, Apple, Telegram), email-форма работает как вход для тех, кто уже задал пароль в профиле. Это отсекает ботов и избавляет от флоу подтверждения почты.

Самый нестандартный поток — вход через Telegram-бота: сервер генерирует шестизначный код, пользователь отправляет его боту, фронтенд поллит статус.

sequenceDiagram
    autonumber
    participant U as Пользователь
    participant F as Frontend
    participant B as Backend
    participant T as Telegram-бот

    F->>B: POST /auth/telegram/init
    B-->>F: { code: "123456", bot_username }
    F-->>U: Показывает код и ссылку на бота
    U->>T: Отправляет «123456» в чат боту
    T->>B: Находит код, создаёт/находит пользователя
    B->>B: status = confirmed
    loop каждые 2 секунды
        F->>B: GET /auth/telegram/poll/:code
    end
    B-->>F: { user, accessToken, refreshToken }

Токены: access — JWT на 15 минут, refresh — UUID на 30 дней в БД. Фронтенд обновляет пару каждые 12 минут в фоне.

SSE-стриминг: почему fetch, а не EventSource

Ответы моделей стримятся через Server-Sent Events. Нюанс: нативный EventSource не умеет передавать заголовок Authorization, поэтому клиент читает поток через fetch + ReadableStream с ручным парсингом строк data:. Во Flutter то же самое делает Dio с ResponseType.stream.

Один HTTP-запрос несёт всю телеметрию: выбранную роутером модель, цену, чанки текста, счётчик токенов и итоговое списание с баланса.

sequenceDiagram
    autonumber
    participant C as Клиент (fetch / Dio)
    participant S as Express /chat/stream
    participant R as Smart Router
    participant P as AI-провайдер
    participant DB as SQLite

    C->>S: POST /api/chat/stream { messages, provider: "auto" }
    S->>DB: Проверка баланса
    S->>R: analyzePrompt(text)
    R-->>S: { tier, model, экономия }
    S-->>C: data: {type:"routing_info", tier:"SIMPLE", ...}
    S-->>C: data: {type:"price", pricePer1k}
    S->>P: Стриминговый запрос к API модели
    S-->>C: data: {type:"start", message_id}
    loop генерация
        P-->>S: чанк текста
        S-->>C: data: {type:"chunk", content}
    end
    S-->>C: data: {type:"tokens", count}
    S->>DB: Атомарная транзакция: сообщение + списание кредитов
    S-->>C: data: {type:"done", full_content, balance_after, cost}

Так это выглядит вживую — вопрос, потоковый ответ и плашка роутера с выбранной моделью и экономией:

Диалог с ответом модели

Markdown-ответы рендерятся с подсветкой синтаксиса, нумерацией строк и кнопкой копирования:

Подсветка кода в ответе

Smart Router: −99% к цене на простых запросах

Фича, которой я горжусь больше всего. В режиме «Авто» бэкенд прогоняет промпт через 14-мерный эвристический классификатор: плотность кода, маркеры рассуждений, техническая терминология, многошаговость, креативность, ограничения формата, доменная специфика и так далее. Каждое измерение даёт вклад с весом, сумма — скор сложности от 0 до 1.

flowchart TD
    P["Промпт пользователя"] --> A["Анализ по 14 измерениям<br/>код · рассуждения · термины ·<br/>многошаговость · креатив · формат · ..."]
    A --> S{"Скор сложности"}
    S -->|"< 0.3"| T1["SIMPLE<br/>GPT-4.1 Nano, Gemini Flash"]
    S -->|"0.3 – 0.5"| T2["MEDIUM<br/>Gemini 2.5 Flash, GPT-4.1, Sonnet"]
    S -->|"≥ 0.5"| T3["COMPLEX<br/>Claude Opus, o3, DeepSeek R1"]
    T1 & T2 & T3 --> K{"Есть ключ<br/>провайдера?"}
    K -->|"да"| M["Выбранная модель<br/>+ расчёт экономии vs Opus"]
    K -->|"нет"| F["Следующий кандидат тира<br/>(fallback по всем тирам)"]
    F --> K

Профиль пользователя сдвигает тир: eco понижает (экономия), premium повышает (качество). Классификатор — чистые регулярки и веса: ноль дополнительных API-вызовов и миллисекунды на решение.

На скриншотах выше видно результат: приветственный вопрос ушёл в GPT-4.1 Nano с плашкой «SIMPLE −99%» — вместо того чтобы жечь Opus по 25 кредитов за 1K токенов.

Логика здесь ровно та же, что и в ручном выборе модели под задачу — только автоматизированная. Если решаете это без роутера, помогут карта выбора модели под задачу и система 10-80-10, которая держит расходы на дорогих моделях в узде.

Биллинг

Кредитная система: админ пополняет баланс (или пользователь платит через ЮKassa либо Telegram-платежи), каждый ответ списывает кредиты по цене модели. Ключевые решения:

  • Атомарность. Сообщение и списание — одна SQLite-транзакция; никаких «ответ есть, а деньги не списались».
  • Приоритет ключей: личный ключ пользователя → глобальный ключ __global__ → ошибка. Кто принёс свой ключ — платит провайдеру сам.
  • Админы не тарифицируются.
  • Цены за 1K токенов настраиваются из админки, наценка ~×2,5 покрывает стоимость input-токенов.

Поиск, шаринг и остальной продуктовый слой

Полнотекстовый поиск — SQLite FTS5 по всем сообщениям с фолбэком на LIKE при невалидном FTS-запросе. Мгновенный, без Elasticsearch:

Полнотекстовый поиск

Из того, что накопилось вокруг ядра:

  • Папки с drag-and-drop для организации бесед
  • Шаблоны промптов — вставка по / в поле ввода, глобальные шаблоны от админа
  • Шаринг беседы публичной ссылкой, опционально под паролем (bcrypt), со счётчиком просмотров
  • Экспорт в Markdown / JSON / TXT
  • Вложения: изображения (vision через base64), PDF, DOCX, кодовые файлы; до 10 файлов по 50 МБ
  • Голос: распознавание через Web Speech API и озвучка ответов с очисткой markdown
  • Системный промпт на уровне беседы + пресеты персонажей
  • i18n: русский, английский, китайский
  • Горячие клавиши: Ctrl+N, Ctrl+K, Ctrl+B, Ctrl+, — весь интерфейс доступен без мыши

API-ключи провайдеров живут в БД и управляются из интерфейса — в .env только секреты инфраструктуры:

Настройки: API-ключи

Админ-панель

Статистика, управление пользователями (роли, блокировки, сброс пароля), глобальные API-ключи, пополнение балансов, история транзакций, редактор тарифов, промокоды:

Админ-панель

Мобильное приложение

Flutter-клиент к тому же API — 7,2K строк Dart. Осознанные отличия от веба:

  • Riverpod вместо BLoC — меньше бойлерплейта для проекта такого размера
  • Нативные SDK Google/Apple Sign-In вместо веб-виджетов
  • SSE через Dio ResponseType.stream с ручным парсингом строк — тот же протокол, что и в вебе
  • Токены в flutter_secure_storage, тёмная тема пиксель-в-пиксель повторяет веб-палитру
  • Telegram-вход через deep-link + поллинг — идентично вебу

Деплой и эксплуатация

git pull → cd frontend && npm run build → pm2 restart uspaichat

Nginx раздаёт собранную статику и проксирует /api на Node-процесс под PM2. SSL — Certbot. Вся база — один файл SQLite, бэкап = копирование файла. Миграции — идемпотентные ALTER TABLE в try/catch при старте: примитивно, но для одного файла БД надёжнее любого фреймворка миграций.

Цифры

Метрика Значение
Строк кода (свои) ~17 000
AI-провайдеров 5 (≈25 моделей)
Способов входа 4
REST-эндпоинтов ~60 в 17 роутерах
Внешних сервисов в рантайме 0 (кроме AI API и платёжек)
Экономия Smart Router на простых запросах до 99%

Что я вынес из проекта

  1. SQLite недооценён. WAL-режим, FTS5 и синхронный better-sqlite3 закрывают потребности продукта на сотни пользователей — без единого контейнера.
  2. SSE поверх fetch — правильный компромисс: HTTP-семантика, работа через любые прокси и при этом полноценный заголовок Authorization, которого нет у EventSource.
  3. Эвристический роутер моделей окупается мгновенно: большинство реальных запросов — простые, и гонять их через премиум-модели просто дорого.
  4. Биллинг должен жить в транзакции. Любая пост-фактум сверка «ответили — списали» рано или поздно расходится.

Проект развивается: из последнего — платежи ЮKassa и Telegram, промокоды, реферальная программа, светлая тема.

Посмотреть вживую

Перейти на сайтapp.aifuturenow.ru Исходники на GitHubircitdev/UspAIChat

Читайте также

Комментарии

Войдите через Telegram, чтобы оставить комментарий:

Пока нет комментариев. Будьте первым.