From 7868bc18bf70fa7ac59d628d45d2d3a17f432641 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Wed, 1 Jul 2026 17:47:23 +0100 Subject: [PATCH 01/10] feat(eve): add evlog/eve integration with defineEvlogHook Export defineEvlogHook and useTurnLogger for one wide event per Eve turn, share turn state across authored-module bundles, and harden finalizeAudit. --- packages/evlog/README.md | 26 ++ packages/evlog/package.json | 15 + packages/evlog/src/audit.ts | 12 +- packages/evlog/src/eve/index.ts | 404 ++++++++++++++++++ packages/evlog/test/core/audit.test.ts | 31 ++ packages/evlog/test/eve.test.ts | 338 +++++++++++++++ .../__snapshots__/api-surface.test.ts.snap | 5 + packages/evlog/tsdown.config.ts | 3 + 8 files changed, 833 insertions(+), 1 deletion(-) create mode 100644 packages/evlog/src/eve/index.ts create mode 100644 packages/evlog/test/eve.test.ts diff --git a/packages/evlog/README.md b/packages/evlog/README.md index f0361342..32c53cfb 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -560,6 +560,31 @@ export default async function fetch(request: Request) { See the full [orpc example](https://github.com/HugoRCD/evlog/tree/main/examples/orpc) for a complete working project. +## Eve + +```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(), +}) +``` + +```typescript +// agent/tools/my_tool.ts — inside execute() +import { useTurnLogger } from 'evlog/eve' + +const log = useTurnLogger(ctx) +log.set({ order: { id: input.orderId } }) +``` + +`defineEvlogHook()` maps Eve turn lifecycle events to one wide event per turn. Use `useTurnLogger(ctx)` in tools for business context. Complements Eve Agent Runs and OpenTelemetry — see the [Eve use case](https://evlog.dev/use-cases/eve/overview). + +See the full [eve example](https://github.com/HugoRCD/evlog/tree/main/examples/eve) for a complete agent layout. + ## Browser Use the `log` API on the client side for structured browser logging: @@ -1388,6 +1413,7 @@ try { | **Fastify** | `app.register(evlog)` with `import { evlog } from 'evlog/fastify'` ([example](./examples/fastify)) | | **Elysia** | `.use(evlog())` with `import { evlog } from 'evlog/elysia'` ([example](./examples/elysia)) | | **oRPC** | `withEvlog(handler)` + `os.use(evlog())` with `import { evlog, withEvlog } from 'evlog/orpc'` ([example](./examples/orpc)) | +| **Eve** | `defineEvlogHook()` in `agent/hooks/evlog.ts` with `import { defineEvlogHook, useTurnLogger } from 'evlog/eve'` ([example](./examples/eve)) | | **Cloudflare Workers** | Manual setup with `import { initWorkersLogger, createWorkersLogger } from 'evlog/workers'` ([example](./examples/workers)) | | **Custom** | Build your own with `import { createMiddlewareLogger } from 'evlog/toolkit'` ([guide](https://evlog.dev/extend/custom-framework)) | | **Analog** | Nitro v2 module setup | diff --git a/packages/evlog/package.json b/packages/evlog/package.json index 3a572451..05fbc2c4 100644 --- a/packages/evlog/package.json +++ b/packages/evlog/package.json @@ -25,6 +25,7 @@ "elysia", "nestjs", "orpc", + "eve", "sveltekit", "react-router", "vite", @@ -194,6 +195,11 @@ "import": "./dist/orpc/index.mjs", "default": "./dist/orpc/index.mjs" }, + "./eve": { + "types": "./dist/eve/index.d.mts", + "import": "./dist/eve/index.mjs", + "default": "./dist/eve/index.mjs" + }, "./react-router": { "types": "./dist/react-router/index.d.mts", "import": "./dist/react-router/index.mjs", @@ -327,6 +333,9 @@ "orpc": [ "./dist/orpc/index.d.mts" ], + "eve": [ + "./dist/eve/index.d.mts" + ], "react-router": [ "./dist/react-router/index.d.mts" ], @@ -383,6 +392,7 @@ "@nuxt/test-utils": "^4.0.3", "@orpc/client": "^1.14.2", "@orpc/server": "^1.14.2", + "ai": "^6.0.168", "@stryker-mutator/core": "^9.6.1", "@stryker-mutator/vitest-runner": "^9.6.1", "@sveltejs/kit": "^2.59.0", @@ -395,6 +405,7 @@ "changelogen": "^0.6.2", "consola": "^3.4.2", "elysia": "^1.4.28", + "eve": "^0.12.3", "express": "^5.2.1", "fastify": "^5.8.5", "h3": "^1.15.11", @@ -419,6 +430,7 @@ "@nestjs/common": ">=11.1.19", "@nuxt/kit": "^4.4.2", "@orpc/server": ">=1.14.0", + "eve": ">=0.12.0", "@tanstack/start-client-core": "^1.167.20", "ai": ">=6.0.168", "elysia": ">=1.4.28", @@ -474,6 +486,9 @@ "@orpc/server": { "optional": true }, + "eve": { + "optional": true + }, "react-router": { "optional": true }, diff --git a/packages/evlog/src/audit.ts b/packages/evlog/src/audit.ts index 8ed4c1e7..7062fb6d 100644 --- a/packages/evlog/src/audit.ts +++ b/packages/evlog/src/audit.ts @@ -477,14 +477,24 @@ export function consumeAuditForceKeep(context: Record): boolean return false } +/** @internal True when `audit` has the minimum fields required before emit decoration. */ +function isCompleteAuditFields(a: unknown): a is AuditFields { + if (!a || typeof a !== 'object') return false + const audit = a as AuditFields + return typeof audit.action === 'string' + && typeof audit.actor?.type === 'string' + && typeof audit.actor?.id === 'string' +} + /** * @internal Decorate the audit field on an event right before drain — fills * in the deterministic idempotency key. Called by the logger pipeline so * it works for both `log.audit()` and direct `log.set({ audit })` paths. + * Skips partial/legacy shapes that omit required actor fields. */ export function finalizeAudit(event: WideEvent): void { const a = event.audit as AuditFields | undefined - if (!a) return + if (!isCompleteAuditFields(a)) return const decorated = decorateAudit(a, String(event.timestamp)) event.audit = decorated _testCollector?.(decorated, event) diff --git a/packages/evlog/src/eve/index.ts b/packages/evlog/src/eve/index.ts new file mode 100644 index 00000000..a8627e2c --- /dev/null +++ b/packages/evlog/src/eve/index.ts @@ -0,0 +1,404 @@ +import { defineHook, type HookContext, type HookDefinition } from 'eve/hooks' +import type { AuditableLogger } from '../audit' +import type { AIToolExecution, AIEventData } from '../ai/index' +import { initLogger } from '../logger' +import type { LoggerConfig } from '../types' +import type { BaseEvlogOptions, MiddlewareLoggerOptions } from '../shared/middleware' +import { createMiddlewareLogger } from '../shared/middleware' + +/** Options for {@link defineEvlogHook}. */ +export interface EvlogEveOptions extends BaseEvlogOptions { + /** Passed to {@link initLogger} on the first hook invocation. */ + init?: LoggerConfig + /** + * When `true` (default), user message content from `message.received` is + * omitted from the wide event. Set to `false` to include a truncated preview. + */ + redactMessage?: boolean +} + +/** Minimal session shape accepted by {@link useTurnLogger}. */ +export interface EveTurnSessionContext { + readonly session: { + readonly id: string + readonly turn?: { readonly id?: string } + } +} + +interface TurnAccumulator { + calls: number + steps: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + finishReason?: string + toolExecutions: AIToolExecution[] +} + +interface TurnState { + logger: AuditableLogger + finish: (opts?: { status?: number; error?: Error }) => Promise + middlewareOptions: MiddlewareLoggerOptions + accumulator: TurnAccumulator + sessionId: string + turnId: string +} + +function turnKey(sessionId: string, turnId: string): string { + return `${sessionId}:${turnId}` +} + +function freshAccumulator(): TurnAccumulator { + return { + calls: 0, + steps: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + toolExecutions: [], + } +} + +function buildAiField(state: TurnAccumulator): AIEventData { + const totalTokens = state.inputTokens + state.outputTokens + const data: AIEventData = { + calls: state.calls, + inputTokens: state.inputTokens, + outputTokens: state.outputTokens, + totalTokens, + steps: state.steps, + } + if (state.cacheReadTokens > 0) data.cacheReadTokens = state.cacheReadTokens + if (state.cacheWriteTokens > 0) data.cacheWriteTokens = state.cacheWriteTokens + if (state.finishReason) data.finishReason = state.finishReason + if (state.toolExecutions.length > 0) { + data.tools = state.toolExecutions.map(t => ({ ...t })) + } + return data +} + +function extractToolName(result: unknown): string { + if (result && typeof result === 'object') { + const record = result as Record + if (typeof record.toolName === 'string') return record.toolName + if (typeof record.name === 'string') return record.name + } + return 'unknown' +} + +function truncateMessage(message: string, maxLength = 500): string { + if (message.length <= maxLength) return message + return `${message.slice(0, maxLength)}…` +} + +let initialized = false + +function ensureInit(options: EvlogEveOptions): void { + if (isEveInitialized()) return + initLogger(options.init ?? { env: { service: 'eve-agent' } }) + setEveInitialized(true) + initialized = true +} + +/** + * Access the turn-scoped logger from an Eve tool `execute()` handler. + * + * Pass the tool context (`ctx`) so evlog can resolve the active session turn. + * Throws when called outside a turn tracked by {@link defineEvlogHook}. + * + * @example + * ```ts + * import { useTurnLogger } from 'evlog/eve' + * + * export default defineTool({ + * async execute(input, ctx) { + * const log = useTurnLogger(ctx) + * log.set({ order: { id: input.orderId } }) + * }, + * }) + * ``` + */ +export function useTurnLogger(ctx?: EveTurnSessionContext): AuditableLogger { + const sessionId = ctx?.session?.id + const turnId = ctx?.session?.turn?.id ?? (sessionId ? activeTurnBySession().get(sessionId) : undefined) + + if (!sessionId || !turnId) { + throw new Error( + '[evlog] useTurnLogger() was called outside an evlog Eve turn. ' + + 'Add agent/hooks/evlog.ts with defineEvlogHook() and pass ctx from the tool handler.', + ) + } + + const state = turnStates().get(turnKey(sessionId, turnId)) + if (!state) { + throw new Error( + '[evlog] useTurnLogger() could not find a logger for the current turn. ' + + 'Ensure defineEvlogHook() is registered and the turn has started.', + ) + } + + return state.logger +} + +interface EveGlobalState { + turnStates: Map + activeTurnBySession: Map + initialized: boolean +} + +const EVE_GLOBAL_STATE = Symbol.for('evlog.eve.state') + +function getEveGlobalState(): EveGlobalState { + const host = globalThis as typeof globalThis & { + [EVE_GLOBAL_STATE]?: EveGlobalState + } + if (!host[EVE_GLOBAL_STATE]) { + host[EVE_GLOBAL_STATE] = { + turnStates: new Map(), + activeTurnBySession: new Map(), + initialized: false, + } + } + return host[EVE_GLOBAL_STATE] +} + +function turnStates(): Map { + return getEveGlobalState().turnStates +} + +function activeTurnBySession(): Map { + return getEveGlobalState().activeTurnBySession +} + +function isEveInitialized(): boolean { + return getEveGlobalState().initialized +} + +function setEveInitialized(value: boolean): void { + getEveGlobalState().initialized = value +} + + +function getOrCreateTurnState( + sessionId: string, + turnId: string, + options: EvlogEveOptions, + ctx: HookContext, +): TurnState | null { + const key = turnKey(sessionId, turnId) + const existing = turnStates().get(key) + if (existing) return existing + + const path = `/sessions/${sessionId}/turns/${turnId}` + const middlewareOptions: MiddlewareLoggerOptions = { + method: 'EVE', + path, + requestId: turnId, + drain: options.drain, + enrich: options.enrich, + keep: options.keep, + redact: options.redact, + plugins: options.plugins, + include: options.include, + exclude: options.exclude, + routes: options.routes, + } + + const { logger, finish, skipped } = createMiddlewareLogger(middlewareOptions) + if (skipped) return null + + const state: TurnState = { + logger, + finish, + middlewareOptions, + accumulator: freshAccumulator(), + sessionId, + turnId, + } + + logger.set({ + eve: { + sessionId, + turnId, + }, + agent: { + name: ctx.agent.name, + ...(ctx.agent.nodeId ? { nodeId: ctx.agent.nodeId } : {}), + }, + channel: { + kind: ctx.channel.kind ?? 'unknown', + ...(ctx.channel.continuationToken + ? { continuationToken: ctx.channel.continuationToken } + : {}), + }, + }) + + turnStates().set(key, state) + activeTurnBySession().set(sessionId, turnId) + return state +} + +function flushAi(state: TurnState): void { + state.logger.set({ ai: buildAiField(state.accumulator) }) +} + +async function finishTurn( + sessionId: string, + turnId: string, + opts: { status?: number; error?: Error }, +): Promise { + const key = turnKey(sessionId, turnId) + const state = turnStates().get(key) + if (!state) return + + flushAi(state) + await state.finish(opts) + turnStates().delete(key) + if (activeTurnBySession().get(sessionId) === turnId) { + activeTurnBySession().delete(sessionId) + } +} + +function runSafe(fn: () => void | Promise): void { + void (async () => { + try { + await fn() + } catch (err) { + console.error('[evlog] Eve hook handler failed:', err) + } + })() +} + +/** + * Create an Eve stream hook that emits one evlog wide event per agent turn. + * + * Export the result as the default export of `agent/hooks/evlog.ts`. Eve + * auto-discovers hook files; evlog maps turn lifecycle events to a wide event + * with AI usage, tool executions, and your drain/enrich/keep pipeline. + * + * Complements Eve Agent Runs and OpenTelemetry — it does not replace them. + * + * @example + * ```ts + * // agent/hooks/evlog.ts + * import { defineEvlogHook } from 'evlog/eve' + * import { createAxiomDrain } from 'evlog/axiom' + * + * export default defineEvlogHook({ + * drain: createAxiomDrain(), + * enrich: (ctx) => { + * ctx.event.runtime = process.env.VERCEL_REGION + * }, + * }) + * ``` + */ +export function defineEvlogHook(options: EvlogEveOptions = {}): HookDefinition { + const redactMessage = options.redactMessage ?? true + + return defineHook({ + events: { + 'turn.started'(event, ctx) { + try { + ensureInit(options) + getOrCreateTurnState(ctx.session.id, event.data.turnId, options, ctx) + const state = turnStates().get(turnKey(ctx.session.id, event.data.turnId)) + state?.logger.set({ + eve: { + turnSequence: event.data.sequence, + }, + }) + } catch (err) { + console.error('[evlog] Eve hook handler failed:', err) + } + }, + + 'message.received'(event, ctx) { + runSafe(() => { + const state = turnStates().get(turnKey(ctx.session.id, event.data.turnId)) + if (!state) return + if (redactMessage) { + state.logger.set({ message: { receivedRedacted: true } }) + } else { + state.logger.set({ + message: { received: truncateMessage(event.data.message) }, + }) + } + }) + }, + + 'step.completed'(event, ctx) { + runSafe(() => { + const state = turnStates().get(turnKey(ctx.session.id, event.data.turnId)) + if (!state) return + const acc = state.accumulator + acc.steps += 1 + acc.calls += 1 + acc.finishReason = event.data.finishReason + const { usage } = event.data + if (usage) { + acc.inputTokens += usage.inputTokens ?? 0 + acc.outputTokens += usage.outputTokens ?? 0 + acc.cacheReadTokens += usage.cacheReadTokens ?? 0 + acc.cacheWriteTokens += usage.cacheWriteTokens ?? 0 + } + }) + }, + + 'action.result'(event, ctx) { + runSafe(() => { + const state = turnStates().get(turnKey(ctx.session.id, event.data.turnId)) + if (!state) return + const success = event.data.status === 'completed' + const execution: AIToolExecution = { + name: extractToolName(event.data.result), + durationMs: 0, + success, + } + if (!success && event.data.error) { + execution.error = event.data.error.message + } + state.accumulator.toolExecutions.push(execution) + }) + }, + + async 'turn.completed'(event, ctx) { + try { + await finishTurn(ctx.session.id, event.data.turnId, { status: 200 }) + } catch (err) { + console.error('[evlog] Eve hook handler failed:', err) + } + }, + + async 'turn.failed'(event, ctx) { + try { + const state = turnStates().get(turnKey(ctx.session.id, event.data.turnId)) + state?.logger.set({ + eve: { + failure: { + code: event.data.code, + message: event.data.message, + ...(event.data.details ? { details: event.data.details } : {}), + }, + }, + }) + const error = new Error(event.data.message) + error.name = event.data.code + await finishTurn(ctx.session.id, event.data.turnId, { error, status: 500 }) + } catch (err) { + console.error('[evlog] Eve hook handler failed:', err) + } + }, + }, + }) +} + +/** @internal Resets module state between unit tests. */ +export function resetEvlogEveForTests(): void { + turnStates().clear() + activeTurnBySession().clear() + setEveInitialized(false) + initialized = false + delete (globalThis as typeof globalThis & { [EVE_GLOBAL_STATE]?: EveGlobalState })[EVE_GLOBAL_STATE] +} diff --git a/packages/evlog/test/core/audit.test.ts b/packages/evlog/test/core/audit.test.ts index 14b8fce8..3d1ea956 100644 --- a/packages/evlog/test/core/audit.test.ts +++ b/packages/evlog/test/core/audit.test.ts @@ -9,6 +9,7 @@ import { auditRedactPreset, buildAuditFields, defineAuditAction, + finalizeAudit, mockAudit, signed, withAudit, @@ -458,6 +459,36 @@ describe('signed() — hash-chain', () => { }) }) +describe('finalizeAudit', () => { + it('skips partial audit objects missing actor fields', () => { + const event: WideEvent = { + timestamp: '2026-06-25T12:00:00.000Z', + level: 'info', + service: 'test', + environment: 'test', + audit: { action: 'refund.issued' } as AuditFields, + } + expect(() => finalizeAudit(event)).not.toThrow() + expect(event.audit).toEqual({ action: 'refund.issued' }) + }) + + it('decorates complete audit objects with an idempotency key', () => { + const event: WideEvent = { + timestamp: '2026-06-25T12:00:00.000Z', + level: 'info', + service: 'test', + environment: 'test', + audit: buildAuditFields({ + action: 'refund.issued', + actor: { type: 'agent', id: 'bot' }, + target: { type: 'order', id: '4821' }, + }), + } + finalizeAudit(event) + expect((event.audit as AuditFields).idempotencyKey).toMatch(/^[\da-f]{32}$/) + }) +}) + describe('idempotency key', () => { it('is stable across identical events in the same second', () => { const e1 = defined(audit({ action: 'a', actor: { type: 'user', id: 'u1' }, target: { type: 't', id: 'r1' } }), 'audit event 1') diff --git a/packages/evlog/test/eve.test.ts b/packages/evlog/test/eve.test.ts new file mode 100644 index 00000000..ae7ec510 --- /dev/null +++ b/packages/evlog/test/eve.test.ts @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { HookContext } from 'eve/hooks' +import { initLogger } from '../src/logger' +import { + resetEvlogEveForTests, + defineEvlogHook, + useTurnLogger, +} from '../src/eve/index' +import { + assertDrainCalledWith, + assertEnrichBeforeDrain, + assertWideEventShape, + createPipelineSpies, + findEventViaDrain, + waitForDrainCalls, +} from './helpers/framework' + +const SESSION_ID = 'sess_abc' +const TURN_ID = 'turn_0' + +function hookContext(overrides: Partial = {}): HookContext { + return { + agent: { name: 'test-agent' }, + channel: { kind: 'http' }, + session: { id: SESSION_ID }, + ...overrides, + } as HookContext +} + +function toolContext(turnId = TURN_ID) { + return { + session: { + id: SESSION_ID, + turn: { id: turnId }, + }, + } +} + +async function runTurn( + hook: ReturnType, + options: { + fail?: boolean + steps?: number + toolResults?: Array<{ toolName: string, status: 'completed' | 'failed' | 'rejected' }> + message?: string + } = {}, +) { + const ctx = hookContext() + const events = hook.events! + + events['turn.started']!({ + type: 'turn.started', + data: { sequence: 0, turnId: TURN_ID }, + }, ctx) + + if (options.message !== undefined) { + events['message.received']!({ + type: 'message.received', + data: { message: options.message, sequence: 1, turnId: TURN_ID }, + }, ctx) + } + + const stepCount = options.steps ?? 1 + for (let i = 0; i < stepCount; i++) { + events['step.completed']!({ + type: 'step.completed', + data: { + finishReason: i === stepCount - 1 ? 'stop' : 'tool-calls', + sequence: 2 + i, + stepIndex: i, + turnId: TURN_ID, + usage: { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 10, + }, + }, + }, ctx) + } + + for (const [index, tool] of (options.toolResults ?? []).entries()) { + events['action.result']!({ + type: 'action.result', + data: { + result: { toolName: tool.toolName }, + sequence: 10 + index, + stepIndex: 0, + status: tool.status, + turnId: TURN_ID, + ...(tool.status === 'failed' + ? { error: { code: 'TOOL_FAILED', message: 'tool broke' } } + : {}), + }, + }, ctx) + } + + if (options.fail) { + await events['turn.failed']!({ + type: 'turn.failed', + data: { + code: 'TURN_ERROR', + message: 'turn exploded', + sequence: 99, + turnId: TURN_ID, + }, + }, ctx) + } else { + await events['turn.completed']!({ + type: 'turn.completed', + data: { sequence: 99, turnId: TURN_ID }, + }, ctx) + } +} + +describe('evlog/eve', () => { + beforeEach(() => { + resetEvlogEveForTests() + initLogger({ env: { service: 'eve-test' } }) + }) + + afterEach(() => { + resetEvlogEveForTests() + }) + + it('creates a turn logger on turn.started with session context', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + + await runTurn(hook) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, e => e.path?.includes(TURN_ID)) + expect(event).toBeDefined() + expect(event?.method).toBe('EVE') + expect(event?.eve).toMatchObject({ + sessionId: SESSION_ID, + turnId: TURN_ID, + turnSequence: 0, + }) + expect(event?.agent).toMatchObject({ name: 'test-agent' }) + expect(event?.channel).toMatchObject({ kind: 'http' }) + }) + + it('accumulates token usage across multiple steps', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + + await runTurn(hook, { steps: 2 }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.ai).toMatchObject({ + calls: 2, + steps: 2, + inputTokens: 200, + outputTokens: 100, + cacheReadTokens: 20, + totalTokens: 300, + finishReason: 'stop', + }) + }) + + it('records tool executions from action.result', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + + await runTurn(hook, { + toolResults: [ + { toolName: 'get_weather', status: 'completed' }, + { toolName: 'search', status: 'failed' }, + ], + }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.ai?.tools).toEqual([ + { name: 'get_weather', durationMs: 0, success: true }, + { name: 'search', durationMs: 0, success: false, error: 'tool broke' }, + ]) + }) + + it('emits a valid wide event on turn.completed', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + + await runTurn(hook) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + assertWideEventShape(event!) + expect(event?.method).toBe('EVE') + expect(event?.path).toBe(`/sessions/${SESSION_ID}/turns/${TURN_ID}`) + expect(event?.status).toBe(200) + }) + + it('captures turn.failed as an error wide event', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + + await runTurn(hook, { fail: true }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.level).toBe('error') + expect(event?.status).toBe(500) + expect(event?.eve?.failure).toMatchObject({ + code: 'TURN_ERROR', + message: 'turn exploded', + }) + }) + + it('does not throw when an internal handler fails', async () => { + const hook = defineEvlogHook({ + enrich: () => { + throw new Error('enrich exploded') + }, + drain: vi.fn(), + }) + + await expect(runTurn(hook)).resolves.toBeUndefined() + }) + + it('useTurnLogger returns the active turn logger', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + const ctx = hookContext() + + hook.events!['turn.started']!({ + type: 'turn.started', + data: { sequence: 0, turnId: TURN_ID }, + }, ctx) + + const log = useTurnLogger(toolContext()) + log.set({ business: { tenant: 'acme' } }) + + await hook.events!['turn.completed']!({ + type: 'turn.completed', + data: { sequence: 1, turnId: TURN_ID }, + }, ctx) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.business).toEqual({ tenant: 'acme' }) + }) + + it('useTurnLogger throws outside an active turn', () => { + expect(() => useTurnLogger()).toThrow(/outside an evlog Eve turn/) + }) + + it('keep callback can force-keep failed tool turns', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ + drain: spies.drain, + keep: (ctx) => { + const tools = (ctx.context.ai as { tools?: Array<{ success: boolean }> } | undefined)?.tools + if (tools?.some(t => !t.success)) ctx.shouldKeep = true + }, + }) + + await runTurn(hook, { + toolResults: [{ toolName: 'broken', status: 'failed' }], + }) + + await waitForDrainCalls(spies.drain) + assertDrainCalledWith(spies.drain, { + path: `/sessions/${SESSION_ID}/turns/${TURN_ID}`, + method: 'EVE', + }) + }) + + it('runs enrich before drain', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ + drain: spies.drain, + enrich: spies.enrich, + }) + + await runTurn(hook) + + await waitForDrainCalls(spies.drain) + assertEnrichBeforeDrain(spies.enrich, spies.drain) + }) + + it('redacts message.received by default', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + + await runTurn(hook, { message: 'secret user prompt' }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.message).toEqual({ receivedRedacted: true }) + expect(event?.message).not.toHaveProperty('received') + }) + + it('includes truncated message when redactMessage is false', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain, redactMessage: false }) + + await runTurn(hook, { message: 'hello from user' }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.message).toEqual({ received: 'hello from user' }) + }) + + it('shares turn state across separate evlog/eve module instances (Eve authored-module bundles)', async () => { + const spies = createPipelineSpies() + const hookModule = await import('../src/eve/index') + hookModule.resetEvlogEveForTests() + initLogger({ env: { service: 'eve-test' } }) + + const hook = hookModule.defineEvlogHook({ drain: spies.drain }) + const ctx = hookContext() + + hook.events!['turn.started']!({ + type: 'turn.started', + data: { sequence: 0, turnId: TURN_ID }, + }, ctx) + + vi.resetModules() + const toolModule = await import('../src/eve/index') + const log = toolModule.useTurnLogger(toolContext()) + log.set({ business: { tenant: 'acme' } }) + + await hook.events!['turn.completed']!({ + type: 'turn.completed', + data: { sequence: 1, turnId: TURN_ID }, + }, ctx) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.business).toEqual({ tenant: 'acme' }) + + const fresh = await import('../src/eve/index') + fresh.resetEvlogEveForTests() + }) +}) diff --git a/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap b/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap index 070c88c2..6f9b66df 100644 --- a/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap +++ b/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap @@ -98,6 +98,11 @@ exports[`public API surface > matches snapshot for all subpath exports 1`] = ` "createTraceContextEnricher", "createUserAgentEnricher", ], + "./eve": [ + "defineEvlogHook", + "resetEvlogEveForTests", + "useTurnLogger", + ], "./express": [ "evlog", "useLogger", diff --git a/packages/evlog/tsdown.config.ts b/packages/evlog/tsdown.config.ts index c66d50a2..5f7ab9fb 100644 --- a/packages/evlog/tsdown.config.ts +++ b/packages/evlog/tsdown.config.ts @@ -52,6 +52,7 @@ export default defineConfig({ 'fastify/index': 'src/fastify/index.ts', 'nestjs/index': 'src/nestjs/index.ts', 'orpc/index': 'src/orpc/index.ts', + 'eve/index': 'src/eve/index.ts', 'react-router/index': 'src/react-router/index.ts', 'sveltekit/index': 'src/sveltekit/index.ts', 'vite/index': 'src/vite/index.ts', @@ -93,6 +94,8 @@ export default defineConfig({ '@nestjs/core', '@orpc/server', '@orpc/server/fetch', + 'eve', + 'eve/hooks', 'react-router', '@sveltejs/kit', 'vite', From 7fb6b820f5a59d1d14046daa13d4b86617290dc5 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Wed, 1 Jul 2026 17:47:30 +0100 Subject: [PATCH 02/10] feat(eve): add Clearbill support example on Eve and Next.js Ship a support-refund copilot with useEveAgent, tool latency, and evlog wide events; wire example:eve and eve>ai workspace override. --- .gitignore | 2 + examples/eve/README.md | 76 + examples/eve/agent/agent.ts | 5 + examples/eve/agent/channels/eve.ts | 15 + examples/eve/agent/hooks/evlog.ts | 31 + examples/eve/agent/instructions.md | 26 + examples/eve/agent/lib/fake-latency.ts | 6 + examples/eve/agent/lib/support-data.ts | 72 + examples/eve/agent/tools/issue_refund.ts | 57 + examples/eve/agent/tools/lookup_customer.ts | 34 + examples/eve/agent/tools/lookup_order.ts | 34 + examples/eve/app/_components/agent-chat.tsx | 187 + .../eve/app/_components/agent-message.tsx | 169 + examples/eve/app/globals.css | 98 + examples/eve/app/layout.tsx | 35 + examples/eve/app/page.tsx | 5 + examples/eve/components.json | 22 + .../ai-elements/chain-of-thought.tsx | 197 + .../eve/components/ai-elements/code-block.tsx | 522 ++ .../components/ai-elements/conversation.tsx | 151 + .../eve/components/ai-elements/message.tsx | 296 + .../components/ai-elements/prompt-input.tsx | 1315 ++++ .../eve/components/ai-elements/reasoning.tsx | 208 + .../eve/components/ai-elements/shimmer.tsx | 72 + examples/eve/components/ai-elements/tool.tsx | 156 + examples/eve/components/ui/badge.tsx | 46 + examples/eve/components/ui/button-group.tsx | 78 + examples/eve/components/ui/button.tsx | 62 + examples/eve/components/ui/collapsible.tsx | 21 + examples/eve/components/ui/command.tsx | 161 + examples/eve/components/ui/dialog.tsx | 144 + examples/eve/components/ui/dropdown-menu.tsx | 228 + examples/eve/components/ui/hover-card.tsx | 38 + examples/eve/components/ui/input-group.tsx | 158 + examples/eve/components/ui/input.tsx | 21 + examples/eve/components/ui/select.tsx | 175 + examples/eve/components/ui/separator.tsx | 28 + examples/eve/components/ui/spinner.tsx | 16 + examples/eve/components/ui/textarea.tsx | 18 + examples/eve/components/ui/tooltip.tsx | 53 + examples/eve/css.d.ts | 1 + examples/eve/lib/utils.ts | 6 + examples/eve/next-env.d.ts | 6 + examples/eve/next.config.ts | 6 + examples/eve/package.json | 62 + examples/eve/postcss.config.mjs | 7 + examples/eve/tsconfig.agent.json | 5 + examples/eve/tsconfig.agent.tsbuildinfo | 1 + examples/eve/tsconfig.json | 32 + examples/eve/tsconfig.tsbuildinfo | 1 + package.json | 1 + pnpm-lock.yaml | 6739 ++++++++++++++--- pnpm-workspace.yaml | 2 + 53 files changed, 10995 insertions(+), 912 deletions(-) create mode 100644 examples/eve/README.md create mode 100644 examples/eve/agent/agent.ts create mode 100644 examples/eve/agent/channels/eve.ts create mode 100644 examples/eve/agent/hooks/evlog.ts create mode 100644 examples/eve/agent/instructions.md create mode 100644 examples/eve/agent/lib/fake-latency.ts create mode 100644 examples/eve/agent/lib/support-data.ts create mode 100644 examples/eve/agent/tools/issue_refund.ts create mode 100644 examples/eve/agent/tools/lookup_customer.ts create mode 100644 examples/eve/agent/tools/lookup_order.ts create mode 100644 examples/eve/app/_components/agent-chat.tsx create mode 100644 examples/eve/app/_components/agent-message.tsx create mode 100644 examples/eve/app/globals.css create mode 100644 examples/eve/app/layout.tsx create mode 100644 examples/eve/app/page.tsx create mode 100644 examples/eve/components.json create mode 100644 examples/eve/components/ai-elements/chain-of-thought.tsx create mode 100644 examples/eve/components/ai-elements/code-block.tsx create mode 100644 examples/eve/components/ai-elements/conversation.tsx create mode 100644 examples/eve/components/ai-elements/message.tsx create mode 100644 examples/eve/components/ai-elements/prompt-input.tsx create mode 100644 examples/eve/components/ai-elements/reasoning.tsx create mode 100644 examples/eve/components/ai-elements/shimmer.tsx create mode 100644 examples/eve/components/ai-elements/tool.tsx create mode 100644 examples/eve/components/ui/badge.tsx create mode 100644 examples/eve/components/ui/button-group.tsx create mode 100644 examples/eve/components/ui/button.tsx create mode 100644 examples/eve/components/ui/collapsible.tsx create mode 100644 examples/eve/components/ui/command.tsx create mode 100644 examples/eve/components/ui/dialog.tsx create mode 100644 examples/eve/components/ui/dropdown-menu.tsx create mode 100644 examples/eve/components/ui/hover-card.tsx create mode 100644 examples/eve/components/ui/input-group.tsx create mode 100644 examples/eve/components/ui/input.tsx create mode 100644 examples/eve/components/ui/select.tsx create mode 100644 examples/eve/components/ui/separator.tsx create mode 100644 examples/eve/components/ui/spinner.tsx create mode 100644 examples/eve/components/ui/textarea.tsx create mode 100644 examples/eve/components/ui/tooltip.tsx create mode 100644 examples/eve/css.d.ts create mode 100644 examples/eve/lib/utils.ts create mode 100644 examples/eve/next-env.d.ts create mode 100644 examples/eve/next.config.ts create mode 100644 examples/eve/package.json create mode 100644 examples/eve/postcss.config.mjs create mode 100644 examples/eve/tsconfig.agent.json create mode 100644 examples/eve/tsconfig.agent.tsbuildinfo create mode 100644 examples/eve/tsconfig.json create mode 100644 examples/eve/tsconfig.tsbuildinfo diff --git a/.gitignore b/.gitignore index 701d3a2d..c785c08d 100644 --- a/.gitignore +++ b/.gitignore @@ -45,12 +45,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/examples/eve/README.md b/examples/eve/README.md new file mode 100644 index 00000000..c5a105de --- /dev/null +++ b/examples/eve/README.md @@ -0,0 +1,76 @@ +# 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 + +```bash +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 → `useTurnLogger(ctx)` | +| `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/overview 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..9c665bea --- /dev/null +++ b/examples/eve/agent/channels/eve.ts @@ -0,0 +1,15 @@ +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(), + // This placeholder will not allow browser requests in production. + // Replace it with your app's auth provider, like Auth.js or Clerk, + // or use none() for a public demo. + placeholderAuth(), + ], +}); diff --git a/examples/eve/agent/hooks/evlog.ts b/examples/eve/agent/hooks/evlog.ts new file mode 100644 index 00000000..65d61ac1 --- /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, requiresApproval?: boolean } | undefined + if (refund?.requiresApproval || (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..c3704f04 --- /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..b7a388aa --- /dev/null +++ b/examples/eve/agent/tools/issue_refund.ts @@ -0,0 +1,57 @@ +import { defineTool } from 'eve/tools' +import { useTurnLogger } 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 + +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 (order?.amount ?? 0) > REFUND_APPROVAL_THRESHOLD_USD + }, + async execute({ orderId, reason }, ctx) { + 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 = useTurnLogger(ctx) + log.set({ + refund: { + orderId: order.id, + amount: order.amount, + currency: order.currency, + reason, + requiresApproval: order.amount > REFUND_APPROVAL_THRESHOLD_USD, + }, + }) + 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..6e5c592d --- /dev/null +++ b/examples/eve/agent/tools/lookup_customer.ts @@ -0,0 +1,34 @@ +import { defineTool } from 'eve/tools' +import { useTurnLogger } 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 }, ctx) { + await fakeLatency(450, 950) + + const customer = findCustomer(query) + if (!customer) { + return { found: false, query } + } + + const log = useTurnLogger(ctx) + 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..39b02860 --- /dev/null +++ b/examples/eve/agent/tools/lookup_order.ts @@ -0,0 +1,34 @@ +import { defineTool } from 'eve/tools' +import { useTurnLogger } 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 }, ctx) { + await fakeLatency(350, 750) + + const order = findOrder(orderId) + if (!order) { + return { found: false, orderId } + } + + const log = useTurnLogger(ctx) + 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..7dd43120 --- /dev/null +++ b/examples/eve/app/_components/agent-chat.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useEveAgent } from "eve/react"; +import { AlertCircleIcon } from "lucide-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"; + +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() { + const agent = useEveAgent(); + const isBusy = agent.status === "submitted" || agent.status === "streaming"; + const isEmpty = agent.data.messages.length === 0; + + 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 ( +
+ {isEmpty ? null : ( +
+ + {AGENT_NAME} + + + + Public preview + +
+ )} + + {agent.error ? ( +
+
+ +
+

Request failed

+

{agent.error.message}

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

+ evlog × Eve demo +

+

{AGENT_NAME}

+

{DEMO_TAGLINE}

+
+
+ {STARTER_PROMPTS.map((prompt) => ( + + ))} +
+ + Public preview + +
+ ) : 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/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..320072ef --- /dev/null +++ b/examples/eve/app/layout.tsx @@ -0,0 +1,35 @@ +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.', +} + +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..ff7d7d8a --- /dev/null +++ b/examples/eve/app/page.tsx @@ -0,0 +1,5 @@ +import { AgentChat } from "@/app/_components/agent-chat"; + +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..7e7b85cd --- /dev/null +++ b/examples/eve/components/ai-elements/chain-of-thought.tsx @@ -0,0 +1,197 @@ +"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, setIsOpen } = 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) => { + const { isOpen } = useChainOfThought(); + + return ( + + + {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..eb529961 --- /dev/null +++ b/examples/eve/components/ai-elements/code-block.tsx @@ -0,0 +1,522 @@ +"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) => { + const start = code.slice(0, 100); + const end = code.length > 100 ? code.slice(-100) : ""; + return `${language}:${code.length}:${start}:${end}`; +}; + +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 ( +