Статьи

Meeseeks Box: Как превратить Python-скрипт и LM Studio в нативное десктопное приложение

Веб-разработка LLM

Возможность запускать нейросеть на собственном железе, без API-ключей и ежемесячных счетов, открывает гигантское поле для экспериментов. Но есть нюанс: взаимодействие с ними часто происходит либо через консоль, либо через громоздкие веб-интерфейсы вроде того, что предлагает LM Studio. А что, если нужна простая, легкая и кастомная оболочка для конкретной задачи?

Сегодня мы разберем проект, который элегантно решает эту проблему — Meeseeks Box. Его автор, вдохновившись "Риком и Морти", создал десктопного AI-помощника, который появляется по нажатию кнопки, выполняет одну-единственную просьбу и... исчезает. Все как в мультфильме.

Но за этой простой концепцией скрывается достаточно грамотная архитектура. Давайте препарируем всю начинку проекта: от бэкенда на Flask, который "общается" с LM Studio, до фронтенда на чистом JavaScript с покадровой SVG-анимацией и, наконец, до упаковки всего этого в нативное десктопное приложение с помощью Pywebview.

Этот проект — неплохой шаблон для создания собственных десктопных утилит с AI-поддержкой.

Архитектура на стыке двух миров: Flask + Pywebview

Первое, что обсудим это выбор технологического стека. Вместо того чтобы писать приложение на "тяжелых" десктопных фреймворках вроде Qt или wxWidgets, автор выбрал гибридный подход.

Meeseeks Box — это, по сути, веб-приложение, которое работает в обертке нативного окна.

Такая архитектура состоит из двух ключевых частей:

  1. Мозг (Бэкенд): Легковесный сервер на Flask. Его задача — принимать запросы от пользователя, "ходить" в API локального сервера LM Studio, получать ответ от нейросети и отдавать его обратно. Это чистый Python, который выполняет всю логическую работу.
  2. Тело (Фронтенд + Оболочка): Пользовательский интерфейс, написанный на HTML, CSS и Vanilla JS. Он отвечает за всю визуальную часть: анимации, отображение ответа, поле для ввода. Этот веб-интерфейс запускается не в браузере, а в специальном легковесном окне, которое создается с помощью библиотеки Pywebview.

Почему этот подход хорош?

  • Скорость и простота разработки: Вы используете привычные веб-технологии для создания интерфейса. Не нужно изучать сложные десктопные фреймворки. Разметка в HTML, стили в CSS, логика на JS — все знакомо и отлаживается прямо в браузере.
  • Кроссплатформенность: Код на Python и веб-фронтенд легко адаптируются под Windows, macOS и Linux. Pywebview работает на всех этих платформах, используя "родные" для каждой системы веб-движки (WebView2/Edge, WebKit).
  • Легковесность: В отличие от Electron-приложений, Pywebview не тащит с собой целый браузер Chromium. Он использует уже имеющийся в системе движок, что делает итоговое приложение значительно компактнее.
  • Полный доступ к Python: Так как бэкенд — это Python, вы можете использовать всю мощь его экосистемы: любые библиотеки для работы с файлами, сетью, данными и, конечно же, для взаимодействия с AI.

Теперь давайте разберем каждую из этих частей.

Сердце проекта — бэкенд на Flask

Вся серверная логика умещается в нескольких файлах, но ключевые — app.py и lm_client.py. Бэкенд выполняет две критически важные функции.

Прокси-сервер к LM Studio

Основная задача Flask-приложения — служить посредником (прокси) между интерфейсом и сервером LM Studio, который по умолчанию работает на http://127.0.0.1:1234.

[!INFO] Почему нельзя обращаться к LM Studio напрямую из JavaScript? Из-за политики безопасности браузеров (CORS — Cross-Origin Resource Sharing). Веб-страница, открытая с одного "источника" (домена/порта), не может просто так делать запросы к другому. Наш бэкенд на Flask решает эту проблему: фронтенд общается только с ним (в рамках одного источника), а уже Flask, как серверное приложение, без всяких ограничений делает запросы к LM Studio.

