Возможность запускать нейросеть на собственном железе, без API-ключей и ежемесячных счетов, открывает гигантское поле для экспериментов. Но есть нюанс: взаимодействие с ними часто происходит либо через консоль, либо через громоздкие веб-интерфейсы вроде того, что предлагает LM Studio. А что, если нужна простая, легкая и кастомная оболочка для конкретной задачи?
Сегодня мы разберем проект, который элегантно решает эту проблему — Meeseeks Box. Его автор, вдохновившись "Риком и Морти", создал десктопного AI-помощника, который появляется по нажатию кнопки, выполняет одну-единственную просьбу и... исчезает. Все как в мультфильме.
Но за этой простой концепцией скрывается достаточно грамотная архитектура. Давайте препарируем всю начинку проекта: от бэкенда на Flask, который "общается" с LM Studio, до фронтенда на чистом JavaScript с покадровой SVG-анимацией и, наконец, до упаковки всего этого в нативное десктопное приложение с помощью Pywebview.
Этот проект — неплохой шаблон для создания собственных десктопных утилит с AI-поддержкой.
Первое, что обсудим это выбор технологического стека. Вместо того чтобы писать приложение на "тяжелых" десктопных фреймворках вроде Qt или wxWidgets, автор выбрал гибридный подход.
Meeseeks Box — это, по сути, веб-приложение, которое работает в обертке нативного окна.
Такая архитектура состоит из двух ключевых частей:
Почему этот подход хорош?
Теперь давайте разберем каждую из этих частей.
Вся серверная логика умещается в нескольких файлах, но ключевые — app.py и lm_client.py. Бэкенд выполняет две критически важные функции.
Основная задача 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
Что здесь происходит:
api_ask принимает POST-запрос на /api/ask с JSON-телом, содержащим prompt.detect_first_responsive_model() для автоматического определения, какая модель сейчас загружена и активна в LM Studio.payload — стандартный для OpenAI-совместимых API запрос, включающий системный промпт, промпт пользователя и параметры генерации (temperature, max_tokens).requests отправляет этот payload на сервер LM Studio.Это классический и очень надежный паттерн: 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 # Ни одна модель не ответила
Как это работает:
/models сервера LM Studio. В ответ он получает JSON со списком всех моделей, которые видит LM Studio, включая те, что не загружены в память.payload для проверки. Промпт — простое слово "ping", max_tokens: 1, temperature: 0.0. Это гарантирует, что запрос будет обработан максимально быстро и с минимальным расходом ресурсов.payload и отправляет "пинг-запрос" на эндпоинт /chat/completions.None, и app.py обработает эту ошибку, сообщив пользователю, что нужно сделать.[!TIP] Почему это хороший паттерн? Этот подход делает приложение отказоустойчивым и дружелюбным к пользователю. Ему не нужно ничего настраивать или выбирать модель вручную в интерфейсе приложения. Он просто запускает LM Studio, загружает любую понравившуюся чат-модель, и Meeseeks Box "сам ее найдет".
Фронтенд Meeseeks Box очень минималистичен. Никаких фреймворков, только HTML, немного CSS (через Tailwind CSS) и чистый JavaScript (Vanilla JS), разделенный на логические модули.
Вся магия происходит в файлах внутри static/js/app/. Рассмотрим ключевые моменты.
Логика интерфейса строится вокруг жизненного цикла персонажа, который соответствует шагам пользователя:
msks_appear1.svg, ожидая вопроса.ACCEPT_FRAMES).REFLECT_FRAMES).msks_done.svg).DONE_FRAMES). Появляется кнопка-коробка для нового вызова.Эта логика управляется в main.js, который дирижирует всеми остальными модулями.
Одна из самых эффектных частей проекта — анимация персонажа. Вместо тяжелых 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-анимации.
Преимущества такого подхода:
Цикличные анимации, как, например, "размышление", реализованы схожим образом в функции 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.
Наш проект уже отлично работает в браузере через python app.py. Но конечная цель — создать единый исполняемый файл (.exe для Windows, .app для macOS), который пользователь может просто скачать и запустить, не устанавливая Python и не запуская сервер вручную из консоли.
Эта достигается связкой двух инструментов: Pywebview и PyInstaller.
Библиотека 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()
Что здесь происходит, шаг за шагом:
find_free_port динамически находит свободный TCP-порт, начиная с 5000. Это избавляет от конфликтов, если порт уже занят другим приложением.threading.Thread. Это критически важно, так как основной поток должен остаться свободным для управления GUI-окном.webview.create_window() создает нативное окно. Самый главный параметр здесь — url, который указывает Pywebview, какой контент загружать. Мы передаем ему адрес нашего локального Flask-сервера.window.events.closing мы "подписываемся" на событие закрытия окна. Когда пользователь нажимает на крестик, мы вызываем srv.shutdown(), чтобы аккуратно остановить фоновый Flask-сервер. Без этого сервер остался бы "висеть" в памяти как зомби-процесс.webview.start() — это блокирующий вызов, который запускает главный цикл обработки событий окна и держит приложение активным.[!INFO] Pywebview vs Electron Если вы знакомы с Electron (на котором сделаны VS Code, Slack, Discord), то можете увидеть аналогию. Но ключевое различие в том, что Electron упаковывает в каждое приложение полноценный браузер Chromium и среду Node.js. Pywebview же использует системный WebView (Edge WebView2 в Windows, WebKit в macOS), что делает итоговый бандл в десятки, а то и сотни раз меньше. Для небольших утилит, как Meeseeks Box, это огромный плюс.
Итак, у нас есть 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 и есть наше готовое к распространению десктопное приложение.
Ваша поддержка — это энергия для новых статей и проектов. Спасибо, что читаете!
Этот проект — хорошая демонстрация готовых паттернов и идей, которые можно использовать в своих разработках.
Vanilla JS. Для проектов такого масштаба, где не требуется сложный state management, чистый JavaScript, разбитый на модули, работает превосходно. Он быстр, легковесен и не требует зависимостей.Берите эти идеи на вооружение и создавайте свои собственные проекты ;)