惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

H
Help Net Security
T
ThreatConnect
SecWiki News
SecWiki News
F
Future of Privacy Forum
AWS News Blog
AWS News Blog
C
Cisco Blogs
A
Arctic Wolf
Vercel News
Vercel News
The GitHub Blog
The GitHub Blog
Scott Helme
Scott Helme
V
V2EX
博客园 - 叶小钗
阮一峰的网络日志
阮一峰的网络日志
K
Kaspersky official blog
G
Google Developers Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
P
Privacy International News Feed
C
Cyber Attacks, Cyber Crime and Cyber Security
N
News | PayPal Newsroom
Schneier on Security
Schneier on Security
NISL@THU
NISL@THU
Microsoft Azure Blog
Microsoft Azure Blog
量子位
The Hacker News
The Hacker News
Stack Overflow Blog
Stack Overflow Blog
Security Latest
Security Latest
M
Microsoft Research Blog - Microsoft Research
Google Online Security Blog
Google Online Security Blog
博客园_首页
C
CXSECURITY Database RSS Feed - CXSecurity.com
I
InfoQ
Google DeepMind News
Google DeepMind News
Y
Y Combinator Blog
The Cloudflare Blog
Microsoft Security Blog
Microsoft Security Blog
Martin Fowler
Martin Fowler
Cisco Talos Blog
Cisco Talos Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
T
Troy Hunt's Blog
F
Fox-IT International blog
S
Security @ Cisco Blogs
博客园 - 司徒正美
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
C
Comments on: Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
L
LINUX DO - 最新话题
GbyAI
GbyAI
Project Zero
Project Zero
腾讯CDC
T
Tailwind CSS Blog

Все публикации подряд на Хабре

