v0-ui-reviewer
Version:
Next-gen UI/UX reviewer with multi-model AI support (OpenAI, Claude, v0), style extraction, and live preview sandbox
270 lines (269 loc) • 10.7 kB
JavaScript
import OpenAI from 'openai';
import Anthropic from '@anthropic-ai/sdk';
import fetch from 'node-fetch';
import { configManager } from './config.js';
import { logger } from './logger.js';
import { SystemPrompts } from './prompts.js';
import { RateLimiter } from './rate-limiter.js';
export class MultiModelAIService {
openai;
anthropic;
v0ApiKey;
defaultModel;
verbose;
rateLimiter;
constructor(config = {}) {
// Initialize from config or environment
const openaiKey = config.openaiApiKey || configManager.get('openaiApiKey') || process.env.OPENAI_API_KEY;
const anthropicKey = config.anthropicApiKey || configManager.get('anthropicApiKey') || process.env.ANTHROPIC_API_KEY;
this.v0ApiKey = config.v0ApiKey || configManager.get('apiKey') || process.env.V0_API_KEY;
this.defaultModel = config.defaultModel || configManager.get('defaultAIModel') || 'v0';
this.verbose = config.verbose || false;
this.rateLimiter = new RateLimiter();
// Initialize clients
if (openaiKey) {
this.openai = new OpenAI({ apiKey: openaiKey });
}
if (anthropicKey) {
this.anthropic = new Anthropic({ apiKey: anthropicKey });
}
this.log('Multi-model AI service initialized', 'debug');
}
log(message, level = 'info') {
if (this.verbose || level === 'error' || level === 'warn') {
logger[level](message);
}
}
async chat(messages, options = {}) {
const model = options.model || this.defaultModel;
// Check rate limits
const service = this.getServiceForModel(model);
const { allowed, remaining, resetIn } = await this.rateLimiter.checkLimit(service);
if (!allowed) {
const resetTime = this.rateLimiter.formatResetTime(resetIn);
throw new Error(`Rate limit exceeded for ${service}. Resets in ${resetTime}. Consider using a different model.`);
}
if (remaining < 10 && this.verbose) {
this.log(`Warning: Only ${remaining} requests remaining for ${service}`, 'warn');
}
this.log(`Using ${model} for chat (${remaining} requests remaining)`, 'debug');
// Add system prompt based on model if prompt variables are provided
if (options.promptVariables && !options.systemPrompt) {
const systemPrompt = SystemPrompts.getPromptForModel(model, options.promptVariables);
messages = [
{ role: 'system', content: systemPrompt },
...messages.filter(m => m.role !== 'system')
];
}
try {
let result;
switch (model) {
case 'v0':
result = await this.chatWithV0(messages, options);
break;
case 'gpt-4':
case 'gpt-4-turbo':
case 'gpt-3.5-turbo':
case 'o3-mini':
result = await this.chatWithOpenAI(messages, options);
break;
case 'claude-3-opus':
case 'claude-3-sonnet':
case 'claude-3-haiku':
case 'claude-sonnet-4':
result = await this.chatWithClaude(messages, options);
break;
default:
throw new Error(`Unsupported model: ${model}`);
}
// Increment rate limit counter on success
await this.rateLimiter.increment(service);
return result;
}
catch (error) {
// Don't increment rate limit on error
throw error;
}
}
async chatWithV0(messages, options) {
if (!this.v0ApiKey) {
throw new Error('V0 API key not configured');
}
// V0 expects a specific format with image support
const v0Messages = messages.map(msg => {
if (msg.imageUrl) {
return {
role: msg.role,
content: [
{ type: 'text', text: msg.content },
{ type: 'image_url', image_url: { url: msg.imageUrl } }
]
};
}
return { role: msg.role, content: msg.content };
});
const response = await fetch('https://api.v0.dev/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.v0ApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'v0-1.0-md',
messages: v0Messages,
temperature: options.temperature || 0.7,
max_tokens: options.maxTokens || 4096,
stream: false
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`V0 API error: ${response.status} - ${errorText}`);
}
const result = await response.json();
return result.choices[0].message.content;
}
async chatWithOpenAI(messages, options) {
if (!this.openai) {
throw new Error('OpenAI API key not configured');
}
const model = options.model === 'gpt-4' ? 'gpt-4' :
options.model === 'gpt-4-turbo' ? 'gpt-4-turbo-preview' :
options.model === 'o3-mini' ? 'gpt-4o-mini' :
'gpt-3.5-turbo';
// Convert messages to OpenAI format
const openaiMessages = messages.map(msg => {
if (msg.imageUrl && msg.role === 'user') {
return {
role: 'user',
content: [
{ type: 'text', text: msg.content },
{ type: 'image_url', image_url: { url: msg.imageUrl } }
]
};
}
return { role: msg.role, content: msg.content };
});
const completion = await this.openai.chat.completions.create({
model,
messages: openaiMessages,
temperature: options.temperature || 0.7,
max_tokens: options.maxTokens || 4096
});
return completion.choices[0]?.message?.content || '';
}
async chatWithClaude(messages, options) {
if (!this.anthropic) {
throw new Error('Anthropic API key not configured');
}
const model = options.model === 'claude-3-opus' ? 'claude-3-opus-20240229' :
options.model === 'claude-3-sonnet' ? 'claude-3-sonnet-20240229' :
options.model === 'claude-sonnet-4' ? 'claude-3-5-sonnet-20241022' :
'claude-3-haiku-20240307';
// Extract system prompt if present
const systemPrompt = messages.find(m => m.role === 'system')?.content || options.systemPrompt;
// Convert messages to Claude format (no system messages)
const claudeMessages = messages
.filter(msg => msg.role !== 'system')
.map(msg => {
if (msg.imageUrl && msg.imageUrl.startsWith('data:image')) {
// Extract base64 data from data URL
const base64Match = msg.imageUrl.match(/^data:image\/(\w+);base64,(.+)$/);
if (base64Match) {
return {
role: msg.role,
content: [
{ type: 'text', text: msg.content },
{
type: 'image',
source: {
type: 'base64',
media_type: `image/${base64Match[1]}`,
data: base64Match[2]
}
}
]
};
}
}
return { role: msg.role, content: msg.content };
});
const message = await this.anthropic.messages.create({
model,
messages: claudeMessages,
system: systemPrompt,
temperature: options.temperature || 0.7,
max_tokens: options.maxTokens || 4096
});
return message.content[0].type === 'text' ? message.content[0].text : '';
}
async isModelAvailable(model) {
switch (model) {
case 'v0':
return !!this.v0ApiKey;
case 'gpt-4':
case 'gpt-4-turbo':
case 'gpt-3.5-turbo':
case 'o3-mini':
return !!this.openai;
case 'claude-3-opus':
case 'claude-3-sonnet':
case 'claude-3-haiku':
case 'claude-sonnet-4':
return !!this.anthropic;
default:
return false;
}
}
getServiceForModel(model) {
switch (model) {
case 'v0':
return 'v0';
case 'gpt-4':
case 'gpt-4-turbo':
case 'gpt-3.5-turbo':
case 'o3-mini':
return 'openai';
case 'claude-3-opus':
case 'claude-3-sonnet':
case 'claude-3-haiku':
case 'claude-sonnet-4':
return 'anthropic';
default:
return 'v0';
}
}
getAvailableModels() {
const models = [];
if (this.v0ApiKey)
models.push('v0');
if (this.openai) {
models.push('gpt-4', 'gpt-4-turbo', 'gpt-3.5-turbo', 'o3-mini');
}
if (this.anthropic) {
models.push('claude-3-opus', 'claude-3-sonnet', 'claude-3-haiku', 'claude-sonnet-4');
}
return models;
}
getModelCapabilities(model) {
switch (model) {
case 'v0':
return { supportsImages: true, maxTokens: 4096 };
case 'gpt-4':
case 'gpt-4-turbo':
return { supportsImages: true, maxTokens: 128000 };
case 'o3-mini':
return { supportsImages: true, maxTokens: 128000 };
case 'gpt-3.5-turbo':
return { supportsImages: false, maxTokens: 16385 };
case 'claude-3-opus':
case 'claude-3-sonnet':
case 'claude-3-haiku':
case 'claude-sonnet-4':
return { supportsImages: true, maxTokens: 200000 };
default:
return { supportsImages: false, maxTokens: 4096 };
}
}
}
export default MultiModelAIService;