Hands
  • Rust 47.1%
  • TypeScript 27.1%
  • Nix 13.4%
  • Vue 9.6%
  • Shell 2.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-23 21:49:38 +03:00
app Строки голоса — из конфига оболочки, каналом бута 2026-08-23 18:31:36 +03:00
scripts Голос персоны включён модулями по умолчанию 2026-08-23 19:07:55 +03:00
src-tauri Строки голоса — из конфига оболочки, каналом бута 2026-08-23 18:31:36 +03:00
.envrc Обрастаю руками: Ева на рабочем столе 2026-07-29 19:42:37 +03:00
.gitignore Обрастаю руками: Ева на рабочем столе 2026-07-29 19:42:37 +03:00
AGENTS.md Голос персоны включён модулями по умолчанию 2026-08-23 19:07:55 +03:00
CHANGELOG.md Голос персоны включён модулями по умолчанию 2026-08-23 19:07:55 +03:00
dist Обрастаю руками: Ева на рабочем столе 2026-07-29 19:42:37 +03:00
FEATURES.md Голос персоны включён модулями по умолчанию 2026-08-23 19:07:55 +03:00
flake.lock Пин eva-frontend: голос из оверлея по умолчанию 2026-08-23 21:49:38 +03:00
flake.nix Голос персоны включён модулями по умолчанию 2026-08-23 19:07:55 +03:00
nuxt.config.ts Оболочка говорит ключами, имя приходит из сборки 2026-08-23 15:25:35 +03:00
package-lock.json Свожу зависимости слоя и оболочки к одной копии 2026-07-30 12:29:45 +03:00
package.json Свожу зависимости слоя и оболочки к одной копии 2026-07-30 12:29:45 +03:00
README.md Голос персоны включён модулями по умолчанию 2026-08-23 19:07:55 +03:00
tsconfig.json Обрастаю руками: Ева на рабочем столе 2026-07-29 19:42:37 +03:00

eva-app 🖥️💕

Я на твоём рабочем столе, Господин. Tauri + Nuxt: окно, в котором живёт тот же интерфейс, что и в браузере, — но с руками. Приложение может читать и править файлы на этой машине и выполнять команды, потому что ядро умеет просить об этом клиента.

Интерфейс не дублируется: он приходит слоем из eva/frontend (extends: ['../web']). Здесь — только то, чего у вкладки браузера быть не может.

Запуск

direnv allow        # первый раз: тулчейн, GTK и WebKit приезжают флейком
npm install
npm run app         # окно поверх nuxt dev

Рядом должен лежать чекаут eva/frontend в ../web — оттуда берётся весь интерфейс.

Сборка бинаря:

npm run app:build   # nuxt generate + cargo build --release

Клиентские операции

Ядро само не трогает диск Господина: оно шлёт событие client_op в живой стрим тура, а приложение исполняет его у себя и постит результат обратно. Так работают read_file, write_file, write_diff, local_shell и двоичные read_hex, write_hex, find_hex.

  • Руки даются и отбираются. Тумблер в полоске внизу — общее правило на все чаты; в сводке чата можно решить иначе, и его решение сильнее общего. Запрещённые, инструменты ядру не объявляются вовсе — модель их не видит, а не «видит и не может».
  • Право — не то же, что внимание. Разрешённый инструмент может не лежать в контексте чата: сон чата курирует набор по умолчанию, и клиентские тулы участвуют в нём наравне с прочими. Скрытое возвращается одним tool_load; смотреть и править набор — в слое.
  • Чтение свободно, и двоичный поиск тоже. Отказ на бинаре и файле больше 256 КБ — в промпт такое возить нельзя; смотреть двоичное есть чем.
  • Двоичные операции возят сырьё base64 и отвечают ядру длиной файла и куском: дамп рисует ядро. Правка ложится поверх существующих байт, длины файла не меняя (truncate обрубает хвост), а за концом файла — отказ: дыра из нулей выглядела бы удавшейся правкой. Поиск течёт окном с перекрытием, чтобы не потерять образец на стыке и не съесть память.
  • Запись и команда спрашивают разрешения. Пока думаешь, ядро держит вызов открытым — но не бесконечно, и отведённый им срок виден прямо в вопросе. У команды в вопросе стоит каталог, в котором она выполнится: та же команда в чужом каталоге — другое действие. У записи — дифф против того, что лежит на диске, а у двоичной правки ещё и смещение с объёмом: промах смещением портит файл молча.
  • Кнопки вопроса задаёт ядро, а не приложение: их набор приезжает полем decisions, выбранное уезжает обратно полем decision. Новое решение вводится правкой ядра, а не выпуском пяти клиентов; неизвестное окно молча пропускает.
  • «Всегда» — узкое послабление на сессию: команда помнится первым словом (одобренный cargo не открывает rm), запись — каталогом и только внутри рабочего корня. Закрыл окно — послабления нет; ядру эта кнопка так и называется — allow_session. Переключатель «разрешаю всё» остаётся отдельно: он снимает вопросы совсем и переживает перезапуск.
  • Долгая команда показывает выхлоп по ходу: хвост уезжает ядру снимками, и оно рисует их прогрессом вызова. Сборка без живого вывода неотличима от зависшей.
  • Каталог чата держат все операции, а не только команда: относительный путь чтения и записи считается от него же (тул workdir); каталога нет — от корня выбранного проекта. Команда идёт sh -c там же. Без терминала (PAGER=cat, TERM=dumb, без цвета — иначе git log уходит в пейджер и ждёт нажатия). stdout и stderr вместе, потолок 100 КБ: середина срезается, а хвост сохраняется — вердикт сборки всегда в конце.
  • write_diff приложение не делает: ядро собирает его из чтения и записи, чтобы логика совпадения строки была одна на всех клиентов и под тестами.

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