20 лет видеокарт в цифрах: как росли FLOPS и TDP и кто вёл в дуэли NVIDIA vs AMD (+ открытый датасет на 13 500 GPU) Архитектура крипто-сканера для биржи: Open Interest, Funding Rate, EMA и MACD в реальном времени @tanstack/vue-table: почему я почти отказался от этого… WHERE превращает ваш LEFT JOIN в INNER JOIN. И никто вам об этом не скажет Гравитация не существует. Вы задали 454 вопроса о времени. Вот ответы с уравнениями Эйнштейна Конец бесплатного кремния: как Google AI Studio превратилась из рая для инженеров в симулятор смены аккаунтов Свой AI-агент из почты, systemd и LLM MemForge2: загрузочная флешка, которая за минуту говорит — какую планку памяти менять Лицензии важны. Разбор ошибок авторов и пользователей программ От RAG-прототипа к агенту в продакшн: путь по метрикам, а не по моде Serial Terminal: кастомный веб-терминал для последовательного порта на Web Serial API Китайский стартап GigaAI обещает робота-домработника за 1 млн рублей уже в 2027 году — правда или PR? Open-source VPN клиент Tunguska Роман за 6 недель без идеи на старте: миф или реальность? ИИ построит ваш план действий за 10 секунд Security Week 2622: эффективность Claude Mythos по версии Cloudflare Reactive Forms vs Signal Forms: Эволюция сложных форм в Angular TorFlash — приложение для Linux: поиск торрентов, скачивание и копирование на флешку в одно нажатие Как я решил проблему русской диктовки для ИИ Оверинжиниринг, потопивший немецкую подлодку или некоторые «баги» не чинятся десятилетиями Как ставить цели и не забывать о них: пошаговая система с примерами в таск-менеджере Как настроить observability в Spring Boot 3 HackTheBox. Прохождение Mini Pro Lab Puppet Обзор серверного ускорителя NVIDIA Tesla V100 16 Gb в корпусе от RTX 4090: Часть 3 — Запуск локальных моделей ИИ Редактирование текста нейросетью: как сделать диплом и курсовую более человечными Самодельный ARM ноутбук, реально ли? Как 100+ авторов пишут 100+ процессов в 3 версиях и не путаются. Или как мы переехали с Wiki на Git Прошла AnalystDays – хорошие выступления и нетворкинг VSCode как IDE для embedded разработки Моделирование широкополосной антенны с двойной круговой поляризацией и высокой изоляцией Ваше прошлое физически существует прямо сейчас. И вы заморожены там навсегда От списка инструментов к technical output: как security engineer’у описывать hands-on опыт в CV и на интервью I just want an agent. Часть 1. Как я научил ИИ собирать ИИ-агентов за пользователей и выиграл конкурс I just want an agent. Часть 1. Как я научил ИИ собирать ИИ-агентов за пользователей и выиграл конкурс Вайбкодинг спас меня от подрядчиков. А потом я поняла, что сама стала подрядчиком для своих агентов Святой Августин и GAN: почему борьба добра и зла — это генеративная состязательная сеть В каждом QR-коде зашита половина лишней информации. Намеренно Я открываю автомат ключом, меняю рулон бумаги и зарабатываю 180 тысяч в месяц с точки Мастер восстановления. Культура достиженства и выгорание Недельный геймдев: #279 — 24 мая, 2026 Защита от дублирования кода агентами: семантические концепции Frontend Status: свежий дайджест фронтенда и AI — 25.05.2026 Где искать IT-работу кроме HH: подборка платформ 2026 Почему простые числа собираются в спирали? OCR для Data Lakehouse: от Apache Tika к собственному решению на базе Docling Jira — Тьюринг-полная Kubernetes-аудит после Wiz и Prisma: как живут без CNAPP в 2026 «Тестируем MVP в 4 раза быстрее»: как нейросети изменили жизнь предпринимателей На каком стеке и железе работает умное наблюдение в вашем городе: обзор технологий от разработчиков видеоаналитики Как мы ускорили согласования на двух заводах в 24 раза Heartbeat-мониторинг cron-job'ов: dead-man-switch на FastAPI [Перевод] Сегодня нет джуниоров, а в 2031 году не станет и синьоров Профайлер для PostgreSQL: от идеи до работающего MVP за сутки [Перевод] Ограничения размера cookie в ASP.NET Core в продакшене: причины и способы решения Проблема «божественного» Obsidian: почему я отказался от централизованного подхода в работе Лицензии GNU GPL: как пройти проверку Минцифры и заказчика для госзакупок и КИИ Хакатон Samsung IT Academy Hack 2026: как студенты оптимизировали поиск в корпоративном мессенджере Хакатон Samsung IT Academy Hack 2026: как студенты оптимизировали поиск в корпоративном мессенджере MTProxy jumper — делаем автоматическое переключение прокси-серверов Telegram Ты уже используешь агента. Просто не заметил Книжный салон. Послевкусие и благодарности Как отлаживать мини‑приложения в MAX и почему без DevTools это боль Cбор биометрических данных. Как защищается наша биометрия на практике Как запустить учет активов без цифровой свалки: первые 90 дней CGE: визуализация кравлера и скрытых связей между поддоменами Зачем банки тратят миллиарды на науку (спойлер: не благотворительности ради) Книга: «Современный Java Concurrency. Глубокое погружение в Virtual Threads, Structured Concurrency и Scoped Values» Как использовать подписку ChatGPT и Claude в Cursor без оплаты за API токены Специализированная ИСУП или модуль в универсальной платформе: вот в чем вопрос Обход белых списков через WebRTC на стероидах (с поддержкой iOS и десктопа) Регата INFOSTART CIO CAMP: когда команда проверяется не в переговорной, а на воде Пет-проект, который не умер: система бронирования устройств как полигон для AI-разработки Не надо встраивать ИИ в каждую корпоративную систему, это архитектурная ошибка Нейросети для дизайна интерьера: Выбираем лучший ИИ для генерации концептов и планировок квартиры Что там с Ил-114-300 Что такое DAS: как и зачем продукт-менеджеры саботируют запуск новых продуктов 8% компаний измеряют критическое мышление руководителей. Что делают остальные 92% CVE, Shell и побег из контейнера: испытываем возможности PT Cloud Application Firewall Как я научил Алису петь: генерация музыки по голосовой команде Восстановление данных с помощью бесплатной утилиты Easy Disk Checker Как мы построили сквозную аналитику в Power BI Год разработки iOS-игры, 266 тысяч показов и $33: как я делал Vault и почти ничего не заработал Ты прокрастинируешь потому, что избегаешь напрасных усилий, а не чрезмерных нагрузок Я построила диагностику «стоит ли это автоматизировать» — и она трижды говорила глупости. Разбор ошибок Как устроены world models, что показал Google на прошлой неделе и где это меняет gamedev и робототехнику Двухдневная рабочая неделя — будущий стандарт CPU не умер, он просто ждал. Китай строит двухэксафлопсный суперкомпьютер без единого GPU — прорыв, необходимость, фейк? 3Sound: поиск бесплатных звуков для игр больше не боль? 3 Тбит/с по-русски: почему DDoS в 2026 году стал угрозой для любого бизнеса 10 Гбит/с — зачем вам такая скорость передачи данных в облаке Ремонтируем аналоговый XY-самописец Endim 622 [Перевод] IPO компании SpaceX: хорошая попытка, но нет «Ща будет шрифт»: история одного русского embedded‑шрифта Как аквариум на подоконнике превратился в full-stack платформу с AI GiftsHub — из чат-бота в полноценный backend-продукт Пиратство, копирайт и DMCA: как Napster, The Pirate Bay и YouTube изменили закон. Часть II Как найти внутренние резервы для развития предприятия Как один французский чиновник от безысходности начал платил зарплаты картами и практически изобрёл банкноты RAG в энтерпрайзе: почему демо работает, а прод нет AI-агент для финансовых процессов: как мы научили ИИ считать числа из базе данных без галлюцинаций
Capacitor: от веба к мобильным приложениям. Часть 4. Интегрируем локальный LLM в проект
sudondie · 2026-05-26 · via Все публикации подряд на Хабре