Главный эндпоинт, который обрабатывает запрос пользователя, находится в app.py:

# app.py

@app.post("/api/ask")
def api_ask():
    # ... получаем JSON от клиента ...
    prompt = body.get("prompt")
    
    # ... здесь происходит магия поиска модели, о ней ниже ...
    model = detect_first_responsive_model()

    payload = {
        "model": model,
        "messages": [
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": prompt},
        ],
        "temperature": TEMPERATURE,
        "max_tokens": MAX_TOKENS,
        "stream": STREAM,
    }

    # Делаем запрос к LM Studio
    upstream = requests.post(
        f"{LM_BASE}/chat/completions",
        json=payload,
        stream=STREAM,
        # ... обработка таймаутов и ошибок ...
    )

    # ... возвращаем ответ клиенту ...
    obj = upstream.json()
    cleaned = _clean_content_from_response(obj)
    return jsonify({"content": cleaned}), upstream.status_code

Что здесь происходит:

  1. Функция api_ask принимает POST-запрос на /api/ask с JSON-телом, содержащим prompt.
  2. Вызывает detect_first_responsive_model() для автоматического определения, какая модель сейчас загружена и активна в LM Studio.
  3. Формирует payload — стандартный для OpenAI-совместимых API запрос, включающий системный промпт, промпт пользователя и параметры генерации (temperature, max_tokens).
  4. С помощью библиотеки requests отправляет этот payload на сервер LM Studio.
  5. Получает ответ, очищает его от служебных тегов (если они есть) и возвращает фронтенду в виде JSON.

Это классический и очень надежный паттерн: Flask выступает в роли "умного" и безопасного моста между фронтендом и внешним API.

Автоматическое обнаружение модели

Что произойдет, если пользователь сменит модель в LM Studio? Или выключит одну и загрузит другую? Приложение "сломается", так как в payload будет зашит уже неактуальный ID модели.

Автор Meeseeks Box решил эту проблему так: перед тем как отправить основной запрос на генерацию ответа, приложение делает "разведку" — само определяет, какая модель сейчас доступна. За это отвечает функция detect_first_responsive_model() из файла lm_client.py.

Разберем ее:

# lm_client.py

def detect_first_responsive_model() -> str | None:
    """Возвращает id первой модели, успешно отвечающей на /chat/completions, иначе None."""
    
    # 1. Получаем список всех доступных моделей
    try:
        r = requests.get(f"{LM_BASE}/models", timeout=5)
        r.raise_for_status()
        models = [m.get("id") for m in r.json().get("data", []) if isinstance(m, dict)]
    except Exception:
        return None

    # 2. Готовим шаблон "пробного" запроса
    probe_template = {
        "model": None,  # подставим в цикле
        "messages": [{"role": "user", "content": "ping"}],
        "max_tokens": 1,
        "temperature": 0.0,
        "stream": False,
    }

    # 3. Перебираем модели и "пингуем" каждую
    for mid in models:
        if not mid:
            continue
        try:
            payload = dict(probe_template)
            payload["model"] = mid
            resp = requests.post(
                f"{LM_BASE}/chat/completions",
                json=payload,
                timeout=(3, 8),  # Короткие таймауты для быстрой проверки
            )
            if resp.status_code == 200:
                return mid  # Нашли рабочую модель!
        except requests.exceptions.RequestException:
            # Модель не ответила, пробуем следующую
            continue
            
    return None # Ни одна модель не ответила

