Сегодня мы построим свою локальную модель на смартфоне. С блэкджеком и WebUI. ai.. ai. android.. ai. android. Big Data.. ai. android. Big Data. HTML.. ai. android. Big Data. HTML. JavaScript.. ai. android. Big Data. HTML. JavaScript. linux.. ai. android. Big Data. HTML. JavaScript. linux. llm.. ai. android. Big Data. HTML. JavaScript. linux. llm. ollama.. ai. android. Big Data. HTML. JavaScript. linux. llm. ollama. termux.
Сегодня мы построим свою локальную модель. С блэкджеком и 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

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

Автор: BlackJackBander

Источник