openclaw-dashboard/components/ModelSwitcher.tsx
AI Assistant c7f037c58a feat: Complete OpenClaw Dashboard with all features
- Dashboard Overview with real-time status display
- Live Log Viewer (scrollable, filterable)
- Config Editor with JSON syntax highlighting
- Model Switcher for provider management
- Provider Manager for API key configuration
- Quick Actions for common tasks
- API Routes: status, logs, config, actions, models, providers

Tech Stack:
- Next.js 14 (App Router)
- TypeScript
- Tailwind CSS
- shadcn/ui components
- CodeMirror for JSON editing
- Docker support with docker-compose
2026-02-27 05:55:23 +00:00

231 lines
8.3 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { RefreshCw, Check, X, Cpu, Search } from 'lucide-react';
interface Model {
id: string;
name: string;
}
export function ModelSwitcher() {
const [models, setModels] = useState<Model[]>([]);
const [activeModel, setActiveModelState] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [switching, setSwitching] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch('/api/models');
const data = await response.json();
setModels(data.models || []);
setActiveModelState(data.activeModel);
} catch (error) {
console.error('Failed to fetch models:', error);
setMessage({ type: 'error', text: 'Failed to load models' });
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleSwitchModel = async (modelId: string) => {
if (switching) return;
setSwitching(modelId);
setMessage(null);
try {
const response = await fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ modelId }),
});
const result = await response.json();
if (result.success) {
setMessage({ type: 'success', text: result.message });
setActiveModelState(modelId);
} else {
setMessage({ type: 'error', text: result.message || 'Failed to switch model' });
}
} catch (error) {
setMessage({ type: 'error', text: 'Failed to switch model' });
} finally {
setSwitching(null);
}
};
const groupedModels = models.reduce((acc, model) => {
const provider = model.id.split('/')[0];
if (!acc[provider]) {
acc[provider] = [];
}
acc[provider].push(model);
return acc;
}, {} as Record<string, Model[]>);
const filteredGroups = filter
? Object.fromEntries(
Object.entries(groupedModels).filter(([_, models]) =>
models.some((m) =>
m.name.toLowerCase().includes(filter.toLowerCase()) ||
m.id.toLowerCase().includes(filter.toLowerCase())
)
)
)
: groupedModels;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-wrap gap-2 items-center justify-between">
<div className="flex items-center gap-4">
<Button
onClick={fetchData}
disabled={loading}
variant="outline"
className="border-zinc-700 hover:bg-zinc-800"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
{activeModel && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/20">
<Cpu className="w-4 h-4 text-green-400" />
<span className="text-sm text-green-400">Active: {activeModel}</span>
</div>
)}
</div>
<div className="relative min-w-[200px] max-w-md flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Input
placeholder="Search models..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-10 bg-zinc-900/50 border-zinc-700 focus:border-orange-500"
/>
</div>
</div>
{/* Message */}
{message && (
<Alert variant={message.type === 'error' ? 'destructive' : 'default'} className={
message.type === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-400' : ''
}>
{message.type === 'success' ? (
<Check className="h-4 w-4" />
) : (
<X className="h-4 w-4" />
)}
<AlertDescription>{message.text}</AlertDescription>
</Alert>
)}
{/* Models List */}
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-8 h-8 animate-spin text-orange-400" />
</div>
) : Object.keys(filteredGroups).length === 0 ? (
<Card className="bg-zinc-900/50 border-zinc-800">
<CardContent className="py-12 text-center text-zinc-500">
<Cpu className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>{filter ? 'No models match your search' : 'No models found'}</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{Object.entries(filteredGroups).map(([provider, providerModels]) => (
<Card key={provider} className="bg-zinc-900/50 border-zinc-800">
<CardHeader>
<CardTitle className="capitalize text-lg">
{provider}
<Badge variant="outline" className="ml-2 border-zinc-700">
{providerModels.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{providerModels
.filter((model) =>
!filter ||
model.name.toLowerCase().includes(filter.toLowerCase()) ||
model.id.toLowerCase().includes(filter.toLowerCase())
)
.map((model) => (
<div
key={model.id}
className={`p-4 rounded-lg border transition-all ${
activeModel === model.id
? 'bg-orange-500/10 border-orange-500/50'
: 'bg-zinc-800/50 border-zinc-700 hover:border-zinc-600'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="font-medium truncate" title={model.name}>
{model.name}
</p>
<p className="text-xs text-zinc-500 truncate mt-1" title={model.id}>
{model.id}
</p>
</div>
<Badge
variant={activeModel === model.id ? 'default' : 'outline'}
className={
activeModel === model.id
? 'bg-orange-500 text-white'
: 'border-zinc-700'
}
>
{activeModel === model.id ? 'Active' : 'Available'}
</Badge>
</div>
<Button
onClick={() => handleSwitchModel(model.id)}
disabled={switching === model.id || activeModel === model.id}
variant={activeModel === model.id ? 'secondary' : 'outline'}
size="sm"
className="mt-3 w-full"
>
{switching === model.id ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Switching...
</>
) : activeModel === model.id ? (
'Active'
) : (
'Switch to this model'
)}
</Button>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
)}
<div className="text-sm text-zinc-500">
<p> Switching models restarts the Gateway to apply changes.</p>
</div>
</div>
);
}