Как это работает:

  1. Запрос списка моделей: Сначала скрипт делает GET-запрос к эндпоинту /models сервера LM Studio. В ответ он получает JSON со списком всех моделей, которые видит LM Studio, включая те, что не загружены в память.
  2. Шаблон "пинг-запроса": Создается минималистичный payload для проверки. Промпт — простое слово "ping", max_tokens: 1, temperature: 0.0. Это гарантирует, что запрос будет обработан максимально быстро и с минимальным расходом ресурсов.
  3. Итерация и проверка: Скрипт в цикле перебирает ID каждой модели из полученного списка. Для каждой модели он подставляет ее ID в payload и отправляет "пинг-запрос" на эндпоинт /chat/completions.
  4. Первый успешный ответ: Как только одна из моделей успешно отвечает (статус-код 200), функция немедленно возвращает ее ID. Цикл прерывается. Если ни одна модель не ответила (например, сервер LM Studio запущен, но модель не загружена), функция вернет None, и app.py обработает эту ошибку, сообщив пользователю, что нужно сделать.

[!TIP] Почему это хороший паттерн? Этот подход делает приложение отказоустойчивым и дружелюбным к пользователю. Ему не нужно ничего настраивать или выбирать модель вручную в интерфейсе приложения. Он просто запускает LM Studio, загружает любую понравившуюся чат-модель, и Meeseeks Box "сам ее найдет".

Лицо проекта: SVG-анимации и логика на Vanilla JS

Фронтенд Meeseeks Box очень минималистичен. Никаких фреймворков, только HTML, немного CSS (через Tailwind CSS) и чистый JavaScript (Vanilla JS), разделенный на логические модули.

Вся магия происходит в файлах внутри static/js/app/. Рассмотрим ключевые моменты.

Жизненный цикл Мисикса: Управление состоянием

Логика интерфейса строится вокруг жизненного цикла персонажа, который соответствует шагам пользователя:

  1. Появление: Коробка "нажимается", исчезает, появляется Мисикс и форма ввода.
  2. Ожидание: Мисикс стоит в позе msks_appear1.svg, ожидая вопроса.
  3. Принятие задачи: Пользователь отправляет запрос. Мисикс переходит в анимацию "принятия" (ACCEPT_FRAMES).
  4. Размышление: Пока Flask-сервер ждет ответ от LLM, Мисикс циклично "думает" (анимация REFLECT_FRAMES).
  5. Выполнено: Ответ получен. Мисикс показывает жест "Готово!" (msks_done.svg).
  6. Исчезновение: Спустя пару секунд после выполнения задачи Мисикс исчезает (анимация DONE_FRAMES). Появляется кнопка-коробка для нового вызова.

Эта логика управляется в main.js, который дирижирует всеми остальными модулями.

Техника покадровой SVG-анимации

Одна из самых эффектных частей проекта — анимация персонажа. Вместо тяжелых GIF или видео, автор использовал покадровую анимацию на основе SVG-файлов.

Каждое состояние или действие Мисикса — это последовательность из нескольких SVG-изображений, хранящихся в static/assets/. Например, для анимации исчезновения используются vanish_1.svg, vanish_2.svg и так далее.

Управляет этим процессом функция playFrameSequence из character.js:

// character.js (упрощенно)

export async function playFrameSequence(
  frames, // Массив путей к SVG-файлам
  { fps = 16, cancelToken, holdLastMs = 0 } = {}
) {
  if (!frames || !frames.length) return;
  
  // 1. Рассчитываем интервал между кадрами
  const interval = Math.floor(1000 / fps);

  // 2. Предзагружаем все кадры, чтобы избежать мерцания
  await preloadFrames(frames);
  
  // 3. В цикле меняем атрибут src у тега <img>
  for (let i = 0; i < frames.length; i++) {
    if (cancelToken?.canceled) return; // Возможность прервать анимацию
    await swapChar(frames[i], { instant: true });
    await sleep(interval);
  }
  // ...
}

Функция swapChar при этом моментально (instant: true) подменяет src у <img> тега, не используя плавные CSS-переходы. Это и создает эффект классической 2D-анимации.

