Сегодня мы построим свою локальную модель. С блэкджеком и WebUI!
Сегодня мы построим свою локальную модель. С блэкджеком и WebUI!

Предыстория

Смотря на столь бурное развитие направления по применении агентов с ИИ для автоматизации OpenClaw убрала поддержку бесплатного использования модель Qwen.
Это было ограниченное количество запросов, но тем не менее - работало весьма хорошо.
Теперь это только платная подписка.

Аналогичным образом поступают и другие вендоры ИИ - какие-то LLM просто не умеют работать как агенты, какие-то подлежат кастомизации (Claude). Опять же - всё хорошее за деньги, средний лимит - 1 млн токенов для демонстрации.

Опыт Apple

Пока все смеются над тем, что только это компания где-то опоздала, на самом деле Apple заключает соглашение с Google об использовании квантованных (сжатых) версий топовых моделей от техно-гиганта.

Недавно Apple подтвердила стратегическое партнерство с Google для интеграции ИИ Gemini в свои устройства. Это решение связано со сложностями в создании нейросети такого масштаба для конкуренции с лидерами рынка.

Основные детали сделки:

  • Интеграция с Siri: Gemini станет основой обновленной Siri.

  • Модель Google будет отвечать за понимание контекста, планирование задач и обобщение информации.

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

Вот именно это мы сейчас и опробуем, только на примере с Android.

Эмуляция

Не секрет, что на Android можно запускать Linux-форки приложений и работать на телефоне в полноценной Linux-среде. Для этого энтузиастами был разработан эмулятор - имя ему Termux Termux Wiki

Я использую это решения для запуска веб-приложений backend/frontend с Mongodb, reactjsб javascript через node.js и nginx, а также для подключения к linux/unix-системам напрямую с телефона. Всё работает локально на устройстве.

Основные плюшки:

  • Пакетный менеджер: Упрощает установку и управление программами с помощью pkg, аналогичного apt.

  • Поддержка языков программирования: Termux поддерживает Python, Ruby, Node.js, PHP, Go, Rust и другие языки.

  • Интеграция с Git и контроля версий: Позволяет работать с репозиториями и контролем версий.

  • Доступ к файловой системе Android: Удобно работает с файловой системой Android, включая внешнюю SD-карту.

  • Интеграция с внешними клавиатурами и терминальными клиентами: Поддержка SSH, VNC и других терминальных клиентов.

  • Автоматизация задач: Позволяет автоматизировать выполнение команд и скриптов.

Таким образом, Termux является мощным инструментом для пользователей Android, которые ищут возможность использовать Linux-подобную среду на своем устройстве.

Установка

Установка на Android устройство проста до безобразия не требует особых умственных усилий, в отличие от вычисления над полями Галуа Finite field - Wikipedia

Шаг 1. Скачиваем Termux с Google Play

В качестве альтернативы и для получения более свежей версии можно выполнить установку, скачав предварительно F-Droid с официального сайта.

Шаг 1.2: Первый запуск

Открываете Termux. Видите чёрный экран с белым текстом и приглашением $ — это командная строка. Теперь вы — пользователь Linux на своём телефоне.

Вводим команду:
uname - a

Проверяем что работает
Проверяем что работает

Консоль отзывается - всё хорошо.

Шаг 2. Ставим LLM

Шаг 2.1: Обновляем всё

pkg update && pkg upgrade -y

Процесс должен выглядеть примерно так:

Скачиваем пакеты
Скачиваем пакеты

Шаг 2.2: Ставим необходимые инструменты

pkg install -y curl wget git nodejs nginx

Это инструменты для работы с web-фреймворками. Они нам потребуются для запуска WebUI.

Шаг 2.3: Скачиваем Ollama

Многие кто погружен в эту историю уже знают про HuggingFace и аналогичные сервисы.
Одним из популярных также является Ollama - это софт для установки и запуска больших языковых моделей локально на устройство. Как правило - на серверы, ПК, ноутбуки.
Но мало кто пробовал ставить на Android - вы будете в числе первых!