Уровень сложностиПростой

Время на прочтение13 мин

Охват и читатели604

FAQ

Привет, Хабр! Продолжаем серию статей о разработке мобильных приложений с помощью Capacitor. Если вы не читали предыдущие части, лучше начать с них:

В этой части разберём, как запустить языковую модель прямо на телефоне — без сервера, без API-ключей и без постоянного интернета.

Зачем локальный AI

На первый взгляд может показаться, что локальная модель на телефоне — это лишняя сложность. Если уже есть ChatGPT, Claude или Gemini, зачем запускать всё на устройстве? Но у облачного подхода есть ограничения, которые в некоторых сценариях становятся критичными.

  • Приватность. Пользователь может вводить заметки, сообщения, документы или даже медицинские данные. Не всегда разумно отправлять это на внешний сервер.

  • Офлайн. Приложение должно работать в метро, в самолёте и в местах с плохой связью.

  • Задержка. Даже быстрый сетевой запрос добавляет лишнее время, а в интерактивных сценариях это чувствуется.

  • Стоимость. API-запросы требуют бюджета, и при росте аудитории это быстро становится заметной статьёй расходов.

Локальный вывод закрывает все эти проблемы сразу. За это приходится платить памятью, батареей и размером модели, но современные компактные модели уже умеют работать достаточно аккуратно даже на потребительских устройствах.

Какую модель выбрать

Для мобильных устройств лучше всего подходят квантизированные модели небольшого размера. Сейчас разумно смотреть на несколько семейств: Gemma, Qwen и Phi. У каждого варианта есть свои сильные стороны, и универсального ответа здесь нет.

Gemma 3

Gemma 3 — понятный и уже хорошо проверенный вариант. Для простых сценариев можно смотреть на две модели:

Модель

Параметры

Размер файла

Для кого

Gemma 3 270M

270 млн

~400 MB

Быстрые задачи, слабые устройства

Gemma 3 1B

1 млрд

~1.2 GB

Баланс качества и скорости

Эти модели работают через LiteRT в формате .task, поэтому их удобно использовать в Android-приложениях.

Gemma 4

Gemma 4 — более свежее поколение, ориентированное в том числе на edge-сценарии. Для мобильных устройств интересны модели E2B и E4B.

Модель

Эфф. параметры

RAM

Что нового

Gemma 4 E2B

2.3 млрд

< 1.5 GB

Текст, изображения и аудио

Gemma 4 E4B

4.5 млрд

~3 GB

Более высокое качество на мощных устройствах

Что здесь важно:

  • Модель стала быстрее и экономичнее.

  • Появилась мультимодальность.

  • Поддерживается function calling.

  • Используется формат .litertlm, который пришёл на смену .task.

Модели доступны на Hugging Face: Gemma 4 E2B и Gemma 4 E4B.

