feat: add hot-swappable executor support for collections and agents
- Add executor configuration to Collection and Agent models in Prisma schema - Create ExecutorConfigPanel component for selecting default or custom executors - Add executor resolution logic with cascade (Agent → Collection → System Default) - Create /api/executors/verify endpoint to test custom executor connectivity - Add executor documentation page at /docs/executors with API specification - Create deployable Vercel executor template in templates/vercel-executor/ - Update MCP handlers and agent tool execution to use configurable executors - Add executor types and schemas to @tpmjs/types package Users can now deploy their own executor instances and configure collections or agents to use custom executors instead of the TPMJS default executor. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
84d894e920
commit
bc36d366bc
27 changed files with 1909 additions and 85 deletions
315
apps/web/src/components/ExecutorConfigPanel.tsx
Normal file
315
apps/web/src/components/ExecutorConfigPanel.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
'use client';
|
||||
|
||||
import type { ExecutorConfig } from '@tpmjs/types/executor';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { FormField } from '@tpmjs/ui/FormField/FormField';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ExecutorConfigPanelProps {
|
||||
value: ExecutorConfig | null;
|
||||
onChange: (config: ExecutorConfig | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface VerificationResult {
|
||||
valid: boolean;
|
||||
healthCheck?: {
|
||||
healthy: boolean;
|
||||
response?: { status: string; version?: string };
|
||||
};
|
||||
testExecution?: {
|
||||
success: boolean;
|
||||
executionTimeMs: number;
|
||||
};
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Component has multiple states for executor configuration
|
||||
export function ExecutorConfigPanel({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: ExecutorConfigPanelProps): React.ReactElement {
|
||||
const [executorType, setExecutorType] = useState<'default' | 'custom_url'>(
|
||||
value?.type === 'custom_url' ? 'custom_url' : 'default'
|
||||
);
|
||||
const [customUrl, setCustomUrl] = useState(value?.type === 'custom_url' ? value.url : '');
|
||||
const [apiKey, setApiKey] = useState(value?.type === 'custom_url' ? (value.apiKey ?? '') : '');
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [verificationResult, setVerificationResult] = useState<VerificationResult | null>(null);
|
||||
const [urlError, setUrlError] = useState<string | null>(null);
|
||||
|
||||
const handleTypeChange = (type: 'default' | 'custom_url') => {
|
||||
setExecutorType(type);
|
||||
setVerificationResult(null);
|
||||
setUrlError(null);
|
||||
|
||||
if (type === 'default') {
|
||||
onChange({ type: 'default' });
|
||||
} else {
|
||||
// Don't update parent until URL is provided
|
||||
if (customUrl) {
|
||||
onChange({
|
||||
type: 'custom_url',
|
||||
url: customUrl,
|
||||
apiKey: apiKey || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlChange = (url: string) => {
|
||||
setCustomUrl(url);
|
||||
setVerificationResult(null);
|
||||
setUrlError(null);
|
||||
|
||||
if (url) {
|
||||
onChange({
|
||||
type: 'custom_url',
|
||||
url,
|
||||
apiKey: apiKey || undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (key: string) => {
|
||||
setApiKey(key);
|
||||
|
||||
if (customUrl) {
|
||||
onChange({
|
||||
type: 'custom_url',
|
||||
url: customUrl,
|
||||
apiKey: key || undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!customUrl) {
|
||||
setUrlError('URL is required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(customUrl);
|
||||
} catch {
|
||||
setUrlError('Invalid URL format');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsVerifying(true);
|
||||
setVerificationResult(null);
|
||||
setUrlError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/executors/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: customUrl, apiKey: apiKey || undefined }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setVerificationResult(data.data);
|
||||
} else {
|
||||
setUrlError(data.error?.message || 'Verification failed');
|
||||
}
|
||||
} catch (error) {
|
||||
setUrlError(error instanceof Error ? error.message : 'Failed to verify executor');
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon icon="link" className="w-4 h-4 text-foreground-secondary" />
|
||||
<h3 className="text-sm font-medium text-foreground">Executor Configuration</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-foreground-tertiary">
|
||||
Choose where tools in this collection/agent will be executed.{' '}
|
||||
<Link href="/docs/executors" className="text-primary hover:underline">
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{/* Executor Type Selection */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTypeChange('default')}
|
||||
disabled={disabled}
|
||||
className={`flex-1 p-3 rounded-lg border-2 text-left transition-colors ${
|
||||
executorType === 'default'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-foreground-secondary'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full border-2 ${
|
||||
executorType === 'default'
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-foreground-tertiary'
|
||||
}`}
|
||||
>
|
||||
{executorType === 'default' && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium text-sm text-foreground">TPMJS Default</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-tertiary mt-1 ml-6">
|
||||
Free, managed executor hosted by TPMJS
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTypeChange('custom_url')}
|
||||
disabled={disabled}
|
||||
className={`flex-1 p-3 rounded-lg border-2 text-left transition-colors ${
|
||||
executorType === 'custom_url'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-foreground-secondary'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full border-2 ${
|
||||
executorType === 'custom_url'
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-foreground-tertiary'
|
||||
}`}
|
||||
>
|
||||
{executorType === 'custom_url' && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium text-sm text-foreground">Custom Executor</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-tertiary mt-1 ml-6">
|
||||
Self-hosted executor on your infrastructure
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Custom URL Configuration */}
|
||||
{executorType === 'custom_url' && (
|
||||
<div className="space-y-3 pt-2 border-t border-border">
|
||||
<FormField
|
||||
label="Executor URL"
|
||||
htmlFor="executor-url"
|
||||
required
|
||||
error={urlError ?? undefined}
|
||||
state={urlError ? 'error' : 'default'}
|
||||
helperText="The base URL of your executor (e.g., https://my-executor.vercel.app)"
|
||||
>
|
||||
<Input
|
||||
id="executor-url"
|
||||
type="url"
|
||||
value={customUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
placeholder="https://my-executor.vercel.app"
|
||||
state={urlError ? 'error' : 'default'}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="API Key (Optional)"
|
||||
htmlFor="executor-api-key"
|
||||
helperText="If your executor requires authentication"
|
||||
>
|
||||
<Input
|
||||
id="executor-api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleVerify}
|
||||
loading={isVerifying}
|
||||
disabled={disabled || !customUrl}
|
||||
>
|
||||
<Icon icon="check" className="w-4 h-4 mr-1" />
|
||||
Verify Connection
|
||||
</Button>
|
||||
|
||||
<Link href="/docs/executors#deploy" className="text-xs text-primary hover:underline">
|
||||
Deploy your own executor
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Verification Result */}
|
||||
{verificationResult && (
|
||||
<div
|
||||
className={`p-3 rounded-lg text-sm ${
|
||||
verificationResult.valid
|
||||
? 'bg-green-500/10 border border-green-500/30'
|
||||
: 'bg-red-500/10 border border-red-500/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
icon={verificationResult.valid ? 'check' : 'x'}
|
||||
className={`w-4 h-4 ${
|
||||
verificationResult.valid ? 'text-green-500' : 'text-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`font-medium ${
|
||||
verificationResult.valid ? 'text-green-500' : 'text-red-500'
|
||||
}`}
|
||||
>
|
||||
{verificationResult.valid ? 'Executor verified' : 'Verification failed'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{verificationResult.healthCheck && (
|
||||
<p className="text-xs text-foreground-secondary mt-2 ml-6">
|
||||
Health: {verificationResult.healthCheck.response?.status ?? 'unknown'}
|
||||
{verificationResult.healthCheck.response?.version &&
|
||||
` (v${verificationResult.healthCheck.response.version})`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{verificationResult.testExecution && (
|
||||
<p className="text-xs text-foreground-secondary ml-6">
|
||||
Test execution: {verificationResult.testExecution.executionTimeMs}ms
|
||||
</p>
|
||||
)}
|
||||
|
||||
{verificationResult.errors.length > 0 && (
|
||||
<ul className="text-xs text-red-400 mt-2 ml-6 list-disc list-inside">
|
||||
{verificationResult.errors.map((error) => (
|
||||
<li key={error}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue