UNPKG

@alexberriman/openai-designer-feedback

Version:

CLI tool that captures website screenshots using @alexberriman/screenshotter and provides professional web design/UX feedback via OpenAI's gpt-image-1 vision model. Focus on identifying critical issues and design errors.

1,528 lines (1,501 loc) 92.1 kB
#!/usr/bin/env node // src/index.ts import { Command } from "commander"; import chalk2 from "chalk"; // src/utils/validator.ts import tsResults from "ts-results"; var { Err, Ok } = tsResults; function validateUrl(url) { if (!url || url.trim().length === 0) { return Err({ message: "URL is required" }); } let validUrl = url; if (!/^https?:\/\//.test(url)) { validUrl = `https://${url}`; } try { new URL(validUrl); return Ok(validUrl); } catch { return Err({ message: "Invalid URL format", field: "url" }); } } function validateViewport(viewport) { if (!viewport) { return Ok({ width: 1920, height: 1080 }); } const viewportPresets = { mobile: { width: 375, height: 812 }, tablet: { width: 768, height: 1024 }, desktop: { width: 1920, height: 1080 } }; if (viewport.toLowerCase() in viewportPresets) { return Ok(viewportPresets[viewport.toLowerCase()]); } const customMatch = /^(\d+)x(\d+)$/.exec(viewport); if (customMatch) { const width = Number.parseInt(customMatch[1], 10); const height = Number.parseInt(customMatch[2], 10); if (width < 320 || width > 3840) { return Err({ message: "Width must be between 320 and 3840", field: "viewport" }); } if (height < 240 || height > 2160) { return Err({ message: "Height must be between 240 and 2160", field: "viewport" }); } return Ok({ width, height }); } return Err({ message: "Invalid viewport format. Use 'mobile', 'tablet', 'desktop' or 'WIDTHxHEIGHT'", field: "viewport" }); } function validateWaitTime(wait) { if (!wait) { return Ok(0); } const waitTime = typeof wait === "string" ? Number.parseInt(wait, 10) : wait; if (Number.isNaN(waitTime) || waitTime < 0) { return Err({ message: "Wait time must be a positive number", field: "wait" }); } if (waitTime > 60) { return Err({ message: "Wait time cannot exceed 60 seconds", field: "wait" }); } return Ok(waitTime); } function validateQuality(quality) { if (!quality) { return Ok(90); } const qualityValue = typeof quality === "string" ? Number.parseInt(quality, 10) : quality; if (Number.isNaN(qualityValue) || qualityValue < 0 || qualityValue > 100) { return Err({ message: "Quality must be between 0 and 100", field: "quality" }); } return Ok(qualityValue); } function validateFormat(format) { if (!format) { return Ok("text"); } const normalizedFormat = format.toLowerCase(); if (normalizedFormat !== "json" && normalizedFormat !== "text") { return Err({ message: "Format must be 'json' or 'text'", field: "format" }); } return Ok(normalizedFormat); } // src/utils/logger.ts import pino from "pino"; function createLogger(options = {}) { const isTest = process.env.NODE_ENV === "test" || process.env.VITEST === "true"; if (isTest) { return pino({ level: "silent" }); } const logLevel = process.env.LOG_LEVEL || (options.verbose ? "debug" : "warn"); return pino({ level: logLevel, transport: { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname", singleLine: false } }, serializers: { // Prevent circular references in error objects err: pino.stdSerializers.err, error: (error) => { if (error instanceof Error) { const { name, message, stack, ...rest } = error; return { name, message, stack, ...rest }; } return error; } } }); } var globalLogger = null; function getGlobalLogger() { if (!globalLogger) { globalLogger = createLogger({ verbose: process.env.VERBOSE === "true" }); } const enhancedLogger = globalLogger; enhancedLogger.debugObject = function(msg, obj) { const formattedObj = formatObject(obj); this.debug(`${msg} ${formattedObj}`); }; enhancedLogger.infoObject = function(msg, obj) { const formattedObj = formatObject(obj); this.info(`${msg} ${formattedObj}`); }; enhancedLogger.warnObject = function(msg, obj) { const formattedObj = formatObject(obj); this.warn(`${msg} ${formattedObj}`); }; enhancedLogger.errorObject = function(msg, obj) { const formattedObj = formatObject(obj); this.error(`${msg} ${formattedObj}`); }; return enhancedLogger; } function formatObject(obj) { try { if (obj === null || typeof obj !== "object") { return ` ${String(obj)}`; } return Object.entries(obj).map(([key, value]) => { if (value && typeof value === "object") { try { return ` ${key}: ${JSON.stringify(value, null, 2).replaceAll("\n", "\n ")}`; } catch { return ` ${key}: [Complex Object]`; } } return ` ${key}: ${value}`; }).join("\n"); } catch (error) { return ` [Unable to format object: ${error instanceof Error ? error.message : String(error)}]`; } } function configureLogger(options) { globalLogger = createLogger(options); } var logger = getGlobalLogger(); // src/utils/config-loader.ts import { promises as fs } from "fs"; import { homedir } from "os"; import path from "path"; import tsResults2 from "ts-results"; import { createInterface } from "readline"; import { stdin, stdout } from "process"; var { Err: Err2, Ok: Ok2 } = tsResults2; var CONFIG_DIR = path.join(homedir(), ".design-feedback"); var CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); async function loadConfig() { try { const envKey = process.env.OPENAI_API_KEY; if (envKey) { return Ok2({ openaiApiKey: envKey }); } try { const configContent = await fs.readFile(CONFIG_FILE, "utf8"); const config = JSON.parse(configContent); return Ok2(config); } catch (error) { if (error.code !== "ENOENT") { return Err2({ type: "CONFIGURATION_ERROR", code: "CONFIG_READ_ERROR", message: `Failed to read config file: ${error}` }); } } return Ok2({}); } catch (error) { return Err2({ type: "CONFIGURATION_ERROR", code: "CONFIG_READ_ERROR", message: `Failed to load config: ${error}` }); } } async function saveConfig(config) { try { await fs.mkdir(CONFIG_DIR, { recursive: true }); await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2)); return Ok2(void 0); } catch (error) { return Err2({ type: "CONFIGURATION_ERROR", code: "CONFIG_READ_ERROR", message: `Failed to save config: ${error}` }); } } async function promptForApiKey() { const rl = createInterface({ input: stdin, output: stdout }); return new Promise((resolve) => { rl.question("Please enter your OpenAI API key: ", (apiKey) => { rl.close(); resolve(apiKey.trim()); }); }); } async function ensureApiKey(providedKey) { if (providedKey) { return Ok2(providedKey); } const configResult = await loadConfig(); if (configResult.err) { return Err2(configResult.val); } const config = configResult.val; if (config.openaiApiKey) { return Ok2(config.openaiApiKey); } const apiKey = await promptForApiKey(); if (!apiKey) { return Err2({ type: "CONFIGURATION_ERROR", code: "MISSING_API_KEY", message: "API key is required" }); } const saveResult = await saveConfig({ ...config, openaiApiKey: apiKey }); if (saveResult.err) { return Err2(saveResult.val); } return Ok2(apiKey); } // src/index.ts import tsResults7 from "ts-results"; // src/services/analysis-service.ts import tsResults5 from "ts-results"; // src/services/screenshot-service.ts import { exec } from "child_process"; import { promisify } from "util"; import { tmpdir } from "os"; import path2 from "path"; import { readFile, unlink } from "fs/promises"; import { existsSync } from "fs"; import tsResults3 from "ts-results"; var { Ok: Ok3, Err: Err3 } = tsResults3; var execAsync = promisify(exec); var ScreenshotService = class { logger = getGlobalLogger(); /** * Validates the URL and captures a screenshot */ async capture(options) { try { this.logCaptureStart(options); const urlValidation = this.validateUrl(options.url); if (urlValidation.err) { this.logUrlValidationError(options.url, urlValidation.val); return Err3(urlValidation.val); } const outputPath = options.outputPath ?? this.generateTempPath(options.url); this.logOutputPath(outputPath, !!options.outputPath); const captureResult = await this.executeScreenshotCapture(options, outputPath); if (captureResult.err) { return captureResult; } return this.processScreenshotFile(outputPath, options); } catch (error) { return this.handleUnexpectedError(error); } } /** * Log capture starting information */ logCaptureStart(options) { this.logger.debugObject("Starting screenshot capture", { url: options.url, viewport: options.viewport, waitTime: options.waitTime, waitFor: options.waitFor, fullPage: options.fullPage, quality: options.quality, outputPathProvided: !!options.outputPath }); } /** * Log URL validation error */ logUrlValidationError(url, error) { this.logger.errorObject("URL validation failed", { url, error: error.message, code: error.code }); } /** * Log output path information */ logOutputPath(outputPath, providedByUser) { this.logger.debugObject("Using output path", { outputPath, isTemporary: !providedByUser, directory: path2.dirname(outputPath), extension: path2.extname(outputPath) }); } /** * Execute the screenshot capture process */ async executeScreenshotCapture(options, outputPath) { const command = this.buildCommand(options, outputPath); this.logger.debugObject("Screenshotter command", { command, commandLength: command.length }); this.logger.debugObject("Executing screenshotter command", { startTime: (/* @__PURE__ */ new Date()).toISOString(), workingDirectory: process.cwd() }); try { const startTime = Date.now(); const execResult = await execAsync(command); const duration = Date.now() - startTime; this.logger.debugObject("Screenshotter command completed", { duration: `${duration}ms`, stdout: execResult.stdout.slice(0, 200) + (execResult.stdout.length > 200 ? "..." : ""), stderr: execResult.stderr.slice(0, 200) + (execResult.stderr.length > 200 ? "..." : ""), exitCode: 0 }); return Ok3.EMPTY; } catch (execError) { return this.handleExecError( execError, command ); } } /** * Handle execution error */ handleExecError(error, command) { this.logger.errorObject("Screenshotter command failed", { error: error.message, stdout: error.stdout?.slice(0, 200) + (error.stdout && error.stdout.length > 200 ? "..." : ""), stderr: error.stderr?.slice(0, 200) + (error.stderr && error.stderr.length > 200 ? "..." : ""), exitCode: error.code, command }); return Err3({ type: "SCREENSHOT_ERROR", message: `Failed to capture screenshot: ${error.message}`, code: "CAPTURE_FAILED", details: { stdout: error.stdout, stderr: error.stderr, exitCode: error.code } }); } /** * Process the screenshot file */ async processScreenshotFile(outputPath, options) { const fileExists = existsSync(outputPath); this.logger.debugObject("Checking screenshot file", { path: outputPath, exists: fileExists }); if (!fileExists) { return this.handleMissingFile(outputPath); } try { return await this.createScreenshotResult(outputPath, options); } catch (fileError) { return this.handleFileProcessingError(outputPath, fileError); } } /** * Handle missing file error */ handleMissingFile(outputPath) { this.logger.errorObject("Screenshot file not created", { path: outputPath, directory: path2.dirname(outputPath), directoryExists: existsSync(path2.dirname(outputPath)) }); return Err3({ type: "SCREENSHOT_ERROR", message: "Screenshot file was not created", code: "CAPTURE_FAILED", path: outputPath }); } /** * Create the screenshot result from the file */ async createScreenshotResult(outputPath, options) { const fileStats = await readFile(outputPath).then((buffer) => ({ sizeBytes: buffer.length, exists: true })).catch((error) => ({ error: error.message, exists: false })); this.logger.debugObject("Screenshot file stats", fileStats); const base64 = await this.encodeToBase64(outputPath); this.logger.debugObject("Image encoded to base64", { base64Length: base64.length, encodedSizeKB: Math.round(base64.length / 1024) }); const result = { path: outputPath, metadata: { viewportSize: options.viewport || "desktop", timestamp: Date.now(), url: options.url, format: outputPath.endsWith(".png") ? "png" : "jpeg" }, base64 }; this.logger.infoObject("Screenshot captured successfully", { path: outputPath, sizeKB: Math.round(base64.length * 0.75 / 1024), // Approximate size of decoded image format: result.metadata.format, viewport: result.metadata.viewportSize }); return Ok3(result); } /** * Handle file processing error */ handleFileProcessingError(outputPath, fileError) { this.logger.errorObject("Failed to process screenshot file", { path: outputPath, error: fileError instanceof Error ? fileError.message : String(fileError) }); return Err3({ type: "SCREENSHOT_ERROR", message: "Failed to process screenshot file", code: "READ_ERROR", details: fileError }); } /** * Handle unexpected errors */ handleUnexpectedError(error) { this.logger.errorObject("Unexpected error during screenshot capture", { error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : String(error) }); return Err3({ type: "SCREENSHOT_ERROR", message: "Unexpected error during screenshot capture", code: "CAPTURE_FAILED", details: error }); } validateUrl(url) { try { new URL(url); return Ok3.EMPTY; } catch { return Err3({ type: "SCREENSHOT_ERROR", code: "INVALID_URL", message: "Invalid URL format" }); } } generateTempPath(url) { const timestamp = Date.now(); const safeName = url.replaceAll(/[^a-z0-9]/gi, "-").toLowerCase(); const filename = `screenshot-${safeName}-${timestamp}.png`; return path2.join(tmpdir(), filename); } buildCommand(options, outputPath) { const parts = ["npx", "@alexberriman/screenshotter", `"${options.url}"`]; parts.push("-o", `"${outputPath}"`); if (options.viewport) { parts.push("-v", options.viewport); } if (options.waitTime !== void 0) { parts.push("-w", options.waitTime.toString()); } if (options.waitFor) { parts.push("--wait-for", `"${options.waitFor}"`); } if (options.quality !== void 0) { parts.push("--quality", options.quality.toString()); } if (!options.fullPage) { parts.push("--no-full-page"); } return parts.join(" "); } async encodeToBase64(filePath) { try { this.logger.debugObject("Reading file for base64 encoding", { filePath }); const startTime = Date.now(); const buffer = await readFile(filePath); const duration = Date.now() - startTime; this.logger.debugObject("File read for base64 encoding", { filePath, sizeBytes: buffer.length, readDuration: `${duration}ms` }); const base64 = buffer.toString("base64"); this.logger.debugObject("Base64 encoding complete", { filePath, originalSizeBytes: buffer.length, encodedLength: base64.length, encodingRatio: (base64.length / buffer.length).toFixed(2) }); return base64; } catch (error) { this.logger.errorObject("Failed to encode file to base64", { filePath, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : void 0 }); throw new Error( `Failed to encode file to base64: ${error instanceof Error ? error.message : String(error)}` ); } } async cleanup(filePath) { try { if (existsSync(filePath)) { await unlink(filePath); this.logger.debugObject("Cleaned up screenshot file", { path: filePath }); } return Ok3.EMPTY; } catch (error) { return Err3({ type: "SCREENSHOT_ERROR", message: "Failed to cleanup screenshot file", code: "CAPTURE_FAILED", details: error }); } } }; // src/services/vision-service.ts import tsResults4 from "ts-results"; import OpenAI from "openai"; import { readFile as readFile2 } from "fs/promises"; var { Ok: Ok4, Err: Err4 } = tsResults4; var VisionService = class { openai; logger = getGlobalLogger(); maxRetries = 3; retryDelay = 1e3; // milliseconds timeout = 6e4; // 60 seconds constructor(apiKey) { this.logger.debugObject("Initializing VisionService", { apiKeyPrefix: apiKey.slice(0, 7) + "...", apiKeyLength: apiKey.length, apiKeyType: apiKey.startsWith("sk-proj-") ? "Project API Key" : "Standard API Key" }); this.openai = new OpenAI({ apiKey, httpAgent: this.createHttpAgent(), maxRetries: this.maxRetries, timeout: this.timeout }); } /** * Try to close built-in client implementation if available */ async tryCloseClient() { try { const client = this.openai; if (client && typeof client.close === "function") { await client.close(); this.logger.debug("Successfully closed OpenAI client via close() method"); } } catch (error) { this.logger.debug(`Error closing client: ${String(error)}`); } } /** * Try to close any connections in the fetch client */ tryCloseConnections(fetchClient) { if (!fetchClient.dispatcher?.connections) return; this.logger.debug("Found connections in fetch dispatcher, closing"); try { for (const conn of Object.values(fetchClient.dispatcher.connections)) { if (typeof conn.close === "function") { conn.close(); this.logger.debug("Closed a connection"); } } } catch (error) { this.logger.debug(`Error closing connections: ${String(error)}`); } } /** * Try to destroy the HTTP agent if it exists */ tryDestroyAgent(fetchClient) { if (!fetchClient.dispatcher?.agent?.destroy) return; this.logger.debug("Found agent in fetch dispatcher, destroying"); try { fetchClient.dispatcher.agent.destroy(); this.logger.debug("Destroyed fetch agent"); } catch (error) { this.logger.debug(`Error destroying agent: ${String(error)}`); } } /** * Try to clean up the fetch client and its resources */ tryCleanupFetchClient() { try { const client = this.openai; const fetchClient = client?.baseClient?.fetch; if (fetchClient) { this.logger.debug("Found OpenAI internal fetch client, attempting to clean up"); this.tryCloseConnections(fetchClient); this.tryDestroyAgent(fetchClient); } } catch (error) { this.logger.debug(`Failed to clean up fetch client: ${String(error)}`); } } /** * Try to force garbage collection if available */ tryForceGarbageCollection() { const nodeProcess = globalThis; if (!nodeProcess.gc) return; try { nodeProcess.gc(); this.logger.debug("Manually triggered garbage collection"); } catch (error) { this.logger.debug(`Error triggering garbage collection: ${String(error)}`); } } /** * Clean up resources to allow proper process termination * This method handles cleaning up the OpenAI client to prevent process hanging * @returns Promise that resolves when cleanup is complete */ async destroy() { try { this.logger.debug("Starting VisionService cleanup..."); await this.tryCloseClient(); this.tryCleanupFetchClient(); this.openai = null; this.tryForceGarbageCollection(); this.logger.debug("VisionService resources cleaned up"); } catch (error) { this.logger.warnObject("Error cleaning up VisionService", { error: error instanceof Error ? error.message : String(error) }); } } createHttpAgent() { return void 0; } /** * Analyzes a screenshot using OpenAI's vision model */ async analyzeScreenshot(options) { this.logger.infoObject("Starting vision analysis", { viewport: options.viewport, path: options.imagePath, isDesignRecommendation: options.isDesignRecommendation }); this.logger.debugObject("Vision options", options); const base64Result = await this.imageToBase64(options.imagePath); if (base64Result.err) { return Err4(base64Result.val); } return this.callOpenAI(base64Result.val, options); } /** * Gets design recommendations for a screenshot */ async getDesignRecommendations(options) { this.logger.infoObject("Starting design recommendations analysis", { viewport: options.viewport, path: options.imagePath }); const designOptions = { ...options, isDesignRecommendation: true }; this.logger.debugObject("Design recommendations options", designOptions); const base64Result = await this.imageToBase64(options.imagePath); if (base64Result.err) { return Err4(base64Result.val); } return this.callOpenAI(base64Result.val, designOptions); } /** * Main method to call OpenAI API with built-in retry logic */ /** * Get the API key type description based on the key format */ getApiKeyType(apiKey) { if (apiKey.startsWith("sk-proj-")) { return "Project-scoped key"; } if (apiKey.startsWith("sk-")) { return "Regular API key"; } return "Unknown format"; } /** * Get the root cause for an API error based on status code */ getRootCause(error) { if (error.status === 401) { return "Project-scoped key may not have access to vision models"; } if (error.status === 404) { return "Model may not exist or is not available to your account"; } return void 0; } /** * Log error details for debugging purposes */ logErrorDetails(attempt, error) { this.logger.debugObject(`Error details (attempt ${attempt + 1}/${this.maxRetries + 1})`, { errorType: error instanceof Error ? error.constructor.name : typeof error, errorMessage: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : void 0, apiError: error instanceof OpenAI.APIError ? { status: error.status, type: error.type, code: error.code, headers: error.headers, error: error.error, rootCause: this.getRootCause(error) } : void 0 }); } async callOpenAI(base64Image, options) { this.logger.debugObject("API call debug info", { apiKeyFormat: `${options.apiKey.slice(0, 7)}... (${options.apiKey.length} chars)`, apiKeyType: this.getApiKeyType(options.apiKey), model: "gpt-4-vision-preview", imageSize: `${Math.round(base64Image.length / 1024)}KB`, isDesignRecommendation: options.isDesignRecommendation }); this.logApiCallStart(base64Image, options); let lastError; for (let attempt = 0; attempt <= this.maxRetries; attempt++) { if (attempt > 0) { await this.handleRetryBackoff(attempt); } const requestResult = await this.attemptApiRequest(attempt, base64Image, options); if (requestResult.ok) { return requestResult.val; } lastError = requestResult.val; this.logErrorDetails(attempt, lastError); if (lastError instanceof Error) { this.logger.debugObject( `Enhanced error details (attempt ${attempt + 1}/${this.maxRetries + 1})`, { errorType: lastError.constructor.name, errorMessage: lastError.message, stack: lastError.stack, apiErrorDetails: lastError instanceof OpenAI.APIError ? { status: lastError.status, type: lastError.type, code: lastError.code } : void 0 } ); } if (this.shouldNotRetry(lastError)) { this.logger.info("Error should not be retried."); return this.handleError(lastError); } } this.logger.errorObject("All retry attempts exhausted", { attempts: this.maxRetries + 1 }); return this.handleError(lastError); } /** * Log the start of an API call sequence */ logApiCallStart(base64Image, options) { this.logger.debugObject("Starting OpenAI API call sequence", { imageSize: base64Image.length, maxRetries: this.maxRetries, timeout: this.timeout, viewport: options.viewport, apiKeyPrefix: options.apiKey.slice(0, 7) + "..." // Log only prefix of API key for debugging }); } /** * Handle retry backoff delay */ async handleRetryBackoff(attempt) { const delay = this.retryDelay * Math.pow(2, attempt - 1); this.logger.info(`Retrying request after ${delay}ms (attempt ${attempt}/${this.maxRetries})`); await new Promise((resolve) => globalThis.setTimeout(resolve, delay)); } /** * Check if error indicates we should not retry */ shouldNotRetry(error) { return error instanceof OpenAI.APIError && error.status !== void 0 && error.status >= 400 && error.status < 500; } /** * Attempt a single API request */ async attemptApiRequest(attempt, base64Image, options) { try { this.logRequestAttempt(attempt, base64Image); const timeoutPromise = this.createTimeoutPromise(); const startTime = Date.now(); const completionPromise = this.makeOpenAIRequest(base64Image, options); const completion = await Promise.race([completionPromise, timeoutPromise]); const duration = Date.now() - startTime; this.logSuccessfulRequest(completion, duration); return Ok4( this.processOpenAIResponse(completion, options.viewport, options.isDesignRecommendation) ); } catch (error) { this.logFailedRequest(attempt, error); return Err4(error); } } /** * Log request attempt */ logRequestAttempt(attempt, base64Image) { this.logger.debugObject( `Attempting OpenAI request (attempt ${attempt + 1}/${this.maxRetries + 1})`, { attempt: attempt + 1, totalAttempts: this.maxRetries + 1, imageBytes: Math.floor(base64Image.length * 0.75), // Approximate size in bytes requestTime: (/* @__PURE__ */ new Date()).toISOString() } ); } /** * Create timeout promise */ createTimeoutPromise() { return new Promise((_, reject) => { globalThis.setTimeout(() => reject(new Error("Request timeout")), this.timeout); }); } /** * Log successful request */ logSuccessfulRequest(completion, duration) { this.logger.debugObject("OpenAI request successful", { duration: `${duration}ms`, model: "gpt-image-1", choicesLength: completion.choices.length, finishReason: completion.choices[0]?.finish_reason, promptTokens: completion.usage?.prompt_tokens, completionTokens: completion.usage?.completion_tokens, totalTokens: completion.usage?.total_tokens }); this.logger.debugObject("Raw OpenAI response", completion); } /** * Log failed request */ /** * Categorize an error for better logging */ categorizeError(error) { if (error instanceof OpenAI.APIError) { return "OpenAI API Error"; } if (error instanceof Error) { if (error.message.includes("timeout") || error.message === "Request timeout") { return "Timeout Error"; } if (error.name === "FetchError") { return "Network Error"; } return "General Error"; } return "Unknown Error"; } /** * Create error details structure for logging */ createErrorDetails(error, errorCategory) { if (error instanceof Error) { return { category: errorCategory, name: error.name, message: error.message, status: error instanceof OpenAI.APIError ? error.status : void 0, type: error instanceof OpenAI.APIError ? error.type : void 0, isTimeout: error.message === "Request timeout" || error.message.includes("timeout") }; } return { error, category: errorCategory }; } /** * Log API error details */ logApiErrorDetails(error) { this.logger.debugObject("OpenAI API error details", { status: error.status, type: error.type, param: error.param, code: error.code, error: error.error, requestId: error.headers?.["x-request-id"] || null, rateLimit: error.headers?.["x-ratelimit-limit"] || null, rateLimitRemaining: error.headers?.["x-ratelimit-remaining"] || null, retryAfter: error.headers?.["retry-after"] || null }); } /** * Log timeout error details */ logTimeoutErrorDetails(error, attempt) { const nextRetryDelay = attempt < this.maxRetries ? `${this.retryDelay * Math.pow(2, attempt)}ms` : "No more retries"; this.logger.debugObject("Request timeout details", { timeoutValue: `${this.timeout}ms`, errorName: error.name, errorStack: error.stack, retryCount: attempt, nextRetryDelay, suggestions: [ "Consider increasing the timeout value", "Check if the image size is too large", "Verify network stability and latency" ] }); } /** * Log general error details */ logGeneralErrorDetails(error, attempt) { this.logger.debugObject("Request error details", { errorName: error.name, errorMessage: error.message, errorStack: error.stack, errorCause: error.cause, attemptNumber: attempt + 1, maxAttempts: this.maxRetries + 1 }); } /** * Log detailed error information based on error type */ logDetailedErrorInfo(attempt, error) { if (error instanceof OpenAI.APIError) { this.logApiErrorDetails(error); } else if (error instanceof Error && error.message === "Request timeout") { this.logTimeoutErrorDetails(error, attempt); } else if (error instanceof Error) { this.logGeneralErrorDetails(error, attempt); } } logFailedRequest(attempt, error) { const errorCategory = this.categorizeError(error); const errorDetails = this.createErrorDetails(error, errorCategory); this.logger.warnObject( `OpenAI request failed (attempt ${attempt + 1}/${this.maxRetries + 1})`, errorDetails ); this.logDetailedErrorInfo(attempt, error); } /** * Make the actual OpenAI API request */ /** * Get likely cause of error based on error type and status */ getLikelyCause(error) { if (!(error instanceof OpenAI.APIError)) { return void 0; } if (error.status === 401) { return "Project-scoped API key doesn't have access to vision models"; } if (error.status === 404) { return "The requested model doesn't exist or isn't available"; } return void 0; } /** * Get recommended solution based on error type */ getSolutionForError(error) { if (!(error instanceof OpenAI.APIError)) { return void 0; } if (error.status === 401) { return "Use a standard OpenAI API key (sk-...) instead"; } if (error.status === 404) { return "Try a different model like gpt-4-vision-preview"; } return void 0; } /** * Log API configuration details */ logApiConfiguration(model) { const apiKeyPrefix = this.openai.apiKey ? this.openai.apiKey.slice(0, 7) + "..." : "undefined"; const apiKeyLength = this.openai.apiKey ? this.openai.apiKey.length : 0; const apiKeyType = this.openai.apiKey ? this.getApiKeyType(this.openai.apiKey) : "Unknown"; this.logger.debugObject("OpenAI API configuration", { apiKeyPrefix, apiKeyLength, apiKeyType, model }); } /** * Execute OpenAI API request */ async executeApiRequest(requestPayload) { this.logger.debug("Using standard chat.completions API with gpt-4.1 model"); if (!requestPayload.messages) { throw new Error("Invalid request payload: messages array is missing"); } this.logger.debug(`Making request to model: ${requestPayload.model}`); try { const response = await this.openai.chat.completions.create({ model: requestPayload.model, messages: requestPayload.messages, max_tokens: requestPayload.max_tokens, temperature: requestPayload.temperature }); this.logger.debug("OpenAI API request successful!"); return response; } catch (innerError) { this.logApiCallError(innerError); throw innerError; } } /** * Log API call error information */ logApiCallError(error) { this.logger.debugObject("Error during OpenAI API call", { status: error instanceof OpenAI.APIError ? error.status : void 0, type: error instanceof OpenAI.APIError ? error.type : void 0, message: error instanceof Error ? error.message : String(error), error: error instanceof OpenAI.APIError ? error.error : error }); } /** * Log failure debug information */ logFailureDebugInfo(error) { const errorInfo = { errorType: error instanceof Error ? error.constructor.name : typeof error, errorMessage: error instanceof Error ? error.message : String(error), status: error instanceof OpenAI.APIError ? error.status : void 0, type: error instanceof OpenAI.APIError ? error.type : void 0, errorResponse: error instanceof OpenAI.APIError ? error.error : void 0, likelyCause: this.getLikelyCause(error), solution: this.getSolutionForError(error) }; this.logger.debugObject("OpenAI request failed", errorInfo); } /** * Make OpenAI API request with error handling */ async makeOpenAIRequest(base64Image, options) { const systemPrompt = this.createSystemPrompt(options.viewport, options.isDesignRecommendation); this.logger.debugObject("System prompt info", { systemPrompt, viewport: options.viewport, isDesignRecommendation: options.isDesignRecommendation }); this.logSystemPrompt(systemPrompt, options.viewport); const requestPayload = this.createRequestPayload( systemPrompt, base64Image, options.isDesignRecommendation ); this.logRequestDetails(requestPayload, base64Image, systemPrompt); this.logApiConfiguration(requestPayload.model); try { return await this.executeApiRequest(requestPayload); } catch (error) { this.logRequestError(error); this.logFailureDebugInfo(error); throw error; } } /** * Log system prompt information */ logSystemPrompt(systemPrompt, viewport) { this.logger.debugObject("Created system prompt", { viewport, promptLength: systemPrompt.length, promptStart: systemPrompt.slice(0, 50) + "...", fullPrompt: systemPrompt // Log the entire system prompt for debugging }); } createRequestPayload(systemPrompt, base64Image, isDesignRecommendation = false) { this.logger.debugObject("OpenAI model check", { previousModel: "gpt-image-1", usingModel: "gpt-4.1", note: "Using widely available model that works with most API keys", isDesignRecommendation }); const userPrompt = isDesignRecommendation ? "Analyze this website screenshot and provide professional design recommendations to make it visually stunning and modern." : "Please analyze this website screenshot and identify only clear, visual layout issues."; const maxTokens = isDesignRecommendation ? 1536 : 512; const temperature = isDesignRecommendation ? 0.4 : 0; return { model: "gpt-4.1", messages: [ { role: "system", content: systemPrompt }, { role: "user", content: [ { type: "text", text: userPrompt }, { type: "image_url", image_url: { url: `data:image/jpeg;base64,${base64Image}` } } ] } ], max_tokens: maxTokens, temperature }; } /** * Log request details */ logRequestDetails(requestPayload, base64Image, systemPrompt) { this.logger.debugObject("OpenAI API request details", { model: requestPayload.model, messageCount: requestPayload.messages.length, systemPromptLength: systemPrompt.length, userMessageContentCount: Array.isArray(requestPayload.messages[1].content) ? requestPayload.messages[1].content.length : 0, imageUrlStart: `data:image/jpeg;base64,${base64Image.slice(0, 20)}...`, max_tokens: requestPayload.max_tokens, temperature: requestPayload.temperature, imageSize: `${Math.round(base64Image.length / 1024)}KB`, fullRequestStructure: JSON.stringify( { model: requestPayload.model, messages: requestPayload.messages.map((msg) => ({ role: msg.role, content: msg.role === "system" ? msg.content : "[Content omitted for brevity]" })), max_tokens: requestPayload.max_tokens, temperature: requestPayload.temperature }, null, 2 ) }); } /** * Log request error */ /** * Build context object for API error debugging */ buildApiErrorContext(error) { const context = { apiErrorType: error.type, status: error.status, headers: error.headers, requestId: error.headers?.["x-request-id"] || null, code: error.code, param: error.param, errorResponse: error.error }; if (error.error) { try { context.fullErrorResponseJson = JSON.stringify(error.error, null, 2); } catch { context.fullErrorResponseJson = "[Error converting error response to JSON]"; } } switch (error.status) { case 401: { context.authenticationNotes = `API key appears to be invalid or unauthorized. Status code: ${error.status}`; context.apiKeyPrefix = this.openai.apiKey ? this.openai.apiKey.slice(0, 7) + "..." : "undefined"; break; } case 404: { context.modelNotes = "Model may not exist or is not available to your account"; break; } case 400: { context.badRequestDetails = "Check image format, size, and API request structure"; break; } } return context; } /** * Build context object for network error debugging */ buildNetworkErrorContext(error) { return { cause: error.cause, isFetchTimeout: error.message.includes("timeout"), isFetchDNSError: error.message.includes("ENOTFOUND"), isFetchConnectionRefused: error.message.includes("ECONNREFUSED") }; } /** * Build debug context for error logging */ buildDebugContext(error) { const baseContext = { stack: error instanceof Error ? error.stack : void 0, time: (/* @__PURE__ */ new Date()).toISOString(), nodeEnv: process.env.NODE_ENV }; if (error instanceof OpenAI.APIError) { return { ...baseContext, ...this.buildApiErrorContext(error) }; } if (error instanceof Error && error.name === "FetchError") { return { ...baseContext, ...this.buildNetworkErrorContext(error) }; } return baseContext; } /** * Log error details for API request errors */ logRequestError(error) { this.logger.errorObject("Error in OpenAI API request", { error: error instanceof Error ? error.message : String(error), type: error instanceof Error ? error.constructor.name : typeof error, isApiError: error instanceof OpenAI.APIError, statusCode: error instanceof OpenAI.APIError ? error.status : void 0 }); const debugContext = this.buildDebugContext(error); this.logger.debugObject("Detailed OpenAI API request error", debugContext); } /** * Process API response into analysis result */ processOpenAIResponse(completion, viewport, isDesignRecommendation = false) { const analysis = completion.choices[0]?.message?.content; this.logger.debugObject("Received response from OpenAI", { hasContent: !!analysis, model: completion.model, finishReason: completion.choices[0]?.finish_reason, promptTokens: completion.usage?.prompt_tokens, completionTokens: completion.usage?.completion_tokens, isDesignRecommendation }); this.logger.debugObject("OpenAI API response success", { model: completion.model, contentPreview: analysis?.slice(0, 100) + "...", apiStatus: "API key works with specified model" }); this.logger.debugObject("Full OpenAI response content", completion); if (!analysis) { this.logger.error("No analysis content received from OpenAI"); return Err4({ type: "ANALYSIS_ERROR", code: "INVALID_RESPONSE", message: "No analysis content received from OpenAI" }); } if (isDesignRecommendation) { this.logger.info("Design recommendations completed successfully"); let formattedRecommendations = analysis; if (analysis.includes("```json")) { formattedRecommendations = analysis.replaceAll("```json", "").replaceAll("```", "").trim(); this.logger.debug("Extracted JSON from markdown code block"); } else if (analysis.includes("```")) { formattedRecommendations = analysis.replaceAll("```", "").trim(); this.logger.debug("Extracted content from generic code block"); } this.logger.debugObject("Processed design recommendations", { originalLength: analysis.length, processedLength: formattedRecommendations.length, startsWithBracket: formattedRecommendations.trim().startsWith("["), endsWithBracket: formattedRecommendations.trim().endsWith("]"), preview: formattedRecommendations.slice(0, 100) + "..." }); return Ok4({ content: "", // The main content field will remain empty for design recommendations designRecommendations: formattedRecommendations, // Store processed design recommendations timestamp: (/* @__PURE__ */ new Date()).toISOString(), viewport, model: completion.model || "gpt-3.5-turbo" }); } this.logger.info("Vision analysis completed successfully"); return Ok4({ content: analysis, timestamp: (/* @__PURE__ */ new Date()).toISOString(), viewport, model: completion.model || "gpt-3.5-turbo" }); } /** * Creates a system prompt based on the requested analysis type */ createSystemPrompt(viewport, isDesignRecommendation = false) { if (isDesignRecommendation) { return `You are a world-class product and UI designer known for creating clean, modern, layout-strong web interfaces for high-performing websites. You specialize in refining pages that already use modern frameworks like Tailwind CSS and need structural layout improvements and visual polish, not a redesign. Viewport: ${viewport} You are reviewing a static screenshot of a webpage. Your task is to suggest 9 specific, actionable visual improvements for this exact layout \u2014 not generic ideas, and not things that cannot be inferred from a screenshot (like animations or interactivity). Focus on: 1. LAYOUT & STRUCTURE (high priority) - Fix section spacing, hierarchy, alignment, flow - Improve visual rhythm and reduce clutter - Ensure clear separation between sections and elements 2. DESIGN ENHANCEMENTS (medium priority) - Add tasteful visual touches that increase visual interest - Improve media placement, component balance, or use of whitespace - Add visual structure or polish using borders, backgrounds, cards 3. AESTHETIC FINESSE (low priority) - Tweak colors, depth, or shadowing \u2014 only if visibly relevant - Refine contrast or typography sparingly, assuming Tailwind handles most of it \u{1F6AB} DO NOT: - Recommend implementing grids, dark mode, Tailwind, or framework-specific systems - Suggest animations, micro-interactions, or loading states (it's a static screenshot) - Provide generic advice not grounded in the actual visible layout - Recommend copy changes, accessibility, or performance improvements \u2705 DO: - Point to specific sections or elements that visually stand out (or don't) - Tailor your advice to what\u2019s actually on the screen - Be precise, visual, and layout-aware Output a JSON array of exactly 9 recommendations, each with: - title (string, max 50 characters) - description (string, max 200 characters) - priority ("high", "medium", or "low") Example format: [ { "title": "Add Spacing Between CTA and Footer", "description": "The call-to-action sits too close to the footer. Increase vertical margin to give it more emphasis and breathing room.", "priority": "high" } ] Your job is to elevate this page from decent to excellent by applying structural, visual design thinking. Focus on refinement, not reinvention.`; } return `You are a strict visual QA tester reviewing website screenshots. Your only task is to detect obvious visual layout issues in the screenshot. You are not performing an accessibility review, usability audit, or subjective design critique. Viewport: ${viewport} \u26A0\uFE0F ONLY report structural layout problems that are clearly visible in the image. Examples include: - Navigation links overlapping the logo or each other - Buttons with no padding or margin (text touching edges) - Groups of logos/icons overlapping each other (e.g. company logos stacked without spacing) - Inline links or elements with no spacing (e.g. "Privacy PolicyTerms of Service" appearing as a single blob) - Text or sections that appear cut off, misaligned, or off-center - Clearly broken visual stacking or layering \u274C DO NOT report on: - Color contrast, font sizes, or typographic opinions - Accessibility (alt tags, keyboard focus, etc.) - Lack of sticky nav or general UX recommendations - "Visual hierarchy" or aesthetic judgments \u{1F9E0} Think like a front-end engineer reviewing a rendering bug. If the page looks visually correct, just say: > No critical layout or visual issues found in this screenshot. Be concise. Only list visual bugs that are clearly broken in the image.`; } /** * Converts an image file to base64 string */ async imageToBase64(imagePath) { try { this.logger.debugObject("Reading image file", { imagePath }); const imageBuffer = await readFile2(imagePath); const base64String = imageBuffer.toString("base64"); this.logger.debugObject("Image converted to base64", { length: base64String.length }); return Ok4(base64String); } catch (error) { this.logger.errorObject("Failed to convert image to base64", { error, imagePath }); return Err4({ type: "FILE_SYSTEM_ERROR", code: "READ_ERROR", message: `Failed to read image file: ${error instanceof Error ? error.message : "Unknown error"}`, path: imagePath }); } } /** * Handle API and other errors */ handleError(error) { if (error instanceof OpenAI.APIError) { return this.handleApiError(error); } if (error instanceof Error && error.name === "FetchError") { return this.handleNetworkError(error); } return this.handleGenericError(error); } /** * Handle OpenAI API errors specifically */ handleApiError(error) { const code = this.getApiErrorCode(error.status); let requestId = null; let rateLimit = null; let rateLimitRemaining = null; let retryAfter = null; if (error.headers) { requestId = error.headers["x-request-id"] || error.headers["x-amzn-requestid"] || null; rateLimit = error.headers["x-ratelimit-limit"] || null; rateLimitRemaining = error.headers["x-ratelimit-remaining"] || null; retryAfter = error.headers["retry-after"] || null; } this.logger.errorObject("OpenAI API error", { status: error.status, code, message: error.message, type: error.type, requestId, rateLimit, rateLimitRemaining, retryAfter, errorType: error.constructor?.name, errorObject: typeof error }); this.logger.debugObject("OpenAI API error details", { stack: error.stack, headers: error.headers, body: error.error, // Log the raw error response body params: error.param, // Parameter that caused the error, if available name: error.name, // Error name code: error.code // Error code from OpenAI }); switch (error.status) { case 400: { this.logger.debugObject("Possible 400 error causes", { possibleCauses: [ "Invalid API key format", "Invalid model name",