Qwen3

Qwen3 от Alibaba тоже хорошо смотрится в on-device сценариях. У младших моделей есть LiteRT-сборки, и это делает их удобными для мобильных приложений.

Модель

Параметры

Для кого

Qwen3-0.6B

600 млн

Очень лёгкие сценарии

Qwen3-1.7B

1.7 млрд

Баланс между качеством и скоростью

У Qwen3 есть удобная особенность: модель может работать в обычном режиме или в режиме thinking. Это полезно, когда простые запросы нужно обрабатывать быстро, а сложные — чуть глубже. Также у модели есть поддержка tool use и хорошая мультиязычность.

Phi-4 mini

Phi-4 mini — вариант от Microsoft, который больше ориентирован на рассуждения и агентские сценарии. Несмотря на небольшой размер, модель показывает хорошие результаты и поддерживает LiteRT-LM, function calling и длинный контекст.

Если нужна модель для сложной логики, она вполне может быть хорошим выбором. Но по памяти и скорости это уже не самый лёгкий вариант.

DeepSeek

DeepSeek часто упоминают в разговорах про эффективные модели, но для локального мобильного сценария он сейчас не подходит в том же смысле, что Gemma или Qwen. Для этой статьи важен именно on-device вариант, а не облачный клиент. Поэтому DeepSeek здесь лучше рассматривать как отдельную облачную историю.

Что выбрать

Если нужен короткий ориентир, я бы смотрел так:

  • Агентский чат с минимальными требованиями — Gemma 4 E2B или Qwen3-1.7B.

  • Самый лёгкий вариант — Qwen3-0.6B.

  • Акцент на рассуждения — Phi-4 mini.

  • Мультимодальность — Gemma 4 E2B или E4B.

  • Широкая совместимость со старыми устройствами — Gemma 3 1B.

На Android такие модели обычно работают через LiteRT или LiteRT-LM, а на iOS проще всего опираться на Apple Intelligence, если устройство и версия системы это позволяют.

Плагин @capgo/capacitor-llm

Для интеграции локальных моделей удобно использовать плагин @capgo/capacitor-llm. Он оборачивает нативные inference-движки в привычный Capacitor-интерфейс и позволяет работать с AI почти так же, как с обычным сервисом в приложении.

Установка стандартная:

npm install @capgo/capacitor-llm
npx cap sync

Плагин рассчитан на современные версии Capacitor. В документации Capgo отдельно указаны методы createChat, sendMessage, getReadiness, setModel и downloadModel, а также события для стриминга токенов и прогресса загрузки.

Основные методы

getReadiness(): Promise<{ ready: boolean }>
setModel(options: { model: string }): Promise<void>
downloadModel(options: { url: string }): Promise<void>
createChat(options?: { instructions?: string }): Promise<{ chatId: string }>
sendMessage(options: { chatId: string; text: string }): Promise<void>

Ответы приходят через события. Это удобно, если хочется обновлять интерфейс по мере генерации текста:

CapacitorLlm.addListener('textFromAi', (event: { text: string }) => { ... })
CapacitorLlm.addListener('aiFinished', () => { ... })
CapacitorLlm.addListener('downloadProgress', (event: { progress: number }) => { ... })
CapacitorLlm.addListener('readinessChange', (event: { ready: boolean }) => { ... })

Схема работы получается простой: UI отправляет запрос в плагин, тот передаёт его нативному движку, а результат возвращается в приложение по стримингу.

Настройка Android

Минимальная версия SDK

Для Android стоит сразу проверить minSdkVersion. В проекте лучше держать его не ниже 24, иначе можно быстро упереться в ограничения платформы.

ext {
    minSdkVersion = 24
}

Где взять модель

Для Gemma 3 модель можно брать в формате .task, а для Gemma 4 — в формате .litertlm. Это важная разница: у них разный runtime и разный способ упаковки.

Есть два нормальных способа доставки модели в приложение:

  • Встроить модель в APK или AAB. Это удобно для тестов и демо, но сильно увеличивает размер сборки.

  • Скачать модель при первом запуске. Это лучше для production, если вы готовы показать пользователю понятный экран загрузки.

Пример загрузки:

await CapacitorLlm.downloadModel({
  url: 'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm'
})