Преимущества такого подхода:

  • Качество: SVG — векторный формат, поэтому анимация выглядит идеально четкой на любом разрешении экрана.
  • Управляемость: Анимацией легко управлять из JavaScript: можно менять скорость (fps), ставить на паузу, прерывать.
  • Легкость: SVG-файлы, особенно если они оптимизированы, весят немного, а предзагрузка делает смену кадров бесшовной.

Цикличные анимации, как, например, "размышление", реализованы схожим образом в функции playLoop, которая просто начинает перебор кадров заново, до тех пор пока не получит сигнал отмены через cancelToken.

Интеграция с бэкендом

Отправка запроса и получение ответа реализованы в обработчике события submit для формы в main.js:

// main.js (упрощенно)

askForm.addEventListener('submit', async (e) => {
    // ... отключаем кнопку, запускаем анимацию размышления ...

    try {
        const resp = await fetch('/api/ask', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ prompt })
        });
        
        if (!resp.ok) { /* ... обработка ошибок ... */ }

        const data = await resp.json(); // Получаем { "content": "..." }

        // ... останавливаем анимацию размышления ...

        // Рендерим Markdown в HTML
        answerCard.innerHTML = window.md(data.content); 
        
        // Подсветка кода и добавление кнопок "Копировать"
        if (window.hljs) {
            answerCard.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el));
        }
        addCopyButtons(answerCard);
        
        // ... запускаем финальную анимацию исчезновения ...

    } catch (err) { /* ... обработка ошибок сети ... */ }
});

Здесь мы видим, как фронтенд общается с нашим Flask API, а после получения ответа использует библиотеки marked.js и DOMPurify для безопасного рендеринга Markdown-разметки и highlight.js для подсветки синтаксиса в блоках кода. Это стандартный набор инструментов для работы с контентом от LLM.

Упаковка в десктопное приложение: Магия Pywebview и PyInstaller

Наш проект уже отлично работает в браузере через python app.py. Но конечная цель — создать единый исполняемый файл (.exe для Windows, .app для macOS), который пользователь может просто скачать и запустить, не устанавливая Python и не запуская сервер вручную из консоли.

Эта достигается связкой двух инструментов: Pywebview и PyInstaller.

Pywebview: Окно в мир веба

Библиотека pywebview — это ядро десктопной обертки. Она позволяет создать нативное окно операционной системы, в котором в качестве содержимого будет отображаться веб-страница. В нашем случае — это URL нашего локального Flask-сервера.

Основная логика запуска описана в файле desktop.py:

# desktop.py

def main():
    # ... настройка логгирования ...

    app = create_app()  # Создаем экземпляр нашего Flask-приложения
    port = find_free_port() # Находим свободный порт (например, 5000)
    url = f"http://{HOST}:{port}/"

    # Запускаем Flask-сервер в отдельном потоке
    srv = FlaskServerThread(app, HOST, port)
    srv.start()

    # ... ждем, пока сервер запустится и будет готов к работе ...

    # Создаем окно Pywebview, которое "смотрит" на наш локальный сервер
    window = webview.create_window(
        APP_NAME,
        url=url,
        width=1024,
        height=1000,
        resizable=True,
        # ... другие настройки окна ...
    )

    # При закрытии окна — корректно останавливаем Flask-сервер
    def on_closing():
        srv.shutdown()
    window.events.closing += on_closing

    # Запускаем главный цикл приложения (GUI)
    webview.start()

Что здесь происходит, шаг за шагом:

  1. Поиск свободного порта: Функция find_free_port динамически находит свободный TCP-порт, начиная с 5000. Это избавляет от конфликтов, если порт уже занят другим приложением.
  2. Запуск Flask в фоновом потоке: Flask-сервер запускается не блокирующим образом, а в отдельном threading.Thread. Это критически важно, так как основной поток должен остаться свободным для управления GUI-окном.
  3. Создание окна: webview.create_window() создает нативное окно. Самый главный параметр здесь — url, который указывает Pywebview, какой контент загружать. Мы передаем ему адрес нашего локального Flask-сервера.
  4. Управление жизненным циклом: С помощью window.events.closing мы "подписываемся" на событие закрытия окна. Когда пользователь нажимает на крестик, мы вызываем srv.shutdown(), чтобы аккуратно остановить фоновый Flask-сервер. Без этого сервер остался бы "висеть" в памяти как зомби-процесс.
  5. Запуск GUI: webview.start() — это блокирующий вызов, который запускает главный цикл обработки событий окна и держит приложение активным.

