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

推荐订阅源

MyScale Blog
MyScale Blog
Microsoft Azure Blog
Microsoft Azure Blog
H
Help Net Security
N
News and Events Feed by Topic
Recent Announcements
Recent Announcements
D
Docker
M
MIT News - Artificial intelligence
L
LangChain Blog
I
InfoQ
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
P
Proofpoint News Feed
博客园_首页
MongoDB | Blog
MongoDB | Blog
美团技术团队
S
Schneier on Security
G
GRAHAM CLULEY
月光博客
月光博客
有赞技术团队
有赞技术团队
Vercel News
Vercel News
Scott Helme
Scott Helme
P
Privacy International News Feed
Last Week in AI
Last Week in AI
Recorded Future
Recorded Future
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
The Cloudflare Blog
Attack and Defense Labs
Attack and Defense Labs
Google Online Security Blog
Google Online Security Blog
Simon Willison's Weblog
Simon Willison's Weblog
量子位
S
Security @ Cisco Blogs
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
V
Visual Studio Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
NISL@THU
NISL@THU
N
Netflix TechBlog - Medium
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
Recent Commits to openclaw:main
Recent Commits to openclaw:main
Spread Privacy
Spread Privacy
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
小众软件
小众软件
罗磊的独立博客
Security Archives - TechRepublic
Security Archives - TechRepublic
T
Threatpost
L
Lohrmann on Cybersecurity
www.infosecurity-magazine.com
www.infosecurity-magazine.com
S
Security Affairs
Cloudbric
Cloudbric
爱范儿
爱范儿
H
Heimdal Security Blog
PCI Perspectives
PCI Perspectives

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

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Capacitor: от веба к мобильным приложениям. Часть 4. Интегрируем локальный LLM в проект
sudondie · 2026-05-26 · via Все публикации подряд на Хабре

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

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

Охват и читатели9.5K

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. На этом у меня все. Пишите любые интересующие вас вопросы в комментарии и в личку.

Ссылки: