Giới thiệu
Backend xong, cần UI để user tương tác. Bài này build React frontend với streaming chat, session management, và admin dashboard.
1. Project Setup
# packages/web — React + Vite + Tailwind
cd packages/web
npm create vite@latest . -- --template react-ts
npm install zustand react-router-dom react-markdown
npm install -D tailwindcss @tailwindcss/typography
App Structure
packages/web/src/
├── main.tsx
├── App.tsx
├── api/
│ ├── client.ts # HTTP client with auth
│ └── chat.ts # Chat API + SSE streaming
├── stores/
│ ├── auth-store.ts # Authentication state
│ ├── chat-store.ts # Chat sessions & messages
│ └── ui-store.ts # Sidebar, theme, modals
├── components/
│ ├── Chat/
│ │ ├── ChatWindow.tsx
│ │ ├── MessageBubble.tsx
│ │ ├── ChatInput.tsx
│ │ └── ToolCallIndicator.tsx
│ ├── Sidebar/
│ │ ├── SessionList.tsx
│ │ └── ModelSelector.tsx
│ └── Dashboard/
│ ├── UsageChart.tsx
│ └── WorkflowBuilder.tsx
└── pages/
├── ChatPage.tsx
├── DashboardPage.tsx
├── KnowledgePage.tsx
└── SettingsPage.tsx
2. Streaming Chat Client
// packages/web/src/api/chat.ts
export async function streamChat(
message: string,
sessionId: string,
onEvent: (event: StreamEvent) => void,
): Promise<void> {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${getToken()}`,
},
body: JSON.stringify({ message, sessionId }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE events
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event = JSON.parse(line.slice(6));
onEvent(event);
} catch {
// Skip malformed events
}
}
}
}
}
3. Chat Window Component
// packages/web/src/components/Chat/ChatWindow.tsx
import { useRef, useEffect } from 'react';
import { useChatStore } from '../../stores/chat-store';
import { MessageBubble } from './MessageBubble';
import { ChatInput } from './ChatInput';
import { ToolCallIndicator } from './ToolCallIndicator';
export function ChatWindow() {
const { messages, isStreaming, streamingContent, activeToolCalls } = useChatStore();
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingContent]);
return (
<div className="flex flex-col h-full">
{/* Messages area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{/* Streaming response */}
{isStreaming && streamingContent && (
<MessageBubble
message={{
id: 'streaming',
role: 'assistant',
content: streamingContent,
timestamp: new Date(),
}}
isStreaming
/>
)}
{/* Active tool calls */}
{activeToolCalls.map((tc) => (
<ToolCallIndicator key={tc.id} toolCall={tc} />
))}
<div ref={bottomRef} />
</div>
{/* Input */}
<ChatInput />
</div>
);
}
4. Chat Store (Zustand)
// packages/web/src/stores/chat-store.ts
import { create } from 'zustand';
import { streamChat } from '../api/chat';
interface ChatState {
messages: Message[];
isStreaming: boolean;
streamingContent: string;
activeToolCalls: ToolCallInfo[];
sessionId: string | null;
sendMessage: (content: string) => Promise<void>;
newSession: () => void;
}
export const useChatStore = create<ChatState>((set, get) => ({
messages: [],
isStreaming: false,
streamingContent: '',
activeToolCalls: [],
sessionId: null,
sendMessage: async (content: string) => {
const sessionId = get().sessionId || crypto.randomUUID();
// Add user message
set((state) => ({
sessionId,
messages: [...state.messages, {
id: crypto.randomUUID(),
role: 'user',
content,
timestamp: new Date(),
}],
isStreaming: true,
streamingContent: '',
}));
try {
await streamChat(content, sessionId, (event) => {
switch (event.type) {
case 'text-delta':
set((state) => ({
streamingContent: state.streamingContent + event.delta,
}));
break;
case 'tool-call-start':
set((state) => ({
activeToolCalls: [...state.activeToolCalls, {
id: event.toolCallId,
name: event.toolName,
status: 'running',
}],
}));
break;
case 'tool-result':
set((state) => ({
activeToolCalls: state.activeToolCalls.filter(
tc => tc.id !== event.toolCallId
),
}));
break;
case 'finish':
const finalContent = get().streamingContent;
set((state) => ({
messages: [...state.messages, {
id: crypto.randomUUID(),
role: 'assistant',
content: finalContent,
timestamp: new Date(),
}],
isStreaming: false,
streamingContent: '',
activeToolCalls: [],
}));
break;
}
});
} catch (error) {
set({ isStreaming: false, streamingContent: '' });
}
},
newSession: () => set({
messages: [],
sessionId: null,
streamingContent: '',
isStreaming: false,
}),
}));
5. Message Bubble
// packages/web/src/components/Chat/MessageBubble.tsx
import ReactMarkdown from 'react-markdown';
interface Props {
message: Message;
isStreaming?: boolean;
}
export function MessageBubble({ message, isStreaming }: Props) {
const isUser = message.role === 'user';
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[80%] rounded-2xl px-4 py-3 ${
isUser
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'
}`}
>
{isUser ? (
<p className="whitespace-pre-wrap">{message.content}</p>
) : (
<div className="prose dark:prose-invert prose-sm max-w-none">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
)}
{isStreaming && (
<span className="inline-block w-2 h-4 bg-current animate-pulse ml-1" />
)}
</div>
</div>
);
}
6. Tổng kết
- Streaming UI — token-by-token display, tool call indicators
- Zustand — lightweight state management, no boilerplate
- SSE client — parse Server-Sent Events trong browser
- Markdown rendering — AI responses với code blocks, lists, tables
Bài tiếp theo: Monitoring, Observability & Production Deploy.
Chúc bạn thành công trong việc xây dựng AI platform của riêng mình! 🚀Source code tham khảo: github.com/xdev-asia-labs/xClaw| Production | Frontend, Monitoring, Deploy | 19-20 || Access | Multi-tenant, RBAC, Channels | 17-18 || Extensibility | Skills, Domains, Plugins, MCP | 14-16 || Automation | Workflow Engine, Validation | 12-13 || Knowledge | Documents, Embeddings, RAG | 9-11 || AI Core | LLM Router, Tools, Agent, Streaming | 5-8 || Foundation | Monorepo, Database, Gateway | 1-4 ||-------|-----------|-----|| Layer | Component | Bài |Bạn đã xây dựng một AI Agent Platform hoàn chỉnh:## 7. Tổng kết Series---- [ ] Audit trail for compliance- [ ] Latency percentile tracking (p50, p99)- [ ] Error rate alerts- [ ] Token usage dashboards- [ ] LLM cost tracking per tenant### Monitoring- [ ] Log aggregation pipeline- [ ] Error alerting (Slack/email)- [ ] Database backups (daily)- [ ] Graceful shutdown handling- [ ] Health check endpoints### Reliability- [ ] Image optimization / CDN- [ ] Streaming enabled for chat endpoints- [ ] Response caching for frequent queries- [ ] Connection pooling (PostgreSQL, MongoDB, Redis)### Performance- [ ] Database credentials not in code- [ ] HTTPS only (TLS termination at load balancer)- [ ] Rate limiting enabled- [ ] CORS restricted to known origins- [ ] API keys encrypted at rest- [ ] JWT_SECRET is strong (32+ random bytes)### Securitymarkdown## 6. Production Checklist--- docker system prune -f docker compose up -d --remove-orphans docker compose pull cd /opt/xclaw script: | key: ${{ secrets.DEPLOY_KEY }} username: ${{ secrets.DEPLOY_USER }} host: ${{ secrets.DEPLOY_HOST }} with: uses: appleboy/ssh-action@v1 - name: Deploy to server steps: runs-on: ubuntu-latest needs: build deploy: cache-to: type=gha,mode=max cache-from: type=gha tags: ghcr.io/${{ github.repository }}:${{ github.sha }} push: true with: - uses: docker/build-push-action@v5 password: ${{ secrets.GITHUB_TOKEN }} username: ${{ github.actor }} registry: ghcr.io with: - uses: docker/login-action@v3 - uses: docker/setup-buildx-action@v3 - uses: actions/checkout@v4 steps: runs-on: ubuntu-latest needs: test build: - run: npm run test - run: npm run lint - run: npm ci cache: npm node-version: 20 with: - uses: actions/setup-node@v4 - uses: actions/checkout@v4 steps: runs-on: ubuntu-latest test:jobs: branches: [main] push:on:name: Build & Deploy# .github/workflows/deploy.ymlyaml## 5. CI/CD Pipeline---chatLogger.info('Chat started', { sessionId, model: 'gpt-4o' });const chatLogger = logger.child({ component: 'chat' });const logger = new Logger({ service: 'xclaw' });// Usage} } console.log(JSON.stringify(entry)); // JSON structured logging — parseable by log aggregators }; ...data, ...this.context, message, level, timestamp: new Date().toISOString(), const entry = { private log(level: string, message: string, data?: Record<string, unknown>) { } }); } : undefined, name: error.name, stack: error.stack, message: error.message, error: error ? { ...data, this.log('error', message, { error(message: string, error?: Error, data?: Record<string, unknown>) { } this.log('info', message, data); info(message: string, data?: Record<string, unknown>) { } return new Logger({ ...this.context, ...additionalContext }); child(additionalContext: Record<string, unknown>) { } this.context = context; constructor(context: Record<string, unknown> = {}) { private context: Record<string, unknown>;export class Logger {// packages/core/src/logging/logger.tstypescript## 4. Structured Logging---} } return { llmStats, toolStats, costByDay }; ]); ]).toArray(), { $sort: { _id: 1 } }, }}, requests: { $sum: 1 }, cost: { $sum: '$cost' }, _id: { $dateToString: { format: '%Y-%m-%d', date: '$timestamp' } }, { $group: { { $match: { tenantId, timestamp: { $gte: since } } }, this.db.collection('llm_usage').aggregate([ // Cost per day ]).toArray(), }}, avgDuration: { $avg: '$durationMs' }, successRate: { $avg: { $cond: ['$success', 1, 0] } }, totalCalls: { $sum: 1 }, _id: '$toolName', { $group: { { $match: { tenantId, timestamp: { $gte: since } } }, this.db.collection('tool_usage').aggregate([ // Tool execution stats ]).toArray(), }}, avgLatency: { $avg: '$latencyMs' }, totalCost: { $sum: '$cost' }, totalTokens: { $sum: { $add: ['$promptTokens', '$completionTokens'] } }, totalRequests: { $sum: 1 }, _id: '$model', { $group: { { $match: { tenantId, timestamp: { $gte: since } } }, this.db.collection('llm_usage').aggregate([ // Total LLM usage const [llmStats, toolStats, costByDay] = await Promise.all([ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); async getUsageAnalytics(tenantId: string, days: number = 30) { // Get usage analytics } }); timestamp: new Date(), ...data, await this.db.collection('tool_usage').insertOne({ }) { durationMs: number; success: boolean; toolName: string; tenantId: string; async recordToolExecution(data: { // Track tool execution } }); timestamp: new Date(), ...data, await this.db.collection('llm_usage').insertOne({ }) { cost: number; latencyMs: number; completionTokens: number; promptTokens: number; model: string; provider: string; userId: string; tenantId: string; async recordLLMUsage(data: { // Track LLM usage private db: Db;export class MonitoringService {// packages/core/src/monitoring/monitoring-service.tstypescript## 3. Monitoring Service---CMD ["node", "packages/server/dist/index.js"]EXPOSE 3000USER xclawCOPY --from=builder --chown=xclaw:nodejs /app/package.json ./COPY --from=builder --chown=xclaw:nodejs /app/packages/*/dist ./packages/COPY --from=builder --chown=xclaw:nodejs /app/node_modules ./node_modulesRUN addgroup -g 1001 -S nodejs && adduser -S xclaw -u 1001WORKDIR /appFROM node:20-alpine AS runner# Production stageRUN npm run buildCOPY . .RUN npm ci --production=falseCOPY packages/*/package.json ./packages/COPY package.json package-lock.json ./WORKDIR /appFROM node:20-alpine AS builder# Dockerfiledockerfile## 2. Dockerfile (Multi-stage Build)--- redisdata: mongodata: pgdata:volumes: - redisdata:/data volumes: image: redis:7-alpine redis: - MONGO_INITDB_DATABASE=xclaw environment: - mongodata:/data/db volumes: image: mongo:7 mongo: retries: 5 timeout: 5s interval: 10s test: ["CMD-SHELL", "pg_isready -U user"] healthcheck: - POSTGRES_PASSWORD=pass - POSTGRES_USER=user - POSTGRES_DB=xclaw environment: - pgdata:/var/lib/postgresql/data volumes: image: postgres:16-alpine postgres: - VITE_API_URL=http://server:3000 environment: - "3001:3001" ports: dockerfile: Dockerfile context: ./packages/web build: web: retries: 3 timeout: 10s interval: 30s test: ["CMD", "curl", "-f", "http://localhost:3000/health"] healthcheck: condition: service_started redis: condition: service_started mongo: condition: service_healthy postgres: depends_on: - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - OPENAI_API_KEY=${OPENAI_API_KEY} - JWT_SECRET=${JWT_SECRET} - REDIS_URL=redis://redis:6379 - MONGODB_URL=mongodb://mongo:27017/xclaw - DATABASE_URL=postgresql://user:pass@postgres:5432/xclaw - NODE_ENV=production environment: - "3000:3000" ports: dockerfile: Dockerfile context: . build: server:services:# docker-compose.ymlyaml## 1. Docker Compose — Production Stack---Bài cuối cùng — deploy AI Agent Platform lên production. Monitoring, alerting, cost tracking, và CI/CD pipeline.## Giới thiệu--- slug: xay-dung-ai-agent-platform title: "Xây dựng AI Agent Platform từ Zero — Thực chiến với xClaw" id: 019c9619-bb03-7003-c003-bb0300000003course:section_title: "Phần 7: Frontend, Monitoring & Production"sort_order: 19video_url: nullis_free: trueduration_minutes: 240 traces. CI/CD pipeline với GitHub Actions. error rates. Observability: structured logging, OpenTelemetry health checks. Monitoring: usage analytics, cost tracking, Production deployment: Docker Compose, environment config,description: >-slug: bai-20-monitoring-deploytitle: "Bài 20: Monitoring, Observability & Production Deploy"id: 019c961a-aa20-7020-e020-aa2000000020id: 019c961a-aa19-7019-e019-aa1900000019
title: "Bài 19: React Frontend — Chat UI & Dashboard"
slug: bai-19-react-frontend
description: >-
Build React frontend: Chat interface với streaming, Sidebar
navigation, Session management, Model selector. Dashboard:
usage analytics, workflow builder UI. Tailwind CSS, Zustand state.
duration_minutes: 240
is_free: true
video_url: null
sort_order: 18
section_title: "Phần 7: Frontend, Monitoring & Production"
course:
id: 019c9619-bb03-7003-c003-bb0300000003
title: "Xây dựng AI Agent Platform từ Zero — Thực chiến với xClaw"
slug: xay-dung-ai-agent-platform
Giới thiệu
Backend xong, cần UI để user tương tác. Bài này build React frontend với streaming chat, session management, và admin dashboard.
1. Project Setup
# packages/web — React + Vite + Tailwind
cd packages/web
npm create vite@latest . -- --template react-ts
npm install zustand react-router-dom react-markdown
npm install -D tailwindcss @tailwindcss/typography
App Structure
packages/web/src/
├── main.tsx
├── App.tsx
├── api/
│ ├── client.ts # HTTP client with auth
│ └── chat.ts # Chat API + SSE streaming
├── stores/
│ ├── auth-store.ts # Authentication state
│ ├── chat-store.ts # Chat sessions & messages
│ └── ui-store.ts # Sidebar, theme, modals
├── components/
│ ├── Chat/
│ │ ├── ChatWindow.tsx
│ │ ├── MessageBubble.tsx
│ │ ├── ChatInput.tsx
│ │ └── ToolCallIndicator.tsx
│ ├── Sidebar/
│ │ ├── SessionList.tsx
│ │ └── ModelSelector.tsx
│ └── Dashboard/
│ ├── UsageChart.tsx
│ └── WorkflowBuilder.tsx
└── pages/
├── ChatPage.tsx
├── DashboardPage.tsx
├── KnowledgePage.tsx
└── SettingsPage.tsx
2. Streaming Chat Client
// packages/web/src/api/chat.ts
export async function streamChat(
message: string,
sessionId: string,
onEvent: (event: StreamEvent) => void,
): Promise<void> {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${getToken()}`,
},
body: JSON.stringify({ message, sessionId }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE events
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event = JSON.parse(line.slice(6));
onEvent(event);
} catch {
// Skip malformed events
}
}
}
}
}
3. Chat Window Component
// packages/web/src/components/Chat/ChatWindow.tsx
import { useRef, useEffect } from 'react';
import { useChatStore } from '../../stores/chat-store';
import { MessageBubble } from './MessageBubble';
import { ChatInput } from './ChatInput';
import { ToolCallIndicator } from './ToolCallIndicator';
export function ChatWindow() {
const { messages, isStreaming, streamingContent, activeToolCalls } = useChatStore();
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingContent]);
return (
<div className="flex flex-col h-full">
{/* Messages area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{/* Streaming response */}
{isStreaming && streamingContent && (
<MessageBubble
message={{
id: 'streaming',
role: 'assistant',
content: streamingContent,
timestamp: new Date(),
}}
isStreaming
/>
)}
{/* Active tool calls */}
{activeToolCalls.map((tc) => (
<ToolCallIndicator key={tc.id} toolCall={tc} />
))}
<div ref={bottomRef} />
</div>
{/* Input */}
<ChatInput />
</div>
);
}
4. Chat Store (Zustand)
// packages/web/src/stores/chat-store.ts
import { create } from 'zustand';
import { streamChat } from '../api/chat';
interface ChatState {
messages: Message[];
isStreaming: boolean;
streamingContent: string;
activeToolCalls: ToolCallInfo[];
sessionId: string | null;
sendMessage: (content: string) => Promise<void>;
newSession: () => void;
}
export const useChatStore = create<ChatState>((set, get) => ({
messages: [],
isStreaming: false,
streamingContent: '',
activeToolCalls: [],
sessionId: null,
sendMessage: async (content: string) => {
const sessionId = get().sessionId || crypto.randomUUID();
// Add user message
set((state) => ({
sessionId,
messages: [...state.messages, {
id: crypto.randomUUID(),
role: 'user',
content,
timestamp: new Date(),
}],
isStreaming: true,
streamingContent: '',
}));
try {
await streamChat(content, sessionId, (event) => {
switch (event.type) {
case 'text-delta':
set((state) => ({
streamingContent: state.streamingContent + event.delta,
}));
break;
case 'tool-call-start':
set((state) => ({
activeToolCalls: [...state.activeToolCalls, {
id: event.toolCallId,
name: event.toolName,
status: 'running',
}],
}));
break;
case 'tool-result':
set((state) => ({
activeToolCalls: state.activeToolCalls.filter(
tc => tc.id !== event.toolCallId
),
}));
break;
case 'finish':
const finalContent = get().streamingContent;
set((state) => ({
messages: [...state.messages, {
id: crypto.randomUUID(),
role: 'assistant',
content: finalContent,
timestamp: new Date(),
}],
isStreaming: false,
streamingContent: '',
activeToolCalls: [],
}));
break;
}
});
} catch (error) {
set({ isStreaming: false, streamingContent: '' });
}
},
newSession: () => set({
messages: [],
sessionId: null,
streamingContent: '',
isStreaming: false,
}),
}));
5. Message Bubble
// packages/web/src/components/Chat/MessageBubble.tsx
import ReactMarkdown from 'react-markdown';
interface Props {
message: Message;
isStreaming?: boolean;
}
export function MessageBubble({ message, isStreaming }: Props) {
const isUser = message.role === 'user';
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[80%] rounded-2xl px-4 py-3 ${
isUser
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'
}`}
>
{isUser ? (
<p className="whitespace-pre-wrap">{message.content}</p>
) : (
<div className="prose dark:prose-invert prose-sm max-w-none">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
)}
{isStreaming && (
<span className="inline-block w-2 h-4 bg-current animate-pulse ml-1" />
)}
</div>
</div>
);
}
6. Tổng kết
- Streaming UI — token-by-token display, tool call indicators
- Zustand — lightweight state management, no boilerplate
- SSE client — parse Server-Sent Events trong browser
- Markdown rendering — AI responses với code blocks, lists, tables
Bài tiếp theo: Monitoring, Observability & Production Deploy.