[!INFO] Pywebview vs Electron Если вы знакомы с Electron (на котором сделаны VS Code, Slack, Discord), то можете увидеть аналогию. Но ключевое различие в том, что Electron упаковывает в каждое приложение полноценный браузер Chromium и среду Node.js. Pywebview же использует системный WebView (Edge WebView2 в Windows, WebKit в macOS), что делает итоговый бандл в десятки, а то и сотни раз меньше. Для небольших утилит, как Meeseeks Box, это огромный плюс.

PyInstaller: Все в одну коробку

Итак, у нас есть desktop.py, который запускает и сервер, и окно. Но как передать это пользователю? Здесь в игру вступает PyInstaller.

PyInstaller — это утилита, которая анализирует ваш Python-проект, находит все зависимости (включая сам интерпретатор Python) и упаковывает их в единую папку или даже в один исполняемый файл.

В проекте Meeseeks Box команда для сборки под Windows выглядит так (из README.md):

python -m PyInstaller --noconfirm --name "Meeseeks Box" --windowed \
--icon=static\assets\icons\windows\box.ico \
--add-data "static;static" \
--clean --noupx desktop.py

Давайте разберем ключевые флаги:

  • --name "Meeseeks Box": Задает имя для итогового .exe файла и папки сборки.
  • --windowed: Указывает, что это GUI-приложение, а не консольное. При запуске не будет появляться черное окно командной строки.
  • --icon=...: Устанавливает иконку для приложения.
  • --add-data "static;static": Это самый важный флаг для нашего проекта. Он говорит PyInstaller'у скопировать всю папку static (с нашими CSS, JS и SVG-анимациями) в финальную сборку. Без этого Flask не смог бы найти и отдать фронтенду файлы интерфейса.
  • desktop.py: Точка входа в наше приложение.

После выполнения этой команды PyInstaller создаст папку dist/Meeseeks Box, внутри которой будет лежать Meeseeks Box.exe и все необходимые для его работы файлы, включая папку static. Этот .exe и есть наше готовое к распространению десктопное приложение.

Что можно вынести для своих проектов?

Этот проект — хорошая демонстрация готовых паттернов и идей, которые можно использовать в своих разработках.

  1. Гибридный подход. Связка Python-бэкенда (Flask/FastAPI) и веб-интерфейса в обертке Pywebview — хороший рецепт для быстрого создания кроссплатформенных десктопных утилит, особенно если ваша основная экспертиза в Python.
  2. Проектируйте отказоустойчивость. Механизм автоматического обнаружения активной модели в LM Studio — пример того, как можно сделать приложение умнее и удобнее для пользователя, избавив его от ручной настройки.
  3. SVG-анимации — это легко и красиво. Для несложных, "мультяшных" анимаций персонажей или элементов интерфейса покадровая смена SVG — отличная альтернатива тяжелым GIF или сложным JS-библиотекам.
  4. Делайте UX простым и сфокусированным. Концепция "один запуск — одна задача — исчезновение" идеально подходит для утилитарных AI-помощников. Это избавляет от "замусоривания" интерфейса историей чатов и лишними элементами, когда они не нужны.
  5. Не бойтесь Vanilla JS. Для проектов такого масштаба, где не требуется сложный state management, чистый JavaScript, разбитый на модули, работает превосходно. Он быстр, легковесен и не требует зависимостей.

Берите эти идеи на вооружение и создавайте свои собственные проекты ;)