From 98a01bc071565216c87f2abbe51a851b9a6e0854 Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 27 Jan 2026 19:29:10 -0800 Subject: [PATCH] feat: add web chat UI example (#3) Co-authored-by: Letta --- examples/web-chat/index.html | 205 ++++++++++++++++++++++++++++ examples/web-chat/server.ts | 253 +++++++++++++++++++++++++++++++++++ 2 files changed, 458 insertions(+) create mode 100644 examples/web-chat/index.html create mode 100644 examples/web-chat/server.ts diff --git a/examples/web-chat/index.html b/examples/web-chat/index.html new file mode 100644 index 0000000..aa8e50a --- /dev/null +++ b/examples/web-chat/index.html @@ -0,0 +1,205 @@ + + + + + + Letta Code Chat + + + +
+

Letta Code Chat

+ +
+ +
+
+ + +
+ + + diff --git a/examples/web-chat/server.ts b/examples/web-chat/server.ts new file mode 100644 index 0000000..f7799ec --- /dev/null +++ b/examples/web-chat/server.ts @@ -0,0 +1,253 @@ +#!/usr/bin/env bun + +/** + * Web Chat Server + * + * A simple web UI for chatting with a Letta agent. + * + * Usage: + * bun server.ts # Start server on port 3000 + * bun server.ts --port=8080 # Custom port + * + * Requirements: + * - LETTA_API_KEY environment variable (or logged in via `letta auth`) + * - bun add @letta-ai/letta-client (for memory reading) + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +import { createSession, resumeSession, type Session } from '../../src/index.js'; +import Letta from '@letta-ai/letta-client'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const STATE_FILE = join(__dirname, 'state.json'); +const HTML_FILE = join(__dirname, 'index.html'); + +interface AppState { + agentId: string | null; +} + +let session: Session | null = null; +let state: AppState = { agentId: null }; + +// Letta client for memory operations +const lettaClient = new Letta({ + baseUrl: process.env.LETTA_BASE_URL || 'https://api.letta.com', + apiKey: process.env.LETTA_API_KEY, +}); + +// Load state +async function loadState(): Promise { + if (existsSync(STATE_FILE)) { + state = JSON.parse(await readFile(STATE_FILE, 'utf-8')); + } +} + +// Save state +async function saveState(): Promise { + await writeFile(STATE_FILE, JSON.stringify(state, null, 2)); +} + +// Get or create session +async function getSession(): Promise { + if (session) return session; + + if (state.agentId) { + console.log(`Resuming agent: ${state.agentId}`); + session = await resumeSession(state.agentId, { + model: 'haiku', + permissionMode: 'bypassPermissions', + }); + } else { + console.log('Creating new agent...'); + session = await createSession({ + model: 'haiku', + systemPrompt: `You are a helpful assistant accessible through a web interface. + +Be concise but friendly. You can help with: +- Answering questions +- Writing and reviewing code +- Brainstorming ideas +- General conversation + +You have memory that persists across conversations. Use it to remember important context about the user and ongoing topics.`, + memory: [ + { + label: 'user-context', + value: '# User Context\n\n(Nothing learned yet)', + description: 'What I know about the user', + }, + { + label: 'conversation-notes', + value: '# Conversation Notes\n\n(No notes yet)', + description: 'Important things from our conversations', + }, + ], + permissionMode: 'bypassPermissions', + }); + } + + return session; +} + +// Parse args +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + port: { type: 'string', default: '3000' }, + }, +}); + +const PORT = parseInt(values.port!, 10); + +// Load state on startup +await loadState(); + +console.log(`Starting web chat server on http://localhost:${PORT}`); + +// Start server +Bun.serve({ + port: PORT, + idleTimeout: 120, // 2 minutes for slow LLM responses + + async fetch(req) { + const url = new URL(req.url); + + // Serve HTML + if (url.pathname === '/' || url.pathname === '/index.html') { + const html = await readFile(HTML_FILE, 'utf-8'); + return new Response(html, { + headers: { 'Content-Type': 'text/html' }, + }); + } + + // API: Get status + if (url.pathname === '/api/status' && req.method === 'GET') { + return Response.json({ + agentId: state.agentId, + connected: session !== null, + }); + } + + // API: Chat (streaming) + if (url.pathname === '/api/chat' && req.method === 'POST') { + const { message } = await req.json(); + + if (!message) { + return Response.json({ error: 'Message required' }, { status: 400 }); + } + + const sess = await getSession(); + + // Save agent ID after first message + if (!state.agentId && sess.agentId) { + state.agentId = sess.agentId; + await saveState(); + console.log(`Agent created: ${state.agentId}`); + } + + // Stream response + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + const send = (data: object) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + }; + + try { + await sess.send(message); + + for await (const msg of sess.stream()) { + if (msg.type === 'assistant') { + send({ type: 'text', content: msg.content }); + } else if (msg.type === 'tool_call' && 'toolName' in msg) { + send({ type: 'tool', name: msg.toolName }); + } else if (msg.type === 'result') { + // Update agent ID if we got it + if (!state.agentId && sess.agentId) { + state.agentId = sess.agentId; + await saveState(); + } + } + } + + send({ type: 'done' }); + controller.close(); + } catch (err) { + send({ type: 'error', message: String(err) }); + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + } + + // API: Get memory + if (url.pathname === '/api/memory' && req.method === 'GET') { + if (!state.agentId) { + return Response.json({ blocks: [] }); + } + + try { + const response = await lettaClient.agents.blocks.list(state.agentId); + // Response is paginated - blocks are in .body or we iterate + const blockList = Array.isArray(response) ? response : (response.body || []); + return Response.json({ + blocks: blockList.map((b: any) => ({ + label: b.label, + value: b.value, + description: b.description, + })) + }); + } catch (err) { + console.error('Failed to read memory:', err); + return Response.json({ blocks: [], error: String(err) }); + } + } + + // API: Update memory + if (url.pathname === '/api/memory' && req.method === 'POST') { + if (!state.agentId) { + return Response.json({ error: 'No agent' }, { status: 400 }); + } + + const { label, value } = await req.json(); + if (!label || value === undefined) { + return Response.json({ error: 'label and value required' }, { status: 400 }); + } + + try { + await lettaClient.agents.blocks.update(state.agentId, label, { value }); + return Response.json({ ok: true }); + } catch (err) { + console.error('Failed to update memory:', err); + return Response.json({ error: String(err) }, { status: 500 }); + } + } + + // API: Reset + if (url.pathname === '/api/reset' && req.method === 'POST') { + if (session) { + session.close(); + session = null; + } + state = { agentId: null }; + await saveState(); + return Response.json({ ok: true }); + } + + return new Response('Not Found', { status: 404 }); + }, +});