From f127b47f085d648efea4ce49ed716c8eec4f685b Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Thu, 4 Dec 2025 11:00:20 +1000 Subject: [PATCH] feat: add playground sidebars with dynamic tools and env var management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Left Sidebar: - Create /api/tools endpoint to fetch tools from registry - Update ToolsSidebar to fetch and display tools dynamically - Add filter input for searching tools by name/description/category - Fix interface to use packageName/exportName from search registry Right Sidebar: - Create SettingsSidebar with environment variable management - Add localStorage persistence for env vars - Implement password masking for values - Export useEnvVars() hook for accessing env vars Environment Variable Forwarding: - Update useChat hook to read and forward env vars to API - Update chat route to extract env vars from request body - Update dynamic-tool-loader to accept and forward env vars - Update Railway executor to inject env vars into Deno environment - Complete chain: localStorage → client → chat → Railway → Deno.env Bug Fixes: - Fix undefined property errors in tool detail page - Add optional chaining for npmDownloadsLastMonth and qualityScore 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/playground/src/app/api/chat/route.ts | 4 +- apps/playground/src/app/api/tools/route.ts | 30 +++ apps/playground/src/app/page.tsx | 2 + .../components/sidebar/SettingsSidebar.tsx | 171 ++++++++++++++++++ .../src/components/sidebar/ToolsSidebar.tsx | 117 +++++++----- apps/playground/src/hooks/useChat.ts | 11 ++ .../playground/src/lib/dynamic-tool-loader.ts | 10 +- apps/railway-executor/server.ts | 13 +- apps/web/src/app/tool/[...slug]/page.tsx | 8 +- 9 files changed, 311 insertions(+), 55 deletions(-) create mode 100644 apps/playground/src/app/api/tools/route.ts create mode 100644 apps/playground/src/components/sidebar/SettingsSidebar.tsx diff --git a/apps/playground/src/app/api/chat/route.ts b/apps/playground/src/app/api/chat/route.ts index 6b265c2..97a3ef4 100644 --- a/apps/playground/src/app/api/chat/route.ts +++ b/apps/playground/src/app/api/chat/route.ts @@ -31,8 +31,10 @@ export async function POST(request: NextRequest) { const messages: UIMessage[] = body.messages || []; const conversationId: string = body.conversationId || 'default'; + const clientEnv: Record = body.env || {}; console.log(`🔑 Conversation ID: ${conversationId}`); + console.log(`🔐 Client env vars: ${Object.keys(clientEnv).length} keys`); // Get or create conversation state if (!conversationStates.has(conversationId)) { @@ -106,7 +108,7 @@ export async function POST(request: NextRequest) { })); try { - const loadedTools = await loadToolsBatch(toolsToLoad); + const loadedTools = await loadToolsBatch(toolsToLoad, clientEnv); console.log(`✅ Successfully loaded ${Object.keys(loadedTools).length} tools`); // Add sanitized tools to conversation state diff --git a/apps/playground/src/app/api/tools/route.ts b/apps/playground/src/app/api/tools/route.ts new file mode 100644 index 0000000..fe0b316 --- /dev/null +++ b/apps/playground/src/app/api/tools/route.ts @@ -0,0 +1,30 @@ +import { searchTpmjsToolsTool } from '@tpmjs/search-registry'; +import { NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + // Search for all tools (empty query returns all) + const result = await searchTpmjsToolsTool.execute({ + query: '', + limit: 100, + }); + + return NextResponse.json({ + success: true, + tools: result.tools, + total: result.total, + }); + } catch (error) { + console.error('Failed to fetch tools:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch tools', + }, + { status: 500 } + ); + } +} diff --git a/apps/playground/src/app/page.tsx b/apps/playground/src/app/page.tsx index 2b9e07b..f616fbc 100644 --- a/apps/playground/src/app/page.tsx +++ b/apps/playground/src/app/page.tsx @@ -2,6 +2,7 @@ import { ChatHeader } from '~/components/chat/ChatHeader'; import { ChatInterface } from '~/components/chat/ChatInterface'; +import { SettingsSidebar } from '~/components/sidebar/SettingsSidebar'; import { ToolsSidebar } from '~/components/sidebar/ToolsSidebar'; export default function PlaygroundPage(): React.ReactElement { @@ -16,6 +17,7 @@ export default function PlaygroundPage(): React.ReactElement {
+
); diff --git a/apps/playground/src/components/sidebar/SettingsSidebar.tsx b/apps/playground/src/components/sidebar/SettingsSidebar.tsx new file mode 100644 index 0000000..c4888e3 --- /dev/null +++ b/apps/playground/src/components/sidebar/SettingsSidebar.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { Button } from '@tpmjs/ui/Button/Button'; +import { Card, CardContent, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card'; +import { Input } from '@tpmjs/ui/Input/Input'; +import { useEffect, useState } from 'react'; + +interface EnvVar { + key: string; + value: string; +} + +const ENV_STORAGE_KEY = 'tpmjs-playground-env-vars'; + +export function SettingsSidebar(): React.ReactElement { + const [envVars, setEnvVars] = useState([]); + const [newKey, setNewKey] = useState(''); + const [newValue, setNewValue] = useState(''); + + // Load env vars from localStorage on mount + useEffect(() => { + try { + const stored = localStorage.getItem(ENV_STORAGE_KEY); + if (stored) { + setEnvVars(JSON.parse(stored)); + } + } catch (error) { + console.error('Failed to load env vars from localStorage:', error); + } + }, []); + + // Save env vars to localStorage whenever they change + useEffect(() => { + try { + localStorage.setItem(ENV_STORAGE_KEY, JSON.stringify(envVars)); + // Dispatch custom event so other components can react to changes + window.dispatchEvent(new CustomEvent('env-vars-updated', { detail: envVars })); + } catch (error) { + console.error('Failed to save env vars to localStorage:', error); + } + }, [envVars]); + + const handleAddEnvVar = () => { + if (!newKey.trim()) return; + + // Check if key already exists + const exists = envVars.some((env) => env.key === newKey); + if (exists) { + // Update existing + setEnvVars(envVars.map((env) => (env.key === newKey ? { key: newKey, value: newValue } : env))); + } else { + // Add new + setEnvVars([...envVars, { key: newKey, value: newValue }]); + } + + setNewKey(''); + setNewValue(''); + }; + + const handleRemoveEnvVar = (key: string) => { + setEnvVars(envVars.filter((env) => env.key !== key)); + }; + + return ( + + ); +} + +/** + * Hook to get current env vars from localStorage + * Can be used in other components to access env vars + */ +export function useEnvVars(): EnvVar[] { + const [envVars, setEnvVars] = useState([]); + + useEffect(() => { + // Load initially + const loadEnvVars = () => { + try { + const stored = localStorage.getItem(ENV_STORAGE_KEY); + if (stored) { + setEnvVars(JSON.parse(stored)); + } + } catch (error) { + console.error('Failed to load env vars:', error); + } + }; + + loadEnvVars(); + + // Listen for updates + const handleUpdate = (event: Event) => { + const customEvent = event as CustomEvent; + setEnvVars(customEvent.detail); + }; + + window.addEventListener('env-vars-updated', handleUpdate); + return () => window.removeEventListener('env-vars-updated', handleUpdate); + }, []); + + return envVars; +} diff --git a/apps/playground/src/components/sidebar/ToolsSidebar.tsx b/apps/playground/src/components/sidebar/ToolsSidebar.tsx index 4c2775a..6edcaae 100644 --- a/apps/playground/src/components/sidebar/ToolsSidebar.tsx +++ b/apps/playground/src/components/sidebar/ToolsSidebar.tsx @@ -2,60 +2,85 @@ import { Badge } from '@tpmjs/ui/Badge/Badge'; import { Card, CardContent, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card'; +import { Input } from '@tpmjs/ui/Input/Input'; +import { useEffect, useState } from 'react'; -// Hardcoded list of installed tools -const INSTALLED_TOOLS = [ - { - name: 'hello-world', - description: 'Returns a simple "Hello, World!" greeting', - category: 'text-analysis', - }, - { - name: 'hello-name', - description: 'Returns a personalized greeting with a name', - category: 'text-analysis', - }, - { - name: 'firecrawl (scrape)', - description: 'Scrape content from any URL', - category: 'web-scraping', - }, - { - name: 'firecrawl (crawl)', - description: 'Crawl entire websites recursively', - category: 'web-scraping', - }, - { - name: 'firecrawl (search)', - description: 'Search the web for content', - category: 'web-scraping', - }, -]; +interface Tool { + packageName: string; + exportName: string; + description: string; + category: string; + version: string; +} export function ToolsSidebar(): React.ReactElement { + const [tools, setTools] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState(''); + + useEffect(() => { + async function fetchTools() { + try { + const response = await fetch('/api/tools'); + const data = await response.json(); + if (data.success) { + setTools(data.tools); + } + } catch (error) { + console.error('Failed to fetch tools:', error); + } finally { + setLoading(false); + } + } + fetchTools(); + }, []); + + const filteredTools = tools.filter( + (tool) => + tool.packageName?.toLowerCase().includes(filter.toLowerCase()) || + tool.exportName?.toLowerCase().includes(filter.toLowerCase()) || + tool.description?.toLowerCase().includes(filter.toLowerCase()) || + tool.category?.toLowerCase().includes(filter.toLowerCase()) + ); + return ( diff --git a/apps/playground/src/hooks/useChat.ts b/apps/playground/src/hooks/useChat.ts index f0b7f46..8f39c86 100644 --- a/apps/playground/src/hooks/useChat.ts +++ b/apps/playground/src/hooks/useChat.ts @@ -4,6 +4,7 @@ import { useChat as useAISDKChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; import { nanoid } from 'nanoid'; import { useState } from 'react'; +import { useEnvVars } from '~/components/sidebar/SettingsSidebar'; /** * Custom chat hook that wraps the official @ai-sdk/react useChat @@ -14,12 +15,22 @@ export function useChat() { // Generate stable conversation ID for session const [conversationId] = useState(() => nanoid()); + // Get environment variables from settings sidebar + const envVars = useEnvVars(); + + // Convert env vars to object format + const envObject = envVars.reduce((acc, { key, value }) => { + acc[key] = value; + return acc; + }, {} as Record); + const chat = useAISDKChat({ transport: new DefaultChatTransport({ api: '/api/chat', }), body: { conversationId, // Pass conversation ID to API + env: envObject, // Pass environment variables to API }, onResponse: (response: Response) => { // Handle "tools loaded" response diff --git a/apps/playground/src/lib/dynamic-tool-loader.ts b/apps/playground/src/lib/dynamic-tool-loader.ts index f9f237b..09a8815 100644 --- a/apps/playground/src/lib/dynamic-tool-loader.ts +++ b/apps/playground/src/lib/dynamic-tool-loader.ts @@ -26,7 +26,8 @@ export async function loadToolDynamically( packageName: string, exportName: string, version: string, - importUrl?: string + importUrl?: string, + env?: Record ): Promise { const cacheKey = getCacheKey(packageName, exportName); @@ -49,6 +50,7 @@ export async function loadToolDynamically( exportName, version, importUrl: importUrl || `https://esm.sh/${packageName}@${version}`, + env: env || {}, }), }); @@ -88,6 +90,7 @@ export async function loadToolDynamically( version, importUrl: importUrl || `https://esm.sh/${packageName}@${version}`, params, + env: env || {}, }), }); @@ -125,10 +128,11 @@ export async function loadToolsBatch( exportName: string; version: string; importUrl?: string; - }> + }>, + env?: Record ): Promise> { const promises = toolMetadata.map((meta) => - loadToolDynamically(meta.packageName, meta.exportName, meta.version, meta.importUrl).then( + loadToolDynamically(meta.packageName, meta.exportName, meta.version, meta.importUrl, env).then( (tool) => ({ key: getCacheKey(meta.packageName, meta.exportName), tool, diff --git a/apps/railway-executor/server.ts b/apps/railway-executor/server.ts index 88b9322..0d78c28 100644 --- a/apps/railway-executor/server.ts +++ b/apps/railway-executor/server.ts @@ -169,7 +169,7 @@ async function loadAndDescribe(req: Request): Promise { async function executeTool(req: Request): Promise { try { const body = await req.json(); - const { packageName, exportName, version, importUrl, params } = body; + const { packageName, exportName, version, importUrl, params, env } = body; if (!packageName || !exportName || !version) { return Response.json( @@ -211,6 +211,17 @@ async function executeTool(req: Request): Promise { moduleCache.set(cacheKey, toolModule); } + // Inject environment variables from client + if (env && typeof env === 'object') { + const envKeys = Object.keys(env); + if (envKeys.length > 0) { + console.log(`🔐 Injecting ${envKeys.length} environment variables:`, envKeys); + for (const [key, value] of Object.entries(env)) { + Deno.env.set(key, String(value)); + } + } + } + // Execute the tool console.log(`🚀 Executing ${cacheKey} with params:`, params); const result = await toolModule.execute(params || {}); diff --git a/apps/web/src/app/tool/[...slug]/page.tsx b/apps/web/src/app/tool/[...slug]/page.tsx index 3a8dd7a..6e22922 100644 --- a/apps/web/src/app/tool/[...slug]/page.tsx +++ b/apps/web/src/app/tool/[...slug]/page.tsx @@ -339,7 +339,7 @@ export default function ToolDetailPage({

Downloads/month

- {tool.npmDownloadsLastMonth.toLocaleString()} + {tool.npmDownloadsLastMonth?.toLocaleString() || '0'}

{tool.githubStars !== null && ( @@ -353,11 +353,11 @@ export default function ToolDetailPage({

Quality Score

= 0.7 + tool.qualityScore && Number.parseFloat(tool.qualityScore) >= 0.7 ? 'success' - : Number.parseFloat(tool.qualityScore) >= 0.5 + : tool.qualityScore && Number.parseFloat(tool.qualityScore) >= 0.5 ? 'primary' : 'warning' }