feat: add web chat UI example (#3)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
205
examples/web-chat/index.html
Normal file
205
examples/web-chat/index.html
Normal 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,'<').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,'<').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,'<')}`)
|
||||
.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
253
examples/web-chat/server.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user