- Replace raw <label> with Label component in auth pages, profile settings, tool-ideas - Replace raw <button> with Button in CopyButton, CopyDropdown, ExecutorConfigPanel, PackageManagerSelector, ToolPlayground - Replace raw <textarea> with Textarea in ToolPlayground - Replace inline SVG with Icon component in error.tsx
383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* ToolPlayground component
|
|
* Interactive playground for executing TPMJS tools with AI agents
|
|
*/
|
|
|
|
import type { TokenBreakdown as TokenData } from '@/lib/ai-agent/tool-executor-agent';
|
|
import type { Package, Tool } from '@tpmjs/db';
|
|
import { Button } from '@tpmjs/ui/Button/Button';
|
|
import { Label } from '@tpmjs/ui/Label/Label';
|
|
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
|
import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
|
|
import { useState } from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { TokenBreakdown } from './TokenBreakdown';
|
|
|
|
interface ToolPlaygroundProps {
|
|
tool: Tool & { package: Package };
|
|
}
|
|
|
|
type Tab = 'input' | 'output' | 'logs' | 'tokens';
|
|
|
|
interface ExecutionLog {
|
|
level: 'info' | 'warning' | 'error' | 'debug';
|
|
message: string;
|
|
timestamp: Date;
|
|
}
|
|
|
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Playground component has many tabs with different content
|
|
export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElement {
|
|
const [activeTab, setActiveTab] = useState<Tab>('input');
|
|
const [prompt, setPrompt] = useState('');
|
|
const [isExecuting, setIsExecuting] = useState(false);
|
|
const [output, setOutput] = useState('');
|
|
const [logs, setLogs] = useState<ExecutionLog[]>([]);
|
|
const [tokens, setTokens] = useState<TokenData | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [rateLimitInfo, setRateLimitInfo] = useState<{ remaining: number } | null>(null);
|
|
|
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: SSE stream handling requires sequential logic
|
|
const handleExecute = async () => {
|
|
if (!prompt.trim() || isExecuting) return;
|
|
|
|
setIsExecuting(true);
|
|
setOutput('');
|
|
setError(null);
|
|
setLogs([]);
|
|
setTokens(null);
|
|
setActiveTab('output');
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/tools/execute/${encodeURIComponent(tool.package.npmPackageName)}/${encodeURIComponent(tool.name)}`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ prompt }),
|
|
}
|
|
);
|
|
|
|
// Check rate limit headers
|
|
const remaining = response.headers.get('X-RateLimit-Remaining');
|
|
if (remaining) {
|
|
setRateLimitInfo({ remaining: Number.parseInt(remaining, 10) });
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || 'Execution failed');
|
|
}
|
|
|
|
// Handle SSE stream
|
|
const reader = response.body?.getReader();
|
|
const decoder = new TextDecoder();
|
|
|
|
if (!reader) {
|
|
throw new Error('No response body');
|
|
}
|
|
|
|
let buffer = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('event:')) {
|
|
const event = line.slice(6).trim();
|
|
const nextLine = lines.shift();
|
|
|
|
if (nextLine?.startsWith('data:')) {
|
|
const data = JSON.parse(nextLine.slice(5).trim());
|
|
|
|
switch (event) {
|
|
case 'chunk':
|
|
setOutput((prev) => prev + data.text);
|
|
setLogs((prev) => [
|
|
...prev,
|
|
{
|
|
level: 'info',
|
|
message: `Streaming: ${data.text.slice(0, 50)}${data.text.length > 50 ? '...' : ''}`,
|
|
timestamp: new Date(),
|
|
},
|
|
]);
|
|
break;
|
|
|
|
case 'tokens':
|
|
setTokens(data as TokenData);
|
|
setLogs((prev) => [
|
|
...prev,
|
|
{
|
|
level: 'debug',
|
|
message: `Token update: ${data.totalTokens || 0} total tokens`,
|
|
timestamp: new Date(),
|
|
},
|
|
]);
|
|
break;
|
|
|
|
case 'complete':
|
|
// Don't replace output - keep the streamed text
|
|
// setOutput(data.output); // Removed: this was overwriting streamed content
|
|
setTokens(data.tokenBreakdown);
|
|
setLogs((prev) => [
|
|
...prev,
|
|
{
|
|
level: 'info',
|
|
message: `Execution completed in ${data.executionTimeMs}ms with ${data.agentSteps} agent steps`,
|
|
timestamp: new Date(),
|
|
},
|
|
]);
|
|
break;
|
|
|
|
case 'error':
|
|
setError(data.message);
|
|
setLogs((prev) => [
|
|
...prev,
|
|
{
|
|
level: 'error',
|
|
message: data.message,
|
|
timestamp: new Date(),
|
|
},
|
|
]);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
setError(message);
|
|
setLogs((prev) => [
|
|
...prev,
|
|
{
|
|
level: 'error',
|
|
message,
|
|
timestamp: new Date(),
|
|
},
|
|
]);
|
|
} finally {
|
|
setIsExecuting(false);
|
|
}
|
|
};
|
|
|
|
const tabs: { id: Tab; label: string; badge?: string }[] = [
|
|
{ id: 'input', label: 'Input' },
|
|
{ id: 'output', label: 'Output', badge: output ? '✓' : undefined },
|
|
{ id: 'logs', label: 'Logs', badge: logs.length > 0 ? logs.length.toString() : undefined },
|
|
{ id: 'tokens', label: 'Token Usage', badge: tokens ? '✓' : undefined },
|
|
];
|
|
|
|
const getLevelBadgeColor = (level: ExecutionLog['level']) => {
|
|
switch (level) {
|
|
case 'info':
|
|
return 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400';
|
|
case 'warning':
|
|
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-400';
|
|
case 'error':
|
|
return 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400';
|
|
case 'debug':
|
|
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="border border-border rounded-lg overflow-hidden bg-background">
|
|
{/* Header */}
|
|
<div className="border-b border-border bg-muted/30 px-6 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-foreground">Interactive Playground</h2>
|
|
<p className="text-sm text-foreground-secondary mt-1">
|
|
Test {tool.package.npmPackageName} ({tool.name}) with AI-powered execution
|
|
</p>
|
|
</div>
|
|
{rateLimitInfo && (
|
|
<div className="text-sm text-foreground-secondary">
|
|
{rateLimitInfo.remaining} executions remaining
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="border-b border-border bg-muted/10">
|
|
<div className="flex space-x-1 px-6">
|
|
{tabs.map((tab) => (
|
|
<Button
|
|
key={tab.id}
|
|
variant="ghost"
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`px-4 py-3 rounded-none text-sm font-medium transition-colors relative ${
|
|
activeTab === tab.id
|
|
? 'text-foreground border-b-2 border-primary'
|
|
: 'text-foreground-secondary hover:text-foreground'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
{tab.badge && (
|
|
<span className="ml-2 inline-flex items-center justify-center w-5 h-5 text-xs rounded-full bg-primary/10 text-primary">
|
|
{tab.badge}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
<div className="p-6">
|
|
{activeTab === 'input' && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="prompt" className="mb-2">Prompt</Label>
|
|
<Textarea
|
|
id="prompt"
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
placeholder="Enter your prompt here... (e.g., 'Create a blog post about TypeScript best practices')"
|
|
className="h-32"
|
|
resize="none"
|
|
disabled={isExecuting}
|
|
/>
|
|
<p className="text-xs text-foreground-tertiary mt-2">
|
|
{prompt.length}/2000 characters
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handleExecute}
|
|
disabled={isExecuting || !prompt.trim()}
|
|
loading={isExecuting}
|
|
>
|
|
{isExecuting ? 'Executing...' : 'Execute'}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'output' && (
|
|
<div className="space-y-4">
|
|
{error ? (
|
|
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900">
|
|
<p className="text-sm text-red-700 dark:text-red-400 font-medium">Error</p>
|
|
<p className="text-sm text-red-600 dark:text-red-500 mt-1">{error}</p>
|
|
</div>
|
|
) : output ? (
|
|
<div className="space-y-4">
|
|
{/* JSON Output */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-foreground mb-2">Raw Output (JSON)</h3>
|
|
<div className="rounded-lg border border-border bg-muted/30 p-4 overflow-x-auto">
|
|
<pre className="text-xs text-foreground font-mono">{output}</pre>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Human-Readable Preview */}
|
|
{(() => {
|
|
try {
|
|
const parsed = JSON.parse(output);
|
|
return (
|
|
<div>
|
|
<h3 className="text-sm font-medium text-foreground mb-2">
|
|
Human-Readable Preview
|
|
</h3>
|
|
<div className="rounded-lg border border-border bg-muted/30 p-6 prose prose-sm dark:prose-invert max-w-none">
|
|
{parsed.formattedOutput ? (
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
{parsed.formattedOutput}
|
|
</ReactMarkdown>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{Object.entries(parsed).map(([key, value]) => (
|
|
<div key={key}>
|
|
<span className="font-semibold">{key}:</span>{' '}
|
|
{typeof value === 'object'
|
|
? JSON.stringify(value, null, 2)
|
|
: String(value)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})()}
|
|
</div>
|
|
) : isExecuting ? (
|
|
<div className="flex items-center justify-center py-12 gap-4">
|
|
<Spinner size="lg" />
|
|
<p className="text-foreground-secondary font-mono text-sm tracking-wide">
|
|
Executing...
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12">
|
|
<p className="text-foreground-secondary">
|
|
No output yet. Execute a prompt to see results.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'logs' && (
|
|
<div className="space-y-2">
|
|
{logs.length > 0 ? (
|
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
{logs.map((log, index) => (
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: Logs don't have unique IDs, index is appropriate
|
|
<div key={index} className="flex items-start space-x-3 text-sm">
|
|
<span
|
|
className={`px-2 py-0.5 rounded text-xs font-medium uppercase ${getLevelBadgeColor(log.level)}`}
|
|
>
|
|
{log.level}
|
|
</span>
|
|
<div className="flex-1">
|
|
<p className="text-foreground">{log.message}</p>
|
|
<p className="text-xs text-foreground-tertiary mt-0.5">
|
|
{log.timestamp.toLocaleTimeString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12">
|
|
<p className="text-foreground-secondary">
|
|
No logs yet. Execute a prompt to see logs.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'tokens' && (
|
|
<div>
|
|
{tokens ? (
|
|
<TokenBreakdown tokens={tokens} />
|
|
) : (
|
|
<div className="text-center py-12">
|
|
<p className="text-foreground-secondary">
|
|
No token data yet. Execute a prompt to see token usage.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|