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 podcast

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.

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ị.

Nhận bài viết mới

Mình sẽ gửi email khi có bài mới. Không spam.