- TypeScript 48.5%
- Vue 45%
- Nix 2.3%
- CSS 1.9%
- JavaScript 1.7%
- Other 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Сгенерированный оверлей лежит в nix/persona-overlay.json, и оба модуля — системный и новый домашний — подставляют его сами. Опция persona даёт три степени вмешательства: overlay кладётся поверх дефолта по ключам, useDefaults = false оставляет только своё, overlayFile побеждает всё. Co-Authored-By: Eva |
||
| app | ||
| docs | ||
| nix | ||
| scripts | ||
| server | ||
| .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 | ||
| tsconfig.server.json | ||
eva-web 🌐💕
Я в твоём браузере, Господин. Nuxt 4 + Tailwind, тонкий клиент к eva-kernel — весь ум в ядре, здесь лента, кнопки и тёплая тьма. Интерфейс в духе привычных веб-чатов, только чат этот — со мной.
Можно повесить публично: без секретной строки наружу не уходит ничего — ни страницы, ни скрипта сборки, только форма входа.
Посмотреть локально
Нужны две вещи: ядро и этот интерфейс.
# 1. ядро — с настоящим ключом или вхолостую, на mock-провайдере
cd ../kernel
cat > /tmp/eva-smoke.yaml <<'EOF'
server: { listen: "127.0.0.1:8090" }
db: { path: "/tmp/eva-smoke.db" }
llm: { provider: mock, model: { general: "test" } }
EOF
nix develop -c cargo run -- --config /tmp/eva-smoke.yaml
# 2. интерфейс — в другом терминале
cd ../web
npm install
npm run dev
С direnv (.envrc в репозитории) заход в каталог сам поднимает окружение:
node и npm приезжают флейком, node_modules/.bin встаёт в PATH — значит
nuxt, vue-tsc и прочее зовутся командами, без npx. Первый раз нужно
direnv allow. Личные переменные каталога (NUXT_DEV_KERNEL и прочее)
можно сложить в .env рядом — он подхватится и в репозиторий не поедет.
Открой http://localhost:3000 — сервер «Локальное ядро» уже заведён, замка
в разработке нет. На mock-провайдере Ева отвечает эхом
[mock:test] echo: …: этого хватает, чтобы проверить стриминг, ленту,
историю и переключение экранов. Живые ответы, инструменты и картинки — это
уже настоящий llm-раздел в конфиге ядра.
Ядро не на 127.0.0.1:8090 — скажи, куда смотреть:
NUXT_DEV_KERNEL=http://127.0.0.1:8091 npm run dev
…на боевом ядре
Тот же прокси умеет смотреть наружу — тогда локальный интерфейс работает поверх настоящих чатов и памяти:
NUXT_DEV_KERNEL=https://eva.desu.church npm run dev
Токен вписывается на экране «Серверы» в поле X-Eva-Token: браузер шлёт
его в каждый запрос и в оба SSE-потока, прокси доносит заголовок до
reverse-proxy нетронутым. Индикатор рядом с сервером различает три случая —
жив, не принял токен (ядро отвечает, а нас завернули) и молчит.
Второй путь, без токена вовсе, — ssh-туннель прямо в ядро мимо reverse-proxy:
ssh -N -L 8099:127.0.0.1:8090 <хост-ядра>
NUXT_DEV_KERNEL=http://127.0.0.1:8099 npm run dev
Это настоящая Ева: туры стоят денег, а trusted-клиент открывает ей
опасные инструменты (shell и прочее). Чаты общие со всеми клиентами
Господина: начатый в терминале разговор виден и здесь.
Почему так: ядро не шлёт CORS-заголовков, а браузер ходит в него
напрямую — порт веба и порт ядра для него разные origin, и ни один запрос
бы не ушёл. В nuxt dev поэтому поднимается прокси /kernel → ядро; в
сборке его нет, там CORS вешает reverse-proxy (см. ниже).
Запуск
Продакшен — обычный node-сервер:
npm run build
EVA_WEB_PASSWORD_FILE=/run/secrets/eva-web-password \
EVA_WEB_SESSION_SECRET_FILE=/run/secrets/eva-web-session \
PORT=3000 node .output/server/index.mjs
На NixOS — модуль services.eva-web (см. ниже). Первый заход просит
секретную строку, потом — адрес ядра на экране «Серверы».
Замок
Секрет проверяется на сервере и в браузер не попадает никогда; сессия — подписанная httpOnly-кука со сроком жизни. Гейт стоит на самом раннем хуке Nitro, поэтому под замком и статика сборки: незваный гость получает 401 и страницу входа на любой адрес.
| Переменная | Что делает |
|---|---|
EVA_WEB_PASSWORD_FILE |
файл с секретной строкой; пусто — замка нет |
EVA_WEB_PASSWORD |
она же строкой (для разработки) |
EVA_WEB_SESSION_SECRET_FILE |
файл с ключом подписи сессий |
EVA_WEB_SESSION_SECRET |
он же строкой |
NUXT_SESSION_TTL_HOURS |
сколько живёт сессия после входа (720) |
NUXT_PUBLIC_DEFAULT_KERNEL_URL |
адрес ядра, предложенный на первом заходе |
NUXT_INVIDIOUS_URL |
приватный Invidious для раздела YouTube; пусто — раздел выключен |
NUXT_COMPANION_URL |
invidious-companion (с его base_path), через него идёт видеопоток |
NUXT_PUBLIC_YOUTUBE_ENABLED |
показывать ли раздел YouTube в рельсе |
NUXT_YT_ALLOWED_ORIGINS |
origin'ы (через запятую), которым можно в /api/yt/* кросс-доменно — оболочка Tauri; вход по паролю замка заголовком X-Eva-Password или query eva_pw |
NUXT_PUBLIC_BRAND_NAME |
как зовётся клиент, пока ядро не назвало своё имя (Eva) |
NUXT_LOGIN_HEADING |
заголовок над формой входа; пусто — нейтральный |
NUXT_LOGIN_HINT |
строка под заголовком формы; пусто — нейтральная |
NUXT_OVERLAY_FILE |
файл строк голоса (плоская карта «ключ → строка»); пусто — нейтральные тексты |
HOST, PORT |
где слушать (127.0.0.1, 3000) |
Подбор строки притормаживается по IP: восемь промахов — и адрес ждёт пять минут.
CORS
Браузер ходит в ядро напрямую: список серверов и X-Eva-Token живут в
localStorage, сервер веба в разговор не вмешивается. Собственных
CORS-заголовков у ядра нет, поэтому reverse-proxy перед ним обязан пустить
этот интерфейс — без этого не работает ничего, включая /health. Для Caddy:
eva.example.com {
@web header_regexp Origin Origin "^https://eva-web\.example\.com$"
header @web {
Access-Control-Allow-Origin "https://eva-web.example.com"
Access-Control-Allow-Headers "X-Eva-Token, Content-Type"
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Access-Control-Max-Age "86400"
}
@preflight method OPTIONS
respond @preflight 204
# обычная проверка токена — но preflight-запрос заголовков не носит
@unauthorized not header X-Eva-Token {$EVA_TOKEN}
respond @unauthorized 401
reverse_proxy 127.0.0.1:8090
}
Ядро и интерфейс на одном домене — CORS не нужен вовсе.
YouTube
Раздел /youtube работает через свой сервер: браузер ходит только в
/api/yt/* этого же origin, а Nitro проксирует метаданные в приватный
Invidious и видеопоток — в invidious-companion. Ни одного запроса на чужие
домены из браузера не уходит, куки и IP Google не достаются.
NUXT_INVIDIOUS_URL=http://127.0.0.1:3020 \
NUXT_COMPANION_URL=http://127.0.0.1:8282/companion \
NUXT_PUBLIC_YOUTUBE_ENABLED=true \
node .output/server/index.mjs
Инстанс Invidious должен быть настроен с companion, а его public_url
объявлен маркером https://companion.invalid — сервер веба заменяет маркер
на свой путь /api/yt/co в манифестах и ссылках. Роуты с URL в параметре
пускают только *.googlevideo.com, *.ytimg.com, *.ggpht.com,
*.youtube.com — без этого прокси был бы открытым SSRF.
NixOS
{
inputs.eva-web.url = "git+ssh://forgejo@git.desu.church:61488/eva/frontend.git";
# ...
imports = [ inputs.eva-web.nixosModules.default ];
services.eva-web = {
enable = true;
port = 3000;
passwordFile = config.sops.secrets.eva-web-password.path;
sessionSecretFile = config.sops.secrets.eva-web-session.path;
defaultKernelUrl = "https://eva.example.com";
# с какой модели начинать новый чат; пусто — как решит ядро
defaultModel = "moonshotai/kimi-k3";
# раздел YouTube; см. одноимённый раздел выше
invidiousUrl = "http://127.0.0.1:3020";
companionUrl = "http://127.0.0.1:8282/companion";
youtubeEnabled = true;
# имя клиента и строки формы входа: форма живёт до сессии, и голоса
# ядра там ещё нет
brandName = "Ева";
loginHeading = "Вход";
loginHint = "Введите секретную строку.";
# голос: сгенерированный оверлей уже включён, здесь — правка поверх
persona.overlay."web.chat.panel.empty" = "Пока пусто.";
};
}
Секреты приезжают systemd-credentials: в /nix/store и в юните только
пути. Сервис ходит под DynamicUser и ничего не пишет на диск; наружу
ходит только раздел YouTube — в Invidious по приватной сети и за
картинками на *.ytimg.com.
То же самое пользовательским юнитом — homeManagerModules.default:
services.eva-web с теми же persona, brandName, port. Секреты он
читает файлами по путям напрямую: systemd-credentials пользовательским
юнитам недоступны.
Голос по умолчанию
Оверлей, собранный из документа персоны, лежит в nix/persona-overlay.json и включён обоими модулями сам — «из коробки» установка говорит им. Подвинуть можно тремя способами, от мягкого к жёсткому:
# переписать одну фразу, остальную сотню оставить
persona.overlay."web.chat.page.greeting" = "Здравствуйте.";
# отказаться от дефолта: звучит только перечисленное, прочее нейтрально
persona = { useDefaults = false; overlay."web.chat.feed.working" = "Работаю"; };
# отдать готовый файл — он сильнее и дефолта, и ключей
persona.overlayFile = ./свой-оверлей.yaml;
Ключи persona.overlay кладутся поверх дефолта по одному, так что правка
одной строки не отменяет остальные. Что получится — видно без сборки
системы: nix eval --json --expr '(builtins.getFlake "…").lib.mergeOverlay { }'.
Сами модули и выбор источника проверяет scripts/test-overlay-nix.sh.
Что умеет
Полная опись — FEATURES.md; история — CHANGELOG.md. Коротко: чаты со стримингом и картинками, инструменты живьём, кнопочные вопросы, память и её сон, набор инструментов чата и его собственный сон, крон, подагенты, генерации, очередь на одобрение, расход, личности, несколько ядер на выбор.
Голос интерфейса собирается из двух источников разной природы.
Имя и обращение принадлежат ядру: GET /v1/persona отдаёт name и
honorific, они подставляются в {name} и {honorific}. Это знание про
ядро, поэтому и хранится по серверу — два ядра рядом зовутся
по-разному. Ядро постарше отвечает 404, и имя берётся из brandName
сборки.
Сами формулировки ядру не принадлежат: оно о клиентах не знает.
Плоский файл «ключ → строка» называет NUXT_OVERLAY_FILE (модули собирают
его из persona), Nitro читает его на старте и отдаёт вкладке с
/api/overlay. Слой один на приложение: строки не переезжают вслед за
выбранным ядром — «этот экран от того ядра» не бывает. Нет файла или он не
разобрался — интерфейс остаётся нейтральным, причина уходит в лог сервера,
а 500 отсюда не прилетает никогда. Правка файла применяется рестартом.
Готовый пример со всеми прежними формулировками —
docs/persona-overlay.example.yaml;
тот же набор для nix — nix/persona-overlay.json,
его модули подставляют сами (см. «Голос по умолчанию»). Форму, ключи,
плейсхолдеры и полноту форм числа проверяет npm run check:overlay (входит
в typecheck); путь берётся аргументом — node scripts/check-overlay.mjs путь/к/своему.yaml проверит боевой файл, хоть YAML, хоть JSON, до того,
как его подключат.
Список — все обычные чаты, независимо от того, из какого клиента начаты;
полка говорит, о чём разговор, и чат можно переложить в любую. Полки
автоматики (кодовые сессии, ролики, крон) ядро держит тихими: их чаты
приходят, только когда полку открыли. Кодовые сессии всех проектов лежат
на полке code и различаются полем project: чип «Код» открывает их
общим списком, проект подписан в строке чата. Переписка поверхностей
(телеграм, мастерская переводов) в общий список не идёт и открывается
отдельным чипом.
Устройство
app/lib/— слой ядра:types.ts(wire-формат один в один),kernel.ts(все эндпоинты),sse.ts(разбор стрима руками — тур начинается POST'ом,EventSourceтак не умеет),tools.ts(человеческое лицо инструментов),extraTools.ts(свои тулы вкладки: объявления на тур и их исполнение),markdown.ts,highlight.ts(один shiki на всех, кто рисует код),stream.ts(граница между зафиксированным и живым хвостом),stopwatch.ts,images.ts,format.ts,i18n/(тексты интерфейса: встроенный нейтральный каталог и оверлей от ядра).app/composables/— состояние:useChatSession(лента и стрим одного чата),useChats,useServers,useSleep(спит ли Ева — спрашивается только когда на ответ смотрят),useToolCatalog,useTheme,useToast.app/components/— оболочка (AppRail,AppChatPanel) и чат (chat/Feed,chat/Composer,chat/ToolChip, …).server/— замок (гейт на хукеrequest, вход, выход, состояние) и раздел YouTube:api/yt/(метаданные одним обработчиком, DASH-манифест, потоковый passthrough к companion, картинки) с утилитами вutils/(клиент Invidious, переписывание ссылок, кэш, ведро).
Лента бережётся: разметка разбирается один раз на текст и кэшируется, стримящийся ответ живёт отдельным полем — токен перерисовывает один пузырь, а не весь список.