v0-ui-reviewer
Version:
Next-gen UI/UX reviewer with multi-model AI support (OpenAI, Claude, v0), style extraction, and live preview sandbox
451 lines (450 loc) ⢠18 kB
JavaScript
import chalk from 'chalk';
import inquirer from 'inquirer';
import readline from 'readline';
import { V0UIReviewerCLI } from './index.js';
import { MultiModelAIService } from './ai-service.js';
import { configManager } from './config.js';
import { EnhancedProgressBar } from './progress.js';
import { promises as fs } from 'fs';
import path from 'path';
export class InteractiveV2Session {
rl;
state;
commands;
isProcessing = false;
constructor(reviewer, options = {}) {
this.state = {
mode: 'remote',
model: configManager.get('defaultAIModel') || 'v0',
reviewer,
aiService: new MultiModelAIService({
verbose: options.verbose
}),
history: [],
verbose: options.verbose || false
};
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: this.getPrompt()
});
this.commands = new Map();
this.registerCommands();
this.setupAutocomplete();
}
getPrompt() {
const mode = chalk.gray(`[${this.state.mode}]`);
const location = this.state.mode === 'remote'
? chalk.cyan(this.state.currentUrl || 'no url')
: chalk.green(this.state.currentPath || process.cwd());
const model = chalk.yellow(`(${this.state.model})`);
return `${mode} ${location} ${model} āŗ `;
}
registerCommands() {
// Mode commands
this.commands.set('/remote', {
name: '/remote <url>',
description: 'Switch to remote mode and set URL',
action: async (args) => {
this.state.mode = 'remote';
if (args.length > 0) {
this.state.currentUrl = args.join(' ');
this.log(`Switched to remote mode: ${this.state.currentUrl}`);
}
else {
this.log('Switched to remote mode');
}
}
});
this.commands.set('/local', {
name: '/local [path]',
description: 'Switch to local mode and set working directory',
action: async (args) => {
this.state.mode = 'local';
if (args.length > 0) {
const targetPath = path.resolve(args.join(' '));
try {
await fs.access(targetPath);
this.state.currentPath = targetPath;
process.chdir(targetPath);
this.log(`Switched to local mode: ${targetPath}`);
}
catch {
this.error(`Path not found: ${targetPath}`);
}
}
else {
this.state.currentPath = process.cwd();
this.log(`Switched to local mode: ${this.state.currentPath}`);
}
}
});
// Review commands
this.commands.set('/review', {
name: '/review [options]',
description: 'Review current URL or local files',
action: async (args) => {
if (this.state.mode === 'remote' && !this.state.currentUrl) {
this.error('No URL set. Use /remote <url> first');
return;
}
const progress = !this.state.verbose ? new EnhancedProgressBar([
{ name: 'Screenshot', weight: 50 },
{ name: 'Analysis', weight: 50 }
]) : null;
try {
progress?.start();
if (this.state.mode === 'remote') {
const result = await this.state.reviewer.reviewURL(this.state.currentUrl, {
model: this.state.model,
verbose: this.state.verbose,
onProgress: progress ? (step, percent) => {
progress.updateStep(step, percent);
if (percent === 100)
progress.completeStep(step);
} : undefined
});
progress?.stop();
this.state.lastScreenshot = result.screenshot;
this.displayReview(result);
}
else {
// Local mode: analyze current directory structure
await this.analyzeLocalProject();
}
}
catch (error) {
progress?.stop();
this.error(error instanceof Error ? error.message : 'Review failed');
}
}
});
// Model commands
this.commands.set('/model', {
name: '/model [model]',
description: 'Switch AI model',
action: async (args) => {
const validModels = ['v0', 'gpt-4', 'o3-mini', 'gpt-3.5-turbo', 'claude-sonnet-4', 'claude-3-opus', 'claude-3-haiku'];
if (args.length === 0) {
this.log(`Current model: ${chalk.green(this.state.model)}`);
this.log('Available models:');
for (const m of validModels) {
const available = await this.state.aiService.isModelAvailable(this.mapModelName(m));
this.log(` ${available ? 'ā' : 'ā'} ${m}`);
}
return;
}
const model = args[0];
const mappedModel = this.mapModelName(model);
if (!validModels.includes(model)) {
this.error(`Invalid model: ${model}`);
return;
}
if (!(await this.state.aiService.isModelAvailable(mappedModel))) {
this.error(`Model ${model} is not configured. Check your API keys.`);
return;
}
this.state.model = mappedModel;
this.log(`Switched to model: ${chalk.green(model)}`);
}
});
// Style extraction
this.commands.set('/extract', {
name: '/extract [format]',
description: 'Extract design tokens (json|css|tailwind)',
action: async (args) => {
if (this.state.mode === 'remote' && !this.state.lastScreenshot) {
this.error('No screenshot available. Run /review first');
return;
}
const format = args[0] || 'json';
this.log('Extracting design tokens...');
// Implementation would extract styles from last screenshot
this.log(`Design tokens extracted in ${format} format`);
}
});
// Chat command
this.commands.set('/chat', {
name: '/chat <message>',
description: 'Chat about the current design',
action: async (args) => {
if (args.length === 0) {
this.error('Please provide a message');
return;
}
const message = args.join(' ');
await this.chatAboutDesign(message);
}
});
// Utility commands
this.commands.set('/verbose', {
name: '/verbose',
description: 'Toggle verbose mode',
action: async () => {
this.state.verbose = !this.state.verbose;
this.log(`Verbose mode: ${this.state.verbose ? 'ON' : 'OFF'}`);
}
});
this.commands.set('/history', {
name: '/history',
description: 'Show command history',
action: async () => {
if (this.state.history.length === 0) {
this.log('No command history');
return;
}
this.state.history.forEach((item, index) => {
this.log(`${chalk.gray(`[${index + 1}]`)} ${item.command}`);
});
}
});
this.commands.set('/clear', {
name: '/clear',
description: 'Clear the screen',
action: async () => {
console.clear();
}
});
this.commands.set('/help', {
name: '/help',
description: 'Show available commands',
action: async () => {
this.showHelp();
}
});
this.commands.set('/exit', {
name: '/exit',
description: 'Exit interactive mode',
action: async () => {
this.log(chalk.yellow('\nGoodbye! š'));
process.exit(0);
}
});
}
mapModelName(model) {
const mapping = {
'o3-mini': 'gpt-4', // Map to GPT-4 for now until o3-mini is available
'claude-sonnet-4': 'claude-3-sonnet', // Map to Claude-3-Sonnet
'claude-sonnet-4-20250514': 'claude-3-sonnet'
};
return (mapping[model] || model);
}
setupAutocomplete() {
// Set up tab completion
const commands = Array.from(this.commands.keys());
this.rl.on('line', (line) => {
if (this.isProcessing)
return;
this.isProcessing = true;
this.handleInput(line.trim()).finally(() => {
this.isProcessing = false;
this.rl.setPrompt(this.getPrompt());
this.rl.prompt();
});
});
// Handle Ctrl+C
this.rl.on('SIGINT', () => {
this.log(chalk.yellow('\nUse /exit to quit'));
this.rl.prompt();
});
// Tab completion for commands
const completer = (line) => {
const completions = commands;
const hits = completions.filter(c => c.startsWith(line));
return [hits.length ? hits : completions, line];
};
this.rl.completer = completer;
}
async handleInput(input) {
if (!input)
return;
// Add to history
this.state.history.push({ command: input });
// Check if it's a command
if (input.startsWith('/')) {
const [cmd, ...args] = input.split(' ');
const command = this.commands.get(cmd);
if (command) {
await command.action(args, this.state);
}
else {
this.showCommandSuggestions(cmd);
}
}
else {
// Natural language input - treat as chat
await this.chatAboutDesign(input);
}
}
showCommandSuggestions(partial) {
const suggestions = Array.from(this.commands.entries())
.filter(([cmd]) => cmd.startsWith(partial))
.slice(0, 5);
if (suggestions.length === 0) {
this.error(`Unknown command: ${partial}`);
this.log('Type /help for available commands');
}
else {
this.log(chalk.yellow('Did you mean:'));
suggestions.forEach(([cmd, command]) => {
this.log(` ${chalk.cyan(cmd)} - ${chalk.gray(command.description)}`);
});
}
}
async analyzeLocalProject() {
this.log('Analyzing local project structure...');
try {
// Simple implementation for now
const cwd = this.state.currentPath || process.cwd();
this.log(`Current directory: ${cwd}`);
// Check for common project files
const hasPackageJson = await fs.access(path.join(cwd, 'package.json')).then(() => true).catch(() => false);
const hasReadme = await fs.access(path.join(cwd, 'README.md')).then(() => true).catch(() => false);
if (hasPackageJson) {
const pkg = JSON.parse(await fs.readFile(path.join(cwd, 'package.json'), 'utf-8'));
this.log(`\nProject: ${pkg.name || 'Unnamed'}`);
this.log(`Description: ${pkg.description || 'No description'}`);
}
if (hasReadme) {
this.log('\nā README.md found');
}
this.log('\nUse /chat to discuss UI/UX improvements for this project');
}
catch (error) {
this.error(`Failed to analyze project: ${error}`);
}
}
async analyzeLocalFile(filePath) {
try {
const content = await fs.readFile(path.join(this.state.currentPath || process.cwd(), filePath), 'utf-8');
const messages = [{
role: 'user',
content: `Analyze this ${path.extname(filePath)} file for UI/UX best practices:\n\n${content.slice(0, 3000)}${content.length > 3000 ? '\n...(truncated)' : ''}`
}];
const response = await this.state.aiService.chat(messages, {
model: this.state.model,
temperature: 0.7
});
this.log(chalk.green(`\nAnalysis of ${filePath}:`));
this.log(response);
}
catch (error) {
this.error(`Failed to analyze file: ${error}`);
}
}
async chatAboutDesign(message) {
try {
const context = this.state.mode === 'remote'
? `Discussing UI/UX for: ${this.state.currentUrl}`
: `Discussing local project at: ${this.state.currentPath}`;
const messages = [
{ role: 'system', content: context },
{ role: 'user', content: message }
];
if (!this.state.verbose) {
const spinner = chalk.gray('š¤ Thinking...');
process.stdout.write(spinner);
}
const response = await this.state.aiService.chat(messages, {
model: this.state.model,
temperature: 0.7
});
if (!this.state.verbose) {
process.stdout.write('\r' + ' '.repeat(20) + '\r');
}
this.log(chalk.white(response));
}
catch (error) {
this.error(`Chat failed: ${error}`);
}
}
displayReview(result) {
if (this.state.verbose) {
// Full review output
console.log(chalk.green('\n=== UI/UX Review ===\n'));
console.log(result.componentBreakdown);
console.log(result.heuristicAudit);
console.log(result.recommendations);
}
else {
// Condensed output
console.log(chalk.green('\nā
Review complete'));
console.log(chalk.gray('Use /chat to discuss specific aspects'));
}
}
showHelp() {
console.log(chalk.bold('\nšØ v0-review Interactive Mode\n'));
const categories = {
'Mode Control': ['/remote', '/local'],
'Analysis': ['/review', '/extract', '/chat'],
'Configuration': ['/model', '/verbose'],
'Utilities': ['/history', '/clear', '/help', '/exit']
};
Object.entries(categories).forEach(([category, cmds]) => {
console.log(chalk.yellow(`${category}:`));
cmds.forEach(cmd => {
const command = this.commands.get(cmd);
if (command) {
console.log(` ${chalk.cyan(command.name.padEnd(20))} ${chalk.gray(command.description)}`);
}
});
console.log();
});
console.log(chalk.gray('š” Tip: Type any message without / to chat about the current design\n'));
}
log(message) {
console.log(message);
}
error(message) {
console.error(chalk.red(`ā ${message}`));
}
async start() {
console.clear();
console.log(chalk.bold.magenta(`
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā v0-review Interactive Mode ā
ā UI/UX Analysis & Design Assistant ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
`));
// Initial setup
const { mode } = await inquirer.prompt([{
type: 'list',
name: 'mode',
message: 'Select mode:',
choices: [
{ name: 'Remote - Analyze websites by URL', value: 'remote' },
{ name: 'Local - Analyze files in current directory', value: 'local' }
],
default: 'remote'
}]);
this.state.mode = mode;
if (mode === 'remote') {
const { url } = await inquirer.prompt([{
type: 'input',
name: 'url',
message: 'Enter URL to analyze:',
validate: (input) => input.length > 0 || 'Please enter a URL'
}]);
this.state.currentUrl = url;
}
else {
this.state.currentPath = process.cwd();
this.log(`Working directory: ${chalk.green(this.state.currentPath)}`);
}
this.log(chalk.gray('\nType /help for commands or just type to chat\n'));
this.rl.setPrompt(this.getPrompt());
this.rl.prompt();
}
}
export async function runInteractiveV2Mode(options = {}) {
const apiKey = configManager.getApiKey();
if (!apiKey) {
console.error(chalk.red('No API key found. Run: v0-review --setup'));
process.exit(1);
}
const reviewer = new V0UIReviewerCLI(apiKey, {
verbose: options.verbose
});
const session = new InteractiveV2Session(reviewer, options);
await session.start();
}