Stream-ready AI chat scaffold with suggested prompts, auto-scroll, thinking messages, message rating, chat export, and persistent voice toggle.
Try a suggested prompt to get started
Alpine.js Chat
huxChat is a logic scaffold for ChatGPT/Claude-style UIs. It manages a message thread, loading state, input handling, message actions, suggested prompts, thinking messages, response rating, chat export, and a voice toggle — without coupling you to any specific backend or streaming protocol.
Replace the _simulateResponse() call in sendMessage() and regenerateResponse() with your own fetch/SSE/WebSocket logic to connect to any AI backend.
copyToClipboard() uses the Clipboard API, which requires a secure context (HTTPS or localhost) and, in some browsers, a user gesture. Copy actions will silently fail (with a logged error) in non-secure contexts.
API
huxChat(config)
Returns an Alpine data object with:
messages: Array<{ id: string, role: 'user' | 'assistant', content: string, status: 'sending' | 'complete' | 'error', rating: 'up' | 'down' | null }>isLoading: booleaninputValue: stringsuggestedPrompts: string[]isVoiceEnabled: booleanthinkingMessage: stringsendMessage(): voidhandleKeydown(event: KeyboardEvent): voidscrollToBottom(): voidcopyToClipboard(id: string): Promise<void>regenerateResponse(id: string): voidrateMessage(id: string, rating: 'up' | 'down'): voidexportChat(): voidtoggleVoice(): voidselectSuggestedPrompt(prompt: string): void
Internal helper methods are private implementation details and are not part of the supported API contract.
Options
messages: Array<{ id: string, role: 'user' | 'assistant', content: string, status: 'sending' | 'complete' | 'error', rating: 'up' | 'down' | null }>(default:[]) Pre-populate the message thread (for example, with a system greeting).suggestedPrompts: string[](default:[]) Starter prompt buttons shown when the thread is empty. Clicking one pre-fills and sends the message.
Quick Start
Minimal
<div x-data="huxChat()">
<div x-ref="messageList" style="height: 400px; overflow-y: auto;">
<template x-for="message in messages" x-bind:key="message.id">
<div x-text="message.content" style="white-space: pre-wrap;"></div>
</template>
</div>
<textarea
x-model="inputValue"
x-on:keydown="handleKeydown($event)"
x-bind:disabled="isLoading"
placeholder="Message…"
></textarea>
<button type="button" x-on:click="sendMessage()">Send</button>
</div>With Suggested Prompts
huxChat({
suggestedPrompts: [
'What is Alpine.js?',
'How do I use x-data?',
'Show me a simple counter example',
],
})Common Usage Patterns
Pre-populate with a Greeting
huxChat({
messages: [
{
id: 'greeting',
role: 'assistant',
content: 'Hello! How can I help you today?',
status: 'complete',
rating: null,
},
],
})Connect to a Real AI API
Replace _simulateResponse() in sendMessage() and regenerateResponse() with your own logic. The scaffold adds a sending assistant message before the call so your streaming loop can update message.content in place. Set this.thinkingMessage at key points to surface interim status:
// API Integration Point
async function streamResponse(messages, onChunk, onDone) {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
onChunk(decoder.decode(value))
}
onDone()
}Thinking Messages
thinkingMessage is a reactive string you set at key points in your integration to surface interim status (“Working on it…”, “Crunching the numbers…”). The demo stub cycles through example messages automatically. Clear it (this.thinkingMessage = '') once the response arrives:
<template x-if="message.status === 'sending' && thinkingMessage !== ''">
<span x-text="thinkingMessage" aria-live="polite"></span>
</template>Message Actions on Hover
Wire copyToClipboard(message.id), rateMessage(message.id, 'up'/'down'), and regenerateResponse(message.id) to action buttons inside the message template:
<template x-if="message.role === 'assistant' && message.status === 'complete'">
<div>
<button type="button" x-on:click="copyToClipboard(message.id)" aria-label="Copy message">
Copy
</button>
<button
type="button"
x-on:click="rateMessage(message.id, 'up')"
x-bind:aria-pressed="(message.rating === 'up').toString()"
aria-label="Thumbs up"
>
👍
</button>
<button
type="button"
x-on:click="rateMessage(message.id, 'down')"
x-bind:aria-pressed="(message.rating === 'down').toString()"
aria-label="Thumbs down"
>
👎
</button>
<button
type="button"
x-on:click="regenerateResponse(message.id)"
aria-label="Regenerate response"
>
Regenerate
</button>
</div>
</template>Export Chat
exportChat() downloads the full conversation as a plain-text file (chat-export.txt). Only complete messages are included. Bind it to a toolbar button:
<button
type="button"
x-on:click="exportChat()"
x-bind:disabled="messages.filter(m => m.status === 'complete').length === 0"
>
Export chat
</button>Voice Toggle
The voice preference is read from and written to localStorage under the key hux-chat:voice-enabled. Bind toggleVoice() to a button and read isVoiceEnabled to drive your TTS logic. The component manages state and persistence only — wire up your own speechSynthesis or voice API call:
<button
type="button"
x-on:click="toggleVoice()"
x-bind:aria-pressed="isVoiceEnabled.toString()"
x-bind:aria-label="isVoiceEnabled ? 'Disable voice' : 'Enable voice'"
>
<span x-text="isVoiceEnabled ? 'Voice on' : 'Voice off'">Voice off</span>
</button>Auto-Expanding Textarea
Use an input handler on the textarea to grow it as the user types:
<textarea
x-ref="chatInput"
x-model="inputValue"
x-on:keydown="handleKeydown($event)"
x-on:input="$event.target.style.height = 'auto'; $event.target.style.height = $event.target.scrollHeight + 'px'"
rows="1"
style="resize: none; overflow: hidden;"
></textarea>Behavior Contract
sendMessage()does nothing ifinputValueis empty orisLoadingistrue.- Each call to
sendMessage()appends a{ role: 'user', status: 'complete', rating: null }message, clearsinputValue, setsisLoading = true, and calls the response handler. - The response handler appends a
{ role: 'assistant', status: 'sending', rating: null }message. Updatecontentin place during streaming. Setstatus = 'complete'andisLoading = falsewhen done. scrollToBottom()defers to$nextTickand scrollsx-ref="messageList"to the bottom.handleKeydown()interceptsEnter(withoutShift) to callsendMessage()and prevents the default newline.Shift+Enterfalls through for normal newline behavior.regenerateResponse(id)removes the targeted assistant message and all messages after it, then re-invokes the response handler.rateMessage(id, rating)setsmessage.ratingto'up'or'down'. Calling it again with the same rating toggles it back tonull.exportChat()collects allcompletemessages, formats them asYou: …/Assistant: …joined by double newlines, and triggers a download ofchat-export.txt. Does nothing if no complete messages exist.toggleVoice()flipsisVoiceEnabledand persists the new value tolocalStorage.selectSuggestedPrompt(prompt)setsinputValueto the prompt and immediately callssendMessage().
Error Handling
- If
copyToClipboard(id)cannot find the message, it logs:[huxChat] copyToClipboard: message not found. - If the clipboard write fails, it logs:
[huxChat] copyToClipboard failed. - If
regenerateResponse(id)cannot find the message, it logs:[huxChat] regenerateResponse: message not found. - If
regenerateResponse(id)is called on a non-assistant or non-complete message, it logs:[huxChat] regenerateResponse: target must be a complete assistant message. - If
rateMessage(id, rating)cannot find the message, it logs:[huxChat] rateMessage: message not found. - If
localStorageis unavailable,init()silently keepsisVoiceEnabled = falseandtoggleVoice()silently skips the write.
Accessibility Notes
- Wrap the message list in a
role="log"element witharia-live="polite"and an accessible label so screen readers announce new messages. - The typing indicator container uses
role="status"andaria-live="polite". It includes ansr-onlytext node (“Assistant is typing”) so screen readers announce the typing status. The decorative dots and visiblethinkingMessagetext are markedaria-hidden="true"to avoid duplication. - Keep send and action buttons as labeled
buttonelements. Usearia-labelfor icon-only controls. - Add
type="button"to all non-submit buttons to prevent accidental form submission. - Provide a visible
<label>(or<label class="sr-only">) for the textarea to support keyboard and screen reader users. - Bind
x-bind:disabled="isLoading"to the textarea and send button to prevent input during a pending response. - Use
aria-pressedon the voice toggle and rating buttons to communicate their on/off state. - Use
white-space: pre-wrap(orclass="whitespace-pre-wrap") on message content elements to preserve user-entered newlines.
Notes
- The
_simulateResponse()method is a demo stub. It is not part of the public API and will be removed once you connect a real backend. - The
statusfield supports'sending','complete', and'error'. The scaffold uses'sending'during streaming and'complete'on success. Implement'error'handling in your API integration layer as needed. - Voice enablement is stored under the key
hux-chat:voice-enabledinlocalStorage. The component does not implement TTS itself — useisVoiceEnabledto gate your own speech synthesis call. copyToClipboard(id)writes message content from component state directly vianavigator.clipboard. It does not usehuxCopybecause the source is reactive state rather than a DOM element. The toolbar “Copy Alpine.js + HTML” button useshuxCopyto copy the snippet sources.- Auto-scroll uses
x-ref="messageList"on the scrollable container. The ref name is fixed; if you rename the element, update thex-refvalue tomessageListto match.