wget https://github.com/ollama/ollama/releases/download/v0.5.4/ollama-linux-arm64
chmod +x ollama-linux-arm64
mv ollama-linux-arm64 $PREFIX/bin/ollama

Шаг 2.4: Проверяем установку

ollama --version

Ожидаемый вывод: ollama version 0.5.4

Также можно просто набрать ollama - программа перейдет в интерактивный режим:

Вошли в консоль управления Ollama
Вошли в консоль управления Ollama

Шаг 3: Загрузка модели Gemma

Gemma 3 — это новое поколение открытых мультимодальных моделей искусственного интеллекта от Google DeepMind, представленное весной 2025 года.
Она оптимизирована для эффективной работы на обычных пользовательских устройствах.

Основные характеристики

  • Мультимодальность: Начиная с версии 4B, Gemma 3 способна одновременно понимать и обрабатывать текст, изображения и короткие видеоролики.

  • Доступные версии: Семейство включает модели разного размера для разных задач:

    • 1B (1 млрд параметров): Эта модель работает только с текстом и английским языком. Подходит для мобильных устройств.

    • 4B, 12B и 27B: Это мультимодальные модели, поддерживающие более 140 языков.

  • Контекстное окно: Модели поддерживают до 128 000 токенов, что позволяет анализировать очень длинные документы.

  • Открытость: Веса моделей доступны для свободного скачивания и использования разработчиками.

Технические преимущества

  • Производительность: Старшие версии (27B) в ряде тестов превосходят более массивные модели, такие как Llama-3 405B.

  • Оптимизация: Модель 27B требует около 62 ГБ видеопамяти при полной точности, но может работать на 15.5 ГБ в квантованном режиме.

  • Инструменты: Модель хорошо справляется со структурированным выводом и вызовом внешних функций, что делает её подходящей для создания автономных ИИ-агентов.

Шаг 3.1: Запускаем сервер Ollama

Открываем ПЕРВОЕ окно Termux (основное) и запускаем:


OLLAMA_ORIGINS="*" ollama serve
Результат работы в фоне
Результат работы в фоне

💡 Важно: флаг OLLAMA_ORIGINS="*" включает CORS. Без него веб-интерфейс не сможет обращаться к Ollama. Так мы запускаем сервер Ollama в фоне.

Шаг 3.2: Скачиваем модель

Открываем ВТОРОЕ окно Termux (свайп от левого края → New session):

ollama pull gemma3:1b

Увидим процесс скачивания модели:

Идёт загрузка
Идёт загрузка

Шаг 3.3: Проверяем модель

ollama run gemma3:1b

Модель загрузится и можно к ней можно обращаться с консоли: пишем наш запрос.

Работаем с моделью из консоли
Работаем с моделью из консоли

Добавляем удобство

Поскольку так работать вполне можно, но немного не удобно. Поэтому нам нужно научиться работать с моделькой как мы привыкли, как через приложение.
Я попробовал запустить все возможные и рекомендуемые форки для WebUI для Ollama - но ничего из этого не заработало или требовало серьезных танцев с бубнами.

Пишем своё

Написание полноценного приложения это весьма затратный путь - я написал WebUI на базе html, javascript, который запускается в termux через nginx, а пользоваться можно просто через любой браузер в смартфоне.

Но для начала нужно немного подготовиться.

Проверяем через curl

Убедимся, что наш сервис отвечат на http-запросы.

curl -X POST http://127.0.0.1:11434/api/generate \
  -d '{"model":"gemma3:1b","prompt":"Привет! Как тебя зовут?","stream":false}' \
  -H "Content-Type: application/json"
Ответ через API от LLM
Ответ через API от LLM

Модель отвечает. Идём дальше!

Создаём веб-интерфейс

По умолчанию Nginx на termux хранит данные по следующему пути:

/usr/share/nginx/html

Создадим файл нашего веб-приложения:

