diff --git a/apps/web/package.json b/apps/web/package.json index cb5c089..d61372d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,15 +11,19 @@ "clean": "rm -rf .next .turbo" }, "dependencies": { + "@ai-sdk/openai": "^2.0.74", "@tpmjs/db": "workspace:*", "@tpmjs/env": "workspace:*", "@tpmjs/npm-client": "workspace:*", + "@tpmjs/package-executor": "workspace:*", "@tpmjs/types": "workspace:*", "@tpmjs/ui": "workspace:*", "@tpmjs/utils": "workspace:*", "@types/react-syntax-highlighter": "^15.5.13", + "ai": "^5.0.104", "next": "^16.0.4", "next-themes": "^0.4.6", + "openai": "^6.9.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", @@ -27,7 +31,8 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", - "zod": "^3.24.1" + "tiktoken": "^1.0.22", + "zod": "^3.25.76" }, "devDependencies": { "@tpmjs/eslint-config": "workspace:*", diff --git a/apps/web/src/app/api/tools/[...slug]/execute/route.ts b/apps/web/src/app/api/tools/[...slug]/execute/route.ts new file mode 100644 index 0000000..a56942c --- /dev/null +++ b/apps/web/src/app/api/tools/[...slug]/execute/route.ts @@ -0,0 +1,190 @@ +/** + * Tool execution endpoint with SSE streaming + * Executes TPMJS tools with AI agents and streams real-time progress + */ + +import { executeToolWithAgent } from '@/lib/ai-agent/tool-executor-agent'; +import { checkRateLimit, getClientIP } from '@/lib/rate-limiter'; +import { prisma } from '@tpmjs/db'; +import { type NextRequest, NextResponse } from 'next/server'; + +// Use Node.js runtime for SSE streaming +export const runtime = 'nodejs'; +export const maxDuration = 60; // 60 seconds timeout + +interface ExecuteRequest { + prompt: string; + parameters?: Record; +} + +/** + * POST /api/tools/[...slug]/execute + * Executes a tool with an AI agent and streams the response via SSE + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ slug: string[] }> } +) { + const { slug } = await params; + const packageName = decodeURIComponent(slug.join('/')); + + try { + // Parse request body + const body = (await request.json()) as ExecuteRequest; + const { prompt, parameters } = body; + + if (!prompt || prompt.length === 0) { + return NextResponse.json({ error: 'Prompt is required' }, { status: 400 }); + } + + if (prompt.length > 2000) { + return NextResponse.json({ error: 'Prompt too long (max 2000 characters)' }, { status: 400 }); + } + + // Get client IP and check rate limit + const ipAddress = getClientIP(request); + const rateLimit = await checkRateLimit(ipAddress); + + if (!rateLimit.allowed) { + return NextResponse.json( + { + error: 'Rate limit exceeded', + resetAt: rateLimit.resetAt, + remaining: 0, + }, + { + status: 429, + headers: { + 'X-RateLimit-Limit': '10', + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': rateLimit.resetAt.toISOString(), + }, + } + ); + } + + // Fetch tool from database + const tool = await prisma.tool.findUnique({ + where: { npmPackageName: packageName }, + }); + + if (!tool) { + return NextResponse.json({ error: 'Tool not found' }, { status: 404 }); + } + + // Create simulation record + const simulation = await prisma.simulation.create({ + data: { + toolId: tool.id, + userPrompt: prompt, + // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround + parameters: parameters ? (parameters as any) : undefined, + ipAddress, + userAgent: request.headers.get('user-agent') || null, + status: 'running', + model: 'gpt-4-turbo', + }, + }); + + // Create readable stream for SSE + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + + const sendEvent = (event: string, data: unknown) => { + const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + controller.enqueue(encoder.encode(message)); + }; + + try { + const startTime = Date.now(); + + // Execute tool with AI agent + const result = await executeToolWithAgent( + tool, + prompt, + (chunk) => { + // Stream text chunks + sendEvent('chunk', { text: chunk }); + }, + (tokens) => { + // Stream token updates + sendEvent('tokens', tokens); + } + ); + + const executionTimeMs = Date.now() - startTime; + + // Update simulation with results + await prisma.simulation.update({ + where: { id: simulation.id }, + data: { + status: 'success', + output: { result: result.output }, + agentSteps: result.agentSteps, + executionTimeMs, + completedAt: new Date(), + }, + }); + + // Create token usage record + await prisma.tokenUsage.create({ + data: { + simulationId: simulation.id, + inputTokens: result.tokenBreakdown.inputTokens, + toolDescTokens: result.tokenBreakdown.toolDescTokens, + schemaTokens: result.tokenBreakdown.schemaTokens, + outputTokens: result.tokenBreakdown.outputTokens, + totalTokens: result.tokenBreakdown.totalTokens, + estimatedCost: result.tokenBreakdown.estimatedCost, + }, + }); + + // Send completion event + sendEvent('complete', { + output: result.output, + tokenBreakdown: result.tokenBreakdown, + executionTimeMs, + agentSteps: result.agentSteps, + }); + } catch (error) { + // Update simulation with error + await prisma.simulation.update({ + where: { id: simulation.id }, + data: { + status: 'error', + error: error instanceof Error ? error.message : 'Unknown error', + completedAt: new Date(), + }, + }); + + // Send error event + sendEvent('error', { + message: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + controller.close(); + } + }, + }); + + // Return SSE stream + return new NextResponse(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-RateLimit-Limit': '10', + 'X-RateLimit-Remaining': rateLimit.remaining.toString(), + }, + }); + } catch (error) { + console.error('Execute endpoint error:', error); + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Internal server error', + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/tools/[...slug]/simulations/route.ts b/apps/web/src/app/api/tools/[...slug]/simulations/route.ts new file mode 100644 index 0000000..d2748c8 --- /dev/null +++ b/apps/web/src/app/api/tools/[...slug]/simulations/route.ts @@ -0,0 +1,51 @@ +/** + * Simulation history endpoint + * Returns recent simulations for a tool with token usage data + */ + +import { prisma } from '@tpmjs/db'; +import { type NextRequest, NextResponse } from 'next/server'; + +/** + * GET /api/tools/[...slug]/simulations + * Returns the last 10 simulations for a tool + */ +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ slug: string[] }> } +) { + const { slug } = await params; + const packageName = decodeURIComponent(slug.join('/')); + + try { + // Fetch tool + const tool = await prisma.tool.findUnique({ + where: { npmPackageName: packageName }, + select: { id: true }, + }); + + if (!tool) { + return NextResponse.json({ error: 'Tool not found' }, { status: 404 }); + } + + // Fetch recent simulations with token usage + const simulations = await prisma.simulation.findMany({ + where: { toolId: tool.id }, + include: { + tokenUsage: true, + }, + orderBy: { createdAt: 'desc' }, + take: 10, + }); + + return NextResponse.json({ simulations }); + } catch (error) { + console.error('Simulations endpoint error:', error); + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Internal server error', + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/tool/[...slug]/page.tsx b/apps/web/src/app/tool/[...slug]/page.tsx index df763fb..37f9d5c 100644 --- a/apps/web/src/app/tool/[...slug]/page.tsx +++ b/apps/web/src/app/tool/[...slug]/page.tsx @@ -12,6 +12,7 @@ import Link from 'next/link'; import { useEffect, useState } from 'react'; import { Markdown } from '../../../components/Markdown'; import { ThemeToggle } from '../../../components/ThemeToggle'; +import { ToolPlayground } from '../../../components/ToolPlayground'; interface Tool { id: string; @@ -241,6 +242,10 @@ export default function ToolDetailPage({
{/* Left column - Main content */}
+ {/* Interactive Playground */} + {/* biome-ignore lint/suspicious/noExplicitAny: Prisma Tool type compatibility with component props */} + + {/* Installation */} diff --git a/apps/web/src/components/TokenBreakdown.tsx b/apps/web/src/components/TokenBreakdown.tsx new file mode 100644 index 0000000..93aa84f --- /dev/null +++ b/apps/web/src/components/TokenBreakdown.tsx @@ -0,0 +1,93 @@ +'use client'; + +/** + * TokenBreakdown component + * Visualizes token usage breakdown with horizontal bars + */ + +import type { TokenBreakdown as TokenData } from '@/lib/ai-agent/tool-executor-agent'; + +interface TokenBreakdownProps { + tokens: TokenData; +} + +interface TokenBar { + label: string; + tokens: number; + color: string; + bgColor: string; +} + +export function TokenBreakdown({ tokens }: TokenBreakdownProps): React.ReactElement { + const { inputTokens, toolDescTokens, schemaTokens, outputTokens, totalTokens, estimatedCost } = + tokens; + + const bars: TokenBar[] = [ + { + label: 'Input', + tokens: inputTokens, + color: 'bg-blue-500', + bgColor: 'bg-blue-100 dark:bg-blue-950', + }, + { + label: 'Tool Description', + tokens: toolDescTokens, + color: 'bg-purple-500', + bgColor: 'bg-purple-100 dark:bg-purple-950', + }, + { + label: 'Schema', + tokens: schemaTokens, + color: 'bg-green-500', + bgColor: 'bg-green-100 dark:bg-green-950', + }, + { + label: 'Output', + tokens: outputTokens, + color: 'bg-orange-500', + bgColor: 'bg-orange-100 dark:bg-orange-950', + }, + ]; + + const getPercentage = (value: number): number => { + if (totalTokens === 0) return 0; + return (value / totalTokens) * 100; + }; + + return ( +
+
+ {bars.map((bar) => { + const percentage = getPercentage(bar.tokens); + return ( +
+
+ {bar.label} + + {bar.tokens.toLocaleString()} tokens ({percentage.toFixed(1)}%) + +
+
+
+
+
+ ); + })} +
+ +
+
+ Total Tokens + {totalTokens.toLocaleString()} +
+
+ Estimated Cost + ${estimatedCost.toFixed(4)} +
+
+
+ ); +} diff --git a/apps/web/src/components/ToolPlayground.tsx b/apps/web/src/components/ToolPlayground.tsx new file mode 100644 index 0000000..a04685f --- /dev/null +++ b/apps/web/src/components/ToolPlayground.tsx @@ -0,0 +1,380 @@ +'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 { Tool } from '@tpmjs/db'; +import { useState } from 'react'; +import { TokenBreakdown } from './TokenBreakdown'; + +interface ToolPlaygroundProps { + tool: Tool; +} + +type Tab = 'input' | 'output' | 'logs' | 'tokens'; + +interface ExecutionLog { + level: 'info' | 'warning' | 'error' | 'debug'; + message: string; + timestamp: Date; +} + +export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElement { + const [activeTab, setActiveTab] = useState('input'); + const [prompt, setPrompt] = useState(''); + const [isExecuting, setIsExecuting] = useState(false); + const [output, setOutput] = useState(''); + const [logs, setLogs] = useState([]); + const [tokens, setTokens] = useState(null); + const [error, setError] = useState(null); + const [rateLimitInfo, setRateLimitInfo] = useState<{ remaining: number } | null>(null); + + 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/${encodeURIComponent(tool.npmPackageName)}/execute`, + { + 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': + setOutput(data.output); + 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 ( +
+ {/* Header */} +
+
+
+

Interactive Playground

+

+ Test {tool.npmPackageName} with AI-powered execution +

+
+ {rateLimitInfo && ( +
+ {rateLimitInfo.remaining} executions remaining +
+ )} +
+
+ + {/* Tabs */} +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ + {/* Tab Content */} +
+ {activeTab === 'input' && ( +
+
+ +