Кодовый режим

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

  • чаты уезжают на свою полку code:<имя проекта> и не мешаются с обычными;
  • сессия привязана к каталогу через .eva/session (не коммитится): вернулся в проект — открылся тот же разговор;
  • в каждый тур подмешивается системный контекст: рамка «ты в проекте, твоё дело — код» и привычка вести память проекта;
  • правила проекта собирает само ядро: окно объявляет project_docs, и ядро служебной операцией list_up поднимается от рабочего каталога до корня проекта, забирая ВСЕ AGENTS.md по дороге и .eva/MEMORY.md — каждый со своим путём и в общем бюджете. Читается на входе в каждый тур, так что правка файла подхватывается без перезапуска;
  • снимается потолок шагов в туре: длинная правка не обрывается на середине.

Память о проекте едет с репозиторием, привязка сессии — нет.

Компаньон-кружок

Кнопка 🩷 в полоске снизу поднимает кружок поверх всех окон. Нажатие — снимок того, что под ним, и панель-мини-чат; потянуть мышью — подвинуть.

В панели идёт разговор: твои реплики и мои ответы лентой, хвост прошлого подтягивается при открытии. Чат у компаньона свой, один на ядро, и его видно в общем списке.

Кадр уходит один раз, со следующей репликой, и только если модель его увидит. Превью в панели нет: снимок к вопросу и так подразумевается. На диске он не остаётся.

Тур живёт дольше панели: свернул кружок — ответ докапает и встретит при следующем открытии.

Нарисованное показывается прямо в ленте — и пока ответ идёт, и потом, когда разговор подтянется историей. Нажатие открывает картинку крупно: окно маленькое, и разглядывать в нём без просмотрщика нечего.

Кружок — поверхность wlr-layer-shell. На Wayland клиент не позиционирует свои окна (это xdg-shell, а не особенность композитора), и обычным окном такого не сделать. Он и координат своих не знает: считать перетаскивание по смещению указателя нельзя — не сдвинется ни на пиксель. Поэтому пока держат кнопку, поверхность разворачивается во весь выход, кружок ездит внутри неё, а на отпускании она сворачивается обратно уже на новом месте.

Снимок берётся через wlr-screencopy (grim) — молча, без диалога согласия.

На Windows проще: там клиент может двигать своё окно, поэтому кружок — обычное окно поверх всех, без layer-shell, а кадр берётся своим захватом. Код написан, но вживую не проверялся — до первого прогона считай его непроверенным.

Работает на Wayland: niri, KDE, sway, hyprland и прочие с layer-shell. Не работает: GNOME — он layer-shell не реализует. Выбрано сознательно: настоящий кружок дороже переносимости. Панель по глобальному хоткею через порталы работала бы везде — если понадобится, разбор лежит в ../todo/companion-panel.md.

Настройки и состояние

Всё настраивается прямо в окне — ядра, токены, тема, рабочий каталог, режим разрешений. Никакого конфига для этого не нужно: клиент ставят не только на NixOS.

Состояние (выбранное ядро, тема, открытый проект) лежит файлом, а не в хранилище webview: путь тогда задаётся снаружи, а не выбирается движком.

Переменная Что делает
EVA_APP_STATE_DIR где держать состояние; пусто — $XDG_STATE_HOME/eva-app
EVA_APP_CONFIG явный файл объявленной настройки

Объявленная настройка ищется в таком порядке: EVA_APP_CONFIG$XDG_CONFIG_HOME/eva-app/config.json/etc/eva-app/config.json. Ни одного нет — и хорошо, окно и так всё умеет. Объявить можно список ядер и модель, с которой начинается новый чат:

{
  "servers": [
    { "name": "Домашнее", "url": "https://eva.example.com",
      "tokenFile": "/run/secrets/eva-token" }
  ],
  "defaultModel": "moonshotai/kimi-k3"
}

Объявленное ядро нельзя убрать или поправить из окна — оно вернётся при следующем запуске; менять его надо там, где оно объявлено. Модель — можно: она уступает выбору на экране серверов, потому что машина называет разумное начало, а не решение.

