feat: add playground sidebars with dynamic tools and env var management

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 <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2025-12-04 11:00:20 +10:00
parent c18d1b7ea6
commit f127b47f08
9 changed files with 311 additions and 55 deletions

View file

@ -31,8 +31,10 @@ export async function POST(request: NextRequest) {
const messages: UIMessage[] = body.messages || [];
const conversationId: string = body.conversationId || 'default';
const clientEnv: Record<string, string> = 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

View file

@ -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 }
);
}
}

View file

@ -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 {
<div className="flex flex-1 overflow-hidden">
<ToolsSidebar />
<ChatInterface />
<SettingsSidebar />
</div>
</div>
);

View file

@ -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<EnvVar[]>([]);
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 (
<aside className="hidden w-80 border-l border-border bg-surface md:block">
<div className="flex h-full flex-col p-4">
<h2 className="mb-4 text-lg font-bold">Settings</h2>
{/* Environment Variables Section */}
<Card variant="outline" className="mb-4">
<CardHeader>
<CardTitle className="text-sm">
Environment Variables <Badge variant="secondary" size="sm">{envVars.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4 text-xs text-foreground-secondary">
Add API keys and other environment variables. They will be forwarded to tool executions.
</p>
{/* Add new env var form */}
<div className="mb-4 space-y-2">
<Input
type="text"
placeholder="Key (e.g., FIRECRAWL_API_KEY)"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
className="font-mono text-xs"
/>
<Input
type="password"
placeholder="Value"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
className="font-mono text-xs"
/>
<Button onClick={handleAddEnvVar} size="sm" variant="primary" className="w-full">
Add Variable
</Button>
</div>
{/* List of env vars */}
<div className="space-y-2">
{envVars.length === 0 ? (
<p className="text-xs text-foreground-tertiary">No environment variables set</p>
) : (
envVars.map((env) => (
<div key={env.key} className="flex items-center justify-between rounded border border-border bg-background p-2">
<div className="flex-1 overflow-hidden">
<p className="truncate font-mono text-xs font-semibold">{env.key}</p>
<p className="truncate font-mono text-xs text-foreground-tertiary">
{env.value ? '•'.repeat(Math.min(env.value.length, 20)) : '(empty)'}
</p>
</div>
<Button onClick={() => handleRemoveEnvVar(env.key)} size="sm" variant="ghost" className="ml-2">
×
</Button>
</div>
))
)}
</div>
</CardContent>
</Card>
{/* Info Section */}
<div className="mt-auto rounded border border-border bg-background p-3">
<p className="text-xs text-foreground-secondary">
<strong>Note:</strong> Environment variables are stored locally in your browser and sent with each tool
execution request.
</p>
</div>
</div>
</aside>
);
}
/**
* 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<EnvVar[]>([]);
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<EnvVar[]>;
setEnvVars(customEvent.detail);
};
window.addEventListener('env-vars-updated', handleUpdate);
return () => window.removeEventListener('env-vars-updated', handleUpdate);
}, []);
return envVars;
}

View file

@ -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<Tool[]>([]);
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 (
<aside className="hidden w-64 border-r border-border bg-surface md:block">
<div className="p-4">
<div className="flex h-full flex-col p-4">
<h2 className="mb-4 text-lg font-bold">
Available Tools <Badge variant="secondary">{INSTALLED_TOOLS.length}</Badge>
Available Tools <Badge variant="secondary">{filteredTools.length}</Badge>
</h2>
<div className="space-y-2">
{INSTALLED_TOOLS.map((tool) => (
<Card key={tool.name} variant="outline" className="cursor-pointer hover:bg-background">
<CardHeader>
<CardTitle className="text-sm">{tool.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-foreground-secondary">{tool.description}</p>
<div className="mt-2">
<Badge variant="secondary" size="sm">
{tool.category}
</Badge>
</div>
</CardContent>
</Card>
))}
<Input
type="text"
placeholder="Filter tools..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="mb-4"
/>
<div className="flex-1 space-y-2 overflow-y-auto">
{loading ? (
<p className="text-sm text-foreground-secondary">Loading tools...</p>
) : filteredTools.length === 0 ? (
<p className="text-sm text-foreground-secondary">No tools found</p>
) : (
filteredTools.map((tool) => (
<Card key={`${tool.packageName}-${tool.exportName}`} variant="outline" className="cursor-pointer hover:bg-background">
<CardHeader>
<CardTitle className="text-sm">{tool.exportName}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-foreground-secondary">{tool.description}</p>
<div className="mt-2 flex items-center gap-2">
<Badge variant="secondary" size="sm">
{tool.category}
</Badge>
<span className="text-xs text-foreground-tertiary">v{tool.version}</span>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
</aside>

View file

@ -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<string, string>);
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

View file

@ -26,7 +26,8 @@ export async function loadToolDynamically(
packageName: string,
exportName: string,
version: string,
importUrl?: string
importUrl?: string,
env?: Record<string, string>
): Promise<any | null> {
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<string, string>
): Promise<Record<string, any>> {
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,

View file

@ -169,7 +169,7 @@ async function loadAndDescribe(req: Request): Promise<Response> {
async function executeTool(req: Request): Promise<Response> {
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<Response> {
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 || {});

View file

@ -339,7 +339,7 @@ export default function ToolDetailPage({
<div>
<p className="text-sm text-foreground-secondary mb-1">Downloads/month</p>
<p className="text-2xl font-bold text-foreground">
{tool.npmDownloadsLastMonth.toLocaleString()}
{tool.npmDownloadsLastMonth?.toLocaleString() || '0'}
</p>
</div>
{tool.githubStars !== null && (
@ -353,11 +353,11 @@ export default function ToolDetailPage({
<div>
<p className="text-sm text-foreground-secondary mb-2">Quality Score</p>
<ProgressBar
value={Number.parseFloat(tool.qualityScore) * 100}
value={(tool.qualityScore ? Number.parseFloat(tool.qualityScore) : 0) * 100}
variant={
Number.parseFloat(tool.qualityScore) >= 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'
}