Если модель кладёте в assets, путь указывается без assets/. Например:

await CapacitorLlm.setModel({ model: 'gemma-4-E2B-it-int4.litertlm' })
await CapacitorLlm.setModel({ model: 'gemma3-1b-it-int4.task' })

Настройка iOS

На iOS ситуация проще, если использовать Apple Intelligence. Это системный путь: не нужно хранить отдельный файл модели и не нужно вручную управлять её загрузкой.

Что нужно учитывать

  • iOS 18.2 и выше.

  • Поддерживаемое устройство.

  • Язык устройства и доступность функции могут влиять на readiness.

Если Apple Intelligence недоступна, getReadiness() вернёт false, и приложение должно это корректно обработать. Лучше сразу предусмотреть заглушку или облачный fallback.

Если вы хотите экспериментировать с кастомными моделями на iOS, это уже отдельная история и не самый стабильный путь. Для прикладного приложения я бы не делал на это ставку как на основную ветку.

Пишем чат-интерфейс: минимальный пример

Прежде чем переходить к production-архитектуре, соберём простой self-contained чат — чтобы разобраться, как плагин работает в принципе. Возьмём React, но логика переносится на любой фреймворк. Если вас интересует сразу полноценный вариант с FSD и Zustand — можно перейти к следующему разделу.

Сервис для модели

Вынесем работу с плагином в отдельный сервис. Так код будет проще тестировать и переиспользовать.

import { CapacitorLlm } from '@capgo/capacitor-llm'

export type MessageRole = 'user' | 'assistant'

export interface ChatMessage {
  role: MessageRole
  text: string
}

export class LlmService {
  private chatId: string | null = null

  async initialize(systemPrompt?: string): Promise<void> {
    const { ready } = await CapacitorLlm.getReadiness()
    if (!ready) {
      throw new Error('LLM не готова. Проверьте поддержку устройства.')
    }

    const { chatId } = await CapacitorLlm.createChat({
      instructions: systemPrompt,
    })
    this.chatId = chatId
  }

  async sendMessage(text: string): Promise<void> {
    if (!this.chatId) {
      throw new Error('Чат не инициализирован. Сначала вызовите initialize().')
    }
    await CapacitorLlm.sendMessage({ chatId: this.chatId, text })
  }

  onToken(callback: (token: string) => void) {
    return CapacitorLlm.addListener('textFromAi', ({ text }) => callback(text))
  }

  onFinished(callback: () => void) {
    return CapacitorLlm.addListener('aiFinished', callback)
  }
}

export const llmService = new LlmService()

Хук состояния

import { useState, useEffect, useRef } from 'react'
import { llmService, ChatMessage } from './llm-service'

export function useChat() {
  const [messages, setMessages] = useState<ChatMessage[]>([])
  const [isGenerating, setIsGenerating] = useState(false)
  const [isReady, setIsReady] = useState(false)
  const currentResponseRef = useRef('')

  useEffect(() => {
    // Системный промпт сильно влияет на поведение модели: чем конкретнее роль и ограничения,
      // тем предсказуемее ответы. Не жалейте времени на его итерацию.
      llmService
      .initialize('Ты — полезный ассистент. Отвечай кратко и по делу.')
      .then(() => setIsReady(true))
      .catch(console.error)

    const tokenListener = llmService.onToken((token) => {
      currentResponseRef.current += token
      setMessages((prev) => {
        const updated = [...prev]
        if (updated.at(-1)?.role === 'assistant') {
          updated[updated.length - 1] = {
            role: 'assistant',
            text: currentResponseRef.current,
          }
        }
        return updated
      })
    })

    const finishListener = llmService.onFinished(() => {
      setIsGenerating(false)
      currentResponseRef.current = ''
    })

    return () => {
      tokenListener.then((l) => l.remove())
      finishListener.then((l) => l.remove())
    }
  }, [])

  const sendMessage = async (text: string) => {
    if (!isReady || isGenerating) return

    setMessages((prev) => [...prev, { role: 'user', text }])
    setMessages((prev) => [...prev, { role: 'assistant', text: '' }])
    setIsGenerating(true)

    await llmService.sendMessage(text)
  }

  return { messages, isGenerating, isReady, sendMessage }
}

Компонент чата

import React, { useState } from 'react'
import { useChat } from '../model/use-chat'

