Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ test_https.zig
test_ws.py
workspace/
coverage/
frontend/dist/
frontend/node_modules/
frontend/.astro/

# Test artifacts
scripts/
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Entry: `src/main.zig`. Module root: `src/root.zig` (re-exports all modules as `@

- **Logging:** All via `zero.App.logger` (Info/Err/Debug/Warn). Never use `std.log.*` or `std.debug.print` in app code.
- **HTTP client:** `zul.http.Client` crashes on `deinit()` if pool has active connections. Workaround: `postJSONZul()` in `src/http_client.zig` sends `Connection: close` header. Do not remove this.
- **WS upgrade + Vite proxy:** In dev, Vite's `/ws` proxy sometimes closes the connection mid-handshake. The server's `upgradeWebsocket` call returns `error.NotOpenForWriting` and the worker then calls `res.write()` on the now-dead FD. That second write hits Zig stdlib's `posix.sendmsg` which has `.BADF => unreachable` and panics the process. Mitigation: `wsUpgrade` in `src/channels/websocket.zig` catches the upgrade error and returns normally (no 500 response) so httpz's catch path doesn't re-write on a dead FD. Real fix for dev users: set `window.__EVA_WS_HOST__ = 'localhost:8765'` in the browser devtools to bypass the Vite WS proxy. In production (`EVA_SERVE_FRONTEND=true`) there's no Vite proxy and the issue does not occur.
- **Telegram HTTPS:** Uses custom `TLSClient` (BearSSL-based), not `zul.http.Client` for polling. Only one `getUpdates` poller at a time — concurrent polls cause 409 conflicts.
- **LLM responses:** Non-streaming only (`stream: false`). SSE streaming disabled due to Zig 0.15.2 `std.json.Scanner` buffer sensitivity.
- **Allocators:** `GeneralPurposeAllocator` for long-lived structs (SessionManager, MessageBus). `ArenaAllocator` for short-lived parsing tasks only.
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
usage:
ps -p $(pgrep -d',' basic) -o %cpu,%mem
ps -p $(pgrep -d',' eva) -o %cpu,%mem

trace:
strace -c zig build eva > /dev/null
Expand Down
Binary file added docs/eva.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
152 changes: 152 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Eva Frontend

Astro 4 + Tailwind 3 single-page app served as static files. ~144 KB
total (96 KB self-hosted woff2, 28 KB JS, 16 KB CSS, 4 KB HTML).

## Quick start

```bash
# One-time: install deps
npm install

# Dev server (port 4321, proxies /api and /ws to :8765)
npm run dev

# Production build (outputs to dist/)
npm run build

# Preview the built bundle locally
npm run preview

# Type-check (Astro + TS)
npx astro check
```

The dev server expects the Eva Zig backend running on `localhost:8765`
(the same port the production binary listens on). Vite proxies both
`/api/*` and `/ws` so the browser sees a single origin.

> **Note on Vite WS proxy:** Vite's dev WebSocket proxy can drop
> long-lived `/ws` connections every few minutes. If you see your
> assistant's response "vanish" after a minute, set
> `window.__EVA_WS_HOST__ = 'localhost:8765'` in the browser devtools
> console and reload — `ws.ts` will then connect to the backend
> directly, bypassing the proxy. (In production
> `EVA_SERVE_FRONTEND=true` the frontend is served by the backend, so
> `location.host` already points there and this is a no-op.)
>
> The frontend also re-fetches the active chat's messages on every
> successful WS `open` event after the first, so responses that
> arrived while the socket was down still appear in the UI.

## Environment

| Eva var | Effect |
|----------------------------|-----------------------------------------------------------|
| `EVA_SERVE_FRONTEND=true` | Make the binary serve `frontend/dist/` from `/` |
| `EVA_FRONTEND_DIR=…` | Override the static dir (default: `frontend/dist`) |
| `EVA_API_KEY=…` | Required for `/api/*` and `/ws`; modal asks for the value |
| `window.__EVA_WS_HOST__` | Browser-only override: WS connects to this host in dev |

The frontend stores the API key in `sessionStorage` (cleared on tab
close). The WebSocket connection sends it as `?token=…` because the
zero upgrader doesn't currently inspect upgrade headers.

## Architecture

```
src/
layouts/Base.astro Shell HTML + <eva-app> custom element
pages/index.astro Loading splash (replaced by boot.ts)
lib/
boot.ts mountEva() — the only entry point
api.ts fetch() wrappers for /api/*
icons.ts Inline SVG icon set
state/
store.ts Proxy-based reactive store + selectors
ws.ts WebSocket client with exponential backoff,
re-fetches active chat on reconnect
components/
Shell.ts Top-level 2-pane layout, event delegation
Sidebar.ts Chat list, header, status footer
MainPanel.ts Header + MessageList + Input + modals
MessageList.ts Render messages + streaming delta bubble
Input.ts Auto-resizing textarea + send
SettingsModal.ts Read-only model + 4 per-turn overrides
TracePanel.ts Right drawer showing agent steps
actions.ts data-action / data-form / data-change router
styles/global.css @font-face, design tokens, scrollbar,
focus ring, fade-in / pulse animations
public/
fonts/*.woff2 Self-hosted variable woff2 (no CDN)
favicon.svg
```

### Data flow

```
boot.ts
┌────────► store.ts ──────────► Shell.ts re-renders
│ ▲ │
│ │ ▼
│ set() │ sidebar + main
│ │ │
│ ┌────┴────┐ │
│ │ ws.ts │ ▼
│ │ api │ actions.ts
│ └─────────┘ │
│ ▼
│ user interaction
```

- **Single source of truth**: a module-level `state` object in `store.ts`.
- **Mutations**: `set(patch)` or `set(s => partial)`. Schedules a rAF
flush of subscribers.
- **Rendering**: `Shell.ts` subscribes once and re-renders both panes
on every change. Each component is a pure `renderX(state): string`
function — no VDOM, no reconciliation.
- **Events**: delegated through `data-action`, `data-form`,
`data-change` attributes, all routed via `actions.ts`.
- **Streaming**: WS deltas accumulate in `state.pendingDelta[chatId]`
and the `MessageList` renders the tail inline (cursor block + pulse).
The full assistant message replaces the tail when `type: "message"`
arrives.

### Why no framework?

The whole UI is ~10 components, <2000 LOC of TS + HTML. A VDOM buys
nothing here and costs bundle size. The trade-off is "re-render the
whole shell on every state change" which is fine at this scale: full
innerHTML rewrite is <5 ms in Chrome.

If a future feature needs targeted sub-tree patches (e.g. live tool
output with thousands of lines), add a `mount(parent, render)` helper
that diffs the parent's children against a list of keyed nodes. Don't
adopt a framework.

## Build output

```
dist/
index.html 4 KB - static HTML with <eva-app> root
favicon.svg 1 KB
fonts/
SpaceGrotesk.woff2 22 KB - variable font (400..700)
DMSans.woff2 36 KB - variable font (400..500)
JetBrainsMono.woff2 31 KB - static
_astro/
index.<hash>.css 16 KB - Tailwind purged
hoisted.<hash>.js 28 KB - all app code (7.8 KB gzipped)
```

## Conventions

- **No emojis** in source, comments, or UI.
- **TypeScript strict mode** is on. `astro check` is the source of truth.
- **No new runtime dependencies** without discussion. The bundle is
intentionally tiny.
- **All async UI work** is awaited; rejections are caught and surfaced
as a transient `role: "system"` message in the active chat.
- **Auth tokens** are session-scoped (sessionStorage), never localStorage.
33 changes: 33 additions & 0 deletions frontend/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';

// https://astro.build/config
export default defineConfig({
integrations: [tailwind({ applyBaseStyles: true })],
output: 'static',
server: {
port: 4321,
host: '0.0.0.0',
},
vite: {
server: {
proxy: {
// Proxy API + WS to the Zig backend during `astro dev`.
// Same shape as the prod single-binary deploy:
// GET /api/* -> eva :8765
// WS /ws -> eva :8765
'/api': {
target: 'http://localhost:8765',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8765',
ws: true,
changeOrigin: true,
},
},
},
},
// Self-host the Space Grotesk + DM Sans woff2 in /public/fonts/.
// See docs/frontend/fonts.md for the fetch + @font-face wiring.
});
Loading
Loading