feat: add web chat UI example (#3)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Cameron
2026-01-27 19:29:10 -08:00
committed by GitHub
parent 05cf219daf
commit 98a01bc071
2 changed files with 458 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Letta Code Chat</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #111;
color: #eee;
height: 100vh;
display: flex;
flex-direction: column;
max-width: 600px;
margin: 0 auto;
padding: 1rem;
}
header {
padding: 0.5rem 0 1rem;
border-bottom: 1px solid #333;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 { font-size: 1rem; color: #888; }
#memory h3 { color: #e94560; font-size: 0.85rem; margin: 0.5rem 0 0.25rem; }
#memory h3:first-child { margin-top: 0; }
.messages {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.msg {
padding: 0.5rem 0.75rem;
border-radius: 6px;
max-width: 85%;
}
.msg.user {
background: #333;
align-self: flex-end;
}
.msg.assistant {
background: #1a1a1a;
border: 1px solid #333;
align-self: flex-start;
}
.msg pre {
background: #000;
padding: 0.5rem;
border-radius: 4px;
overflow-x: auto;
margin: 0.25rem 0;
font-size: 0.85em;
}
.input-row {
display: flex;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #333;
margin-top: 1rem;
}
input {
flex: 1;
padding: 0.6rem;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a1a;
color: #eee;
font-size: 1rem;
}
input:focus { outline: none; border-color: #555; }
button {
padding: 0.6rem 1rem;
background: #e94560;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
button:disabled { background: #444; }
</style>
</head>
<body>
<header>
<h1>Letta Code Chat</h1>
<button id="mem-toggle" style="font-size:0.8rem;padding:0.3rem 0.6rem;background:#333;">Memory</button>
</header>
<div id="memory" style="display:none;background:#1a1a1a;border:1px solid #333;border-radius:6px;padding:0.75rem;margin-bottom:1rem;max-height:200px;overflow-y:auto;font-size:0.8rem;">
<div id="mem-content" style="white-space:pre-wrap;color:#888;">Loading...</div>
</div>
<div id="messages" class="messages"></div>
<div class="input-row">
<input type="text" id="input" placeholder="Message..." autocomplete="off">
<button id="send">Send</button>
</div>
<script>
const msgs = document.getElementById('messages');
const input = document.getElementById('input');
const btn = document.getElementById('send');
let busy = false;
function add(role, text) {
const d = document.createElement('div');
d.className = 'msg ' + role;
d.innerHTML = text.replace(/</g,'&lt;').replace(/\n/g,'<br>');
msgs.appendChild(d);
msgs.scrollTop = msgs.scrollHeight;
return d;
}
async function send() {
const text = input.value.trim();
if (!text || busy) return;
input.value = '';
busy = true;
btn.disabled = true;
add('user', text);
const el = add('assistant', '...');
let full = '';
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: text})
});
if (!res.ok) {
el.innerHTML = 'Error: ' + res.status;
busy = false;
btn.disabled = false;
return;
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
buf += dec.decode(value, {stream: true});
const lines = buf.split('\n\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const d = JSON.parse(line.slice(6));
if (d.type === 'text') {
full += d.content;
el.innerHTML = full.replace(/</g,'&lt;').replace(/\n/g,'<br>');
msgs.scrollTop = msgs.scrollHeight;
} else if (d.type === 'error') {
el.innerHTML = 'Error: ' + d.message;
}
}
}
if (!full) el.innerHTML = '<em>(no response)</em>';
} catch(e) {
el.innerHTML = 'Error: ' + e.message;
}
busy = false;
btn.disabled = false;
}
btn.onclick = () => send();
input.onkeydown = e => { if (e.key === 'Enter') send(); };
input.focus();
// Memory toggle
const memDiv = document.getElementById('memory');
const memContent = document.getElementById('mem-content');
const memToggle = document.getElementById('mem-toggle');
let memVisible = false;
async function loadMemory() {
try {
const res = await fetch('/api/memory');
const data = await res.json();
if (!data.blocks || data.blocks.length === 0) {
memContent.innerHTML = '<em>No memory yet</em>';
return;
}
memContent.innerHTML = data.blocks
.filter(b => !['skills', 'loaded_skills'].includes(b.label))
.map(b => `<h3>${b.label}</h3>${(b.value || '').replace(/</g,'&lt;')}`)
.join('');
} catch(e) {
memContent.textContent = 'Failed to load';
}
}
memToggle.onclick = () => {
memVisible = !memVisible;
memDiv.style.display = memVisible ? 'block' : 'none';
if (memVisible) loadMemory();
};
</script>
</body>
</html>

253
examples/web-chat/server.ts Normal file
View File

@@ -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<void> {
if (existsSync(STATE_FILE)) {
state = JSON.parse(await readFile(STATE_FILE, 'utf-8'));
}
}
// Save state
async function saveState(): Promise<void> {
await writeFile(STATE_FILE, JSON.stringify(state, null, 2));
}
// Get or create session
async function getSession(): Promise<Session> {
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 });
},
});