nano /usr/share/nginx/html/chat.html
Код приложения
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>Gemma 3 WebUI</title>
    <style>
        :root {
            --bg-color: #0f172a;
            --chat-bg: #1e293b;
            --user-msg: #3b82f6;
            --bot-msg: #334155;
            --text-main: #f8fafc;
            --text-muted: #94a3b8;
            --accent: #60a5fa;
            --border: #475569;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            background-color: var(--bg-color);
            color: var(--text-main);
            margin: 0;
            padding: 0;
            display: flex;
            flex-direction: column;
            height: 100vh;
            box-sizing: border-box;
        }

        header {
            background-color: var(--chat-bg);
            padding: 15px 20px;
            display: flex;
            justify-content: space-between;
            align-items: center;
            border-bottom: 1px solid var(--border);
            box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
            z-index: 10;
        }

        header h1 {
            margin: 0;
            font-size: 1.2rem;
            color: var(--accent);
        }

        select {
            background-color: var(--bg-color);
            color: var(--text-main);
            border: 1px solid var(--border);
            padding: 8px 12px;
            border-radius: 6px;
            outline: none;
            font-size: 0.9rem;
        }

        #chat-container {
            flex-grow: 1;
            overflow-y: auto;
            padding: 20px;
            display: flex;
            flex-direction: column;
            gap: 15px;
            scroll-behavior: smooth;
        }

        .message {
            max-width: 85%;
            padding: 12px 16px;
            border-radius: 16px;
            font-size: 1rem;
            line-height: 1.5;
            word-wrap: break-word;
            animation: fadeIn 0.3s ease-out;
        }

        @keyframes fadeIn {
            from { opacity: 0; transform: translateY(10px); }
            to { opacity: 1; transform: translateY(0); }
        }

        .user {
            background-color: var(--user-msg);
            color: white;
            align-self: flex-end;
            border-bottom-right-radius: 4px;
        }

        .bot {
            background-color: var(--bot-msg);
            color: var(--text-main);
            align-self: flex-start;
            border-bottom-left-radius: 4px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.2);
        }

        .error {
            background-color: #ef4444;
            color: white;
            align-self: center;
            font-size: 0.85rem;
        }

        .typing-indicator {
            display: inline-flex;
            gap: 4px;
            align-items: center;
            height: 20px;
        }

        .typing-indicator span {
            width: 6px;
            height: 6px;
            background-color: var(--text-muted);
            border-radius: 50%;
            animation: bounce 1.4s infinite ease-in-out both;
        }

        .typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
        .typing-indicator span:nth-child(2) { animation-delay: -0.16s; }

        @keyframes bounce {
            0%, 80%, 100% { transform: scale(0); }
            40% { transform: scale(1); }
        }

        #input-area {
            background-color: var(--chat-bg);
            padding: 15px;
            border-top: 1px solid var(--border);
            display: flex;
            gap: 10px;
            align-items: flex-end;
        }

        textarea {
            flex-grow: 1;
            background-color: var(--bg-color);
            color: var(--text-main);
            border: 1px solid var(--border);
            border-radius: 12px;
            padding: 12px;
            font-size: 1rem;
            resize: none;
            outline: none;
            min-height: 24px;
            max-height: 120px;
            font-family: inherit;
            transition: border-color 0.2s;
        }

        textarea:focus {
            border-color: var(--accent);
        }

        button {
            background-color: var(--accent);
            color: #000;
            border: none;
            border-radius: 12px;
            padding: 0 20px;
            height: 50px;
            font-size: 1rem;
            font-weight: 600;
            cursor: pointer;
            transition: opacity 0.2s, transform 0.1s;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        button:active { transform: scale(0.95); }
        button:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
        
    </style>
</head>
<body>

    <header>
        <h1>Local LLM</h1>
        <select id="model-select">
            <option value="">Загрузка моделей...</option>
        </select>
    </header>

    <div id="chat-container">
        <div class="message bot">Привет! Я готова к общению.</div>
    </div>

    <div id="input-area">
        <textarea id="prompt" rows="1" placeholder="Введите сообщение..."></textarea>
        <button id="send-btn">➔</button>
    </div>

    <script>
        const OLLAMA_URL = 'http://127.0.0.1:11434';
        const chatContainer = document.getElementById('chat-container');
        const promptInput = document.getElementById('prompt');
        const sendBtn = document.getElementById('send-btn');
        const modelSelect = document.getElementById('model-select');
        
        let chatHistory = []; // Массив для хранения контекста диалога

        // Автоматическое изменение высоты поля ввода
        promptInput.addEventListener('input', function() {
            this.style.height = 'auto';
            this.style.height = (this.scrollHeight) + 'px';
        });

        // Отправка по Enter (без Shift)
        promptInput.addEventListener('keydown', function(e) {
            if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                sendMessage();
            }
        });

        sendBtn.addEventListener('click', sendMessage);

        // Асинхронная загрузка доступных моделей
        async function fetchModels() {
            try {
                const response = await fetch(`${OLLAMA_URL}/api/tags`);
                if (!response.ok) throw new Error('Network error');
                const data = await response.json();
                
                modelSelect.innerHTML = '';
                if (data.models.length === 0) {
                    modelSelect.innerHTML = '<option>Нет установленных моделей</option>';
                    return;
                }
                
                data.models.forEach(model => {
                    const option = document.createElement('option');
                    option.value = model.name;
                    option.textContent = model.name;
                    // Автовыбор gemma3 если она есть
                    if(model.name.includes('gemma3')) option.selected = true;
                    modelSelect.appendChild(option);
                });
            } catch (error) {
                console.error('Ошибка загрузки моделей:', error);
                modelSelect.innerHTML = '<option value="gemma3">gemma3 (Оффлайн)</option>';
            }
        }

        function createMessageElement(sender) {
            const msgDiv = document.createElement('div');
            msgDiv.className = `message ${sender}`;
            chatContainer.appendChild(msgDiv);
            return msgDiv;
        }

        function scrollToBottom() {
            chatContainer.scrollTop = chatContainer.scrollHeight;
        }

        async function sendMessage() {
            const text = promptInput.value.trim();
            const selectedModel = modelSelect.value;
            
            if (!text || !selectedModel) return;

            // Блокируем ввод
            promptInput.value = '';
            promptInput.style.height = 'auto';
            promptInput.disabled = true;
            sendBtn.disabled = true;

            // Добавляем сообщение пользователя
            const userMsgDiv = createMessageElement('user');
            userMsgDiv.textContent = text;
            chatHistory.push({ role: 'user', content: text });
            scrollToBottom();

            // Создаем блок для ответа бота с индикатором печати
            const botMsgDiv = createMessageElement('bot');
            botMsgDiv.innerHTML = '<div class="typing-indicator"><span></span><span></span><span></span></div>';
            scrollToBottom();

            try {
                const response = await fetch(`${OLLAMA_URL}/api/chat`, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        model: selectedModel,
                        messages: chatHistory,
                        stream: true
                    })
                });

                if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);

                // Очищаем индикатор загрузки
                botMsgDiv.innerHTML = '';
                let fullResponse = '';

                // Асинхронное чтение потока
                const reader = response.body.getReader();
                const decoder = new TextDecoder('utf-8');

                while (true) {
                    const { done, value } = await reader.read();
                    if (done) break;

                    const chunk = decoder.decode(value, { stream: true });
                    const lines = chunk.split('\n').filter(line => line.trim() !== '');

                    for (const line of lines) {
                        const json = JSON.parse(line);
                        if (json.message && json.message.content) {
                            fullResponse += json.message.content;
                            // Простая замена переносов строк для HTML
                            botMsgDiv.innerHTML = fullResponse.replace(/\n/g, '<br>');
                            scrollToBottom();
                        }
                    }
                }

                // Сохраняем ответ в историю
                chatHistory.push({ role: 'assistant', content: fullResponse });

            } catch (error) {
                console.error(error);
                botMsgDiv.className = 'message error';
                botMsgDiv.textContent = 'Ошибка подключения. Проверьте OLLAMA_ORIGINS="*" и запущен ли сервер.';
            } finally {
                // Разблокируем ввод
                promptInput.disabled = false;
                sendBtn.disabled = false;
                promptInput.focus();
                scrollToBottom();
            }
        }

        // Инициализация
        window.onload = fetchModels;
    </script>
