Face
  • TypeScript 48.5%
  • Vue 45%
  • Nix 2.3%
  • CSS 1.9%
  • JavaScript 1.7%
  • Other 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Aleksandr 467b737ce1 Голос персоны включён модулями по умолчанию
Сгенерированный оверлей лежит в nix/persona-overlay.json, и оба модуля —
системный и новый домашний — подставляют его сами. Опция persona даёт три
степени вмешательства: overlay кладётся поверх дефолта по ключам,
useDefaults = false оставляет только своё, overlayFile побеждает всё.

Co-Authored-By: Eva
2026-08-23 19:05:19 +03:00
app Обращения нет — строка с ним уступает встроенной 2026-08-23 18:40:27 +03:00
docs Строки голоса даёт развёртывание, а не ядро 2026-08-23 18:31:21 +03:00
nix Голос персоны включён модулями по умолчанию 2026-08-23 19:05:19 +03:00
scripts Голос персоны включён модулями по умолчанию 2026-08-23 19:05:19 +03:00
server Строки голоса даёт развёртывание, а не ядро 2026-08-23 18:31:21 +03:00
.envrc Встречаю на пороге: direnv с флейком 2026-07-29 18:35:36 +03:00
.gitignore Встречаю на пороге: direnv с флейком 2026-07-29 18:35:36 +03:00
AGENTS.md Голос персоны включён модулями по умолчанию 2026-08-23 19:05:19 +03:00
CHANGELOG.md Голос персоны включён модулями по умолчанию 2026-08-23 19:05:19 +03:00
dist Не подменяю страницу заглушкой — накрываю её 2026-07-30 12:27:25 +03:00
FEATURES.md Голос персоны включён модулями по умолчанию 2026-08-23 19:05:19 +03:00
flake.lock Завожу себе лицо в браузере 2026-07-29 18:24:58 +03:00
flake.nix Голос персоны включён модулями по умолчанию 2026-08-23 19:05:19 +03:00
nuxt.config.ts Строки голоса даёт развёртывание, а не ядро 2026-08-23 18:31:21 +03:00
package-lock.json YouTube смотрится из вкладки, не выдавая Господина Google 2026-08-03 19:47:09 +03:00
package.json Обращения нет — строка с ним уступает встроенной 2026-08-23 18:40:27 +03:00
README.md Голос персоны включён модулями по умолчанию 2026-08-23 19:05:19 +03:00
tsconfig.json Завожу себе лицо в браузере 2026-07-29 18:24:58 +03:00
tsconfig.server.json Завожу себе лицо в браузере 2026-07-29 18:24:58 +03:00

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, переписывание ссылок, кэш, ведро).

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