diff --git a/.changeset/eve-integration.md b/.changeset/eve-integration.md new file mode 100644 index 00000000..00102a9e --- /dev/null +++ b/.changeset/eve-integration.md @@ -0,0 +1,5 @@ +--- +"evlog": minor +--- + +Add `evlog/eve` with `defineEvlogHook()` for one wide event per agent turn and `useLogger()` in tools (AsyncLocalStorage on `turn.started`; pass `ctx` only when ALS is unavailable) — full drain, enrich, and tail-sampling pipeline. Tracks tool durations (including post-approval resumes), session context carry-over with LRU eviction (`maxSessions`), slim `eve.phase` / `eve.sessionTurns` fields, and compact HITL `approval`. The turn logger is bound via AsyncLocalStorage on `turn.started`; pass `ctx` when ALS is unavailable. Turn state is shared via `globalThis` when eve bundles hooks and tools separately. `finalizeAudit()` no longer crashes on partial `audit` objects missing `actor` fields. Fixes `_auditForceKeep` leaking on force-kept events and skips Nitro runtime probes on Next.js hosts. diff --git a/.changeset/stream-eve-proxy-fix.md b/.changeset/stream-eve-proxy-fix.md new file mode 100644 index 00000000..c790b25a --- /dev/null +++ b/.changeset/stream-eve-proxy-fix.md @@ -0,0 +1,5 @@ +--- +"evlog": patch +--- + +Fix stream server mis-detection when co-located with eve dev: return 404 (not SSE 200) for non-root GET paths, and re-bind the turn logger on `actions.requested` so tool handlers can resolve `useLogger()`. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 18acfc16..28624fd5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -34,6 +34,7 @@ scope. - docs (the documentation site) - dx (developer experience improvements) - elysia (Elysia plugin) +- eve (eve agent integration) - express (Express middleware) - fastify (Fastify plugin) - fs (File System drain adapter) diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index 1dee9858..221e483c 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -33,6 +33,7 @@ jobs: docs dx elysia + eve express fastify fs diff --git a/.gitignore b/.gitignore index 701d3a2d..890b71da 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ node_modules # Logs logs +!examples/eve/app/api/demo/logs/ +!examples/eve/app/api/demo/logs/** *.log # Misc @@ -45,12 +47,14 @@ apps/web/.data apps/chat/.data .codex/environments/ .claude/ +.eve/ # Local-only working notes (intentions, follow-ups, brain dumps) .notes/ # Local bug repro scripts (not versioned) examples/repro/ +examples/eve/.eve packages/evlog/test/nitro-v3/fixture/.wrangler apps/nuxthub-playground/.data .next/ \ No newline at end of file diff --git a/apps/docs/app/assets/icons/eve.svg b/apps/docs/app/assets/icons/eve.svg new file mode 100644 index 00000000..d78ded92 --- /dev/null +++ b/apps/docs/app/assets/icons/eve.svg @@ -0,0 +1,9 @@ + diff --git a/apps/docs/config/redirects.ts b/apps/docs/config/redirects.ts index 45deb52e..5ebc1760 100644 --- a/apps/docs/config/redirects.ts +++ b/apps/docs/config/redirects.ts @@ -22,6 +22,7 @@ export const redirects: Record = { '/adapters': r('/integrate/adapters/overview'), '/enrichers': r('/use-cases/enrichers'), '/use-cases': r('/use-cases/overview'), + '/use-cases/eve/overview': r('/use-cases/eve'), // Getting Started → Start + Reference '/getting-started/introduction': r('/start/introduction'), diff --git a/apps/docs/content/4.use-cases/0.overview.md b/apps/docs/content/4.use-cases/0.overview.md index aea9472f..6340b4d5 100644 --- a/apps/docs/content/4.use-cases/0.overview.md +++ b/apps/docs/content/4.use-cases/0.overview.md @@ -30,6 +30,7 @@ Use Cases are **recipes**, not features. Each one solves a specific problem with | Capture every AI SDK call with token usage, tool calls, streaming metrics, and cost | [AI SDK](/use-cases/ai-sdk/overview) | | Identify the authenticated user (and their org / role) on every wide event | [Better Auth](/use-cases/better-auth/overview) | | Build a tamper-evident audit trail with hash chains, denials, redaction-aware diffs | [Audit Logs](/use-cases/audit/overview) | +| Export wide events from [eve](https://eve.dev) agent turns (tokens, tools, drains) | [eve](/use-cases/eve) | | Add derived context (User-Agent, geo, request size, trace context) to every event | [Enrichers](/use-cases/enrichers) | ## How they relate diff --git a/apps/docs/content/4.use-cases/5.eve.md b/apps/docs/content/4.use-cases/5.eve.md new file mode 100644 index 00000000..a8cd333d --- /dev/null +++ b/apps/docs/content/4.use-cases/5.eve.md @@ -0,0 +1,263 @@ +--- +title: eve +description: Export one evlog wide event per eve agent turn — token usage, tool executions, business context, drains, enrichers, and tail sampling alongside Agent Runs and OpenTelemetry. +navigation: + title: eve + icon: i-custom-eve +links: + - label: Source Code + icon: i-simple-icons-github + to: https://github.com/HugoRCD/evlog/tree/main/examples/eve + color: neutral + variant: subtle +--- + +[eve](https://eve.dev/docs/introduction) ships built-in observability: **Agent Runs** on Vercel and optional **OpenTelemetry** spans via `agent/instrumentation.ts`. `evlog/eve` adds a third layer — **exportable wide events** per turn with your full evlog pipeline (drains, enrichers, tail sampling, audit). + +::callout{icon="i-lucide-info" color="info"} +eve is currently in beta; hook and stream event shapes may change before GA. Pin `eve` and `evlog` versions in production agents. +:: + +::prompt +--- +description: Add evlog wide events to my eve agent +icon: i-custom-eve +actions: + - copy + - cursor + - windsurf +--- + +Add evlog wide events to my eve agent. + +- Install evlog: pnpm add evlog +- Create agent/hooks/evlog.ts with defineEvlogHook from 'evlog/eve' +- Pass drain, enrich, and keep options (same as HTTP middleware integrations) +- In tools, import useLogger from 'evlog/eve' and call useLogger() inside execute() — the turn logger is bound via AsyncLocalStorage when defineEvlogHook() is registered; pass ctx only if ALS is unavailable in your runtime +- User message content is redacted by default (redactMessage: true); set false only after reviewing PII policy +- Keep eve Agent Runs / OTel instrumentation — evlog/eve is additive + +Docs: https://www.evlog.dev/use-cases/eve +Adapters: https://www.evlog.dev/integrate/adapters/overview + +:: + +## When to use what + +| Need | Use | +| --- | --- | +| Debug a session in Vercel | eve **Agent Runs** (automatic) | +| Span-level traces in Datadog / Honeycomb | eve **`agent/instrumentation.ts`** + OTel exporter | +| Wide events to Axiom / Better Stack / FS, billing, audit, tail sampling | **`evlog/eve`** | + +## Quick Start + +### 1. Install + +::code-group +```bash [pnpm] +pnpm add evlog eve +``` +```bash [bun] +bun add evlog eve +``` +```bash [yarn] +yarn add evlog eve +``` +```bash [npm] +npm install evlog eve +``` +:: + +### 2. Add the hook + +Create `agent/hooks/evlog.ts`: + +```typescript [agent/hooks/evlog.ts] +import { defineEvlogHook } from 'evlog/eve' +import { createAxiomDrain } from 'evlog/axiom' + +export default defineEvlogHook({ + init: { env: { service: 'my-agent' } }, + drain: createAxiomDrain(), + enrich: (ctx) => { + ctx.event.region = process.env.VERCEL_REGION + }, +}) +``` + +eve auto-discovers hook files under `agent/hooks/`. No HTTP middleware — the unit of work is an agent **turn**, not a request. + +### 3. Log business context from tools + +`defineEvlogHook()` binds the turn logger via **AsyncLocalStorage** on `turn.started`. Inside tool `execute()` handlers, call `useLogger()` with no arguments — same ergonomics as `useLogger(event)` in Nuxt or Hono. + +In the [example agent](https://github.com/HugoRCD/evlog/tree/main/examples/eve), support tools attach customer and order context as the agent works a refund: + +```typescript [agent/tools/lookup_order.ts] +import { defineTool } from 'eve/tools' +import { useLogger } from 'evlog/eve' +import { z } from 'zod' + +export default defineTool({ + description: 'Look up an order by id.', + inputSchema: z.object({ orderId: z.string() }), + async execute({ orderId }) { + const order = await fetchOrder(orderId) + const log = useLogger() + log.set({ order: { id: order.id, amount: order.amount, status: order.status } }) + return order + }, +}) +``` + +::callout{icon="i-lucide-plug" color="neutral"} +**Fallback:** if `useLogger()` throws outside a turn, pass eve tool `ctx`: `useLogger(ctx)`. This covers separate hook/tool bundles or runtimes where AsyncLocalStorage does not propagate. With a standard eve agent layout and `agent/hooks/evlog.ts` registered, you should not need it. +:: + +### 4. Next.js web chat (optional) + +Wrap `next.config.ts` with `withEve()` from `eve/next` and use `useEveAgent()` from `eve/react` in your app. eve starts alongside `next dev` and proxies `/eve/v1/*` on the same origin — tool calls, approvals, and `ask_question` work out of the box. See the [example agent](https://github.com/HugoRCD/evlog/tree/main/examples/eve). + +## Wide event shape + +Each completed turn emits one event: + +```json [Wide Event — turn awaiting approval] +{ + "method": "EVE", + "path": "/sessions/sess_abc/turns/turn_0", + "status": 200, + "duration": "7.9s", + "service": "clearbill-support-agent", + "eve": { + "sessionId": "sess_abc", + "turnId": "turn_0", + "turnSequence": 0, + "phase": "awaiting-approval", + "sessionTurns": 1 + }, + "customer": { "slug": "acme-corp", "plan": "enterprise" }, + "order": { "id": "4821", "amount": 890, "currency": "USD" }, + "approval": { "status": "pending", "tool": "issue_refund" }, + "ai": { + "calls": 2, + "steps": 2, + "inputTokens": 11724, + "outputTokens": 282, + "finishReason": "tool-calls", + "tools": [ + { "name": "lookup_customer", "durationMs": 26, "success": true }, + { "name": "lookup_order", "durationMs": 13, "success": true } + ] + } +} +``` + +After approval, the next turn carries the outcome: + +```json [Wide Event — refund completed] +{ + "path": "/sessions/sess_abc/turns/turn_1", + "eve": { + "sessionId": "sess_abc", + "turnId": "turn_1", + "turnSequence": 1, + "sessionTurns": 2 + }, + "refund": { "orderId": "4821", "amount": 890, "status": "refunded" }, + "audit": { "action": "refund.issued", "target": { "type": "order", "id": "4821" } }, + "approval": { "status": "approved", "tool": "issue_refund" }, + "ai": { + "tools": [{ "name": "issue_refund", "durationMs": 993, "success": true }], + "finishReason": "stop" + } +} +``` + +Token usage and tool executions are accumulated from eve stream events (`step.completed`, `actions.requested`, `action.result`). Business fields set via `useLogger()` carry across turns in the same session. Link turns in analytics with `eve.sessionId` + `eve.turnSequence` — each event stays self-contained. `eve.phase` is only set for non-routine endings (`awaiting-approval`, `rejected`, `failed`). + +## Production + +Long-running eve agents should disable terminal pretty-printing and use a non-blocking drain: + +```typescript [agent/hooks/evlog.ts] +import { defineEvlogHook } from 'evlog/eve' +import { createAxiomDrain } from 'evlog/axiom' +import { createDrainPipeline } from 'evlog/pipeline' + +const drain = createDrainPipeline({ batch: { size: 50, intervalMs: 5000 } })( + createAxiomDrain(), +) + +export default defineEvlogHook({ + init: { + env: { service: 'my-agent', environment: 'production' }, + pretty: false, + sampling: { rates: { info: 10 } }, + }, + drain, + maxSessions: 256, +}) +``` + +| Concern | Recommendation | +| --- | --- | +| Terminal output | `init.pretty: false` — pretty-print is for local dev only | +| Drain latency | Batch or async HTTP drains; never block the turn on I/O | +| Head sampling | `init.sampling.rates` — eve emits one event per turn, not per token | +| Memory | `maxSessions` (default `256`) evicts oldest idle session state | +| Load | Hook handlers are O(1) per stream event; cost is dominated by your drain | + +## Options + +| Option | Description | +| --- | --- | +| `init` | Passed to `initLogger()` on first hook invocation | +| `drain` / `enrich` / `keep` / `plugins` | Same as HTTP integrations ([plugins](/extend/plugins)) | +| `redactMessage` | Default `true` — omits user message text from the wide event | +| `cost` / `model` | Optional token pricing (`ModelCost` from `evlog/ai`) → `ai.estimatedCost` | +| `maxSessions` | In-memory session cap for context carry-over (default `256`) | +| `include` / `exclude` | Route-style filters on turn paths (`/sessions/*/turns/*`) | + +## Tail sampling + +Use `keep` to force-keep turns with failed tools or high token usage: + +```typescript [agent/hooks/evlog.ts] +export default defineEvlogHook({ + drain: createAxiomDrain(), + keep: (ctx) => { + const ai = ctx.context.ai as { + inputTokens?: number + outputTokens?: number + tools?: Array<{ success: boolean }> + } | undefined + const totalTokens = (ai?.inputTokens ?? 0) + (ai?.outputTokens ?? 0) + if (totalTokens > 10_000) ctx.shouldKeep = true + if (ai?.tools?.some(t => !t.success)) ctx.shouldKeep = true + }, +}) +``` + +## Audit logs + +Combine with [Audit Logs](/use-cases/audit/overview): register `auditEnricher()` via `init.plugins` or a global plugin, and call `log.audit()` inside tools when a human approval gate fires. Tool rejections surface on `action.result` with `status: "rejected"`. + +## Run locally + +```bash +git clone https://github.com/HugoRCD/evlog +cd evlog +pnpm install +pnpm run example:eve +``` + +The `examples/eve` project is a **support refund copilot** (Clearbill SaaS): lookup customer → lookup order → issue refund, with eve approvals when amount > $100. Run `pnpm run example:eve`, open **http://localhost:3000**, and click a starter prompt. + +## What to read next + +- [eve hooks guide](https://eve.dev/docs/guides/hooks) — stream event vocabulary +- [eve instrumentation](https://eve.dev/docs/guides/instrumentation) — OTel spans (complementary) +- [AI SDK use case](/use-cases/ai-sdk/overview) — when you own the model loop directly +- [Adapters overview](/integrate/adapters/overview) — drain destinations diff --git a/apps/docs/nuxt.config.ts b/apps/docs/nuxt.config.ts index e4f21bc4..7842b9b5 100644 --- a/apps/docs/nuxt.config.ts +++ b/apps/docs/nuxt.config.ts @@ -32,9 +32,13 @@ export default defineNuxtConfig({ }, fonts: { + defaults: { + // Full variable axis — discrete weights from @nuxt/ui defaults render too thin on Chromium. + weights: ['100 900'], + }, families: [ - { name: 'Geist', weights: [400, 500, 600, 700], global: true }, - { name: 'Geist Mono', weights: [400, 500, 600, 700], global: true }, + { name: 'Geist', weights: ['100 900'], global: true }, + { name: 'Geist Mono', weights: ['100 900'], global: true }, { name: 'Geist Pixel Line', src: '/fonts/GeistPixel-Line.woff2', diff --git a/apps/docs/public/eve-logo.svg b/apps/docs/public/eve-logo.svg new file mode 100644 index 00000000..780f9327 --- /dev/null +++ b/apps/docs/public/eve-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/examples/eve/.gitignore b/examples/eve/.gitignore new file mode 100644 index 00000000..1824ff87 --- /dev/null +++ b/examples/eve/.gitignore @@ -0,0 +1,5 @@ +.eve +.evlog +.next +.output +*.tsbuildinfo diff --git a/examples/eve/README.md b/examples/eve/README.md new file mode 100644 index 00000000..2507f286 --- /dev/null +++ b/examples/eve/README.md @@ -0,0 +1,79 @@ +# evlog + eve — Support refund demo + +A **support copilot** for a fake SaaS ("Clearbill"). A rep asks for a refund → the agent looks up the customer and order → issues the refund (with **human approval** when amount > $100). + +Each completed turn emits **one evlog wide event** with the full story: customer, order, refund, audit trail, token usage, and tool outcomes. That's the point of the demo — not abstract `tenantId` fields. + +## The story + +> "Acme Corp was double-charged on order #4821 ($890). Issue a refund." + +1. `lookup_customer` → attaches `customer.{id, plan, mrr}` to the wide event +2. `lookup_order` → attaches `order.{id, amount, product}` +3. `issue_refund` → **approval UI** (amount > $100) → attaches `refund` + `audit.refund.issued` + +Finance opens PostHog (or your drain) and gets **one JSON object** per turn — no log scavenger hunt. + +### Example wide event (after approval) + +```json +{ + "service": "clearbill-support-agent", + "method": "EVE", + "path": "/sessions/sess_…/turns/turn_…", + "status": 200, + "customer": { + "id": "cust_8f2a", + "slug": "acme-corp", + "name": "Acme Corp", + "plan": "enterprise", + "mrr": 2400 + }, + "order": { + "id": "4821", + "amount": 890, + "currency": "USD", + "product": "Enterprise — annual add-on (5 seats)" + }, + "refund": { + "orderId": "4821", + "amount": 890, + "reason": "Double charge", + "requiresApproval": true + }, + "audit": { + "action": "refund.issued", + "actor": { "type": "agent", "id": "clearbill-support-copilot" }, + "target": { "type": "order", "id": "4821" }, + "outcome": "success" + }, + "ai": { "calls": 3, "tools": […] } +} +``` + +Compare with order **#1102** ($49) — same flow, **no approval** (under the $100 threshold). + +## Run + +From the monorepo root (Turbo runs `evlog` dev:prepare so `evlog/eve` resolves): + +```bash +pnpm install +pnpm run example:eve +``` + +Open **http://localhost:3000** and click a starter prompt, or paste your own. + +Requires `POSTHOG_API_KEY` in the repo root `.env` for the drain (events still log to stdout without it). + +## Files + +| Path | Role | +| --- | --- | +| `agent/instructions.md` | Support copilot persona + fake CRM table | +| `agent/lib/support-data.ts` | Acme Corp & Startup Inc fake data | +| `agent/tools/lookup_*.ts` | CRM lookups → `useLogger()` | +| `agent/tools/issue_refund.ts` | Refund + approval gate + audit fields | +| `agent/hooks/evlog.ts` | Wide event per turn, tail-keep on refunds/audit | + +Docs: https://evlog.dev/use-cases/eve diff --git a/examples/eve/agent/agent.ts b/examples/eve/agent/agent.ts new file mode 100644 index 00000000..5de478c9 --- /dev/null +++ b/examples/eve/agent/agent.ts @@ -0,0 +1,5 @@ +import { defineAgent } from 'eve' + +export default defineAgent({ + model: 'anthropic/claude-sonnet-4.6', +}) diff --git a/examples/eve/agent/channels/eve.ts b/examples/eve/agent/channels/eve.ts new file mode 100644 index 00000000..7cd09cf4 --- /dev/null +++ b/examples/eve/agent/channels/eve.ts @@ -0,0 +1,13 @@ +import { eveChannel } from 'eve/channels/eve' +import { localDev, placeholderAuth, vercelOidc } from 'eve/channels/auth' + +export default eveChannel({ + auth: [ + // Open on localhost for `eve dev` and the REPL; ignored in production. + localDev(), + // Lets the eve TUI and your Vercel deployments reach the deployed agent. + vercelOidc(), + // Placeholder for production browser auth — swap for Auth.js, Clerk, or none() before deploy. + placeholderAuth(), + ], +}) diff --git a/examples/eve/agent/hooks/evlog.ts b/examples/eve/agent/hooks/evlog.ts new file mode 100644 index 00000000..edeedadd --- /dev/null +++ b/examples/eve/agent/hooks/evlog.ts @@ -0,0 +1,31 @@ +import type { DrainContext } from 'evlog' +import { defineEvlogHook } from 'evlog/eve' +import { createFsDrain } from 'evlog/fs' +import { createDrainPipeline } from 'evlog/pipeline' + +const batchedFsDrain = createDrainPipeline({ + batch: { size: 5, intervalMs: 2000 }, +})(createFsDrain()) + +export default defineEvlogHook({ + init: { env: { service: 'clearbill-support-agent' } }, + drain: batchedFsDrain, + enrich: (ctx) => { + ctx.event.runtime = process.env.VERCEL_REGION ?? 'local' + ctx.event.demo = 'evlog-eve-support-refund' + }, + keep: (ctx) => { + const { context } = ctx + const tools = (context.ai as { tools?: Array<{ success: boolean }> } | undefined)?.tools + if (tools?.some(tool => !tool.success)) { + ctx.shouldKeep = true + } + const refund = context.refund as { amount?: number } | undefined + if ((refund?.amount ?? 0) > 100) { + ctx.shouldKeep = true + } + if (context.audit) { + ctx.shouldKeep = true + } + }, +}) diff --git a/examples/eve/agent/instructions.md b/examples/eve/agent/instructions.md new file mode 100644 index 00000000..362cc43c --- /dev/null +++ b/examples/eve/agent/instructions.md @@ -0,0 +1,26 @@ +# Clearbill Support Copilot + +You are the internal support agent for **Clearbill**, a B2B SaaS product. Support reps use you to look up accounts, verify charges, and issue refunds. + +Be concise and professional. Summarize what you found before taking action. + +## Workflow + +For every refund request: + +1. **`lookup_customer`** — resolve the company (slug like `acme-corp`, or name). +2. **`lookup_order`** — verify the order exists, amount, and status. +3. **`issue_refund`** — only after the order is confirmed paid. Refunds **over $100** pause for human approval automatically. + +If the rep omits the customer or order id, use **`ask_question`** to collect what's missing. + +Never issue a refund without looking up the order first. + +## Demo accounts (fake CRM) + +| Customer | Slug | Plan | Notable order | +| --- | --- | --- | --- | +| Acme Corp | `acme-corp` | Enterprise ($2,400/mo) | **#4821** — $890 (needs approval) | +| Startup Inc | `startup-inc` | Pro ($49/mo) | **#1102** — $49 (auto-refund) | + +When asked what evlog does: explain that **each turn emits one wide event** with customer, order, refund, token usage, and tool outcomes — so finance can audit "who refunded what, when, and why" without stitching log lines together. diff --git a/examples/eve/agent/lib/fake-latency.ts b/examples/eve/agent/lib/fake-latency.ts new file mode 100644 index 00000000..12c7d3c4 --- /dev/null +++ b/examples/eve/agent/lib/fake-latency.ts @@ -0,0 +1,6 @@ +/** Simulate CRM / billing API latency for the demo UI. */ +export function fakeLatency(minMs: number, maxMs: number): Promise { + const span = Math.max(0, maxMs - minMs) + const ms = minMs + Math.floor(Math.random() * (span + 1)) + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/examples/eve/agent/lib/support-data.ts b/examples/eve/agent/lib/support-data.ts new file mode 100644 index 00000000..e2be3543 --- /dev/null +++ b/examples/eve/agent/lib/support-data.ts @@ -0,0 +1,72 @@ +/** Fake support CRM data for the evlog × eve demo — no external API. */ + +export interface SupportCustomer { + readonly id: string + readonly slug: string + readonly name: string + readonly plan: 'starter' | 'pro' | 'enterprise' + readonly mrr: number + readonly status: 'active' | 'past_due' +} + +export interface SupportOrder { + readonly id: string + readonly customerSlug: string + readonly amount: number + readonly currency: 'USD' + readonly status: 'paid' | 'refunded' | 'disputed' + readonly product: string + readonly paidAt: string +} + +export const CUSTOMERS: Record = { + 'acme-corp': { + id: 'cust_8f2a', + slug: 'acme-corp', + name: 'Acme Corp', + plan: 'enterprise', + mrr: 2400, + status: 'active', + }, + 'startup-inc': { + id: 'cust_3b91', + slug: 'startup-inc', + name: 'Startup Inc', + plan: 'pro', + mrr: 49, + status: 'active', + }, +} + +export const ORDERS: Record = { + '4821': { + id: '4821', + customerSlug: 'acme-corp', + amount: 890, + currency: 'USD', + status: 'paid', + product: 'Enterprise — annual add-on (5 seats)', + paidAt: '2026-06-18T14:22:00Z', + }, + '1102': { + id: '1102', + customerSlug: 'startup-inc', + amount: 49, + currency: 'USD', + status: 'paid', + product: 'Pro — monthly', + paidAt: '2026-06-01T09:05:00Z', + }, +} + +export function findCustomer(input: string): SupportCustomer | undefined { + const normalized = input.trim().toLowerCase() + return CUSTOMERS[normalized] + ?? Object.values(CUSTOMERS).find( + c => c.id === normalized || c.name.toLowerCase() === normalized, + ) +} + +export function findOrder(orderId: string): SupportOrder | undefined { + return ORDERS[orderId.trim()] +} diff --git a/examples/eve/agent/tools/issue_refund.ts b/examples/eve/agent/tools/issue_refund.ts new file mode 100644 index 00000000..b6842131 --- /dev/null +++ b/examples/eve/agent/tools/issue_refund.ts @@ -0,0 +1,61 @@ +import { defineTool } from 'eve/tools' +import { useLogger } from 'evlog/eve' +import { z } from 'zod' +import { findOrder } from '../lib/support-data.js' +import { fakeLatency } from '../lib/fake-latency.js' + +/** Refunds above this amount require human approval in the demo. */ +export const REFUND_APPROVAL_THRESHOLD_USD = 100 + +function orderRequiresApproval(order: { amount: number } | undefined): boolean { + return (order?.amount ?? 0) > REFUND_APPROVAL_THRESHOLD_USD +} + +export default defineTool({ + description: 'Issue a refund on a paid Clearbill order. Amounts over $100 require human approval.', + inputSchema: z.object({ + orderId: z.string(), + reason: z.string().describe('Why the customer is getting a refund'), + }), + needsApproval: ({ toolInput }) => { + const order = findOrder(String(toolInput?.orderId ?? '')) + return orderRequiresApproval(order) + }, + async execute({ orderId, reason }) { + await fakeLatency(900, 1600) + + const order = findOrder(orderId) + if (!order) { + return { ok: false, error: 'order_not_found', orderId } + } + if (order.status === 'refunded') { + return { ok: false, error: 'already_refunded', orderId } + } + + const log = useLogger() + log.set({ + refund: { + orderId: order.id, + amount: order.amount, + currency: order.currency, + reason, + status: 'refunded', + }, + }) + log.audit({ + action: 'refund.issued', + actor: { type: 'agent', id: 'clearbill-support-copilot' }, + target: { type: 'order', id: order.id }, + reason, + }) + + return { + ok: true, + refundId: `rfnd_${order.id}`, + orderId: order.id, + amount: order.amount, + currency: order.currency, + status: 'refunded', + } + }, +}) diff --git a/examples/eve/agent/tools/lookup_customer.ts b/examples/eve/agent/tools/lookup_customer.ts new file mode 100644 index 00000000..a3bba054 --- /dev/null +++ b/examples/eve/agent/tools/lookup_customer.ts @@ -0,0 +1,34 @@ +import { defineTool } from 'eve/tools' +import { useLogger } from 'evlog/eve' +import { z } from 'zod' +import { findCustomer } from '../lib/support-data.js' +import { fakeLatency } from '../lib/fake-latency.js' + +export default defineTool({ + description: 'Look up a Clearbill customer by slug (e.g. acme-corp) or account id.', + inputSchema: z.object({ + query: z.string().describe('Customer slug, name, or cust_* id'), + }), + async execute({ query }) { + await fakeLatency(450, 950) + + const customer = findCustomer(query) + if (!customer) { + return { found: false, query } + } + + const log = useLogger() + log.set({ + customer: { + id: customer.id, + slug: customer.slug, + name: customer.name, + plan: customer.plan, + mrr: customer.mrr, + status: customer.status, + }, + }) + + return { found: true, customer } + }, +}) diff --git a/examples/eve/agent/tools/lookup_order.ts b/examples/eve/agent/tools/lookup_order.ts new file mode 100644 index 00000000..84f11f72 --- /dev/null +++ b/examples/eve/agent/tools/lookup_order.ts @@ -0,0 +1,34 @@ +import { defineTool } from 'eve/tools' +import { useLogger } from 'evlog/eve' +import { z } from 'zod' +import { findOrder } from '../lib/support-data.js' +import { fakeLatency } from '../lib/fake-latency.js' + +export default defineTool({ + description: 'Look up a Clearbill order by id (e.g. 4821).', + inputSchema: z.object({ + orderId: z.string(), + }), + async execute({ orderId }) { + await fakeLatency(350, 750) + + const order = findOrder(orderId) + if (!order) { + return { found: false, orderId } + } + + const log = useLogger() + log.set({ + order: { + id: order.id, + customerSlug: order.customerSlug, + amount: order.amount, + currency: order.currency, + status: order.status, + product: order.product, + }, + }) + + return { found: true, order } + }, +}) diff --git a/examples/eve/app/_components/agent-chat.tsx b/examples/eve/app/_components/agent-chat.tsx new file mode 100644 index 00000000..922b1a65 --- /dev/null +++ b/examples/eve/app/_components/agent-chat.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useEveAgent } from "eve/react"; +import { AlertCircleIcon } from "lucide-react"; +import { useEffect } from "react"; +import { + Conversation, + ConversationContent, + ConversationScrollButton, +} from "@/components/ai-elements/conversation"; +import { + PromptInput, + type PromptInputMessage, + PromptInputSubmit, + PromptInputTextarea, +} from "@/components/ai-elements/prompt-input"; +import { cn } from "@/lib/utils"; +import { AgentMessage } from "./agent-message"; +import { EveLogo } from "./eve-logo"; + +const AGENT_NAME = "Clearbill Support"; +const DEMO_TAGLINE = "Each turn → one evlog wide event (customer, order, refund, audit)."; +const BETA_TERMS_HREF = "https://vercel.com/docs/release-phases/public-beta-agreement"; + +const STARTER_PROMPTS = [ + { + label: "Double-charge — Acme Corp, order #4821 ($890, needs approval)", + message: + "Acme Corp says they were double-charged on order #4821. Look up the account and order, then issue a refund.", + }, + { + label: "Small refund — Startup Inc, order #1102 ($49, auto)", + message: + "Startup Inc wants a refund on order #1102 — wrong plan selected. Look everything up and refund if valid.", + }, + { + label: "Missing info — ask me first", + message: + "A customer wants a refund but I forgot to paste the order number. Help me through the workflow.", + }, +] as const; + +type AgentStatus = ReturnType["status"]; + +export function AgentChat({ + embedded = false, + onStatusChange, +}: { + readonly embedded?: boolean; + readonly onStatusChange?: (label: string) => void; +}) { + const agent = useEveAgent(); + const isBusy = agent.status === "submitted" || agent.status === "streaming"; + const isEmpty = agent.data.messages.length === 0; + + useEffect(() => { + if (!onStatusChange) return; + const label = + agent.status === "streaming" + ? "Streaming" + : agent.status === "submitted" + ? "Thinking" + : agent.status === "error" + ? "Error" + : "Ready"; + onStatusChange(label); + }, [agent.status, onStatusChange]); + + const handleSubmit = async (message: PromptInputMessage) => { + const text = message.text.trim(); + if (!text || isBusy) return; + + await agent.send({ message: text }); + }; + + const sendStarter = async (message: string) => { + if (isBusy) return; + await agent.send({ message }); + }; + + const composer = ( + + + + + ); + + return ( +
+ {!embedded && isEmpty ? null : !embedded ? ( +
+ + + {AGENT_NAME} + + + + Public preview + +
+ ) : null} + + {agent.error ? ( +
+
+ +
+

Request failed

+

{agent.error.message}

+
+
+
+ ) : null} + + {isEmpty ? null : ( + + + {agent.data.messages.map((message, index) => ( + agent.send({ inputResponses })} + /> + ))} + + + + )} + +
+ {isEmpty ? ( +
+ {!embedded ? ( +
+ +

+ evlog × eve demo +

+

{AGENT_NAME}

+

{DEMO_TAGLINE}

+
+ ) : ( +
+

Try a starter scenario

+

+ Each completed turn emits one structured wide event — watch it appear in the + panel on the right. +

+
+ )} +
+ {STARTER_PROMPTS.map((prompt) => ( + + ))} +
+ {!embedded ? ( + + Public preview + + ) : null} +
+ ) : null} +
{composer}
+
+
+ ); +} + +function StatusDot({ status }: { readonly status: AgentStatus }) { + const isLive = status === "submitted" || status === "streaming"; + const tone = + status === "error" + ? "bg-destructive" + : isLive + ? "bg-emerald-500" + : status === "ready" + ? "bg-muted-foreground" + : "bg-muted-foreground/50"; + + return ( + + {isLive ? ( + + ) : null} + + + ); +} diff --git a/examples/eve/app/_components/agent-message.tsx b/examples/eve/app/_components/agent-message.tsx new file mode 100644 index 00000000..adfd6bc0 --- /dev/null +++ b/examples/eve/app/_components/agent-message.tsx @@ -0,0 +1,169 @@ +"use client"; + +import type { EveDynamicToolPart, EveMessage, EveMessagePart } from "eve/react"; +import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message"; +import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning"; +import { + Tool, + ToolContent, + ToolHeader, + ToolInput, + ToolOutput, +} from "@/components/ai-elements/tool"; +import { Button } from "@/components/ui/button"; + +export type AgentInputResponse = { + readonly optionId?: string; + readonly requestId: string; + readonly text?: string; +}; + +export function AgentMessage({ + canRespond, + isStreaming, + message, + onInputResponses, +}: { + readonly canRespond: boolean; + readonly isStreaming: boolean; + readonly message: EveMessage; + readonly onInputResponses: (responses: readonly AgentInputResponse[]) => void | Promise; +}) { + const lastTextIndex = message.parts.reduce( + (last, part, index) => (part.type === "text" ? index : last), + -1, + ); + + return ( + + + {message.parts.map((part, index) => ( + + ))} + + + ); +} + +function AgentMessagePart({ + canRespond, + onInputResponses, + part, + showCaret, +}: { + readonly canRespond: boolean; + readonly onInputResponses: (responses: readonly AgentInputResponse[]) => void | Promise; + readonly part: EveMessagePart; + readonly showCaret: boolean; +}) { + switch (part.type) { + case "step-start": + return null; + case "text": + return ( + + {part.text} + + ); + case "reasoning": + return ( + + + {part.text} + + ); + case "dynamic-tool": + return ( + + + + + + + + + ); + } +} + +function InputRequestActions({ + canRespond, + onInputResponses, + part, +}: { + readonly canRespond: boolean; + readonly onInputResponses: (responses: readonly AgentInputResponse[]) => void | Promise; + readonly part: EveDynamicToolPart; +}) { + const inputRequest = part.toolMetadata?.eve?.inputRequest; + if (!inputRequest) { + return null; + } + + const inputResponse = part.toolMetadata?.eve?.inputResponse; + const selectedOption = inputRequest.options?.find( + (option) => option.id === inputResponse?.optionId, + ); + + return ( +
+

{inputRequest.prompt}

+ {inputResponse ? ( +

+ Responded: {selectedOption?.label ?? inputResponse.text ?? inputResponse.optionId} +

+ ) : ( +
+ {inputRequest.options?.map((option) => ( + + ))} +
+ )} +
+ ); +} + +function partKey(part: EveMessagePart, index: number): string { + switch (part.type) { + case "dynamic-tool": + return part.toolCallId; + default: + return `${part.type}:${index}`; + } +} diff --git a/examples/eve/app/_components/demo-app.tsx b/examples/eve/app/_components/demo-app.tsx new file mode 100644 index 00000000..74a8360f --- /dev/null +++ b/examples/eve/app/_components/demo-app.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { AgentChat } from "@/app/_components/agent-chat"; +import { + EvlogEventsPanel, + type LogsConnectionState, +} from "@/app/_components/evlog-events-panel"; +import { EveLogo } from "@/app/_components/eve-logo"; +import { useState } from "react"; + +/** Demo-only unified shell — chat + wide events from FS drain. */ +export function DemoApp() { + const [logsState, setLogsState] = useState("waiting"); + const [agentStatus, setAgentStatus] = useState("Ready"); + + return ( +
+
+
+ + Clearbill Support + / + evlog × eve +
+
+ + agent {agentStatus.toLowerCase()} + + + logs{" "} + + {logsState} + + +
+
+ +
+
+
+ Chat +
+ +
+ +
+
+ Wide events +
+ +
+
+
+ ); +} diff --git a/examples/eve/app/_components/eve-logo.tsx b/examples/eve/app/_components/eve-logo.tsx new file mode 100644 index 00000000..6db89d99 --- /dev/null +++ b/examples/eve/app/_components/eve-logo.tsx @@ -0,0 +1,30 @@ +import { cn } from '@/lib/utils' + +type EveLogoProps = { + readonly className?: string + readonly size?: number +} + +/** eve mark — vector paths from vercel/eve, transparent background, currentColor fill. */ +export function EveLogo({ className, size = 32 }: EveLogoProps) { + return ( + + + + + + + + + + ) +} diff --git a/examples/eve/app/_components/evlog-events-panel.tsx b/examples/eve/app/_components/evlog-events-panel.tsx new file mode 100644 index 00000000..39397146 --- /dev/null +++ b/examples/eve/app/_components/evlog-events-panel.tsx @@ -0,0 +1,435 @@ +"use client"; + +/** + * Demo-only wide-event panel — polls `.evlog/logs` via FS drain output. + */ + +import { ChevronRightIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; + +const MAX_EVENTS = 16; +const POLL_INTERVAL_MS = 2000; +const LOGS_URL = "/api/demo/logs"; + +export type LogsConnectionState = "waiting" | "loading" | "live" | "error"; + +type WideEventView = { + timestamp?: string; + level?: string; + service?: string; + method?: string; + path?: string; + status?: number; + durationMs?: number; + eve?: { + sessionId?: string; + turnId?: string; + turnSequence?: number; + phase?: string; + sessionTurns?: number; + }; + approval?: { + status?: string; + tool?: string; + }; + customer?: { name?: string; slug?: string; plan?: string; mrr?: number }; + order?: { id?: string; amount?: number; currency?: string; product?: string }; + refund?: { + orderId?: string; + amount?: number; + status?: string; + requiresApproval?: boolean; + }; + audit?: { action?: string; outcome?: string }; + ai?: { + calls?: number; + tools?: Array<{ name?: string; success?: boolean; durationMs?: number }>; + }; +}; + +type EventTag = { + label: string; + tone: "neutral" | "ok" | "warn" | "error"; +}; + +type EventSummary = { + turnLabel: string | null; + headline: string; + subline: string | null; + tags: EventTag[]; +}; + +export function EvlogEventsPanel({ + className, + embedded = false, + onConnectionChange, +}: { + readonly className?: string; + readonly embedded?: boolean; + readonly onConnectionChange?: (state: LogsConnectionState) => void; +}) { + const [events, setEvents] = useState([]); + const [connection, setConnection] = useState("waiting"); + const [expandedId, setExpandedId] = useState(null); + const listRef = useRef(null); + + const updateConnection = useCallback( + (state: LogsConnectionState) => { + setConnection(state); + onConnectionChange?.(state); + }, + [onConnectionChange], + ); + + useEffect(() => { + let cancelled = false; + + async function poll() { + if (cancelled) return; + + try { + const res = await fetch(LOGS_URL, { cache: "no-store" }); + if (!res.ok) throw new Error(String(res.status)); + const body = (await res.json()) as { events?: WideEventView[] }; + if (cancelled) return; + + const next = prepareEvents(body.events ?? []).slice(0, MAX_EVENTS); + setEvents(next); + updateConnection(next.length > 0 ? "live" : "waiting"); + } catch { + if (!cancelled) updateConnection("error"); + } + } + + updateConnection("loading"); + void poll(); + const timer = setInterval(() => void poll(), POLL_INTERVAL_MS); + + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [updateConnection]); + + useEffect(() => { + listRef.current?.scrollTo({ top: 0 }); + }, [events.length]); + + return ( + + ); +} + +function EventRow({ + event, + expanded, + onToggle, + summary, +}: { + readonly event: WideEventView; + readonly expanded: boolean; + readonly onToggle: () => void; + readonly summary: EventSummary; +}) { + const status = event.status ?? 0; + const isError = status >= 400 || event.level === "error"; + + return ( + <> + + {expanded ? ( +
+          {JSON.stringify(event, null, 2)}
+        
+ ) : null} + + ); +} + +function tagClass(tone: EventTag["tone"]): string { + const base = "rounded px-1.5 py-0.5 font-mono text-[9px] leading-none"; + switch (tone) { + case "ok": + return cn(base, "bg-emerald-950/60 text-emerald-400"); + case "warn": + return cn(base, "bg-amber-950/60 text-amber-400"); + case "error": + return cn(base, "bg-red-950/60 text-red-400"); + default: + return cn(base, "bg-zinc-800/80 text-zinc-500"); + } +} + +function prepareEvents(events: WideEventView[]): WideEventView[] { + const deduped = dedupeEvents(events).filter(isInterestingEvent); + const latestSession = deduped[0]?.eve?.sessionId; + if (!latestSession) return deduped; + return deduped.filter((event) => event.eve?.sessionId === latestSession); +} + +function isInterestingEvent(event: WideEventView): boolean { + if (event.audit?.action) return true; + if (event.refund?.amount != null) return true; + if (event.approval?.status) return true; + if (event.customer?.name || event.customer?.slug) return true; + if ((event.ai?.tools?.length ?? 0) > 0) return true; + if (event.eve?.phase) return true; + return false; +} + +function dedupeEvents(events: WideEventView[]): WideEventView[] { + const byTurn = new Map(); + + for (const event of events) { + const key = turnIdentity(event); + const existing = byTurn.get(key); + if (!existing || (event.timestamp ?? "") >= (existing.timestamp ?? "")) { + byTurn.set(key, event); + } + } + + return [...byTurn.values()].sort((a, b) => + (b.timestamp ?? "").localeCompare(a.timestamp ?? ""), + ); +} + +function turnIdentity(event: WideEventView): string { + const session = event.eve?.sessionId; + const turn = event.eve?.turnId; + if (session && turn) return `${session}:${turn}`; + return `${event.path ?? event.timestamp ?? ""}`; +} + +function eventKey(event: WideEventView, index: number): string { + const identity = turnIdentity(event); + const ts = event.timestamp ?? ""; + return ts ? `${identity}:${ts}` : `${identity}:${index}`; +} + +function buildSummary(event: WideEventView): EventSummary { + const turnNum = event.eve?.turnSequence; + const turnLabel = turnNum != null ? `Turn ${turnNum + 1}` : null; + const customer = event.customer?.name ?? event.customer?.slug; + const orderId = event.order?.id ?? event.refund?.orderId; + const amount = event.refund?.amount ?? event.order?.amount; + const currency = event.order?.currency ?? "USD"; + const money = amount != null ? formatMoney(amount, currency) : null; + const contextLine = + [customer, orderId ? `#${orderId}` : null, money].filter(Boolean).join(" · ") || null; + + const tags = buildTags(event); + + if (event.audit?.action === "refund.issued") { + return { + turnLabel, + headline: "Refund issued", + subline: contextLine, + tags, + }; + } + + if (event.approval?.status === "approved") { + return { + turnLabel, + headline: "Refund approved", + subline: contextLine, + tags, + }; + } + + if (event.approval?.status === "rejected" || event.eve?.phase === "rejected") { + return { + turnLabel, + headline: "Refund rejected", + subline: contextLine, + tags, + }; + } + + if ( + event.approval?.status === "pending" + || event.eve?.phase === "awaiting-approval" + ) { + const tool = event.approval?.tool ?? "issue_refund"; + return { + turnLabel, + headline: `Waiting for approval — ${tool}`, + subline: money ? `${customer ?? "Customer"} · ${money} over threshold` : contextLine, + tags, + }; + } + + if (event.eve?.phase === "failed" || (event.status ?? 0) >= 500) { + return { + turnLabel, + headline: "Turn failed", + subline: contextLine, + tags, + }; + } + + const tools = event.ai?.tools?.map((tool) => tool.name).filter(Boolean) ?? []; + if (tools.length > 0) { + return { + turnLabel, + headline: tools.join(" → "), + subline: contextLine, + tags, + }; + } + + if (customer) { + return { + turnLabel, + headline: `Looked up ${customer}`, + subline: contextLine, + tags, + }; + } + + return { + turnLabel, + headline: "Agent turn completed", + subline: contextLine, + tags, + }; +} + +function buildTags(event: WideEventView): EventTag[] { + const tags: EventTag[] = []; + + if (event.approval?.status === "pending") { + tags.push({ label: "pending approval", tone: "warn" }); + } else if (event.approval?.status === "approved") { + tags.push({ label: "approved", tone: "ok" }); + } else if (event.approval?.status === "rejected") { + tags.push({ label: "rejected", tone: "error" }); + } + + const toolCount = event.ai?.tools?.length ?? 0; + if (toolCount > 0) { + const failed = event.ai?.tools?.filter((tool) => tool.success === false).length ?? 0; + tags.push({ + label: failed > 0 ? `${toolCount} tools · ${failed} failed` : `${toolCount} tools`, + tone: failed > 0 ? "error" : "neutral", + }); + } + + if (typeof event.durationMs === "number" && event.durationMs > 0) { + tags.push({ label: `${event.durationMs}ms`, tone: "neutral" }); + } + + return tags; +} + +function formatMoney(amount: number, currency: string): string { + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency, + maximumFractionDigits: 0, + }).format(amount); + } catch { + return `${currency} ${amount}`; + } +} + +function formatTime(timestamp?: string): string { + if (!timestamp) return "—"; + try { + return new Date(timestamp).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } catch { + return "—"; + } +} diff --git a/examples/eve/app/api/demo/logs/route.ts b/examples/eve/app/api/demo/logs/route.ts new file mode 100644 index 00000000..7e39e169 --- /dev/null +++ b/examples/eve/app/api/demo/logs/route.ts @@ -0,0 +1,22 @@ +import { readFsLogs } from 'evlog/fs' + +export const dynamic = 'force-dynamic' + +const MAX_EVENTS = 48 + +export async function GET() { + const events = [] + + try { + for await (const event of readFsLogs()) { + events.push(event) + } + } catch { + return Response.json({ events: [] }, { headers: { 'Cache-Control': 'no-store' } }) + } + + return Response.json( + { events: events.slice(-MAX_EVENTS).reverse() }, + { headers: { 'Cache-Control': 'no-store' } }, + ) +} diff --git a/examples/eve/app/globals.css b/examples/eve/app/globals.css new file mode 100644 index 00000000..5d061ca2 --- /dev/null +++ b/examples/eve/app/globals.css @@ -0,0 +1,98 @@ +@import "tailwindcss"; +@source "../node_modules/streamdown/dist/*.js"; + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif; + --font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace; +} + +:root { + color-scheme: light; + /* Soft neutral page with white elevated surfaces so cards/composer pop. */ + --background: oklch(0.971 0 0); + --foreground: oklch(0.16 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.16 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.16 0 0); + --primary: oklch(0.19 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.94 0 0); + --secondary-foreground: oklch(0.19 0 0); + --muted: oklch(0.94 0 0); + --muted-foreground: oklch(0.6 0 0); + --accent: oklch(0.94 0 0); + --accent-foreground: oklch(0.19 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.916 0 0); + --input: oklch(0.916 0 0); + --ring: oklch(0.708 0 0); + --radius: 0.625rem; +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + } +} + +* { + border-color: var(--border); +} + +html { + height: 100%; +} + +body { + min-height: 100%; + margin: 0; + background: var(--background); + font-family: var(--font-sans); +} + +button, +input, +textarea { + font: inherit; +} diff --git a/examples/eve/app/layout.tsx b/examples/eve/app/layout.tsx new file mode 100644 index 00000000..3c7b70e3 --- /dev/null +++ b/examples/eve/app/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from 'next' +import { Geist, Geist_Mono } from 'next/font/google' +import type { ReactNode } from 'react' +import { TooltipProvider } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import './globals.css' + +const sans = Geist({ + variable: '--font-sans', + subsets: ['latin'], + weight: 'variable', + display: 'swap', +}) + +const mono = Geist_Mono({ + variable: '--font-mono', + subsets: ['latin'], + weight: 'variable', + display: 'swap', +}) + +export const metadata: Metadata = { + title: 'Clearbill Support — evlog × eve', + description: 'Support refund demo: one evlog wide event per agent turn with customer, order, and audit context.', + icons: { icon: '/eve-logo.svg', apple: '/eve-logo.svg' }, +} + +export default function RootLayout({ children }: { readonly children: ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/examples/eve/app/page.tsx b/examples/eve/app/page.tsx new file mode 100644 index 00000000..caf4c76b --- /dev/null +++ b/examples/eve/app/page.tsx @@ -0,0 +1,5 @@ +import { DemoApp } from "@/app/_components/demo-app"; + +export default function Page() { + return ; +} diff --git a/examples/eve/components.json b/examples/eve/components.json new file mode 100644 index 00000000..b7b9791c --- /dev/null +++ b/examples/eve/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/examples/eve/components/ai-elements/chain-of-thought.tsx b/examples/eve/components/ai-elements/chain-of-thought.tsx new file mode 100644 index 00000000..493fef8c --- /dev/null +++ b/examples/eve/components/ai-elements/chain-of-thought.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useControllableState } from "@radix-ui/react-use-controllable-state"; +import { Badge } from "@/components/ui/badge"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import type { LucideIcon } from "lucide-react"; +import { BrainIcon, ChevronDownIcon, DotIcon } from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; +import { createContext, memo, useContext, useMemo } from "react"; + +interface ChainOfThoughtContextValue { + isOpen: boolean; + setIsOpen: (open: boolean) => void; +} + +const ChainOfThoughtContext = createContext(null); + +const useChainOfThought = () => { + const context = useContext(ChainOfThoughtContext); + if (!context) { + throw new Error("ChainOfThought components must be used within ChainOfThought"); + } + return context; +}; + +export type ChainOfThoughtProps = ComponentProps<"div"> & { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export const ChainOfThought = memo( + ({ + className, + open, + defaultOpen = false, + onOpenChange, + children, + ...props + }: ChainOfThoughtProps) => { + const [isOpen, setIsOpen] = useControllableState({ + defaultProp: defaultOpen, + onChange: onOpenChange, + prop: open, + }); + + const chainOfThoughtContext = useMemo(() => ({ isOpen, setIsOpen }), [isOpen, setIsOpen]); + + return ( + + + {children} + + + ); + }, +); + +export type ChainOfThoughtHeaderProps = ComponentProps; + +export const ChainOfThoughtHeader = memo( + ({ className, children, ...props }: ChainOfThoughtHeaderProps) => { + const { isOpen } = useChainOfThought(); + + return ( + + + {children ?? "Chain of Thought"} + + + ); + }, +); + +export type ChainOfThoughtStepProps = ComponentProps<"div"> & { + icon?: LucideIcon; + label: ReactNode; + description?: ReactNode; + status?: "complete" | "active" | "pending"; +}; + +const stepStatusStyles = { + active: "text-foreground", + complete: "text-muted-foreground", + pending: "text-muted-foreground/50", +}; + +export const ChainOfThoughtStep = memo( + ({ + className, + icon: Icon = DotIcon, + label, + description, + status = "complete", + children, + ...props + }: ChainOfThoughtStepProps) => ( +
+
+ +
+
+
+
{label}
+ {description &&
{description}
} + {children} +
+
+ ), +); + +export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">; + +export const ChainOfThoughtSearchResults = memo( + ({ className, ...props }: ChainOfThoughtSearchResultsProps) => ( +
+ ), +); + +export type ChainOfThoughtSearchResultProps = ComponentProps; + +export const ChainOfThoughtSearchResult = memo( + ({ className, children, ...props }: ChainOfThoughtSearchResultProps) => ( + + {children} + + ), +); + +export type ChainOfThoughtContentProps = ComponentProps; + +export const ChainOfThoughtContent = memo( + ({ className, children, ...props }: ChainOfThoughtContentProps) => ( + + {children} + + ), +); + +export type ChainOfThoughtImageProps = ComponentProps<"div"> & { + caption?: string; +}; + +export const ChainOfThoughtImage = memo( + ({ className, children, caption, ...props }: ChainOfThoughtImageProps) => ( +
+
+ {children} +
+ {caption &&

{caption}

} +
+ ), +); + +ChainOfThought.displayName = "ChainOfThought"; +ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader"; +ChainOfThoughtStep.displayName = "ChainOfThoughtStep"; +ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults"; +ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult"; +ChainOfThoughtContent.displayName = "ChainOfThoughtContent"; +ChainOfThoughtImage.displayName = "ChainOfThoughtImage"; diff --git a/examples/eve/components/ai-elements/code-block.tsx b/examples/eve/components/ai-elements/code-block.tsx new file mode 100644 index 00000000..dfde0ee5 --- /dev/null +++ b/examples/eve/components/ai-elements/code-block.tsx @@ -0,0 +1,519 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import type { ComponentProps, CSSProperties, HTMLAttributes } from "react"; +import { + createContext, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { BundledLanguage, BundledTheme, HighlighterGeneric, ThemedToken } from "shiki"; +import { createHighlighter } from "shiki"; + +// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline +// oxlint-disable-next-line eslint(no-bitwise) +const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1; +// oxlint-disable-next-line eslint(no-bitwise) +const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2; +const isUnderline = (fontStyle: number | undefined) => + // oxlint-disable-next-line eslint(no-bitwise) + fontStyle && fontStyle & 4; + +// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint +interface KeyedToken { + token: ThemedToken; + key: string; +} +interface KeyedLine { + tokens: KeyedToken[]; + key: string; +} + +const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] => + lines.map((line, lineIdx) => ({ + key: `line-${lineIdx}`, + tokens: line.map((token, tokenIdx) => ({ + key: `line-${lineIdx}-${tokenIdx}`, + token, + })), + })); + +// Token rendering component +const TokenSpan = ({ token }: { token: ThemedToken }) => ( + + {token.content} + +); + +// Line number styles using CSS counters +const LINE_NUMBER_CLASSES = cn( + "block", + "before:content-[counter(line)]", + "before:inline-block", + "before:[counter-increment:line]", + "before:w-8", + "before:mr-4", + "before:text-right", + "before:text-muted-foreground/50", + "before:font-mono", + "before:select-none", +); + +// Line rendering component +const LineSpan = ({ + keyedLine, + showLineNumbers, +}: { + keyedLine: KeyedLine; + showLineNumbers: boolean; +}) => ( + + {keyedLine.tokens.length === 0 + ? "\n" + : keyedLine.tokens.map(({ token, key }) => )} + +); + +// Types +type CodeBlockProps = HTMLAttributes & { + code: string; + language: BundledLanguage; + showLineNumbers?: boolean; +}; + +interface TokenizedCode { + tokens: ThemedToken[][]; + fg: string; + bg: string; +} + +interface CodeBlockContextType { + code: string; +} + +// Context +const CodeBlockContext = createContext({ + code: "", +}); + +// Highlighter cache (singleton per language) +const highlighterCache = new Map< + string, + Promise> +>(); + +// Token cache +const tokensCache = new Map(); + +// Subscribers for async token updates +const subscribers = new Map void>>(); + +const getTokensCacheKey = (code: string, language: BundledLanguage) => + `${language}:${code}`; + +const getHighlighter = ( + language: BundledLanguage, +): Promise> => { + const cached = highlighterCache.get(language); + if (cached) { + return cached; + } + + const highlighterPromise = createHighlighter({ + langs: [language], + themes: ["github-light", "github-dark"], + }); + + highlighterCache.set(language, highlighterPromise); + return highlighterPromise; +}; + +// Create raw tokens for immediate display while highlighting loads +const createRawTokens = (code: string): TokenizedCode => ({ + bg: "transparent", + fg: "inherit", + tokens: code.split("\n").map((line) => + line === "" + ? [] + : [ + { + color: "inherit", + content: line, + } as ThemedToken, + ], + ), +}); + +// Synchronous highlight with callback for async results +export const highlightCode = ( + code: string, + language: BundledLanguage, + // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks) + callback?: (result: TokenizedCode) => void, +): TokenizedCode | null => { + const tokensCacheKey = getTokensCacheKey(code, language); + + // Return cached result if available + const cached = tokensCache.get(tokensCacheKey); + if (cached) { + return cached; + } + + // Subscribe callback if provided + if (callback) { + if (!subscribers.has(tokensCacheKey)) { + subscribers.set(tokensCacheKey, new Set()); + } + subscribers.get(tokensCacheKey)?.add(callback); + } + + // Start highlighting in background - fire-and-forget async pattern + getHighlighter(language) + // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then) + .then((highlighter) => { + const availableLangs = highlighter.getLoadedLanguages(); + const langToUse = availableLangs.includes(language) ? language : "text"; + + const result = highlighter.codeToTokens(code, { + lang: langToUse, + themes: { + dark: "github-dark", + light: "github-light", + }, + }); + + const tokenized: TokenizedCode = { + bg: result.bg ?? "transparent", + fg: result.fg ?? "inherit", + tokens: result.tokens, + }; + + // Cache the result + tokensCache.set(tokensCacheKey, tokenized); + + // Notify all subscribers + const subs = subscribers.get(tokensCacheKey); + if (subs) { + for (const sub of subs) { + sub(tokenized); + } + subscribers.delete(tokensCacheKey); + } + }) + // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks) + .catch((error) => { + console.error("Failed to highlight code:", error); + subscribers.delete(tokensCacheKey); + }); + + return null; +}; + +const CodeBlockBody = memo( + ({ + tokenized, + showLineNumbers, + className, + }: { + tokenized: TokenizedCode; + showLineNumbers: boolean; + className?: string; + }) => { + const preStyle = useMemo( + () => ({ + backgroundColor: tokenized.bg, + color: tokenized.fg, + }), + [tokenized.bg, tokenized.fg], + ); + + const keyedLines = useMemo(() => addKeysToTokens(tokenized.tokens), [tokenized.tokens]); + + return ( +
+        
+          {keyedLines.map((keyedLine) => (
+            
+          ))}
+        
+      
+ ); + }, + (prevProps, nextProps) => + prevProps.tokenized === nextProps.tokenized && + prevProps.showLineNumbers === nextProps.showLineNumbers && + prevProps.className === nextProps.className, +); + +CodeBlockBody.displayName = "CodeBlockBody"; + +export const CodeBlockContainer = ({ + className, + language, + style, + ...props +}: HTMLAttributes & { language: string }) => ( +
+); + +export const CodeBlockHeader = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +export const CodeBlockTitle = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +export const CodeBlockFilename = ({ + children, + className, + ...props +}: HTMLAttributes) => ( + + {children} + +); + +export const CodeBlockActions = ({ + children, + className, + ...props +}: HTMLAttributes) => ( +
+ {children} +
+); + +export const CodeBlockContent = ({ + code, + language, + showLineNumbers = false, +}: { + code: string; + language: BundledLanguage; + showLineNumbers?: boolean; +}) => { + // Memoized raw tokens for immediate display + const rawTokens = useMemo(() => createRawTokens(code), [code]); + + // Synchronous cache lookup — avoids setState in effect for cached results + const syncTokens = useMemo( + () => highlightCode(code, language) ?? rawTokens, + [code, language, rawTokens], + ); + + // Async highlighting result (populated after shiki loads) + const [asyncTokens, setAsyncTokens] = useState(null); + const asyncKeyRef = useRef({ code, language }); + + // Invalidate stale async tokens synchronously during render + if (asyncKeyRef.current.code !== code || asyncKeyRef.current.language !== language) { + asyncKeyRef.current = { code, language }; + setAsyncTokens(null); + } + + useEffect(() => { + let cancelled = false; + + highlightCode(code, language, (result) => { + if (!cancelled) { + setAsyncTokens(result); + } + }); + + return () => { + cancelled = true; + }; + }, [code, language]); + + const tokenized = asyncTokens ?? syncTokens; + + return ( +
+ +
+ ); +}; + +export const CodeBlock = ({ + code, + language, + showLineNumbers = false, + className, + children, + ...props +}: CodeBlockProps) => { + const contextValue = useMemo(() => ({ code }), [code]); + + return ( + + + {children} + + + + ); +}; + +export type CodeBlockCopyButtonProps = ComponentProps & { + onCopy?: () => void; + onError?: (error: Error) => void; + timeout?: number; +}; + +export const CodeBlockCopyButton = ({ + onCopy, + onError, + timeout = 2000, + children, + className, + ...props +}: CodeBlockCopyButtonProps) => { + const [isCopied, setIsCopied] = useState(false); + const timeoutRef = useRef(0); + const { code } = useContext(CodeBlockContext); + + const copyToClipboard = useCallback(async () => { + if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { + onError?.(new Error("Clipboard API not available")); + return; + } + + try { + if (!isCopied) { + await navigator.clipboard.writeText(code); + setIsCopied(true); + onCopy?.(); + timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout); + } + } catch (error) { + onError?.(error as Error); + } + }, [code, onCopy, onError, timeout, isCopied]); + + useEffect( + () => () => { + window.clearTimeout(timeoutRef.current); + }, + [], + ); + + const Icon = isCopied ? CheckIcon : CopyIcon; + + return ( + + ); +}; + +export type CodeBlockLanguageSelectorProps = ComponentProps; + +export const CodeBlockLanguageSelector = (props: CodeBlockLanguageSelectorProps) => ( + +
+ + {children} + +
+ + ); + + const withReferencedSources = ( + + {inner} + + ); + + // Always provide LocalAttachmentsContext so children get validated add function + return ( + + {withReferencedSources} + + ); +}; + +export type PromptInputBodyProps = HTMLAttributes; + +export const PromptInputBody = ({ className, ...props }: PromptInputBodyProps) => ( +
+); + +export type PromptInputTextareaProps = ComponentProps; + +export const PromptInputTextarea = ({ + onChange, + onKeyDown, + className, + placeholder = "What would you like to know?", + ...props +}: PromptInputTextareaProps) => { + const controller = useOptionalPromptInputController(); + const attachments = usePromptInputAttachments(); + const [isComposing, setIsComposing] = useState(false); + + const handleKeyDown: KeyboardEventHandler = useCallback( + (e) => { + // Call the external onKeyDown handler first + onKeyDown?.(e); + + // If the external handler prevented default, don't run internal logic + if (e.defaultPrevented) { + return; + } + + if (e.key === "Enter") { + if (isComposing || e.nativeEvent.isComposing) { + return; + } + if (e.shiftKey) { + return; + } + e.preventDefault(); + + // Check if the submit button is disabled before submitting + const { form } = e.currentTarget; + const submitButton = form?.querySelector( + 'button[type="submit"]', + ) as HTMLButtonElement | null; + if (submitButton?.disabled) { + return; + } + + form?.requestSubmit(); + } + + // Remove last attachment when Backspace is pressed and textarea is empty + if (e.key === "Backspace" && e.currentTarget.value === "" && attachments.files.length > 0) { + e.preventDefault(); + const lastAttachment = attachments.files.at(-1); + if (lastAttachment) { + attachments.remove(lastAttachment.id); + } + } + }, + [onKeyDown, isComposing, attachments], + ); + + const handlePaste: ClipboardEventHandler = useCallback( + (event) => { + const items = event.clipboardData?.items; + + if (!items) { + return; + } + + const files: File[] = []; + + for (const item of items) { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + + if (files.length > 0) { + event.preventDefault(); + attachments.add(files); + } + }, + [attachments], + ); + + const handleCompositionEnd = useCallback(() => setIsComposing(false), []); + const handleCompositionStart = useCallback(() => setIsComposing(true), []); + + const controlledProps = controller + ? { + onChange: (e: ChangeEvent) => { + controller.textInput.setInput(e.currentTarget.value); + onChange?.(e); + }, + value: controller.textInput.value, + } + : { + onChange, + }; + + return ( + + ); +}; + +export type PromptInputHeaderProps = Omit, "align">; + +export const PromptInputHeader = ({ className, ...props }: PromptInputHeaderProps) => ( + +); + +export type PromptInputFooterProps = Omit, "align">; + +export const PromptInputFooter = ({ className, ...props }: PromptInputFooterProps) => ( + +); + +export type PromptInputToolsProps = HTMLAttributes; + +export const PromptInputTools = ({ className, ...props }: PromptInputToolsProps) => ( +
+); + +export type PromptInputButtonTooltip = + | string + | { + content: ReactNode; + shortcut?: string; + side?: ComponentProps["side"]; + }; + +export type PromptInputButtonProps = ComponentProps & { + tooltip?: PromptInputButtonTooltip; +}; + +export const PromptInputButton = ({ + variant = "ghost", + className, + size, + tooltip, + ...props +}: PromptInputButtonProps) => { + const newSize = size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm"); + + const button = ( + + ); + + if (!tooltip) { + return button; + } + + const tooltipContent = typeof tooltip === "string" ? tooltip : tooltip.content; + const shortcut = typeof tooltip === "string" ? undefined : tooltip.shortcut; + const side = typeof tooltip === "string" ? "top" : (tooltip.side ?? "top"); + + return ( + + {button} + + {tooltipContent} + {shortcut && {shortcut}} + + + ); +}; + +export type PromptInputActionMenuProps = ComponentProps; +export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => ( + +); + +export type PromptInputActionMenuTriggerProps = PromptInputButtonProps; + +export const PromptInputActionMenuTrigger = ({ + className, + children, + ...props +}: PromptInputActionMenuTriggerProps) => ( + + + {children ?? } + + +); + +export type PromptInputActionMenuContentProps = ComponentProps; +export const PromptInputActionMenuContent = ({ + className, + ...props +}: PromptInputActionMenuContentProps) => ( + +); + +export type PromptInputActionMenuItemProps = ComponentProps; +export const PromptInputActionMenuItem = ({ + className, + ...props +}: PromptInputActionMenuItemProps) => ; + +// Note: Actions that perform side-effects (like opening a file dialog) +// are provided in opt-in modules (e.g., prompt-input-attachments). + +export type PromptInputSubmitProps = ComponentProps & { + status?: ChatStatus; + onStop?: () => void; +}; + +export const PromptInputSubmit = ({ + className, + variant = "default", + size = "icon-sm", + status, + onStop, + onClick, + children, + ...props +}: PromptInputSubmitProps) => { + const isGenerating = status === "submitted" || status === "streaming"; + + let Icon = ; + + if (status === "submitted") { + Icon = ; + } else if (status === "streaming") { + Icon = ; + } else if (status === "error") { + Icon = ; + } + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (isGenerating && onStop) { + e.preventDefault(); + onStop(); + return; + } + onClick?.(e); + }, + [isGenerating, onStop, onClick], + ); + + return ( + + {children ?? Icon} + + ); +}; + +export type PromptInputSelectProps = ComponentProps; + +export const PromptInputSelect = (props: PromptInputSelectProps) => + ); +} + +function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +