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.

12 phút đọc
Tập này đang được chuẩn bị, quay lại sau nhé.

flare-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_SECRET trong 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.