export function Chat() {
  const { messages, isGenerating, isReady, sendMessage } = useChat()
  const [input, setInput] = useState('')

  const handleSend = async () => {
    if (!input.trim()) return
    const text = input
    setInput('')
    await sendMessage(text)
  }

  if (!isReady) {
    return (
      <div className="flex items-center justify-center h-full">
        <p className="text-gray-500">Загружаем модель...</p>
      </div>
    )
  }

  return (
    <div className="flex flex-col h-full">
      <div className="flex-1 overflow-y-auto p-4 space-y-3">
        {messages.map((msg, i) => (
          <div
            key={i}
            className={`max-w-[80%] rounded-2xl px-4 py-2 text-sm ${
              msg.role === 'user'
                ? 'ml-auto bg-blue-500 text-white'
                : 'mr-auto bg-gray-100 text-gray-900'
            }`}
          >
            {msg.text || (isGenerating ? '▌' : '')}
          </div>
        ))}
      </div>

      <div className="p-4 border-t flex gap-2">
        <input
          className="flex-1 border rounded-xl px-3 py-2 text-sm outline-none"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && !isGenerating && handleSend()}
          placeholder="Напишите сообщение..."
          disabled={isGenerating}
        />
        <button
          className="px-4 py-2 bg-blue-500 text-white rounded-xl text-sm disabled:opacity-50"
          onClick={handleSend}
          disabled={isGenerating || !input.trim()}
        >
          {isGenerating ? '...' : 'Отправить'}
        </button>
      </div>
    </div>
  )
}

Скачивание модели

Если модель скачивается при первом запуске, обязательно показывайте прогресс. Иначе пользователю будет казаться, что приложение зависло.

import React, { useState } from 'react'
import { CapacitorLlm } from '@capgo/capacitor-llm'

const MODEL_URL = 'https://your-cdn.com/gemma3-1b-it-int4.task'

export function ModelDownload({ onComplete }: { onComplete: () => void }) {
  const [progress, setProgress] = useState(0)
  const [isDownloading, setIsDownloading] = useState(false)

  const startDownload = async () => {
    setIsDownloading(true)

    const listener = await CapacitorLlm.addListener(
      'downloadProgress',
      ({ progress }) => setProgress(Math.round(progress))
    )

    try {
      await CapacitorLlm.downloadModel({ url: MODEL_URL })
      onComplete()
    } finally {
      listener.remove()
      setIsDownloading(false)
    }
  }

  return (
    <div className="flex flex-col items-center justify-center h-full gap-6 p-8">
      <h2 className="text-xl font-semibold text-center">
        Для работы нужно скачать AI-модель
      </h2>
      <p className="text-gray-500 text-sm text-center">
        Gemma 3 1B (~1.2 GB), загрузка нужна только один раз
      </p>

      {isDownloading ? (
        <div className="w-full max-w-xs">
          <div className="h-2 bg-gray-200 rounded-full overflow-hidden">
            <div
              className="h-full bg-blue-500 transition-all duration-300"
              style={{ width: `${progress}%` }}
            />
          </div>
          <p className="text-center text-sm text-gray-500 mt-2">{progress}%</p>
        </div>
      ) : (
        <button
          className="px-6 py-3 bg-blue-500 text-white rounded-xl"
          onClick={startDownload}
        >
          Скачать модель
        </button>
      )}
    </div>
  )
}

Практика на проекте

Пример выше показал минимальный рабочий вариант — класс LlmService и хук useChat. Для небольшого изолированного чата этого достаточно. В реальном приложении с несколькими экранами, тестами и доменной логикой удобнее выстроить более явную архитектуру: вынести плагин за интерфейс LlmGateway, управлять состоянием через Zustand и отделить инициализацию от UI. Именно это и разберём на примере моего пет проекта PaperFlow.

PaperFlow — сервис для хранения и отслеживания документов. Пользователь сканирует паспорта, договоры и гарантии, а приложение напоминает о сроках действия. В такой задаче локальный AI особенно полезен: он может отвечать на вопросы о документах, не отправляя их на сервер.

Структура фичи

Если придерживаться FSD, удобно вынести всё в отдельный срез в features.