</body>
</html>

Вставляем код из вложения (можно взять на моём GitHub BlackJackBander/Ollama-WebUI: Ollama-WebUI interface for works from Browser on smartphone).

🔑 Ключевые фрагменты кода:

Отправка запроса к Ollama:

// Асинхронная загрузка доступных моделей
        async function fetchModels() {
            try {
                const response = await fetch(`${OLLAMA_URL}/api/tags`);
                if (!response.ok) throw new Error('Network error');
                const data = await response.json();

Обработка потокового ответа (текст появляется постепенно):

// Асинхронное чтение потока
                const reader = response.body.getReader();
                const decoder = new TextDecoder('utf-8');

                while (true) {
                    const { done, value } = await reader.read();
                    if (done) break;

                    const chunk = decoder.decode(value, { stream: true });
                    const lines = chunk.split('\n').filter(line => line.trim() !== '');

                    for (const line of lines) {
                        const json = JSON.parse(line);
                        if (json.message && json.message.content) {
                            fullResponse += json.message.content;
                            // Простая замена переносов строк для HTML
                            botMsgDiv.innerHTML = fullResponse.replace(/\n/g, '<br>');
                            scrollToBottom();
                        }
                    }
                }

Шаг 4.4: Запускаем Nginx

nginx -t
nginx

Если ничего не пишет, проверяем работает ли сервис и если нет - запускаем:

sv status nginx
sv up nginx

🚀 Запускаем всё вместе

Открываем свой любимый браузер. Лично мой - Firefox за возможность отрезания рекламы везде где можно и широкой кастомизации:

В адресной строке браузера указываем адрес для доступа:

http://<ip адрес вашего устройства>:8080/chat.html

WebUI сам подхватит текущую запущенную модель.
Если всё ОК - будет выглядеть так:

Работаем с удобного браузера
Работаем с удобного браузера

Типичные проблемы и их решение

Проблема 1: CORS ошибка в браузере
Решение: Убедитесь, что Ollama запущен с OLLAMA_ORIGINS="*".

Проблема 2: Ошибка 405 при curl
Решение: Используйте -X POST.

Проблема 3: Порт уже занят
Решение: pkill -9 ollama или pkill -9 nginx.

Проблема 4: Termux убивает процесс в фоне
Решение: Отключите оптимизацию батареи для Termux в настройках телефона.

Советы по оптимизации

Выбирайте правильную модель

Я выбрал gemma3:1b потому что только она поместилась на моё устройство.

Модель

Параметры

Требования к ОЗУ

gemma3:1b

1.5 млрд

3+ ГБ

gemma3:4b

4 млрд

6+ ГБ

llama3.2:1b

1.5 млрд

3+ ГБ

Экономим память

du -sh ~/.ollama/models/   # проверить занятое место
ollama rm <имя_модели>    # удалить модель

🔮 Что дальше?

Теперь у вас есть собственный локальный ИИ-ассистент в телефоне и вы уже обошли Apple!

Вы можете:

  • Общаться с ним в дороге (даже в самолёте)

  • Использовать как офлайн-помощника для работы с текстом

  • Ставить другие модели (Mistral, Phi-3, CodeLlama)

  • Интегрировать его в свои приложения через API

Полезные команды:

ollama list                    # список установленных моделей
ollama pull mistral:7b         # скачать другую модель
ollama rm gemma3:1b            # удалить модель
ollama show gemma3:1b          # информация о модели
ollama ps                      # Выведет информацию о запущенной модели

🔒 P.S. Насколько это безопасно?
Вся обработка данных происходит локально на вашем устройстве. Ollama не отправляет ваши запросы никуда. Вы можете выключить интернет, и всё будет работать. Ваши диалоги остаются только у вас.

Хотите знать больше?

Подписывайтесь на мой канал в VC.ru и Telegram

Будьте осторожны! Статья сгенерирована человеком :-D

Созданное решение это больше демонстрация, чем полноценное использование. Например, я не стал писать сохранение сессий чатов- это уже отдельная история.