Building Chat Experiences
Learn how to build production-ready chat interfaces with streaming responses, source attribution, and reactive state management using Orama’s Answer Engine.Complete Chat Implementation
Here’s a full example of a chat application with React:Chat.tsx
import { useState, useEffect, useRef } from 'react'
import { create, insert } from '@orama/orama'
import { pluginSecureProxy } from '@orama/plugin-secure-proxy'
import { AnswerSession } from '@orama/orama'
import type { Interaction, Results } from '@orama/orama'
interface Message {
id: string
role: 'user' | 'assistant'
content: string
sources?: Results<any>
loading?: boolean
}
export function ChatInterface() {
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const sessionRef = useRef<AnswerSession | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
// Initialize Orama and Answer Session
useEffect(() => {
async function init() {
const db = await create({
schema: {
title: 'string',
content: 'string',
url: 'string'
},
plugins: [
await pluginSecureProxy({
apiKey: process.env.NEXT_PUBLIC_ORAMA_API_KEY!,
models: {
chat: 'openai/gpt-4o-mini'
}
})
]
})
// Insert your documentation or data
await insert(db, {
title: 'Getting Started with Orama',
content: 'Orama is a fast, batteries-included full-text search engine...',
url: '/docs/getting-started'
})
await insert(db, {
title: 'Vector Search',
content: 'Vector search allows you to find semantically similar documents...',
url: '/docs/vector-search'
})
// Create Answer Session
const session = new AnswerSession(db, {
systemPrompt: `You are a helpful documentation assistant.
- Answer questions based on the provided documentation
- Be concise but thorough
- Include relevant code examples
- If you're unsure, acknowledge it`,
events: {
onStateChange: (state: Interaction[]) => {
const latest = state[state.length - 1]
setMessages(prev => {
const updated = [...prev]
const lastMsg = updated[updated.length - 1]
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.content = latest.response
lastMsg.sources = latest.sources || undefined
lastMsg.loading = latest.loading
}
return updated
})
setIsLoading(latest.loading)
}
}
})
sessionRef.current = session
}
init()
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!input.trim() || !sessionRef.current || isLoading) return
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content: input.trim()
}
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
loading: true
}
setMessages(prev => [...prev, userMessage, assistantMessage])
setInput('')
setIsLoading(true)
try {
// Use streaming for real-time updates
const stream = await sessionRef.current.askStream({
term: userMessage.content,
properties: ['title', 'content'],
limit: 5
})
// Stream automatically updates via onStateChange
for await (const chunk of stream) {
// Chunks are handled by the onStateChange event
}
} catch (error) {
console.error('Chat error:', error)
setMessages(prev => {
const updated = [...prev]
const lastMsg = updated[updated.length - 1]
if (lastMsg.role === 'assistant') {
lastMsg.content = 'Sorry, an error occurred. Please try again.'
lastMsg.loading = false
}
return updated
})
} finally {
setIsLoading(false)
}
}
const handleStop = () => {
sessionRef.current?.abortAnswer()
}
return (
<div className="chat-container">
<div className="messages">
{messages.map((message) => (
<div key={message.id} className={`message ${message.role}`}>
<div className="message-content">
{message.content}
{message.loading && <span className="loading-indicator">▊</span>}
</div>
{message.sources && message.sources.hits.length > 0 && (
<div className="sources">
<h4>Sources:</h4>
<ul>
{message.sources.hits.map((hit, idx) => (
<li key={idx}>
<a href={hit.document.url}>
{hit.document.title}
</a>
<span className="score">
(relevance: {(hit.score * 100).toFixed(0)}%)
</span>
</li>
))}
</ul>
</div>
)}
</div>
))}
</div>
<form onSubmit={handleSubmit} className="input-form">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask a question..."
disabled={isLoading}
/>
{isLoading ? (
<button type="button" onClick={handleStop}>
Stop
</button>
) : (
<button type="submit" disabled={!input.trim()}>
Send
</button>
)}
</form>
</div>
)
}
Streaming Responses
Streaming provides a better user experience by showing responses as they’re generated:Basic Streaming
const stream = await session.askStream({
term: 'What is vector search?'
})
for await (const chunk of stream) {
console.log(chunk) // Each token as it arrives
}
Streaming with State Management
TheonStateChange event is called with each new chunk:
const session = new AnswerSession(db, {
events: {
onStateChange: (state) => {
const current = state[state.length - 1]
// Update your UI progressively
updateUI({
message: current.response, // Growing response text
isLoading: current.loading,
sources: current.sources
})
}
}
})
Vanilla JavaScript Example
<!DOCTYPE html>
<html>
<head>
<title>Orama Chat</title>
</head>
<body>
<div id="chat">
<div id="messages"></div>
<form id="chat-form">
<input id="input" type="text" placeholder="Ask a question..." />
<button type="submit">Send</button>
</form>
</div>
<script type="module">
import { create, insert } from '@orama/orama'
import { pluginSecureProxy } from '@orama/plugin-secure-proxy'
import { AnswerSession } from '@orama/orama'
const messagesDiv = document.getElementById('messages')
const form = document.getElementById('chat-form')
const input = document.getElementById('input')
// Initialize
const db = await create({
schema: { title: 'string', content: 'string' },
plugins: [
await pluginSecureProxy({
apiKey: 'your-api-key',
models: { chat: 'openai/gpt-4o-mini' }
})
]
})
await insert(db, {
title: 'Orama Docs',
content: 'Documentation content here...'
})
const session = new AnswerSession(db, {
systemPrompt: 'You are a helpful assistant.',
events: {
onStateChange: (state) => {
const latest = state[state.length - 1]
const assistantDiv = document.querySelector('.assistant:last-child')
if (assistantDiv) {
assistantDiv.textContent = latest.response
if (latest.loading) {
assistantDiv.classList.add('loading')
} else {
assistantDiv.classList.remove('loading')
}
}
}
}
})
form.addEventListener('submit', async (e) => {
e.preventDefault()
const query = input.value.trim()
if (!query) return
// Add user message
const userDiv = document.createElement('div')
userDiv.className = 'message user'
userDiv.textContent = query
messagesDiv.appendChild(userDiv)
// Add assistant message placeholder
const assistantDiv = document.createElement('div')
assistantDiv.className = 'message assistant loading'
messagesDiv.appendChild(assistantDiv)
input.value = ''
// Stream response
const stream = await session.askStream({ term: query })
for await (const chunk of stream) {
// Updates happen via onStateChange
}
})
</script>
</body>
</html>
Source Attribution
Show users which documents were used to generate responses:const session = new AnswerSession(db, {
events: {
onStateChange: (state) => {
const current = state[state.length - 1]
if (current.sources) {
displaySources(current.sources.hits.map(hit => ({
title: hit.document.title,
url: hit.document.url,
score: hit.score,
excerpt: hit.document.content.substring(0, 200)
})))
}
}
}
})
function displaySources(sources) {
const sourcesHTML = sources
.map(source => `
<div class="source">
<a href="${source.url}">
<h4>${source.title}</h4>
<p>${source.excerpt}...</p>
</a>
<span class="relevance">${(source.score * 100).toFixed(0)}% relevant</span>
</div>
`)
.join('')
document.getElementById('sources').innerHTML = sourcesHTML
}
Conversation Management
Persisting Conversations
import { AnswerSession } from '@orama/orama'
import type { Message } from '@orama/orama'
class ConversationManager {
private storage: Storage
constructor() {
this.storage = window.localStorage
}
saveConversation(conversationId: string, messages: Message[]) {
this.storage.setItem(
`conversation:${conversationId}`,
JSON.stringify(messages)
)
}
loadConversation(conversationId: string): Message[] | null {
const data = this.storage.getItem(`conversation:${conversationId}`)
return data ? JSON.parse(data) : null
}
createSession(db: any, conversationId: string) {
const savedMessages = this.loadConversation(conversationId)
const session = new AnswerSession(db, {
conversationID: conversationId,
initialMessages: savedMessages || [],
events: {
onStateChange: (state) => {
// Auto-save on every state change
const messages = session.getMessages()
this.saveConversation(conversationId, messages)
}
}
})
return session
}
listConversations(): string[] {
const keys = Object.keys(this.storage)
return keys
.filter(key => key.startsWith('conversation:'))
.map(key => key.replace('conversation:', ''))
}
deleteConversation(conversationId: string) {
this.storage.removeItem(`conversation:${conversationId}`)
}
}
// Usage
const manager = new ConversationManager()
const session = manager.createSession(db, 'user-123-conv-1')
// List all saved conversations
const conversations = manager.listConversations()
console.log('Saved conversations:', conversations)
Managing Multiple Sessions
class SessionManager {
private sessions = new Map<string, AnswerSession>()
private db: any
constructor(db: any) {
this.db = db
}
getOrCreateSession(userId: string, conversationId?: string): AnswerSession {
const id = conversationId || `${userId}-${Date.now()}`
if (!this.sessions.has(id)) {
const session = new AnswerSession(this.db, {
conversationID: id,
systemPrompt: this.getSystemPromptForUser(userId),
userContext: this.getUserContext(userId)
})
this.sessions.set(id, session)
}
return this.sessions.get(id)!
}
clearSession(conversationId: string) {
const session = this.sessions.get(conversationId)
session?.clearSession()
this.sessions.delete(conversationId)
}
private getSystemPromptForUser(userId: string): string {
// Customize based on user
return 'You are a helpful assistant.'
}
private getUserContext(userId: string): object {
// Load user preferences, history, etc.
return { userId, preferences: {} }
}
}
Error Handling
Handle errors gracefully to provide a good user experience:const session = new AnswerSession(db, {
events: {
onStateChange: (state) => {
const current = state[state.length - 1]
if (current.error) {
handleError(current.errorMessage)
}
if (current.aborted) {
showMessage('Response generation was cancelled')
}
}
}
})
function handleError(message: string | null) {
const errorMessages = {
'PLUGIN_SECURE_PROXY_NOT_FOUND':
'Configuration error: Secure Proxy plugin not found',
'PLUGIN_SECURE_PROXY_MISSING_CHAT_MODEL':
'Configuration error: No chat model specified',
'AbortError':
'Request was cancelled'
}
const userMessage = errorMessages[message] ||
'An unexpected error occurred. Please try again.'
displayError(userMessage)
logError(message) // Log for debugging
}
async function askWithRetry(session: AnswerSession, query: string, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await session.ask({ term: query })
} catch (error) {
if (i === maxRetries - 1) throw error
// Wait before retry (exponential backoff)
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000))
}
}
}
Advanced Features
Regenerating Responses
Allow users to regenerate responses they’re not satisfied with:function RegenerateButton({ session }: { session: AnswerSession }) {
const handleRegenerate = async () => {
try {
const stream = session.regenerateLast({ stream: true })
for await (const chunk of stream) {
// Updates via onStateChange
}
} catch (error) {
if (error.message.includes('No messages')) {
alert('No messages to regenerate')
} else {
console.error('Regeneration failed:', error)
}
}
}
return (
<button onClick={handleRegenerate}>
🔄 Regenerate Response
</button>
)
}
Stop Generation
Allow users to cancel long-running responses:function ChatInput({ session }: { session: AnswerSession }) {
const [isGenerating, setIsGenerating] = useState(false)
const handleStop = () => {
session.abortAnswer()
setIsGenerating(false)
}
return (
<div>
{isGenerating ? (
<button onClick={handleStop} className="stop-button">
⏹ Stop Generating
</button>
) : (
<button type="submit">Send</button>
)}
</div>
)
}
Context-Aware Search
Customize search parameters based on the conversation:const session = new AnswerSession(db, {
systemPrompt: 'You are a helpful assistant.'
})
// Adjust search based on query intent
async function intelligentAsk(query: string) {
const searchParams = {
term: query,
// Use more results for complex questions
limit: query.split(' ').length > 5 ? 10 : 5,
// Boost recent docs for "latest" or "new" queries
...(query.match(/latest|new|recent/i) && {
sortBy: { property: 'publishDate', order: 'DESC' }
})
}
return await session.askStream(searchParams)
}
Performance Optimization
Debouncing Input
import { debounce } from 'lodash'
const debouncedAsk = debounce(async (query: string) => {
const stream = await session.askStream({ term: query })
for await (const chunk of stream) {
// Handle stream
}
}, 500)
// In your input handler
const handleInputChange = (value: string) => {
setInput(value)
if (value.length > 3) {
debouncedAsk(value)
}
}
Caching Responses
class CachedSession {
private cache = new Map<string, string>()
private session: AnswerSession
constructor(session: AnswerSession) {
this.session = session
}
async ask(query: string): Promise<string> {
const cacheKey = query.toLowerCase().trim()
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)!
}
const response = await this.session.ask({ term: query })
this.cache.set(cacheKey, response)
return response
}
clearCache() {
this.cache.clear()
}
}
Testing
import { describe, it, expect, beforeEach } from 'vitest'
import { create, insert } from '@orama/orama'
import { AnswerSession } from '@orama/orama'
describe('AnswerSession', () => {
let db: any
let session: AnswerSession
beforeEach(async () => {
db = await create({
schema: { title: 'string', content: 'string' }
})
await insert(db, {
title: 'Test Doc',
content: 'Test content'
})
session = new AnswerSession(db, {
systemPrompt: 'You are a test assistant.'
})
})
it('should generate responses', async () => {
const response = await session.ask({ term: 'test' })
expect(response).toBeTruthy()
expect(typeof response).toBe('string')
})
it('should track message history', async () => {
await session.ask({ term: 'first question' })
await session.ask({ term: 'second question' })
const messages = session.getMessages()
expect(messages.length).toBeGreaterThan(0)
})
it('should clear session', () => {
session.clearSession()
expect(session.getMessages()).toEqual([])
expect(session.state).toEqual([])
})
})
Best Practices
Use streaming for real-time feedback
Use streaming for real-time feedback
Always prefer
askStream() over ask() in user-facing applications to provide immediate feedback and reduce perceived latency.Display sources for transparency
Display sources for transparency
Show users which documents were used to generate responses. This builds trust and allows users to verify information.
Implement graceful error handling
Implement graceful error handling
Handle network errors, timeouts, and API failures gracefully with user-friendly error messages and retry logic.
Persist conversation history
Persist conversation history
Save conversation state to allow users to continue conversations across sessions and devices.
Allow response cancellation
Allow response cancellation
Implement a “Stop” button to let users cancel long-running generations.
Optimize search parameters
Optimize search parameters
Fine-tune search parameters (limit, boost, properties) based on your use case to improve response quality.
Next Steps
Search API
Learn about all available search parameters
Secure Proxy
Configure the Secure Proxy plugin
Answer Sessions
Configure and customize answer sessions
Performance
Optimize your Answer Engine performance