src/
  features/
    document-assistant/
      model/
        types.ts
        contracts.ts
        context-builder.ts
        use-cases.ts
        store.ts
        hooks.ts
      api/
        llm-gateway.ts
      ui/
        AssistantSheet.tsx
        MessageBubble.tsx
      index.ts

Такой подход изолирует AI-логику от остального приложения. Доменные сущности не знают про ассистента, а сам ассистент можно выключить, не затронув остальной код.

Контракт

Сначала описываем интерфейс, чтобы UI не зависел от конкретного плагина.

export type AssistantMessage = {
  role: 'user' | 'assistant'
  text: string
}

export interface LlmGateway {
  initialize(systemPrompt: string): Promise<void>
  sendMessage(text: string): Promise<void>
  onToken(cb: (token: string) => void): Promise<() => void>
  onFinished(cb: () => void): Promise<() => void>
  getReadiness(): Promise<boolean>
}

Реализация шлюза

import { CapacitorLlm } from '@capgo/capacitor-llm'
import { LlmGateway } from '../model/contracts'

export const createCapacitorLlmGateway = (): LlmGateway => {
  let chatId: string | null = null

  return {
    async getReadiness() {
      const { ready } = await CapacitorLlm.getReadiness()
      return ready
    },

    async initialize(systemPrompt) {
      const { chatId: id } = await CapacitorLlm.createChat({
        instructions: systemPrompt,
      })
      chatId = id
    },

    async sendMessage(text) {
      if (!chatId) throw new Error('LLM не инициализирована')
      await CapacitorLlm.sendMessage({ chatId, text })
    },

    async onToken(cb) {
      const listener = await CapacitorLlm.addListener('textFromAi', ({ text }) => cb(text))
      return () => listener.remove()
    },

    async onFinished(cb) {
      const listener = await CapacitorLlm.addListener('aiFinished', cb)
      return () => listener.remove()
    },
  }
}

Контекст из документов

import { Document } from '@/entities/documents'