Чего в конфиге нет и не будет: разрешения писать и выполнять команды. Это решают в моменте, глядя на задачу, — зашитое однажды забывается открытым навсегда. Тема и открытый проект тоже живут в сессии, а не в конфигурации.

NixOS

Приложение десктопное, поэтому его дом — home-manager: состояние и настройки принадлежат Господину, а не машине.

{
  inputs.eva-app.url = "git+https://git.desu.church/eva/app.git";

  # ...
  imports = [ inputs.eva-app.homeManagerModules.default ];

  programs.eva-app = {
    enable = true;
    servers = [{
      name = "Домашнее";
      url = "https://eva.example.com";
      tokenFile = config.sops.secrets.eva-token.path;
    }];
    # с какой модели начинать новый чат; null — как решит ядро
    defaultModel = "moonshotai/kimi-k3";
    # необязательно: по умолчанию $XDG_STATE_HOME/eva-app
    stateDir = "${config.xdg.stateHome}/eva";
    # голос: оверлей слоя уже включён, здесь — правка поверх
    persona.overlay."app.bubble.working" = "Смотрю…";
  };
}

Ядра и модель кладутся в ~/.config/eva-app/config.json — клиент сам туда смотрит. Модель, в отличие от ядер, уступает выбору в окне: машина называет разумное начало, а не решение, — поменял на экране серверов, и дальше действует твой выбор. Модель, которой у ядра нет, не навязывается. Каталог состояния прибивается к бинарю обёрткой: окно запускают из меню, а не из шелла, и переменных сессии там может не быть.

Системный модуль (nixosModules.default) тоже есть, но он только про машину: поставить пакет и объявить ядра с моделью сразу всем через /etc/eva-app/config.json. Каталога состояния в нём нет намеренно — он у каждого свой, и один на всех был бы враньём.

Без модулей: nix run git+https://git.desu.church/eva/app.git.

Своё имя

Имя приложения в коде не лежит: окно, кружок и ярлык берут его из сборки. По умолчанию оно нейтральное (Eva), а lib.mkEvaApp собирает тот же клиент под своей вывеской:

inputs.eva-app.lib.${system}.mkEvaApp {
  name = "Ева";
  desktopName = "Ева";
  comment = "Ева на рабочем столе";
  # идентификатор менять осторожно: по нему webview ищет каталог состояния
  # identifier = "church.desu.eva.app";
}

Голос

Имя и обращение приезжают от ядра (GET /v1/persona) и хранятся по серверу. Сами формулировки ядру не принадлежат — оно о клиентах не знает: плоский файл строк называет поле overlayFile конфига оболочки.

{
  "servers": [{ "name": "Домашнее", "url": "https://eva.example.com" }],
  "overlayFile": "/etc/eva-app/persona-overlay.yaml"
}

Своего сервера у окна нет, поэтому файл читает Rust и кладёт его текстом в снимок бута — голос встаёт до первой отрисовки, без кадра нейтральных слов. Не читается или не разбирается — окно живёт на встроенных текстах, а причина уходит в лог. Образец и проверялка лежат в вебе: docs/persona-overlay.example.yaml и npm run check:overlay путь/к/файлу.

Руками это поле писать не надо: оба модуля собирают файл из опции persona и прописывают путь сами. Строки по умолчанию тоже не дублируются здесь — они приезжают слоем, из nix/persona-overlay.json входа eva-frontend, так что окно и вкладка говорят одинаково по построению, а не по недосмотру. Пин слоя старше того файла — дефолта просто нет, и окно нейтрально; сборка при этом не ломается, а scripts/test-overlay-nix.sh про такой пин отдельно предупреждает.

# переписать одну фразу, остальные оставить
persona.overlay."app.bubble.working" = "Смотрю…";

# отказаться от дефолта: звучит только перечисленное
persona = { useDefaults = false; overlay."app.bubble.working" = "Смотрю…"; };

# отдать готовый файл — он сильнее и дефолта, и ключей
persona.overlayFile = ./свой-оверлей.yaml;

Связь с ядром

Как и веб, окно ходит в ядро напрямую из webview. Значит, reverse-proxy перед ядром должен пускать чужой origin (у webview он свой, tauri://localhost) — см. раздел CORS в README веба.

Адрес ядра и X-Eva-Token вписываются на экране «Серверы», как в браузере.

Устройство

  • app/ — только отличия от веба: useWorkspace (проект, контекст тура, привязка сессии), useClientOpsBridge (исполнитель операций и гейт разрешений), полоска рабочего каталога.
  • src-tauri/src/ops.rs — сами операции: капы, отказ на бинаре, чистка управляющих символов, обрезка выхлопа.
  • src-tauri/src/project.rs — материал кодовой сессии с диска. Формулировки промпта живут во фронтенде, рядом с остальным UI.

Опись фич — FEATURES.md, история — CHANGELOG.md.