- Rust 47.1%
- TypeScript 27.1%
- Nix 13.4%
- Vue 9.6%
- Shell 2.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| app | ||
| scripts | ||
| src-tauri | ||
| .envrc | ||
| .gitignore | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| dist | ||
| FEATURES.md | ||
| flake.lock | ||
| flake.nix | ||
| nuxt.config.ts | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
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.