const formatDocument = (doc: Document): string => {
  const parts = [`— "${doc.title}"`]

  if (doc.categoryId) {
    parts.push(`категория: ${doc.categoryId}`)
  }

  if (doc.expiresAt) {
    const expiresDate = new Date(doc.expiresAt).toLocaleDateString('ru-RU')
    const daysLeft = Math.ceil(
      (new Date(doc.expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
    )

    if (daysLeft <= 0) {
      parts.push(`истёк ${expiresDate}`)
    } else {
      parts.push(`истекает ${expiresDate} (через ${daysLeft} дн.)`)
    }
  } else {
    parts.push('без срока действия')
  }

  if (doc.tagIds.length > 0) {
    parts.push(`теги: ${doc.tagIds.join(', ')}`)
  }

  return parts.join(' ')
}

export const buildDocumentContext = (documents: Document[]) => {
  const active = documents.filter((d) => !d.archived)
  const today = new Date().toLocaleDateString('ru-RU')

  const docList =
    active.length > 0 ? active.map(formatDocument).join('\n') : 'документов пока нет'

  const systemPrompt = `
Ты — ассистент приложения PaperFlow для работы с документами.
Сегодня: ${today}.

Документы пользователя:
${docList}

Правила:
1. Отвечай только по документам из списка.
2. Если документа нет в списке, честно скажи об этом.
3. Отвечай кратко и по делу.
4. На вопросы вне темы документов вежливо отказывай.
`.trim()

  return { systemPrompt }
}

Юз-кейсы и стор

import { LlmGateway } from './contracts'
import { buildDocumentContext } from './context-builder'
import { Document } from '@/entities/documents'

export const initializeAssistant = async (
  gateway: LlmGateway,
  documents: Document[]
): Promise<void> => {
  const ready = await gateway.getReadiness()
  if (!ready) {
    throw new Error('LOCAL_LLM_NOT_READY')
  }

  const { systemPrompt } = buildDocumentContext(documents)
  await gateway.initialize(systemPrompt)
}

export const sendAssistantMessage = async (
  gateway: LlmGateway,
  text: string
): Promise<void> => {
  await gateway.sendMessage(text)
}
import { create } from 'zustand'
import { createCapacitorLlmGateway } from '../api/llm-gateway'
import { initializeAssistant, sendAssistantMessage } from './use-cases'
import { AssistantMessage } from './contracts'
import { Document } from '@/entities/documents'

type AssistantStatus = 'idle' | 'initializing' | 'ready' | 'generating' | 'unavailable'

type AssistantStore = {
  status: AssistantStatus
  messages: AssistantMessage[]
  initialize: (documents: Document[]) => Promise<void>
  send: (text: string) => Promise<void>
  appendToken: (token: string) => void
  finishGeneration: () => void
}

const gateway = createCapacitorLlmGateway()

export const useAssistantStore = create<AssistantStore>((set, get) => ({
  status: 'idle',
  messages: [],

  initialize: async (documents) => {
    set({ status: 'initializing' })
    try {
      await initializeAssistant(gateway, documents)
      await gateway.onToken((token) => get().appendToken(token))
      await gateway.onFinished(() => get().finishGeneration())
      set({ status: 'ready' })
    } catch {
      set({ status: 'unavailable' })
    }
  },

  send: async (text) => {
    if (get().status !== 'ready') return

    set((s) => ({
      status: 'generating',
      messages: [
        ...s.messages,
        { role: 'user', text },
        { role: 'assistant', text: '' },
      ],
    }))

    await sendAssistantMessage(gateway, text)
  },

  appendToken: (token) => {
    set((s) => {
      const messages = [...s.messages]
      const last = messages.at(-1)
      if (last?.role === 'assistant') {
        messages[messages.length - 1] = {
          role: 'assistant',
          text: last.text + token,
        }
      }
      return { messages }
    })
  },

  finishGeneration: () => {
    set({ status: 'ready' })
  },
}))

// История чата не сохраняется между сессиями автоматически — каждый createChat начинается с чистого листа.
// Если нужна персистентность, сохраняйте messages в AsyncStorage или SQLite и передавайте
// историю заново в системный промпт при следующем запуске.

Подключение к экрану

import { useEffect } from 'react'
import { useDocumentsStore } from '@/entities/documents'
import { useAssistantStore } from './store'

export const useDocumentAssistant = () => {
  const documents = useDocumentsStore((s) => s.documents)
  const { status, messages, initialize, send } = useAssistantStore()

  useEffect(() => {
    if (status === 'idle') {
      initialize(documents)
    }
  }, [status, documents, initialize])

  return { status, messages, send }
}
import { useState } from 'react'
import { useDocumentAssistant } from '@/features/document-assistant'
import { AssistantSheet } from '@/features/document-assistant'

export function HomeToolbar() {
  const [open, setOpen] = useState(false)
  const { status } = useDocumentAssistant()

  return (
    <>
      <button
        onClick={() => setOpen(true)}
        disabled={status === 'initializing' || status === 'unavailable'}
        className="p-2 rounded-full bg-gray-100"
        aria-label="Открыть ассистента"
      >
        Открыть ассистента
      </button>

      <AssistantSheet open={open} onClose={() => setOpen(false)} />
    </>
  )
}

Пока модель инициализируется (status === 'initializing'), кнопка задизейблена. Пользователь видит чат в состоянии загрузки:

Модель ещё не готова — «Загружаем модель...» по центру экрана, кнопка «Отправить» задимлена

Модель ещё не готова — «Загружаем модель...» по центру экрана, кнопка «Отправить» задимлена

Как только status переходит в ready — чат готов к работе, и ответы на вопросы о документах приходят полностью локально:

Диалог с ассистентом — вопросы о документах и ответы прямо на устройстве

Диалог с ассистентом — вопросы о документах и ответы прямо на устройстве

Итог

Локальный AI в Capacitor-приложении — это уже не эксперимент, а вполне рабочий инструмент. Он помогает сохранить приватность, работать офлайн и не платить за каждый запрос к API.

Мы прошли путь от выбора модели до готовой фичи: разобрали настройку под Android и iOS, написали базовый чат, встроили ассистента в реальное приложение и обернули всё в нормальную архитектуру с разделением ответственности. Компактные модели — Gemma 4 E2B, Qwen3-1.7B, Phi-4 mini — уже сегодня способны решать практические задачи прямо на устройстве.

В следующей части займёмся CI/CD для Capacitor. На этом у меня все. Пишите любые интересующие вас вопросы в комментарии и в личку.

Ссылки: