flare-agent Bài 12: Playground Dashboard — Test, Trace, Config không cần sửa code
Xây dựng @flare-agent/playground — dashboard React full-featured với Chat UI, Trace Viewer, Config Editor, Workspace Browser và Metrics. Deploy local + Cloudflare Pages.
Nghe bài viết này dưới dạng podcastflare-agent Bài 12: Playground Dashboard
Series: Build Your Own AI Agent Framework trên Cloudflare Bài: 12 / 12 —
@flare-agent/playground
Tổng quan
Playground là React SPA chạy trên Cloudflare Pages (hoặc localhost:5173 khi dev), gọi vào Worker API để:
- Chat UI — test agent trực tiếp, xem stream
- Trace Viewer — xem từng iteration, tool call, latency
- Config Editor — đổi model, provider, system prompt, tools không cần sửa code
- Workspace Browser — upload, xem, xóa files trong R2
- Metrics — token usage, latency, error rate theo thời gian
Cấu trúc
apps/playground/ # Cloudflare Pages app
src/
App.tsx # Router + layout
components/
Sidebar.tsx
panels/
ChatPanel.tsx
TracePanel.tsx
ConfigPanel.tsx
WorkspacePanel.tsx
MetricsPanel.tsx
hooks/
useAgent.ts # chat + stream
useTraces.ts # fetch traces từ D1
useConfig.ts # đọc/ghi config
useWorkspace.ts # file operations
useMetrics.ts # aggregate metrics
lib/
api.ts # fetch wrapper
public/
index.html
vite.config.ts
apps/worker/src/
routes/
playground.ts # Playground API routes
Worker — Playground API Routes
// apps/worker/src/routes/playground.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
const playground = new Hono<{ Bindings: Env }>();
// Chỉ enable trong dev hoặc khi có PLAYGROUND_SECRET
playground.use('*', async (c, next) => {
const secret = c.env.PLAYGROUND_SECRET;
if (secret) {
const auth = c.req.header('X-Playground-Secret');
if (auth !== secret) return c.json({ error: 'Unauthorized' }, 401);
}
return next();
});
// --- Config ---
// Đọc config hiện tại
playground.get('/config', async (c) => {
const raw = await c.env.KV.get('playground:config');
const defaultConfig = {
provider: 'groq',
model: 'llama-3.3-70b-versatile',
systemPrompt: 'You are a helpful assistant.',
maxIterations: 10,
temperature: 0.7,
tools: [],
};
return c.json(raw ? JSON.parse(raw) : defaultConfig);
});
// Lưu config
playground.put('/config', async (c) => {
const config = await c.req.json();
await c.env.KV.put('playground:config', JSON.stringify(config));
return c.json({ success: true });
});
// --- Chat ---
// Chat với config hiện tại (non-stream)
playground.post('/chat', async (c) => {
const { input, sessionId } = await c.req.json();
const raw = await c.env.KV.get('playground:config');
const config = raw ? JSON.parse(raw) : {};
// Build agent động từ config
const agent = buildAgentFromConfig(config, c.env);
const tracer = new Tracer().addExporter(new D1Exporter(c.env.DB));
const result = await agent.run(input, {
sessionId: sessionId ?? crypto.randomUUID(),
env: { ...c.env, tracer },
});
return c.json({
output: result.output,
iterations: result.iterations,
toolCalls: result.toolCalls,
traceId: tracer.getTrace().traceId,
});
});
// Stream chat
playground.post('/chat/stream', async (c) => {
const { input, sessionId } = await c.req.json();
const raw = await c.env.KV.get('playground:config');
const config = raw ? JSON.parse(raw) : {};
const provider = buildProviderFromConfig(config, c.env);
const messages = [{ role: 'user' as const, content: input }];
const stream = provider.stream([
{ role: 'system', content: config.systemPrompt },
...messages,
]);
return createSSEResponse(stream);
});
// --- Traces ---
playground.get('/traces', async (c) => {
const limit = parseInt(c.req.query('limit') ?? '20');
const { results } = await c.env.DB
.prepare(
`SELECT trace_id, MIN(start_time) as start_time,
MAX(end_time) as end_time,
SUM(duration_ms) as total_ms,
COUNT(*) as span_count,
MAX(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as has_error
FROM agent_traces
GROUP BY trace_id
ORDER BY start_time DESC
LIMIT ?`
)
.bind(limit)
.all();
return c.json({ traces: results });
});
playground.get('/traces/:traceId', async (c) => {
const { traceId } = c.req.param();
const { results } = await c.env.DB
.prepare(
`SELECT * FROM agent_traces
WHERE trace_id = ?
ORDER BY start_time ASC`
)
.bind(traceId)
.all();
return c.json({ spans: results });
});
// --- Workspace ---
playground.get('/workspace/files', async (c) => {
const workspaceId = c.req.query('workspaceId') ?? 'playground';
const prefix = `workspaces/${workspaceId}/files/`;
const result = await c.env.R2.list({ prefix });
return c.json({
files: result.objects.map((o) => ({
path: o.key.replace(prefix, ''),
size: o.size,
uploaded: o.uploaded,
})),
});
});
playground.post('/workspace/upload', async (c) => {
const formData = await c.req.formData();
const file = formData.get('file') as File;
const workspaceId = (formData.get('workspaceId') as string) ?? 'playground';
const key = `workspaces/${workspaceId}/files/${file.name}`;
await c.env.R2.put(key, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type },
});
return c.json({ success: true, path: file.name });
});
playground.delete('/workspace/files/:path', async (c) => {
const path = c.req.param('path');
const workspaceId = c.req.query('workspaceId') ?? 'playground';
await c.env.R2.delete(`workspaces/${workspaceId}/files/${path}`);
return c.json({ success: true });
});
// --- Metrics ---
playground.get('/metrics', async (c) => {
const days = parseInt(c.req.query('days') ?? '7');
const since = Date.now() - days * 24 * 60 * 60 * 1000;
const { results } = await c.env.DB
.prepare(
`SELECT
COUNT(DISTINCT trace_id) as total_runs,
AVG(duration_ms) as avg_latency_ms,
MAX(duration_ms) as max_latency_ms,
SUM(CASE WHEN status='error' THEN 1 ELSE 0 END) as error_count,
COUNT(CASE WHEN name LIKE 'tool.%' THEN 1 END) as tool_calls
FROM agent_traces
WHERE start_time > ?`
)
.bind(since)
.all();
// Latency by day
const { results: daily } = await c.env.DB
.prepare(
`SELECT
DATE(start_time/1000, 'unixepoch') as date,
COUNT(DISTINCT trace_id) as runs,
AVG(duration_ms) as avg_ms
FROM agent_traces
WHERE start_time > ? AND name = 'agent.run'
GROUP BY date
ORDER BY date ASC`
)
.bind(since)
.all();
return c.json({ summary: results[0], daily });
});
export { playground };
React App — Layout
// src/App.tsx
import { useState } from 'react';
import { Sidebar } from './components/Sidebar';
import { ChatPanel } from './components/panels/ChatPanel';
import { TracePanel } from './components/panels/TracePanel';
import { ConfigPanel } from './components/panels/ConfigPanel';
import { WorkspacePanel } from './components/panels/WorkspacePanel';
import { MetricsPanel } from './components/panels/MetricsPanel';
type Panel = 'chat' | 'traces' | 'config' | 'workspace' | 'metrics';
export default function App() {
const [activePanel, setActivePanel] = useState<Panel>('chat');
const panels: Record<Panel, React.ReactNode> = {
chat: <ChatPanel />,
traces: <TracePanel />,
config: <ConfigPanel />,
workspace: <WorkspacePanel />,
metrics: <MetricsPanel />,
};
return (
<div className="flex h-screen bg-gray-950 text-gray-100">
<Sidebar active={activePanel} onChange={setActivePanel} />
<main className="flex-1 overflow-auto">
{panels[activePanel]}
</main>
</div>
);
}
Config Panel — đổi tham số không sửa code
// src/components/panels/ConfigPanel.tsx
import { useState, useEffect } from 'react';
import { api } from '../../lib/api';
const PROVIDERS = ['groq', 'workersai', 'ollama'];
const MODELS: Record<string, string[]> = {
groq: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'],
workersai: ['@cf/meta/llama-3.3-70b-instruct-fp8-fast', '@cf/meta/llama-3.1-8b-instruct'],
ollama: ['qwen3:8b', 'qwen3:4b', 'llama3.2:3b'],
};
export function ConfigPanel() {
const [config, setConfig] = useState<any>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
api.get('/playground/config').then(setConfig);
}, []);
const save = async () => {
await api.put('/playground/config', config);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
if (!config) return <div className="p-6">Loading...</div>;
return (
<div className="p-6 max-w-2xl space-y-6">
<h2 className="text-xl font-bold">Agent Config</h2>
{/* Provider + Model */}
<div className="space-y-3">
<label className="block text-sm text-gray-400">Provider</label>
<select
className="w-full bg-gray-800 rounded px-3 py-2"
value={config.provider}
onChange={(e) => setConfig({ ...config, provider: e.target.value, model: MODELS[e.target.value][0] })}
>
{PROVIDERS.map((p) => <option key={p}>{p}</option>)}
</select>
<label className="block text-sm text-gray-400">Model</label>
<select
className="w-full bg-gray-800 rounded px-3 py-2"
value={config.model}
onChange={(e) => setConfig({ ...config, model: e.target.value })}
>
{(MODELS[config.provider] ?? []).map((m) => <option key={m}>{m}</option>)}
</select>
</div>
{/* System Prompt */}
<div>
<label className="block text-sm text-gray-400 mb-1">System Prompt</label>
<textarea
className="w-full bg-gray-800 rounded px-3 py-2 h-32 font-mono text-sm"
value={config.systemPrompt}
onChange={(e) => setConfig({ ...config, systemPrompt: e.target.value })}
/>
</div>
{/* Parameters */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-1">
Max Iterations: {config.maxIterations}
</label>
<input
type="range" min={1} max={20}
value={config.maxIterations}
onChange={(e) => setConfig({ ...config, maxIterations: parseInt(e.target.value) })}
className="w-full"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">
Temperature: {config.temperature}
</label>
<input
type="range" min={0} max={1} step={0.1}
value={config.temperature}
onChange={(e) => setConfig({ ...config, temperature: parseFloat(e.target.value) })}
className="w-full"
/>
</div>
</div>
<button
onClick={save}
className="bg-blue-600 hover:bg-blue-500 px-6 py-2 rounded font-medium"
>
{saved ? '✓ Saved' : 'Save Config'}
</button>
</div>
);
}
Chat Panel — test agent trực tiếp
// src/components/panels/ChatPanel.tsx
import { useState, useRef, useEffect } from 'react';
import { api } from '../../lib/api';
export function ChatPanel() {
const [messages, setMessages] = useState<Array<{ role: string; content: string; traceId?: string }>>([]);
const [input, setInput] = useState('');
const [streaming, setStreaming] = useState('');
const [loading, setLoading] = useState(false);
const [sessionId] = useState(crypto.randomUUID());
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streaming]);
const send = async () => {
if (!input.trim() || loading) return;
const text = input;
setInput('');
setLoading(true);
setMessages((prev) => [...prev, { role: 'user', content: text }]);
const res = await fetch(`${api.baseUrl}/playground/chat/stream`, {
method: 'POST',
headers: api.headers(),
body: JSON.stringify({ input: text, sessionId }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let full = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
for (const line of decoder.decode(value).split('\n')) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
try { full += JSON.parse(data).text; setStreaming(full); } catch { /* skip */ }
}
}
setMessages((prev) => [...prev, { role: 'assistant', content: full }]);
setStreaming('');
setLoading(false);
};
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-auto p-4 space-y-3">
{messages.map((m, i) => (
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-xl px-4 py-2 rounded-lg text-sm ${
m.role === 'user' ? 'bg-blue-600' : 'bg-gray-800'
}`}>
{m.content}
</div>
</div>
))}
{streaming && (
<div className="flex justify-start">
<div className="max-w-xl px-4 py-2 rounded-lg text-sm bg-gray-800">
{streaming}<span className="animate-pulse">▋</span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
<div className="border-t border-gray-800 p-4 flex gap-2">
<input
className="flex-1 bg-gray-800 rounded px-4 py-2 text-sm"
placeholder="Nhập message..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && send()}
/>
<button
onClick={send}
disabled={loading}
className="bg-blue-600 hover:bg-blue-500 disabled:opacity-50 px-4 py-2 rounded text-sm"
>
Send
</button>
</div>
</div>
);
}
Trace Viewer — xem từng bước
// src/components/panels/TracePanel.tsx
import { useState, useEffect } from 'react';
import { api } from '../../lib/api';
export function TracePanel() {
const [traces, setTraces] = useState<any[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [spans, setSpans] = useState<any[]>([]);
useEffect(() => {
api.get('/playground/traces').then((d) => setTraces(d.traces));
}, []);
const loadTrace = async (traceId: string) => {
setSelected(traceId);
const data = await api.get(`/playground/traces/${traceId}`);
setSpans(data.spans);
};
const statusColor = (s: string) =>
s === 'ok' ? 'text-green-400' : s === 'error' ? 'text-red-400' : 'text-yellow-400';
return (
<div className="flex h-full">
{/* Trace list */}
<div className="w-80 border-r border-gray-800 overflow-auto">
<div className="p-4 font-medium text-sm text-gray-400">Recent Traces</div>
{traces.map((t) => (
<div
key={t.trace_id}
onClick={() => loadTrace(t.trace_id)}
className={`px-4 py-3 cursor-pointer hover:bg-gray-800 border-b border-gray-800/50 ${
selected === t.trace_id ? 'bg-gray-800' : ''
}`}
>
<div className="text-xs font-mono text-gray-500">{t.trace_id.slice(0, 8)}...</div>
<div className="flex justify-between mt-1">
<span className={`text-xs ${t.has_error ? 'text-red-400' : 'text-green-400'}`}>
{t.has_error ? '✗ error' : '✓ ok'}
</span>
<span className="text-xs text-gray-500">{t.total_ms}ms</span>
</div>
</div>
))}
</div>
{/* Span detail */}
<div className="flex-1 overflow-auto p-4">
{!selected && <div className="text-gray-500 text-sm">Chọn trace để xem chi tiết</div>}
{spans.map((span) => (
<div
key={span.span_id}
className="mb-2 bg-gray-800 rounded p-3"
style={{ marginLeft: span.parent_span_id ? 24 : 0 }}
>
<div className="flex justify-between">
<span className={`text-sm font-mono ${statusColor(span.status)}`}>
{span.status === 'ok' ? '✓' : '✗'} {span.name}
</span>
<span className="text-xs text-gray-500">{span.duration_ms}ms</span>
</div>
{span.attributes && (
<pre className="text-xs text-gray-400 mt-1 overflow-auto">
{JSON.stringify(JSON.parse(span.attributes || '{}'), null, 2)}
</pre>
)}
{span.error && (
<div className="text-xs text-red-400 mt-1">Error: {span.error}</div>
)}
</div>
))}
</div>
</div>
);
}
Metrics Panel
// src/components/panels/MetricsPanel.tsx
import { useEffect, useState } from 'react';
import { api } from '../../lib/api';
export function MetricsPanel() {
const [metrics, setMetrics] = useState<any>(null);
const [days, setDays] = useState(7);
useEffect(() => {
api.get(`/playground/metrics?days=${days}`).then(setMetrics);
}, [days]);
if (!metrics) return <div className="p-6">Loading...</div>;
const { summary, daily } = metrics;
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Metrics</h2>
<select
className="bg-gray-800 rounded px-3 py-1 text-sm"
value={days}
onChange={(e) => setDays(parseInt(e.target.value))}
>
<option value={1}>1 day</option>
<option value={7}>7 days</option>
<option value={30}>30 days</option>
</select>
</div>
{/* Summary cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: 'Total Runs', value: summary.total_runs },
{ label: 'Avg Latency', value: `${Math.round(summary.avg_latency_ms)}ms` },
{ label: 'Max Latency', value: `${summary.max_latency_ms}ms` },
{ label: 'Errors', value: summary.error_count, danger: summary.error_count > 0 },
].map((card) => (
<div key={card.label} className="bg-gray-800 rounded-lg p-4">
<div className="text-xs text-gray-400 mb-1">{card.label}</div>
<div className={`text-2xl font-bold ${card.danger ? 'text-red-400' : 'text-white'}`}>
{card.value}
</div>
</div>
))}
</div>
{/* Daily chart — simple bar */}
<div className="bg-gray-800 rounded-lg p-4">
<div className="text-sm text-gray-400 mb-3">Runs per day</div>
<div className="flex items-end gap-1 h-24">
{daily.map((d: any) => {
const max = Math.max(...daily.map((x: any) => x.runs));
const height = max ? (d.runs / max) * 100 : 0;
return (
<div key={d.date} className="flex-1 flex flex-col items-center gap-1">
<div
className="w-full bg-blue-600 rounded-t"
style={{ height: `${height}%` }}
title={`${d.date}: ${d.runs} runs`}
/>
<div className="text-xs text-gray-500 rotate-45">
{d.date?.slice(5)}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
API Client
// src/lib/api.ts
const BASE_URL = import.meta.env.VITE_WORKER_URL ?? 'http://localhost:8787';
const SECRET = import.meta.env.VITE_PLAYGROUND_SECRET ?? '';
export const api = {
baseUrl: BASE_URL,
headers() {
return {
'Content-Type': 'application/json',
...(SECRET && { 'X-Playground-Secret': SECRET }),
};
},
async get(path: string) {
const res = await fetch(`${BASE_URL}${path}`, { headers: this.headers() });
return res.json();
},
async put(path: string, body: unknown) {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'PUT',
headers: this.headers(),
body: JSON.stringify(body),
});
return res.json();
},
async post(path: string, body: unknown) {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify(body),
});
return res.json();
},
async delete(path: string) {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'DELETE',
headers: this.headers(),
});
return res.json();
},
};
Setup local dev
# Terminal 1 — Worker
cd apps/worker
wrangler dev --port 8787
# Terminal 2 — Playground
cd apps/playground
echo 'VITE_WORKER_URL=http://localhost:8787' > .env.local
npm run dev # http://localhost:5173
Deploy lên Cloudflare Pages
# Build
cd apps/playground
npm run build
# Deploy
wrangler pages deploy dist --project-name flare-agent-playground
# Pages environment variables
VITE_WORKER_URL=https://your-worker.workers.dev
VITE_PLAYGROUND_SECRET=your-secret-token
wrangler.toml — thêm playground route
# Trong apps/worker/wrangler.toml
[vars]
PLAYGROUND_SECRET = "" # set qua secret khi production
// apps/worker/src/index.ts
import { playground } from './routes/playground';
app.route('/playground', playground);
Checklist
- Tạo
apps/playground/với Vite + React - Add playground routes vào worker
- Set
PLAYGROUND_SECRETtrong wrangler secrets - Test local: worker port 8787, playground port 5173
- Deploy playground lên Cloudflare Pages
- Verify Config Editor save/load hoạt động
Series hoàn tất — 12 bài, 9 packages + 1 app
@flare-agent/types
@flare-agent/providers — Groq, WorkersAI, Ollama
@flare-agent/memory — KV, D1, Vectorize
@flare-agent/core — Agent Loop, Tool Registry
@flare-agent/workflow — Graph-based Workflow
@flare-agent/multi-agent — Agent Network, Handoff
@flare-agent/observability — Tracing, Debugging
@flare-agent/channels — Telegram, Web Chat
@flare-agent/workspace — R2 Filesystem, Search, Skills
apps/playground — Dashboard UI ← mới
Giờ bạn có thể đổi model, system prompt, temperature ngay trên UI — không cần wrangler deploy lại mỗi lần test.
Chưa có bình luận
Để lại bình luận
Bình luận sẽ được phê duyệt trước khi hiển thị.