@livefire2015/solid-ag-chat
v1.0.7
Published
SolidJS chat components with official AG-UI protocol support
Maintainers
Readme
@livefire2015/solid-ag-chat v1
SolidJS library for building chat UIs with official AG-UI protocol support.
What This Library Provides
This is a library - not a ready-to-use chat UI. It provides:
- ✅ SolidJS Primitives - Provider, hooks for building custom chat UIs
- ✅ Official AG-UI SDK Client - Stateless agent execution with streaming
- ✅ Reactive State Management - Client-side state with conversation context
- ✅ Type Safety - Full TypeScript types for AG-UI protocol
- ✅ Mock Client - Testing infrastructure included
You build the UI - This library handles the protocol, state, and reactivity.
AG-UI Protocol Overview
AG-UI is a stateless agent protocol using the official @ag-ui/client SDK:
┌─────────────┐ RxJS Observable ┌─────────────┐
│ Browser │◄───────────────────────│ Server │
│ Client │ /agent/run │ (Python) │
│ (SolidJS) │───────────────────────►│ (PydanticAI)│
└─────────────┘ └─────────────┘Key Features:
- 📨 Stateless Execution: Client sends full conversation history with each request
- 🔄 Streaming Events: TEXT_MESSAGE_START/CONTENT/END, TOOL_CALL_*, etc.
- 🛠️ Tool Calls: Official
toolCallsarray in messages - 💬 Conversation Management: Client-side multi-turn context tracking
Installation
npm install @livefire2015/solid-ag-chatPeer dependencies:
solid-js@^1.8.0@ag-ui/core@^0.0.39@ag-ui/client@^0.0.39rxjs@^7.8.1
Quick Start
1. Wrap Your App with ChatProvider
import { ChatProvider, createSdkAgent } from '@livefire2015/solid-ag-chat';
function App() {
// Create official AG-UI SDK client
const client = createSdkAgent({
baseUrl: 'http://localhost:8000',
headers: {
'Authorization': 'Bearer YOUR_TOKEN' // Optional auth
},
agentEndpoint: '/agent/run', // Default: /agent/run
conversationsEndpoint: '/conversations' // Default: /conversations
});
return (
<ChatProvider client={client}>
<YourChatUI />
</ChatProvider>
);
}2. Build Your UI with Hooks
import { useConversationList, useConversation } from '@livefire2015/solid-ag-chat';
import { For, onMount } from 'solid-js';
function YourChatUI() {
// Manage conversation list
const {
conversations,
activeId,
load,
create,
setActive
} = useConversationList();
// Work with active conversation
const { messages, isStreaming, send } = useConversation();
// Load conversations on mount
onMount(() => load());
return (
<div class="flex h-screen">
{/* Conversation List */}
<aside class="w-64 border-r">
<button onClick={() => create('New Chat')}>+ New Chat</button>
<For each={conversations()}>
{conv => (
<button
onClick={() => setActive(conv.id)}
class={activeId() === conv.id ? 'active' : ''}
>
{conv.title}
</button>
)}
</For>
</aside>
{/* Messages */}
<main class="flex-1 flex flex-col">
<div class="flex-1 overflow-auto">
<For each={messages()}>
{msg => (
<div class={`message ${msg.role}`}>
{/* Text content */}
{msg.content && <p>{msg.content}</p>}
{/* Tool calls */}
{msg.toolCalls && (
<For each={msg.toolCalls}>
{tc => (
<div class="tool-call">
🔧 {tc.function.name}({tc.function.arguments})
</div>
)}
</For>
)}
</div>
)}
</For>
{isStreaming() && <div class="typing-indicator">...</div>}
</div>
{/* Input */}
<form onSubmit={e => {
e.preventDefault();
const input = e.target.elements.message;
send(input.value); // Sends to /agent/run with conversation history
input.value = '';
}}>
<input name="message" placeholder="Type a message..." />
<button type="submit">Send</button>
</form>
</main>
</div>
);
}API Reference
ChatProvider
Provides AG-UI client and reactive state to all children.
interface ChatProviderProps {
client: AgUiClient; // Required: AG-UI client instance
upload?: (files: File[]) => Promise<AttachmentDoc[]>; // Optional: file upload handler
sessionId?: string; // Optional: session identifier
initialConversationId?: string; // Optional: initial conversation to load
children: JSX.Element;
}Example:
<ChatProvider
client={createSdkAgent({ baseUrl: 'http://localhost:8000' })}
upload={async (files) => {
// Upload files to your server
const formData = new FormData();
files.forEach(f => formData.append('files', f));
const res = await fetch('/upload', { method: 'POST', body: formData });
return res.json();
}}
>
<App />
</ChatProvider>useConversationList()
Hook for managing the conversation list.
interface UseConversationListReturn {
conversations: () => ConversationDoc[]; // Reactive conversation list
activeId: () => Id | undefined; // Current conversation ID
load: () => Promise<void>; // Load conversations from server
create: (title?, metadata?) => Promise<ConversationDoc>; // Create new conversation
setActive: (id: Id) => void; // Switch conversation
archive: (id: Id) => Promise<void>; // Archive conversation
}Example:
function ConversationSidebar() {
const { conversations, activeId, load, create, setActive } = useConversationList();
onMount(() => load()); // Load on mount
return (
<div>
<button onClick={() => create('New Chat')}>+ New</button>
<For each={conversations()}>
{conv => (
<button
onClick={() => setActive(conv.id)}
class={activeId() === conv.id ? 'active' : ''}
>
{conv.title}
</button>
)}
</For>
</div>
);
}useConversation(conversationId?)
Hook for a specific conversation's messages and actions.
interface UseConversationReturn {
messages: () => MessageDoc[]; // Reactive message list
isStreaming: () => boolean; // Is any message streaming?
load: () => Promise<void>; // Load messages from server
send: (text: string, opts?) => Promise<void>; // Send message (streams via /agent/run)
cancel: (messageId: Id) => Promise<void>; // Cancel streaming message
}Example:
function MessageView() {
const { messages, isStreaming, load, send, cancel } = useConversation();
onMount(() => load()); // Load messages on mount
return (
<div>
<For each={messages()}>
{msg => (
<div>
<MessageBubble message={msg} />
{msg.status === 'streaming' && (
<button onClick={() => cancel(msg.id)}>Cancel</button>
)}
</div>
)}
</For>
{isStreaming() && <TypingIndicator />}
<input onSubmit={e => send(e.target.value)} />
</div>
);
}useMessages(conversationId?)
Get reactive messages array for a conversation.
const messages: () => MessageDoc[] = useMessages(conversationId);Example:
function MessageList(props: { conversationId: string }) {
const messages = useMessages(props.conversationId);
return (
<For each={messages()}>
{msg => <div>{msg.content}</div>}
</For>
);
}useStreamingText(messageId)
Get current streaming text for a message (if streaming).
const streamingText: () => string = useStreamingText(messageId);Example:
function MessageBubble(props: { message: MessageDoc }) {
const streamingText = useStreamingText(props.message.id);
return (
<div>
{props.message.status === 'streaming' ? (
<p>{streamingText()}</p>
) : (
<p>{props.message.content}</p>
)}
</div>
);
}Message Structure
Messages follow the official AG-UI schema with content for text and toolCalls for tool interactions:
interface Message {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool' | 'developer';
content: string; // Text content
toolCalls?: ToolCall[]; // Tool invocations (assistant messages)
// Custom fields for conversation management
conversationId?: string;
status?: 'streaming' | 'completed' | 'errored' | 'canceled';
usage?: { prompt?: number; completion?: number; total?: number };
createdAt?: string;
metadata?: Record<string, unknown>;
attachments?: string[]; // Attachment IDs
}
interface ToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string; // JSON string
};
}Rendering Messages:
<For each={messages()}>
{msg => (
<div class={`message-${msg.role}`}>
{/* Text content */}
{msg.content && <p class="content">{msg.content}</p>}
{/* Tool calls */}
{msg.toolCalls && (
<For each={msg.toolCalls}>
{tc => {
const args = JSON.parse(tc.function.arguments);
return (
<div class="tool-call">
<span class="tool-icon">🔧</span>
<span class="tool-name">{tc.function.name}</span>
<pre class="tool-args">{JSON.stringify(args, null, 2)}</pre>
</div>
);
}}
</For>
)}
</div>
)}
</For>Advanced Usage
Custom Transport (Bring Your Own Client)
import type { AgUiClient } from '@livefire2015/solid-ag-chat';
class MyCustomClient implements AgUiClient {
on(type, handler) { /* ... */ }
off(type, handler) { /* ... */ }
async createConversation(title?, metadata?) { /* ... */ }
async sendMessage(convId, text, opts?) { /* ... */ }
// ... implement other methods
close() { /* ... */ }
}
<ChatProvider client={new MyCustomClient()}>
<App />
</ChatProvider>File Upload Handling
<ChatProvider
client={client}
upload={async (files) => {
// 1. Upload files to your server
const formData = new FormData();
files.forEach(f => formData.append('files', f));
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
// 2. Return attachment metadata
const attachments = await res.json();
return attachments.map(att => ({
id: att.id,
name: att.filename,
mime: att.contentType,
size: att.size,
url: att.downloadUrl,
state: 'available' as const,
}));
}}
>
<App />
</ChatProvider>Testing with MockAgClient
import { MockAgClient, ChatProvider } from '@livefire2015/solid-ag-chat';
function DevApp() {
const mockClient = new MockAgClient({
tokenDelayMs: 30, // Simulate streaming
replyGenerator: (userText) => {
return `Mock: ${userText}`.match(/.{1,5}/g) || [];
}
});
return (
<ChatProvider client={mockClient}>
<App />
</ChatProvider>
);
}Backend Integration
Your server must implement the official AG-UI protocol. Example with PydanticAI:
from fastapi import FastAPI
from pydantic_ai import Agent
from pydantic_ai.messages import ModelMessage
from pydantic_ai.models.openai import OpenAIModel
from ag_ui.server import stream_run_events
app = FastAPI()
agent = Agent(OpenAIModel('gpt-4'))
# Conversation CRUD (custom endpoints)
@app.get("/conversations")
async def list_conversations():
return [{"id": "c1", "title": "Chat 1", "createdAt": "...", ...}]
@app.post("/conversations")
async def create_conversation(payload: dict):
conv = create_new_conversation(payload.get('title'))
return conv
@app.get("/conversations/{id}/messages")
async def get_messages(id: str):
return [{"id": "m1", "conversationId": id, "role": "user", "content": "Hello"}]
# Official AG-UI agent execution endpoint
@app.post("/agent/run")
async def run_agent(input: RunAgentInput):
"""
Official AG-UI endpoint for stateless agent execution.
Client sends full conversation history with each request.
"""
# Convert AG-UI messages to PydanticAI format
message_history = [
ModelMessage(role=msg['role'], content=msg['content'])
for msg in input.messages
]
# Run agent and stream AG-UI events
result = await agent.run(
user_prompt=input.messages[-1]['content'],
message_history=message_history[:-1]
)
# Stream official AG-UI events (TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, etc.)
return StreamingResponse(
stream_run_events(result),
media_type='text/event-stream'
)Official AG-UI Events
The SDK client emits these official AG-UI events during streaming:
Message Events
| Event | Description |
|-------|-------------|
| TEXT_MESSAGE_START | Text message streaming started |
| TEXT_MESSAGE_CONTENT | Text delta (incremental content) |
| TEXT_MESSAGE_END | Text message completed |
Tool Events
| Event | Description |
|-------|-------------|
| TOOL_CALL_START | Tool call started |
| TOOL_CALL_ARGS | Tool arguments delta |
| TOOL_CALL_END | Tool call completed |
| TOOL_CALL_RESULT | Tool result available |
State Events
| Event | Description |
|-------|-------------|
| STATE_SNAPSHOT | Full state snapshot |
| STATE_DELTA | JSON Patch state delta |
| MESSAGES_SNAPSHOT | Messages snapshot |
Custom Conversation Events
| Event | Description |
|-------|-------------|
| conversation.created | New conversation created |
| conversation.updated | Conversation changed |
| conversation.archived | Conversation archived |
| message.created | User message created |
| message.errored | Message failed |
| message.canceled | Message canceled |
Migration from v0.x
If you're upgrading from the old parts[] system:
Before (v0.x):
<For each={msg.parts}>
{part => (
<>
{part.kind === 'text' && <p>{part.text}</p>}
{part.kind === 'tool_call' && <div>{part.name}</div>}
</>
)}
</For>After (v1.0):
{msg.content && <p>{msg.content}</p>}
{msg.toolCalls && (
<For each={msg.toolCalls}>
{tc => <div>{tc.function.name}</div>}
</For>
)}Client Migration:
// Before: SseAgClient with REST + SSE
const client = new SseAgClient({
baseUrl: 'http://localhost:8000',
paths: { conversations: '/conversations', messages: '/conversations/:id/messages' }
});
// After: Official SDK client
const client = createSdkAgent({
baseUrl: 'http://localhost:8000',
agentEndpoint: '/agent/run', // Stateless agent execution
conversationsEndpoint: '/conversations' // Conversation management
});License
MIT
