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:
parent
c18d1b7ea6
commit
f127b47f08
9 changed files with 311 additions and 55 deletions
|
|
@ -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
|
||||
|
|
|
|||
30
apps/playground/src/app/api/tools/route.ts
Normal file
30
apps/playground/src/app/api/tools/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
171
apps/playground/src/components/sidebar/SettingsSidebar.tsx
Normal file
171
apps/playground/src/components/sidebar/SettingsSidebar.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 || {});
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue