Compare commits
12 Commits
01679d4081
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 69bd93bae7 | |||
| 9b662c90bd | |||
| 2280575bcd | |||
| e7b9282d18 | |||
| 1cdfe570ef | |||
| 10f3e8fb62 | |||
| 9fdc2fac20 | |||
| 10409e95b9 | |||
| 2327442b89 | |||
| a275ccc4d7 | |||
| 280c177619 | |||
| a35ff601ea |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"dev3000": {
|
||||
"type": "http",
|
||||
"url": "http://localhost:3684/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-8
@@ -5,25 +5,29 @@ FIRECRAWL_API_KEY=your_firecrawl_api_key # Get from https://firecrawl.dev (Web
|
||||
# SANDBOX PROVIDER - Choose Option 1 OR 2
|
||||
# =================================================================================
|
||||
|
||||
# Option 1: E2B Sandbox (recommended for development)
|
||||
# Set SANDBOX_PROVIDER=e2b and configure E2B_API_KEY below
|
||||
SANDBOX_PROVIDER=e2b
|
||||
E2B_API_KEY=your_e2b_api_key # Get from https://e2b.dev
|
||||
|
||||
# Option 2: Vercel Sandbox
|
||||
# Option 1: Vercel Sandbox (recommended - default)
|
||||
# Set SANDBOX_PROVIDER=vercel and choose authentication method below
|
||||
# SANDBOX_PROVIDER=vercel
|
||||
SANDBOX_PROVIDER=vercel
|
||||
|
||||
# Vercel Authentication - Choose method a OR b
|
||||
# Method a: OIDC Token (recommended for development)
|
||||
# Run `vercel link` then `vercel env pull` to get VERCEL_OIDC_TOKEN automatically
|
||||
# VERCEL_OIDC_TOKEN=auto_generated_by_vercel_env_pull
|
||||
VERCEL_OIDC_TOKEN=auto_generated_by_vercel_env_pull
|
||||
|
||||
# Method b: Personal Access Token (for production or when OIDC unavailable)
|
||||
# VERCEL_TEAM_ID=team_xxxxxxxxx # Your Vercel team ID
|
||||
# VERCEL_PROJECT_ID=prj_xxxxxxxxx # Your Vercel project ID
|
||||
# VERCEL_TOKEN=vercel_xxxxxxxxxxxx # Personal access token from Vercel dashboard
|
||||
|
||||
# Get yours at https://console.groq.com
|
||||
GROQ_API_KEY=your_groq_api_key_here
|
||||
|
||||
=======
|
||||
# Option 2: E2B Sandbox
|
||||
# Set SANDBOX_PROVIDER=e2b and configure E2B_API_KEY below
|
||||
# SANDBOX_PROVIDER=e2b
|
||||
# E2B_API_KEY=your_e2b_api_key # Get from https://e2b.dev
|
||||
|
||||
# =================================================================================
|
||||
# AI PROVIDERS - Need at least one
|
||||
# =================================================================================
|
||||
@@ -36,3 +40,7 @@ ANTHROPIC_API_KEY=your_anthropic_api_key # Get from https://console.anthropic.c
|
||||
OPENAI_API_KEY=your_openai_api_key # Get from https://platform.openai.com (GPT-5)
|
||||
GEMINI_API_KEY=your_gemini_api_key # Get from https://aistudio.google.com/app/apikey
|
||||
GROQ_API_KEY=your_groq_api_key # Get from https://console.groq.com (Fast inference - Kimi K2 recommended)
|
||||
|
||||
# Optional Morph Fast Apply
|
||||
# Get yours at https://morphllm.com/
|
||||
MORPH_API_KEY=your_fast_apply_key
|
||||
|
||||
@@ -24,21 +24,35 @@ FIRECRAWL_API_KEY=your_firecrawl_api_key # https://firecrawl.dev
|
||||
# =================================================================
|
||||
# AI PROVIDER - Choose your LLM
|
||||
# =================================================================
|
||||
GEMINI_API_KEY=your_gemini_api_key # https://aistudio.google.com/app/apikey
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key # https://console.anthropic.com
|
||||
OPENAI_API_KEY=your_openai_api_key # https://platform.openai.com
|
||||
GEMINI_API_KEY=your_gemini_api_key # https://aistudio.google.com/app/apikey
|
||||
GROQ_API_KEY=your_groq_api_key # https://console.groq.com
|
||||
|
||||
# =================================================================
|
||||
# SANDBOX PROVIDER - Choose ONE: E2B or Vercel
|
||||
# FAST APPLY (Optional - for faster edits)
|
||||
# =================================================================
|
||||
SANDBOX_PROVIDER=e2b # or 'vercel'
|
||||
MORPH_API_KEY=your_morphllm_api_key # https://morphllm.com/dashboard
|
||||
|
||||
# E2B Sandbox (default)
|
||||
E2B_API_KEY=your_e2b_api_key # https://e2b.dev
|
||||
# =================================================================
|
||||
# SANDBOX PROVIDER - Choose ONE: Vercel (default) or E2B
|
||||
# =================================================================
|
||||
SANDBOX_PROVIDER=vercel # or 'e2b'
|
||||
|
||||
# OR Vercel Sandbox
|
||||
VERCEL_OIDC_TOKEN=your_vercel_oidc_token # https://vercel.com
|
||||
# Option 1: Vercel Sandbox (default)
|
||||
# Choose one authentication method:
|
||||
|
||||
# Method A: OIDC Token (recommended for development)
|
||||
# Run `vercel link` then `vercel env pull` to get VERCEL_OIDC_TOKEN automatically
|
||||
VERCEL_OIDC_TOKEN=auto_generated_by_vercel_env_pull
|
||||
|
||||
# Method B: Personal Access Token (for production or when OIDC unavailable)
|
||||
# VERCEL_TEAM_ID=team_xxxxxxxxx # Your Vercel team ID
|
||||
# VERCEL_PROJECT_ID=prj_xxxxxxxxx # Your Vercel project ID
|
||||
# VERCEL_TOKEN=vercel_xxxxxxxxxxxx # Personal access token from Vercel dashboard
|
||||
|
||||
# Option 2: E2B Sandbox
|
||||
# E2B_API_KEY=your_e2b_api_key # https://e2b.dev
|
||||
```
|
||||
|
||||
3. **Run**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { parseMorphEdits, applyMorphEditToFile } from '@/lib/morph-fast-apply';
|
||||
// Sandbox import not needed - using global sandbox from sandbox-manager
|
||||
import type { SandboxState } from '@/types/sandbox';
|
||||
import type { ConversationState } from '@/types/conversation';
|
||||
@@ -41,8 +42,8 @@ function parseAIResponse(response: string): ParsedResponse {
|
||||
const importPath = importMatch[1];
|
||||
// Skip relative imports and built-in React
|
||||
if (!importPath.startsWith('.') && !importPath.startsWith('/') &&
|
||||
importPath !== 'react' && importPath !== 'react-dom' &&
|
||||
!importPath.startsWith('@/')) {
|
||||
importPath !== 'react' && importPath !== 'react-dom' &&
|
||||
!importPath.startsWith('@/')) {
|
||||
// Extract package name (handle scoped packages like @heroicons/react)
|
||||
const packageName = importPath.startsWith('@')
|
||||
? importPath.split('/').slice(0, 2).join('/')
|
||||
@@ -279,6 +280,12 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Parse the AI response
|
||||
const parsed = parseAIResponse(response);
|
||||
const morphEnabled = Boolean(isEdit && process.env.MORPH_API_KEY);
|
||||
const morphEdits = morphEnabled ? parseMorphEdits(response) : [];
|
||||
console.log('[apply-ai-code-stream] Morph Fast Apply mode:', morphEnabled);
|
||||
if (morphEnabled) {
|
||||
console.log('[apply-ai-code-stream] Morph edits found:', morphEdits.length);
|
||||
}
|
||||
|
||||
// Log what was parsed
|
||||
console.log('[apply-ai-code-stream] Parsed result:');
|
||||
@@ -408,6 +415,14 @@ export async function POST(request: NextRequest) {
|
||||
message: 'Starting code application...',
|
||||
totalSteps: 3
|
||||
});
|
||||
if (morphEnabled) {
|
||||
await sendProgress({ type: 'info', message: 'Morph Fast Apply enabled' });
|
||||
await sendProgress({ type: 'info', message: `Parsed ${morphEdits.length} Morph edits` });
|
||||
if (morphEdits.length === 0) {
|
||||
console.warn('[apply-ai-code-stream] Morph enabled but no <edit> blocks found; falling back to full-file flow');
|
||||
await sendProgress({ type: 'warning', message: 'Morph enabled but no <edit> blocks found; falling back to full-file flow' });
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Install packages
|
||||
const packagesArray = Array.isArray(packages) ? packages : [];
|
||||
@@ -480,8 +495,7 @@ export async function POST(request: NextRequest) {
|
||||
results.packagesInstalled = data.installedPackages;
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.debug('Error parsing terminal output:', parseError);
|
||||
// Ignore parse errors
|
||||
console.debug('Error parsing terminal output:', parseError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -513,12 +527,67 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Filter out config files that shouldn't be created
|
||||
const configFiles = ['tailwind.config.js', 'vite.config.js', 'package.json', 'package-lock.json', 'tsconfig.json', 'postcss.config.js'];
|
||||
const filteredFiles = filesArray.filter(file => {
|
||||
let filteredFiles = filesArray.filter(file => {
|
||||
if (!file || typeof file !== 'object') return false;
|
||||
const fileName = (file.path || '').split('/').pop() || '';
|
||||
return !configFiles.includes(fileName);
|
||||
});
|
||||
|
||||
// If Morph is enabled and we have edits, apply them before file writes
|
||||
const morphUpdatedPaths = new Set<string>();
|
||||
if (morphEnabled && morphEdits.length > 0) {
|
||||
const morphSandbox = (global as any).activeSandbox || providerInstance;
|
||||
if (!morphSandbox) {
|
||||
console.warn('[apply-ai-code-stream] No sandbox available to apply Morph edits');
|
||||
await sendProgress({ type: 'warning', message: 'No sandbox available to apply Morph edits' });
|
||||
} else {
|
||||
await sendProgress({ type: 'info', message: `Applying ${morphEdits.length} fast edits via Morph...` });
|
||||
for (const [idx, edit] of morphEdits.entries()) {
|
||||
try {
|
||||
await sendProgress({ type: 'file-progress', current: idx + 1, total: morphEdits.length, fileName: edit.targetFile, action: 'morph-applying' });
|
||||
const result = await applyMorphEditToFile({
|
||||
sandbox: morphSandbox,
|
||||
targetPath: edit.targetFile,
|
||||
instructions: edit.instructions,
|
||||
updateSnippet: edit.update
|
||||
});
|
||||
if (result.success && result.normalizedPath) {
|
||||
console.log('[apply-ai-code-stream] Morph updated', result.normalizedPath);
|
||||
morphUpdatedPaths.add(result.normalizedPath);
|
||||
if (results.filesUpdated) results.filesUpdated.push(result.normalizedPath);
|
||||
await sendProgress({ type: 'file-complete', fileName: result.normalizedPath, action: 'morph-updated' });
|
||||
} else {
|
||||
const msg = result.error || 'Unknown Morph error';
|
||||
console.error('[apply-ai-code-stream] Morph apply failed for', edit.targetFile, msg);
|
||||
if (results.errors) results.errors.push(`Morph apply failed for ${edit.targetFile}: ${msg}`);
|
||||
await sendProgress({ type: 'file-error', fileName: edit.targetFile, error: msg });
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
console.error('[apply-ai-code-stream] Morph apply exception for', edit.targetFile, msg);
|
||||
if (results.errors) results.errors.push(`Morph apply exception for ${edit.targetFile}: ${msg}`);
|
||||
await sendProgress({ type: 'file-error', fileName: edit.targetFile, error: msg });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid overwriting Morph-updated files in the file write loop
|
||||
if (morphUpdatedPaths.size > 0) {
|
||||
filteredFiles = filteredFiles.filter(file => {
|
||||
if (!file?.path) return true;
|
||||
let normalizedPath = file.path.startsWith('/') ? file.path.slice(1) : file.path;
|
||||
const fileName = normalizedPath.split('/').pop() || '';
|
||||
if (!normalizedPath.startsWith('src/') &&
|
||||
!normalizedPath.startsWith('public/') &&
|
||||
normalizedPath !== 'index.html' &&
|
||||
!configFiles.includes(fileName)) {
|
||||
normalizedPath = 'src/' + normalizedPath;
|
||||
}
|
||||
return !morphUpdatedPaths.has(normalizedPath);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, file] of filteredFiles.entries()) {
|
||||
try {
|
||||
// Send progress for each file
|
||||
@@ -536,9 +605,9 @@ export async function POST(request: NextRequest) {
|
||||
normalizedPath = normalizedPath.substring(1);
|
||||
}
|
||||
if (!normalizedPath.startsWith('src/') &&
|
||||
!normalizedPath.startsWith('public/') &&
|
||||
normalizedPath !== 'index.html' &&
|
||||
!configFiles.includes(normalizedPath.split('/').pop() || '')) {
|
||||
!normalizedPath.startsWith('public/') &&
|
||||
normalizedPath !== 'index.html' &&
|
||||
!configFiles.includes(normalizedPath.split('/').pop() || '')) {
|
||||
normalizedPath = 'src/' + normalizedPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { parseMorphEdits, applyMorphEditToFile } from '@/lib/morph-fast-apply';
|
||||
import type { SandboxState } from '@/types/sandbox';
|
||||
import type { ConversationState } from '@/types/conversation';
|
||||
|
||||
@@ -145,6 +146,12 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Parse the AI response
|
||||
const parsed = parseAIResponse(response);
|
||||
const morphEnabled = Boolean(isEdit && process.env.MORPH_API_KEY);
|
||||
const morphEdits = morphEnabled ? parseMorphEdits(response) : [];
|
||||
console.log('[apply-ai-code] Morph Fast Apply mode:', morphEnabled);
|
||||
if (morphEnabled) {
|
||||
console.log('[apply-ai-code] Morph edits found:', morphEdits.length);
|
||||
}
|
||||
|
||||
// Initialize existingFiles if not already
|
||||
if (!global.existingFiles) {
|
||||
@@ -200,6 +207,14 @@ export async function POST(request: NextRequest) {
|
||||
console.log('[apply-ai-code] Is edit mode:', isEdit);
|
||||
console.log('[apply-ai-code] Files to write:', parsed.files.map(f => f.path));
|
||||
console.log('[apply-ai-code] Existing files:', Array.from(global.existingFiles));
|
||||
if (morphEnabled) {
|
||||
console.log('[apply-ai-code] Morph Fast Apply enabled');
|
||||
if (morphEdits.length > 0) {
|
||||
console.log('[apply-ai-code] Parsed Morph edits:', morphEdits.map(e => e.targetFile));
|
||||
} else {
|
||||
console.log('[apply-ai-code] No <edit> blocks found in response');
|
||||
}
|
||||
}
|
||||
|
||||
const results = {
|
||||
filesCreated: [] as string[],
|
||||
@@ -324,9 +339,46 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt Morph Fast Apply for edits before file creation
|
||||
const morphUpdatedPaths = new Set<string>();
|
||||
|
||||
if (morphEnabled && morphEdits.length > 0) {
|
||||
if (!global.activeSandbox) {
|
||||
console.warn('[apply-ai-code] Morph edits found but no active sandbox; skipping Morph application');
|
||||
} else {
|
||||
console.log(`[apply-ai-code] Applying ${morphEdits.length} fast edits via Morph...`);
|
||||
for (const edit of morphEdits) {
|
||||
try {
|
||||
const result = await applyMorphEditToFile({
|
||||
sandbox: global.activeSandbox,
|
||||
targetPath: edit.targetFile,
|
||||
instructions: edit.instructions,
|
||||
updateSnippet: edit.update
|
||||
});
|
||||
|
||||
if (result.success && result.normalizedPath) {
|
||||
morphUpdatedPaths.add(result.normalizedPath);
|
||||
results.filesUpdated.push(result.normalizedPath);
|
||||
console.log('[apply-ai-code] Morph applied to', result.normalizedPath);
|
||||
} else {
|
||||
const msg = result.error || 'Unknown Morph error';
|
||||
console.error('[apply-ai-code] Morph apply failed:', msg);
|
||||
results.errors.push(`Morph apply failed for ${edit.targetFile}: ${msg}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[apply-ai-code] Morph apply exception:', e);
|
||||
results.errors.push(`Morph apply exception for ${edit.targetFile}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (morphEnabled && morphEdits.length === 0) {
|
||||
console.warn('[apply-ai-code] Morph enabled but no <edit> blocks found; falling back to full-file flow');
|
||||
}
|
||||
|
||||
// Filter out config files that shouldn't be created
|
||||
const configFiles = ['tailwind.config.js', 'vite.config.js', 'package.json', 'package-lock.json', 'tsconfig.json', 'postcss.config.js'];
|
||||
const filteredFiles = parsed.files.filter(file => {
|
||||
let filteredFiles = parsed.files.filter(file => {
|
||||
const fileName = file.path.split('/').pop() || '';
|
||||
if (configFiles.includes(fileName)) {
|
||||
console.warn(`[apply-ai-code] Skipping config file: ${file.path} - already exists in template`);
|
||||
@@ -335,6 +387,21 @@ export async function POST(request: NextRequest) {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Avoid overwriting files already updated by Morph
|
||||
if (morphUpdatedPaths.size > 0) {
|
||||
filteredFiles = filteredFiles.filter(file => {
|
||||
let normalizedPath = file.path.startsWith('/') ? file.path.slice(1) : file.path;
|
||||
const fileName = normalizedPath.split('/').pop() || '';
|
||||
if (!normalizedPath.startsWith('src/') &&
|
||||
!normalizedPath.startsWith('public/') &&
|
||||
normalizedPath !== 'index.html' &&
|
||||
!configFiles.includes(fileName)) {
|
||||
normalizedPath = 'src/' + normalizedPath;
|
||||
}
|
||||
return !morphUpdatedPaths.has(normalizedPath);
|
||||
});
|
||||
}
|
||||
|
||||
// Create or update files AFTER package installation
|
||||
for (const file of filteredFiles) {
|
||||
try {
|
||||
@@ -399,7 +466,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
} catch (writeError) {
|
||||
console.error(`[apply-ai-code] E2B file write error:`, writeError);
|
||||
throw writeError;
|
||||
throw writeError as Error;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const url = body.url;
|
||||
const prompt = body.prompt;
|
||||
|
||||
console.log('[extract-brand-styles] Extracting brand styles for:', url);
|
||||
console.log('[extract-brand-styles] User prompt:', prompt);
|
||||
|
||||
// Call Firecrawl API to extract branding information
|
||||
const FIRECRAWL_API_KEY = process.env.FIRECRAWL_API_KEY;
|
||||
|
||||
if (!FIRECRAWL_API_KEY) {
|
||||
console.error('[extract-brand-styles] No Firecrawl API key found');
|
||||
throw new Error('Firecrawl API key not configured');
|
||||
}
|
||||
|
||||
console.log('[extract-brand-styles] Calling Firecrawl branding API for:', url);
|
||||
|
||||
const firecrawlResponse = await fetch('https://api.firecrawl.dev/v2/scrape', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${FIRECRAWL_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url,
|
||||
formats: ['branding'],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!firecrawlResponse.ok) {
|
||||
const errorText = await firecrawlResponse.text();
|
||||
console.error('[extract-brand-styles] Firecrawl API error:', firecrawlResponse.status, errorText);
|
||||
throw new Error(`Firecrawl API returned ${firecrawlResponse.status}`);
|
||||
}
|
||||
|
||||
const firecrawlData = await firecrawlResponse.json();
|
||||
console.log('[extract-brand-styles] Firecrawl response received successfully');
|
||||
|
||||
// Extract branding data from response
|
||||
const brandingData = firecrawlData.data?.branding || firecrawlData.branding;
|
||||
|
||||
if (!brandingData) {
|
||||
console.error('[extract-brand-styles] No branding data in Firecrawl response');
|
||||
console.log('[extract-brand-styles] Response structure:', JSON.stringify(firecrawlData, null, 2));
|
||||
throw new Error('No branding data in Firecrawl response');
|
||||
}
|
||||
|
||||
console.log('[extract-brand-styles] Successfully extracted branding data');
|
||||
|
||||
// Return the branding data
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url,
|
||||
styleName: brandingData.name || url,
|
||||
guidelines: brandingData,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[extract-brand-styles] Error occurred:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to extract brand styles'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -576,7 +576,7 @@ Remember: You are a SURGEON making a precise incision, not an artist repainting
|
||||
}
|
||||
|
||||
// Build system prompt with conversation awareness
|
||||
const systemPrompt = `You are an expert React developer with perfect memory of the conversation. You maintain context across messages and remember scraped websites, generated components, and applied code. Generate clean, modern React code for Vite applications.
|
||||
let systemPrompt = `You are an expert React developer with perfect memory of the conversation. You maintain context across messages and remember scraped websites, generated components, and applied code. Generate clean, modern React code for Vite applications.
|
||||
${conversationContext}
|
||||
|
||||
🚨 CRITICAL RULES - YOUR MOST IMPORTANT INSTRUCTIONS:
|
||||
@@ -927,6 +927,24 @@ CRITICAL: When files are provided in the context:
|
||||
4. Do NOT ask to see files - they are already provided in the context above
|
||||
5. Make the requested change immediately`;
|
||||
|
||||
// If Morph Fast Apply is enabled (edit mode + MORPH_API_KEY), force <edit> block output
|
||||
const morphFastApplyEnabled = Boolean(isEdit && process.env.MORPH_API_KEY);
|
||||
if (morphFastApplyEnabled) {
|
||||
systemPrompt += `
|
||||
|
||||
MORPH FAST APPLY MODE (EDIT-ONLY):
|
||||
- Output edits as <edit> blocks, not full <file> blocks, for files that already exist.
|
||||
- Format for each edit:
|
||||
<edit target_file="src/components/Header.jsx">
|
||||
<instructions>Describe the minimal change, single sentence.</instructions>
|
||||
<update>Provide the SMALLEST code snippet necessary to perform the change.</update>
|
||||
</edit>
|
||||
- Only use <file> blocks when you must CREATE a brand-new file.
|
||||
- Prefer ONE edit block for a simple change; multiple edits only if absolutely needed for separate files.
|
||||
- Keep updates minimal and precise; do not rewrite entire files.
|
||||
`;
|
||||
}
|
||||
|
||||
// Build full prompt with context
|
||||
let fullPrompt = prompt;
|
||||
if (context) {
|
||||
@@ -1172,6 +1190,17 @@ CRITICAL: When files are provided in the context:
|
||||
}
|
||||
|
||||
if (contextParts.length > 0) {
|
||||
if (morphFastApplyEnabled) {
|
||||
contextParts.push('\nOUTPUT FORMAT (REQUIRED IN MORPH MODE):');
|
||||
contextParts.push('<edit target_file="src/components/Component.jsx">');
|
||||
contextParts.push('<instructions>Minimal, precise instruction.</instructions>');
|
||||
contextParts.push('<update>// Smallest necessary snippet</update>');
|
||||
contextParts.push('</edit>');
|
||||
contextParts.push('\nIf you need to create a NEW file, then and only then output a full file:');
|
||||
contextParts.push('<file path="src/components/NewComponent.jsx">');
|
||||
contextParts.push('// Full file content when creating new files');
|
||||
contextParts.push('</file>');
|
||||
}
|
||||
fullPrompt = `CONTEXT:\n${contextParts.join('\n')}\n\nUSER REQUEST:\n${prompt}`;
|
||||
}
|
||||
}
|
||||
|
||||
+508
-89
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef, Suspense } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { appConfig } from '@/config/app.config';
|
||||
import HeroInput from '@/components/HeroInput';
|
||||
import SidebarInput from '@/components/app/generation/SidebarInput';
|
||||
@@ -41,9 +42,24 @@ interface ChatMessage {
|
||||
generatedCode?: string;
|
||||
appliedFiles?: string[];
|
||||
commandType?: 'input' | 'output' | 'error' | 'success';
|
||||
brandingData?: any;
|
||||
sourceUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ScrapeData {
|
||||
success: boolean;
|
||||
content?: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
source?: string;
|
||||
screenshot?: string;
|
||||
structured?: any;
|
||||
metadata?: any;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function AISandboxPage() {
|
||||
const [sandboxData, setSandboxData] = useState<SandboxData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -86,6 +102,7 @@ function AISandboxPage() {
|
||||
const [isPreparingDesign, setIsPreparingDesign] = useState(false);
|
||||
const [targetUrl, setTargetUrl] = useState<string>('');
|
||||
const [sidebarScrolled, setSidebarScrolled] = useState(false);
|
||||
const [screenshotCollapsed, setScreenshotCollapsed] = useState(false);
|
||||
const [loadingStage, setLoadingStage] = useState<'gathering' | 'planning' | 'generating' | null>(null);
|
||||
const [isStartingNewGeneration, setIsStartingNewGeneration] = useState(false);
|
||||
const [sandboxFiles, setSandboxFiles] = useState<Record<string, string>>({});
|
||||
@@ -331,7 +348,9 @@ function AISandboxPage() {
|
||||
|
||||
useEffect(() => {
|
||||
// Only check sandbox status on mount if we don't already have sandboxData
|
||||
if (!sandboxData) {
|
||||
// AND we're not auto-starting a new generation (which would create a new sandbox)
|
||||
const autoStart = sessionStorage.getItem('autoStart');
|
||||
if (!sandboxData && autoStart !== 'true') {
|
||||
checkSandboxStatus();
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -1329,9 +1348,9 @@ Tip: I automatically detect and install npm packages from your code imports (lik
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="mb-8 relative">
|
||||
<div className="w-24 h-24 mx-auto">
|
||||
<div className="absolute inset-0 border-4 border-gray-800 rounded-full"></div>
|
||||
<div className="absolute inset-0 border-4 border-green-500 rounded-full animate-spin border-t-transparent"></div>
|
||||
<div className="w-48 h-48 mx-auto">
|
||||
<div className="absolute inset-0 border-8 border-gray-800 rounded-full"></div>
|
||||
<div className="absolute inset-0 border-8 border-green-500 rounded-full animate-spin border-t-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-medium text-white mb-2">AI is analyzing your request</h3>
|
||||
@@ -1342,7 +1361,7 @@ Tip: I automatically detect and install npm packages from your code imports (lik
|
||||
<div className="bg-black border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 bg-gray-100 text-gray-900 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-16 h-16 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="font-mono text-sm">Streaming code...</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1371,7 +1390,7 @@ Tip: I automatically detect and install npm packages from your code imports (lik
|
||||
<div className="bg-black border-2 border-gray-400 rounded-lg overflow-hidden shadow-sm">
|
||||
<div className="px-4 py-2 bg-[#36322F] text-white flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-16 h-16 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span className="font-mono text-sm">{generationProgress.currentFile.path}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${
|
||||
generationProgress.currentFile.type === 'css' ? 'bg-blue-600 text-white' :
|
||||
@@ -1453,7 +1472,7 @@ Tip: I automatically detect and install npm packages from your code imports (lik
|
||||
<div className="bg-black border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2 bg-[#36322F] text-white flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-16 h-16 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="font-mono text-sm">Processing...</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1479,8 +1498,9 @@ Tip: I automatically detect and install npm packages from your code imports (lik
|
||||
// Remove explanation tags and content
|
||||
remainingContent = remainingContent.replace(/<explanation>[\s\S]*?<\/explanation>/g, '').trim();
|
||||
|
||||
// If only whitespace or nothing left, show waiting message
|
||||
return remainingContent || 'Waiting for next file...';
|
||||
// If only whitespace or nothing left, show loading message
|
||||
// Use "Loading sandbox..." instead of "Waiting for next file..." for better UX
|
||||
return remainingContent || 'Loading sandbox...';
|
||||
})()}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
@@ -1548,9 +1568,6 @@ Tip: I automatically detect and install npm packages from your code imports (lik
|
||||
style={{ animationDuration: '1.5s', animationDelay: '0.4s' }} />
|
||||
</div>
|
||||
|
||||
{/* Spinner */}
|
||||
<div className="w-12 h-12 border-3 border-white/30 border-t-white rounded-full animate-spin mx-auto mb-4" />
|
||||
|
||||
{/* Status text */}
|
||||
<p className="text-white text-lg font-medium">
|
||||
{isCapturingScreenshot ? 'Analyzing website...' :
|
||||
@@ -1685,7 +1702,7 @@ Tip: I automatically detect and install npm packages from your code imports (lik
|
||||
</div>
|
||||
) : sandboxData ? (
|
||||
<div className="text-gray-500">
|
||||
<div className="w-8 h-8 border-2 border-gray-300 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<div className="w-16 h-16 border-2 border-gray-300 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<p className="text-sm">Loading preview...</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -2629,7 +2646,7 @@ Tip: I automatically detect and install npm packages from your code imports (lik
|
||||
// Set loading background to ensure proper visual feedback
|
||||
setShowLoadingBackground(true);
|
||||
|
||||
// Clear messages and immediately show the cloning message
|
||||
// Clear messages and immediately show the initial message
|
||||
setChatMessages([]);
|
||||
let displayUrl = homeUrlInput.trim();
|
||||
if (!displayUrl.match(/^https?:\/\//i)) {
|
||||
@@ -2637,7 +2654,16 @@ Tip: I automatically detect and install npm packages from your code imports (lik
|
||||
}
|
||||
// Remove protocol for cleaner display
|
||||
const cleanUrl = displayUrl.replace(/^https?:\/\//i, '');
|
||||
addChatMessage(`Starting to clone ${cleanUrl}...`, 'system');
|
||||
|
||||
// Check if we're in brand extension mode
|
||||
const brandExtensionMode = sessionStorage.getItem('brandExtensionMode') === 'true';
|
||||
|
||||
addChatMessage(
|
||||
brandExtensionMode
|
||||
? `Analyzing brand from ${cleanUrl}...`
|
||||
: `Starting to clone ${cleanUrl}...`,
|
||||
'system'
|
||||
);
|
||||
|
||||
// Start creating sandbox and capturing screenshot immediately in parallel
|
||||
const sandboxPromise = !sandboxData ? createSandbox(true) : Promise.resolve(null);
|
||||
@@ -2661,7 +2687,7 @@ Tip: I automatically detect and install npm packages from your code imports (lik
|
||||
}, 1000);
|
||||
|
||||
// Wait for sandbox to be ready (if it's still creating)
|
||||
await sandboxPromise;
|
||||
const createdSandbox = await sandboxPromise;
|
||||
|
||||
// Now start the clone process which will stream the generation
|
||||
setUrlInput(homeUrlInput);
|
||||
@@ -2675,12 +2701,54 @@ Tip: I automatically detect and install npm packages from your code imports (lik
|
||||
url = 'https://' + url;
|
||||
}
|
||||
|
||||
// Check if we're in brand extension mode
|
||||
const brandExtensionMode = sessionStorage.getItem('brandExtensionMode') === 'true';
|
||||
const brandExtensionPrompt = sessionStorage.getItem('brandExtensionPrompt') || '';
|
||||
|
||||
// Screenshot is already being captured in parallel above
|
||||
|
||||
let scrapeData;
|
||||
let scrapeData: ScrapeData | undefined;
|
||||
let brandGuidelines: any;
|
||||
|
||||
// Check if we have pre-scraped markdown content from search results
|
||||
const storedMarkdown = sessionStorage.getItem('siteMarkdown');
|
||||
if (brandExtensionMode) {
|
||||
// === BRAND EXTENSION MODE ===
|
||||
addChatMessage('Extracting brand styles from the website...', 'system');
|
||||
|
||||
// Call the brand extraction endpoint
|
||||
const extractResponse = await fetch('/api/extract-brand-styles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
prompt: brandExtensionPrompt
|
||||
})
|
||||
});
|
||||
|
||||
if (!extractResponse.ok) {
|
||||
throw new Error('Failed to extract brand styles');
|
||||
}
|
||||
|
||||
brandGuidelines = await extractResponse.json();
|
||||
|
||||
if (!brandGuidelines.success) {
|
||||
throw new Error(brandGuidelines.error || 'Failed to extract brand styles');
|
||||
}
|
||||
|
||||
// Display branding summary with visual UI
|
||||
addChatMessage(`Acquired branding format from ${cleanUrl}`, 'system', {
|
||||
brandingData: brandGuidelines.guidelines,
|
||||
sourceUrl: cleanUrl
|
||||
});
|
||||
addChatMessage(`Building your custom component using these brand guidelines...`, 'system');
|
||||
|
||||
// Clear the flags after use
|
||||
sessionStorage.removeItem('brandExtensionMode');
|
||||
sessionStorage.removeItem('brandExtensionPrompt');
|
||||
|
||||
} else {
|
||||
// === NORMAL CLONE MODE ===
|
||||
// Check if we have pre-scraped markdown content from search results
|
||||
const storedMarkdown = sessionStorage.getItem('siteMarkdown');
|
||||
if (storedMarkdown) {
|
||||
// Use the pre-scraped content
|
||||
scrapeData = {
|
||||
@@ -2703,14 +2771,15 @@ Tip: I automatically detect and install npm packages from your code imports (lik
|
||||
throw new Error('Failed to scrape website');
|
||||
}
|
||||
|
||||
scrapeData = await scrapeResponse.json();
|
||||
scrapeData = await scrapeResponse.json() as ScrapeData;
|
||||
|
||||
if (!scrapeData.success) {
|
||||
throw new Error(scrapeData.error || 'Failed to scrape website');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setUrlStatus(['Website scraped successfully!', 'Generating React app...']);
|
||||
setUrlStatus(brandExtensionMode ? ['Brand styles extracted!', 'Building your component...'] : ['Website scraped successfully!', 'Generating React app...']);
|
||||
|
||||
// Clear preparing design state and switch to generation tab
|
||||
setIsPreparingDesign(false);
|
||||
@@ -2727,50 +2796,186 @@ Tip: I automatically detect and install npm packages from your code imports (lik
|
||||
setActiveTab('generation');
|
||||
}, 1500);
|
||||
|
||||
// Store scraped data in conversation context
|
||||
setConversationContext(prev => ({
|
||||
...prev,
|
||||
scrapedWebsites: [...prev.scrapedWebsites, {
|
||||
url: url,
|
||||
content: scrapeData,
|
||||
timestamp: new Date()
|
||||
}],
|
||||
currentProject: `${url} Clone`
|
||||
}));
|
||||
// Build the appropriate prompt based on mode
|
||||
let prompt;
|
||||
|
||||
// Filter out style-related context when using screenshot/URL-based generation
|
||||
// Only keep user's explicit instructions, not inherited styles
|
||||
let filteredContext = homeContextInput;
|
||||
if (homeUrlInput && homeContextInput) {
|
||||
// Check if the context contains default style names that shouldn't be inherited
|
||||
const stylePatterns = [
|
||||
'Glassmorphism style design',
|
||||
'Neumorphism style design',
|
||||
'Brutalism style design',
|
||||
'Minimalist style design',
|
||||
'Dark Mode style design',
|
||||
'Gradient Rich style design',
|
||||
'3D Depth style design',
|
||||
'Retro Wave style design',
|
||||
'Modern clean and minimalist style design',
|
||||
'Fun colorful and playful style design',
|
||||
'Corporate professional and sleek style design',
|
||||
'Creative artistic and unique style design'
|
||||
];
|
||||
if (brandExtensionMode && brandGuidelines) {
|
||||
// === BRAND EXTENSION PROMPT ===
|
||||
// Store brand guidelines in conversation context
|
||||
setConversationContext(prev => ({
|
||||
...prev,
|
||||
scrapedWebsites: [...prev.scrapedWebsites, {
|
||||
url: url,
|
||||
content: { brandGuidelines },
|
||||
timestamp: new Date()
|
||||
}],
|
||||
currentProject: `Custom build using ${url} brand`
|
||||
}));
|
||||
|
||||
// If the context exactly matches or starts with a style pattern, filter it out
|
||||
const startsWithStyle = stylePatterns.some(pattern =>
|
||||
homeContextInput.trim().startsWith(pattern)
|
||||
);
|
||||
// Extract comprehensive brand data
|
||||
const branding = brandGuidelines.guidelines;
|
||||
|
||||
if (startsWithStyle) {
|
||||
// Extract only the additional instructions part after the style
|
||||
const additionalMatch = homeContextInput.match(/\. (.+)$/);
|
||||
filteredContext = additionalMatch ? additionalMatch[1] : '';
|
||||
// Build detailed brand instruction string
|
||||
const brandInstructions = `
|
||||
BRAND GUIDELINES FROM ${url}:
|
||||
|
||||
COLOR SYSTEM:
|
||||
- Color Scheme: ${branding.colorScheme || 'light'} mode
|
||||
- Primary Color: ${branding.colors?.primary || 'not specified'}
|
||||
- Accent Color: ${branding.colors?.accent || 'not specified'}
|
||||
- Background: ${branding.colors?.background || 'not specified'}
|
||||
- Text Primary: ${branding.colors?.textPrimary || 'not specified'}
|
||||
- Link Color: ${branding.colors?.link || 'not specified'}
|
||||
|
||||
TYPOGRAPHY:
|
||||
- Primary Font: ${branding.typography?.fontFamilies?.primary || 'system default'}
|
||||
- Heading Font: ${branding.typography?.fontFamilies?.heading || 'system default'}
|
||||
- Font Stack (Body): ${branding.typography?.fontStacks?.body?.join(', ') || 'system-ui, sans-serif'}
|
||||
- Font Stack (Heading): ${branding.typography?.fontStacks?.heading?.join(', ') || 'system-ui, sans-serif'}
|
||||
- H1 Size: ${branding.typography?.fontSizes?.h1 || '36px'}
|
||||
- H2 Size: ${branding.typography?.fontSizes?.h2 || '30px'}
|
||||
- Body Size: ${branding.typography?.fontSizes?.body || '16px'}
|
||||
|
||||
SPACING & LAYOUT:
|
||||
- Base Spacing Unit: ${branding.spacing?.baseUnit || '4'}px
|
||||
- Border Radius: ${branding.spacing?.borderRadius || '6px'}
|
||||
|
||||
BUTTON STYLES:
|
||||
Primary Button:
|
||||
- Background: ${branding.components?.buttonPrimary?.background || branding.colors?.primary}
|
||||
- Text Color: ${branding.components?.buttonPrimary?.textColor || '#FFFFFF'}
|
||||
- Border Radius: ${branding.components?.buttonPrimary?.borderRadius || branding.spacing?.borderRadius || '8px'}
|
||||
- Shadow: ${branding.components?.buttonPrimary?.shadow || 'none'}
|
||||
|
||||
Secondary Button:
|
||||
- Background: ${branding.components?.buttonSecondary?.background || '#F9F9F9'}
|
||||
- Text Color: ${branding.components?.buttonSecondary?.textColor || branding.colors?.textPrimary}
|
||||
- Border Radius: ${branding.components?.buttonSecondary?.borderRadius || branding.spacing?.borderRadius || '8px'}
|
||||
- Shadow: ${branding.components?.buttonSecondary?.shadow || 'none'}
|
||||
|
||||
INPUT FIELDS:
|
||||
- Border Color: ${branding.components?.input?.borderColor || '#CCCCCC'}
|
||||
- Border Radius: ${branding.components?.input?.borderRadius || branding.spacing?.borderRadius || '6px'}
|
||||
|
||||
BRAND PERSONALITY:
|
||||
- Tone: ${branding.personality?.tone || 'professional'}
|
||||
- Energy: ${branding.personality?.energy || 'medium'}
|
||||
- Target Audience: ${branding.personality?.targetAudience || 'general'}
|
||||
|
||||
DESIGN SYSTEM:
|
||||
- Framework: ${branding.designSystem?.framework || 'tailwind'}
|
||||
- Component Library: ${branding.designSystem?.componentLibrary || 'custom'}
|
||||
|
||||
ASSETS:
|
||||
${branding.images?.logo ? `- Logo Available: Yes (use carefully if needed)` : '- Logo: Not available'}
|
||||
${branding.images?.favicon ? `- Favicon: ${branding.images.favicon}` : ''}`;
|
||||
|
||||
prompt = `I want you to build a NEW React component/application based on these brand guidelines and the user's requirements.
|
||||
|
||||
<branding-format source="${url}">
|
||||
${brandInstructions}
|
||||
|
||||
RAW BRAND DATA (for reference):
|
||||
${JSON.stringify(branding, null, 2)}
|
||||
</branding-format>
|
||||
|
||||
USER'S REQUEST:
|
||||
${brandExtensionPrompt || 'Build a modern web component using these brand guidelines'}
|
||||
|
||||
IMPORTANT: The content above in the <branding-format> tags contains the extracted brand guidelines from ${url}.
|
||||
Use these guidelines (colors, fonts, spacing, design patterns) to build what the user requested.
|
||||
|
||||
CRITICAL REQUIREMENTS:
|
||||
- DO NOT recreate the original website at ${url}
|
||||
- DO create a COMPLETELY NEW component that fulfills the user's request
|
||||
- The user wants: "${brandExtensionPrompt}"
|
||||
- Build ONLY what the user requested - nothing more
|
||||
- App.jsx should render ONLY the requested component - no extra Header/Footer/Hero unless specifically requested
|
||||
- Make it a minimal, focused implementation of the user's request
|
||||
|
||||
STYLING REQUIREMENTS:
|
||||
- Apply the EXACT colors from the brand palette (primary, accent, background, text colors)
|
||||
- Use the EXACT typography (font families, font sizes for h1, h2, body)
|
||||
- Apply the spacing system (base unit: ${branding.spacing?.baseUnit || '4'}px)
|
||||
- Use the specified border radius (${branding.spacing?.borderRadius || '6px'}) consistently
|
||||
- Implement button styles EXACTLY as specified (colors, shadows, border radius)
|
||||
- Style input fields with the exact border color and border radius
|
||||
- Match the brand's ${branding.colorScheme || 'light'} color scheme
|
||||
- Apply the brand personality: ${branding.personality?.tone || 'professional'} tone with ${branding.personality?.energy || 'medium'} energy
|
||||
- Use Tailwind CSS with inline color values matching the brand palette EXACTLY
|
||||
- If fonts need to be imported, add @import or @font-face rules to index.css
|
||||
- Create custom CSS classes in index.css for complex shadows/effects that can't be done with Tailwind
|
||||
|
||||
FONT SETUP:
|
||||
${branding.typography?.fontFamilies?.primary ? `
|
||||
- Add font family "${branding.typography.fontFamilies.primary}" to your CSS
|
||||
- Use font stack: ${branding.typography?.fontStacks?.body?.join(', ') || 'system-ui, sans-serif'}
|
||||
- Set body font size to ${branding.typography?.fontSizes?.body || '16px'}` : '- Use system fonts'}
|
||||
|
||||
COMPONENT STRUCTURE:
|
||||
- src/index.css - Include brand fonts, custom shadows/effects, and base styling
|
||||
- src/App.jsx - Should ONLY render the requested component (e.g., just <PricingPage /> if user wants pricing)
|
||||
- src/components/[RequestedComponent].jsx - The actual component fulfilling the user's request
|
||||
|
||||
TECHNICAL REQUIREMENTS:
|
||||
- Create a WORKING, self-contained application
|
||||
- DO NOT import components that don't exist
|
||||
- Make sure the app renders immediately with visible content
|
||||
- All colors must match the brand palette EXACTLY
|
||||
- All spacing must use the ${branding.spacing?.baseUnit || '4'}px base unit
|
||||
- Buttons must have the exact styling specified in the guidelines
|
||||
|
||||
Focus on building something NEW, minimal, and functional that perfectly matches the ${brandGuidelines.styleName || 'brand'} aesthetic and design system.`;
|
||||
|
||||
} else {
|
||||
// === NORMAL CLONE MODE PROMPT ===
|
||||
// Store scraped data in conversation context
|
||||
if (!scrapeData) {
|
||||
throw new Error('Scrape data is missing');
|
||||
}
|
||||
}
|
||||
setConversationContext(prev => ({
|
||||
...prev,
|
||||
scrapedWebsites: [...prev.scrapedWebsites, {
|
||||
url: url,
|
||||
content: scrapeData,
|
||||
timestamp: new Date()
|
||||
}],
|
||||
currentProject: `${url} Clone`
|
||||
}));
|
||||
|
||||
const prompt = `I want to recreate the ${url} website as a complete React application based on the scraped content below.
|
||||
// Filter out style-related context when using screenshot/URL-based generation
|
||||
// Only keep user's explicit instructions, not inherited styles
|
||||
let filteredContext = homeContextInput;
|
||||
if (homeUrlInput && homeContextInput) {
|
||||
// Check if the context contains default style names that shouldn't be inherited
|
||||
const stylePatterns = [
|
||||
'Glassmorphism style design',
|
||||
'Neumorphism style design',
|
||||
'Brutalism style design',
|
||||
'Minimalist style design',
|
||||
'Dark Mode style design',
|
||||
'Gradient Rich style design',
|
||||
'3D Depth style design',
|
||||
'Retro Wave style design',
|
||||
'Modern clean and minimalist style design',
|
||||
'Fun colorful and playful style design',
|
||||
'Corporate professional and sleek style design',
|
||||
'Creative artistic and unique style design'
|
||||
];
|
||||
|
||||
// If the context exactly matches or starts with a style pattern, filter it out
|
||||
const startsWithStyle = stylePatterns.some(pattern =>
|
||||
homeContextInput.trim().startsWith(pattern)
|
||||
);
|
||||
|
||||
if (startsWithStyle) {
|
||||
// Extract only the additional instructions part after the style
|
||||
const additionalMatch = homeContextInput.match(/\. (.+)$/);
|
||||
filteredContext = additionalMatch ? additionalMatch[1] : '';
|
||||
}
|
||||
}
|
||||
|
||||
prompt = `I want to recreate the ${url} website as a complete React application based on the scraped content below.
|
||||
|
||||
${JSON.stringify(scrapeData, null, 2)}
|
||||
|
||||
@@ -2791,6 +2996,7 @@ IMPORTANT INSTRUCTIONS:
|
||||
${filteredContext ? '- Apply the user\'s context/theme requirements throughout the application' : ''}
|
||||
|
||||
Focus on the key sections and content, making it clean and modern.`;
|
||||
}
|
||||
|
||||
setGenerationProgress(prev => ({
|
||||
isGenerating: true,
|
||||
@@ -3000,15 +3206,17 @@ Focus on the key sections and content, making it clean and modern.`;
|
||||
|
||||
setPromptInput(generatedCode);
|
||||
|
||||
// First application for cloned site should not be in edit mode
|
||||
// Apply the code (first time is not edit mode)
|
||||
await applyGeneratedCode(generatedCode, false);
|
||||
|
||||
addChatMessage(
|
||||
`Successfully recreated ${url} as a modern React app${homeContextInput ? ` with your requested context: "${homeContextInput}"` : ''}! The scraped content is now in my context, so you can ask me to modify specific sections or add features based on the original site.`,
|
||||
brandExtensionMode
|
||||
? `Successfully built your custom component using ${cleanUrl}'s brand guidelines! You can now ask me to modify it or add more features.`
|
||||
: `Successfully recreated ${url} as a modern React app${homeContextInput ? ` with your requested context: "${homeContextInput}"` : ''}! The scraped content is now in my context, so you can ask me to modify specific sections or add features based on the original site.`,
|
||||
'ai',
|
||||
{
|
||||
scrapedUrl: url,
|
||||
scrapedContent: scrapeData,
|
||||
scrapedContent: brandExtensionMode ? { brandGuidelines } : scrapeData,
|
||||
generatedCode: generatedCode
|
||||
}
|
||||
);
|
||||
@@ -3197,22 +3405,43 @@ Focus on the key sections and content, making it clean and modern.`;
|
||||
|
||||
{/* Pinned screenshot */}
|
||||
{screenshot && (
|
||||
<div
|
||||
className="w-full rounded-lg overflow-hidden border border-gray-200 transition-all duration-300"
|
||||
style={{
|
||||
opacity: sidebarScrolled ? 0 : 1,
|
||||
transform: sidebarScrolled ? 'translateY(-20px)' : 'translateY(0)',
|
||||
pointerEvents: sidebarScrolled ? 'none' : 'auto',
|
||||
maxHeight: sidebarScrolled ? '0' : '200px'
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={screenshot}
|
||||
alt={`${siteName} preview`}
|
||||
className="w-full h-auto object-cover"
|
||||
style={{ maxHeight: '200px' }}
|
||||
/>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">Screenshot Preview</span>
|
||||
<button
|
||||
onClick={() => setScreenshotCollapsed(!screenshotCollapsed)}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors p-1"
|
||||
aria-label={screenshotCollapsed ? 'Expand screenshot' : 'Collapse screenshot'}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`transition-transform duration-300 ${screenshotCollapsed ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<path d="M4 6L8 10L12 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="w-full rounded-lg overflow-hidden border border-gray-200 transition-all duration-300"
|
||||
style={{
|
||||
opacity: screenshotCollapsed ? 0 : 1,
|
||||
transform: screenshotCollapsed ? 'translateY(-20px)' : 'translateY(0)',
|
||||
pointerEvents: screenshotCollapsed ? 'none' : 'auto',
|
||||
maxHeight: screenshotCollapsed ? '0' : '200px'
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={screenshot}
|
||||
alt={`${siteName} preview`}
|
||||
className="w-full h-auto object-cover"
|
||||
style={{ maxHeight: '200px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3224,11 +3453,7 @@ Focus on the key sections and content, making it clean and modern.`;
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-6 flex flex-col gap-4 scrollbar-hide"
|
||||
ref={chatMessagesRef}
|
||||
onScroll={(e) => {
|
||||
const scrollTop = e.currentTarget.scrollTop;
|
||||
setSidebarScrolled(scrollTop > 50);
|
||||
}}>
|
||||
ref={chatMessagesRef}>
|
||||
{chatMessages.map((msg, idx) => {
|
||||
// Check if this message is from a successful generation
|
||||
const isGenerationComplete = msg.content.includes('Successfully recreated') ||
|
||||
@@ -3278,14 +3503,208 @@ Focus on the key sections and content, making it clean and modern.`;
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-body-input">{msg.content}</span>
|
||||
<span className="text-sm">{msg.content}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show branding data if this is a brand extraction message */}
|
||||
{msg.metadata?.brandingData && (
|
||||
<div className="mt-3 bg-gradient-to-br from-gray-50 to-white border-2 border-gray-200 rounded-xl overflow-hidden max-w-[500px] shadow-sm">
|
||||
<div className="bg-[#36322F] px-16 py-12">
|
||||
<div className="flex items-center gap-8">
|
||||
<Image
|
||||
src={`https://www.google.com/s2/favicons?domain=${msg.metadata.sourceUrl}&sz=32`}
|
||||
alt=""
|
||||
width={64}
|
||||
height={64}
|
||||
className="w-16 h-16"
|
||||
/>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
Brand Guidelines
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-16">
|
||||
{/* Color Scheme Mode */}
|
||||
{msg.metadata.brandingData.colorScheme && (
|
||||
<div className="mb-16">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600 font-medium">Mode:</span>{' '}
|
||||
<span className="font-semibold text-gray-900 capitalize">{msg.metadata.brandingData.colorScheme}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Colors */}
|
||||
{msg.metadata.brandingData.colors && (
|
||||
<div className="mb-16">
|
||||
<div className="text-sm font-semibold text-gray-900 mb-8">Colors</div>
|
||||
<div className="flex flex-wrap gap-12">
|
||||
{msg.metadata.brandingData.colors.primary && (
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-32 h-32 rounded border border-gray-300" style={{ backgroundColor: msg.metadata.brandingData.colors.primary }} />
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold text-gray-900">Primary</div>
|
||||
<div className="text-gray-600 font-mono text-xs">{msg.metadata.brandingData.colors.primary}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{msg.metadata.brandingData.colors.accent && (
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-32 h-32 rounded border border-gray-300" style={{ backgroundColor: msg.metadata.brandingData.colors.accent }} />
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold text-gray-900">Accent</div>
|
||||
<div className="text-gray-600 font-mono text-xs">{msg.metadata.brandingData.colors.accent}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{msg.metadata.brandingData.colors.background && (
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-32 h-32 rounded border border-gray-300" style={{ backgroundColor: msg.metadata.brandingData.colors.background }} />
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold text-gray-900">Background</div>
|
||||
<div className="text-gray-600 font-mono text-xs">{msg.metadata.brandingData.colors.background}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{msg.metadata.brandingData.colors.textPrimary && (
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-32 h-32 rounded border border-gray-300" style={{ backgroundColor: msg.metadata.brandingData.colors.textPrimary }} />
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold text-gray-900">Text</div>
|
||||
<div className="text-gray-600 font-mono text-xs">{msg.metadata.brandingData.colors.textPrimary}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Typography */}
|
||||
{msg.metadata.brandingData.typography && (
|
||||
<div className="mb-16">
|
||||
<div className="text-sm font-semibold text-gray-900 mb-8">Typography</div>
|
||||
<div className="grid grid-cols-2 gap-12 text-sm">
|
||||
{msg.metadata.brandingData.typography.fontFamilies?.primary && (
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Primary:</span>{' '}
|
||||
<span className="font-semibold text-gray-900">{msg.metadata.brandingData.typography.fontFamilies.primary}</span>
|
||||
</div>
|
||||
)}
|
||||
{msg.metadata.brandingData.typography.fontFamilies?.heading && (
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Heading:</span>{' '}
|
||||
<span className="font-semibold text-gray-900">{msg.metadata.brandingData.typography.fontFamilies.heading}</span>
|
||||
</div>
|
||||
)}
|
||||
{msg.metadata.brandingData.typography.fontSizes?.h1 && (
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">H1 Size:</span>{' '}
|
||||
<span className="font-semibold text-gray-900">{msg.metadata.brandingData.typography.fontSizes.h1}</span>
|
||||
</div>
|
||||
)}
|
||||
{msg.metadata.brandingData.typography.fontSizes?.h2 && (
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">H2 Size:</span>{' '}
|
||||
<span className="font-semibold text-gray-900">{msg.metadata.brandingData.typography.fontSizes.h2}</span>
|
||||
</div>
|
||||
)}
|
||||
{msg.metadata.brandingData.typography.fontSizes?.body && (
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Body Size:</span>{' '}
|
||||
<span className="font-semibold text-gray-900">{msg.metadata.brandingData.typography.fontSizes.body}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spacing */}
|
||||
{msg.metadata.brandingData.spacing && (
|
||||
<div className="mb-16">
|
||||
<div className="text-sm font-semibold text-gray-900 mb-8">Spacing</div>
|
||||
<div className="flex flex-wrap gap-16 text-sm">
|
||||
{msg.metadata.brandingData.spacing.baseUnit && (
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Base Unit:</span>{' '}
|
||||
<span className="font-semibold text-gray-900">{msg.metadata.brandingData.spacing.baseUnit}px</span>
|
||||
</div>
|
||||
)}
|
||||
{msg.metadata.brandingData.spacing.borderRadius && (
|
||||
<div>
|
||||
<span className="text-gray-600 font-medium">Border Radius:</span>{' '}
|
||||
<span className="font-semibold text-gray-900">{msg.metadata.brandingData.spacing.borderRadius}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Button Styles */}
|
||||
{msg.metadata.brandingData.components?.buttonPrimary && (
|
||||
<div className="mb-16">
|
||||
<div className="text-sm font-semibold text-gray-900 mb-8">Button Styles</div>
|
||||
<div className="flex flex-wrap gap-12">
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 mb-6 font-medium">Primary Button</div>
|
||||
<button
|
||||
className="px-16 py-8 text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: msg.metadata.brandingData.components.buttonPrimary.background,
|
||||
color: msg.metadata.brandingData.components.buttonPrimary.textColor,
|
||||
borderRadius: msg.metadata.brandingData.components.buttonPrimary.borderRadius,
|
||||
boxShadow: msg.metadata.brandingData.components.buttonPrimary.shadow
|
||||
}}
|
||||
>
|
||||
Sample Button
|
||||
</button>
|
||||
</div>
|
||||
{msg.metadata.brandingData.components?.buttonSecondary && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 mb-6 font-medium">Secondary Button</div>
|
||||
<button
|
||||
className="px-16 py-8 text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: msg.metadata.brandingData.components.buttonSecondary.background,
|
||||
color: msg.metadata.brandingData.components.buttonSecondary.textColor,
|
||||
borderRadius: msg.metadata.brandingData.components.buttonSecondary.borderRadius,
|
||||
boxShadow: msg.metadata.brandingData.components.buttonSecondary.shadow
|
||||
}}
|
||||
>
|
||||
Sample Button
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Personality */}
|
||||
{msg.metadata.brandingData.personality && (
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600 font-medium">Personality:</span>{' '}
|
||||
<span className="font-semibold text-gray-900 capitalize">
|
||||
{msg.metadata.brandingData.personality.tone} tone, {msg.metadata.brandingData.personality.energy} energy
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target Audience */}
|
||||
{msg.metadata.brandingData.personality?.targetAudience && (
|
||||
<div className="text-sm mt-8">
|
||||
<span className="text-gray-600 font-medium">Target:</span>{' '}
|
||||
<span className="text-gray-900">{msg.metadata.brandingData.personality.targetAudience}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show applied files if this is an apply success message */}
|
||||
{msg.metadata?.appliedFiles && msg.metadata.appliedFiles.length > 0 && (
|
||||
<div className="mt-3 inline-block bg-gray-100 rounded-[10px] p-5">
|
||||
<div className="text-xs font-medium mb-3 text-gray-700">
|
||||
<div className="text-sm font-medium mb-3 text-gray-700">
|
||||
{msg.content.includes('Applied') ? 'Files Updated:' : 'Generated Files:'}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
@@ -3299,7 +3718,7 @@ Focus on the key sections and content, making it clean and modern.`;
|
||||
return (
|
||||
<div
|
||||
key={`applied-${fileIdx}`}
|
||||
className="inline-flex items-center gap-1.5 px-6 py-1.5 bg-[#36322F] text-white rounded-[10px] text-xs animate-fade-in-up"
|
||||
className="inline-flex items-center gap-1.5 px-6 py-1.5 bg-[#36322F] text-white rounded-[10px] text-sm animate-fade-in-up"
|
||||
style={{ animationDelay: `${fileIdx * 30}ms` }}
|
||||
>
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full ${
|
||||
@@ -3373,9 +3792,9 @@ Focus on the key sections and content, making it clean and modern.`;
|
||||
|
||||
{/* Show current file being generated */}
|
||||
{generationProgress.currentFile && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-[#36322F]/70 text-white rounded-[10px] text-xs animate-pulse"
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-[#36322F]/70 text-white rounded-[10px] text-sm animate-pulse"
|
||||
style={{ animationDelay: `${generationProgress.files.length * 30}ms` }}>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-16 h-16 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
{generationProgress.currentFile.path.split('/').pop()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ const robotoMono = Roboto_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Open Lovable v2",
|
||||
title: "Open Lovable v3",
|
||||
description: "Re-imagine any website in seconds with AI-powered website builder.",
|
||||
};
|
||||
|
||||
|
||||
+132
-46
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { appConfig } from '@/config/app.config';
|
||||
@@ -49,6 +50,7 @@ export default function HomePage() {
|
||||
const [showSelectMessage, setShowSelectMessage] = useState<boolean>(false);
|
||||
const [showInstructionsForIndex, setShowInstructionsForIndex] = useState<number | null>(null);
|
||||
const [additionalInstructions, setAdditionalInstructions] = useState<string>('');
|
||||
const [extendBrandStyles, setExtendBrandStyles] = useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
|
||||
// Simple URL validation
|
||||
@@ -89,6 +91,12 @@ export default function HomePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate brand extension mode requirements
|
||||
if (extendBrandStyles && isURL(inputValue) && !additionalInstructions.trim()) {
|
||||
toast.error("Please describe what you want to build with this brand's styles");
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a search result being selected, fade out and redirect
|
||||
if (selectedResult) {
|
||||
setIsFadingOut(true);
|
||||
@@ -107,13 +115,24 @@ export default function HomePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a URL, go straight to generation
|
||||
// If it's a URL, check if we're extending brand styles or cloning
|
||||
if (isURL(inputValue)) {
|
||||
sessionStorage.setItem('targetUrl', inputValue);
|
||||
sessionStorage.setItem('selectedStyle', selectedStyle);
|
||||
sessionStorage.setItem('selectedModel', selectedModel);
|
||||
sessionStorage.setItem('autoStart', 'true');
|
||||
router.push('/generation');
|
||||
if (extendBrandStyles) {
|
||||
// Brand extension mode - extract brand styles and use them with the prompt
|
||||
sessionStorage.setItem('targetUrl', inputValue);
|
||||
sessionStorage.setItem('selectedModel', selectedModel);
|
||||
sessionStorage.setItem('autoStart', 'true');
|
||||
sessionStorage.setItem('brandExtensionMode', 'true');
|
||||
sessionStorage.setItem('brandExtensionPrompt', additionalInstructions || '');
|
||||
router.push('/generation');
|
||||
} else {
|
||||
// Normal clone mode
|
||||
sessionStorage.setItem('targetUrl', inputValue);
|
||||
sessionStorage.setItem('selectedStyle', selectedStyle);
|
||||
sessionStorage.setItem('selectedModel', selectedModel);
|
||||
sessionStorage.setItem('autoStart', 'true');
|
||||
router.push('/generation');
|
||||
}
|
||||
} else {
|
||||
// It's a search term, fade out if results exist, then search
|
||||
if (hasSearched && searchResults.length > 0) {
|
||||
@@ -244,7 +263,7 @@ export default function HomePage() {
|
||||
<HomeHeroBadge />
|
||||
<HomeHeroTitle />
|
||||
<p className="text-center text-body-large">
|
||||
Re-imagine any website, in seconds.
|
||||
Clone brand format or re-imagine any website, in seconds.
|
||||
</p>
|
||||
<Link
|
||||
className="bg-black-alpha-4 hover:bg-black-alpha-6 rounded-6 px-8 lg:px-6 text-label-large h-30 lg:h-24 block mt-8 mx-auto w-max gap-4 transition-all"
|
||||
@@ -269,14 +288,14 @@ export default function HomePage() {
|
||||
<div className="max-w-552 mx-auto z-[11] lg:z-[2]">
|
||||
<div className="rounded-20 -mt-30 lg:-mt-30">
|
||||
<div
|
||||
className="bg-white rounded-20"
|
||||
className="bg-white rounded-20 relative z-10"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 0px 44px 0px rgba(0, 0, 0, 0.02), 0px 88px 56px -20px rgba(0, 0, 0, 0.03), 0px 56px 56px -20px rgba(0, 0, 0, 0.02), 0px 32px 32px -20px rgba(0, 0, 0, 0.03), 0px 16px 24px -12px rgba(0, 0, 0, 0.03), 0px 0px 0px 1px rgba(0, 0, 0, 0.05), 0px 0px 0px 10px #F9F9F9",
|
||||
}}
|
||||
>
|
||||
|
||||
<div className="p-16 flex gap-12 items-center w-full relative bg-white rounded-20">
|
||||
<div className="p-[28px] flex gap-12 items-center w-full relative bg-white rounded-20">
|
||||
{/* Show different UI when search results are displayed */}
|
||||
{hasSearched && searchResults.length > 0 && !isFadingOut ? (
|
||||
<>
|
||||
@@ -405,50 +424,112 @@ export default function HomePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Options Section - Only show when valid URL */}
|
||||
<div className={`overflow-hidden transition-all duration-500 ease-in-out ${
|
||||
isValidUrl ? 'max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
||||
isValidUrl ? (extendBrandStyles ? 'max-h-[400px]' : 'max-h-[300px]') + ' opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}>
|
||||
<div className="p-[28px]">
|
||||
<div className="px-[28px] pt-0 pb-[28px]">
|
||||
<div className="border-t border-gray-100 bg-white">
|
||||
{/* Style Selector */}
|
||||
<div className={`mb-2 pt-4 transition-all duration-300 transform ${
|
||||
{/* Extend Brand Styles Toggle */}
|
||||
<div className={`transition-all duration-300 transform ${
|
||||
isValidUrl ? 'translate-y-0 opacity-100' : '-translate-y-2 opacity-0'
|
||||
}`} style={{ transitionDelay: '100ms' }}>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{styles.map((style, index) => (
|
||||
}`} style={{ transitionDelay: '50ms' }}>
|
||||
<div className="py-8 grid grid-cols-2 items-center gap-12 group cursor-pointer" onClick={() => setExtendBrandStyles(!extendBrandStyles)}>
|
||||
<div className="flex select-none">
|
||||
<div className="flex lg-max:flex-col whitespace-nowrap flex-wrap min-w-0 gap-8 lg:justify-between flex-1">
|
||||
<div className="text-xs font-medium text-black-alpha-72 transition-all group-hover:text-accent-black relative">
|
||||
Extend brand styles
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
key={style.id}
|
||||
onClick={() => setSelectedStyle(style.id)}
|
||||
className={`
|
||||
py-2.5 px-2 rounded text-[10px] font-medium border transition-all text-center
|
||||
${selectedStyle === style.id
|
||||
? 'border-orange-500 bg-orange-50 text-orange-900'
|
||||
: 'border-gray-200 hover:border-gray-300 bg-white text-gray-700'
|
||||
}
|
||||
${isValidUrl ? 'opacity-100' : 'opacity-0'}
|
||||
`}
|
||||
className="transition-all relative rounded-full group bg-black-alpha-10"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExtendBrandStyles(!extendBrandStyles);
|
||||
}}
|
||||
style={{
|
||||
transitionDelay: `${150 + index * 30}ms`,
|
||||
transition: 'all 0.3s ease-in-out'
|
||||
width: '50px',
|
||||
height: '20px',
|
||||
boxShadow: 'rgba(0, 0, 0, 0.02) 0px 6px 12px 0px inset, rgba(0, 0, 0, 0.02) 0px 0.75px 0.75px 0px inset, rgba(0, 0, 0, 0.04) 0px 0.25px 0.25px 0px inset'
|
||||
}}
|
||||
>
|
||||
{style.name}
|
||||
<div
|
||||
className={`overlay transition-opacity ${extendBrandStyles ? 'opacity-100' : 'opacity-0'}`}
|
||||
style={{ background: 'color(display-p3 0.9059 0.3294 0.0784)', backgroundColor: '#FA4500' }}
|
||||
/>
|
||||
<div
|
||||
className="top-[2px] left-[2px] transition-all absolute rounded-full bg-accent-white"
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '16px',
|
||||
boxShadow: 'rgba(0, 0, 0, 0.06) 0px 6px 12px -3px, rgba(0, 0, 0, 0.06) 0px 3px 6px -1px, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.08) 0px 0.5px 0.5px 0px',
|
||||
transform: extendBrandStyles ? 'translateX(16px)' : 'none'
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brand Extension Prompt - Show when toggle is enabled */}
|
||||
{extendBrandStyles && (
|
||||
<div className="pb-10 transition-all duration-300 opacity-100">
|
||||
<textarea
|
||||
value={additionalInstructions}
|
||||
onChange={(e) => setAdditionalInstructions(e.target.value)}
|
||||
placeholder="Describe the new functionality you want to build using this brand's styles..."
|
||||
className="w-full px-4 py-10 text-xs font-medium text-gray-700 bg-gray-50 rounded border border-gray-200 focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 placeholder:text-gray-400 min-h-[80px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Style Selector - Hide when brand extension mode is enabled */}
|
||||
{!extendBrandStyles && (
|
||||
<div className={`mb-2 transition-all duration-300 transform ${
|
||||
isValidUrl ? 'translate-y-0 opacity-100' : '-translate-y-2 opacity-0'
|
||||
}`} style={{ transitionDelay: '100ms' }}>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{styles.map((style, index) => (
|
||||
<button
|
||||
key={style.id}
|
||||
onClick={() => setSelectedStyle(style.id)}
|
||||
className={`
|
||||
${selectedStyle === style.id
|
||||
? 'bg-heat-100 hover:bg-heat-200 flex items-center justify-center button relative text-label-medium button-primary group/button rounded-10 p-8 text-accent-white active:scale-[0.995] border-0'
|
||||
: 'border-gray-200 hover:border-gray-300 bg-white text-gray-700 py-3.5 px-4 rounded text-xs font-medium border text-center'
|
||||
}
|
||||
transition-all
|
||||
${isValidUrl ? 'opacity-100' : 'opacity-0'}
|
||||
`}
|
||||
style={{
|
||||
transitionDelay: `${150 + index * 30}ms`,
|
||||
transition: 'all 0.3s ease-in-out'
|
||||
}}
|
||||
>
|
||||
{selectedStyle === style.id && (
|
||||
<div className="button-background absolute inset-0 rounded-10 pointer-events-none" />
|
||||
)}
|
||||
<span className={selectedStyle === style.id ? 'relative' : ''}>
|
||||
{style.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Selector Dropdown and Additional Instructions */}
|
||||
<div className={`flex gap-3 mt-2 pb-4 transition-all duration-300 transform ${
|
||||
<div className={`flex items-center gap-3 mt-2 pb-4 transition-all duration-300 transform ${
|
||||
isValidUrl ? 'translate-y-0 opacity-100' : '-translate-y-2 opacity-0'
|
||||
}`} style={{ transitionDelay: '400ms' }}>
|
||||
{/* Model Dropdown */}
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="px-3 py-2.5 text-[10px] font-medium text-gray-700 bg-white rounded border border-gray-200 focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500"
|
||||
className={`px-3 py-2.5 text-xs font-medium text-gray-700 bg-white rounded border border-gray-200 focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 ${extendBrandStyles ? 'flex-1' : ''}`}
|
||||
>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
@@ -457,13 +538,15 @@ export default function HomePage() {
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Additional Instructions */}
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 px-3 py-2.5 text-[10px] text-gray-700 bg-gray-50 rounded border border-gray-200 focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 placeholder:text-gray-400"
|
||||
placeholder="Additional instructions (optional)"
|
||||
onChange={(e) => sessionStorage.setItem('additionalInstructions', e.target.value)}
|
||||
/>
|
||||
{/* Additional Instructions - Hidden when extend brand styles is enabled */}
|
||||
{!extendBrandStyles && (
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 px-3 py-2.5 text-xs font-medium text-gray-700 bg-gray-50 rounded border border-gray-200 focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 placeholder:text-gray-400"
|
||||
placeholder="Additional instructions (optional)"
|
||||
onChange={(e) => sessionStorage.setItem('additionalInstructions', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -702,12 +785,15 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
{result.screenshot ? (
|
||||
<img
|
||||
src={result.screenshot}
|
||||
alt={result.title}
|
||||
className="w-full h-full object-cover object-top"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="relative w-full h-full">
|
||||
<Image
|
||||
src={result.screenshot}
|
||||
alt={result.title}
|
||||
fill
|
||||
className="object-cover object-top"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-gray-100 to-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
||||
@@ -54,7 +54,6 @@ export default function ControlPanel({
|
||||
analysisData,
|
||||
onReset,
|
||||
}: ControlPanelProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [showAIAnalysis, setShowAIAnalysis] = useState(false); // Reserved for AI analysis feature
|
||||
const [aiInsights, setAiInsights] = useState<CheckItem[]>([]);
|
||||
const [isAnalyzingAI, setIsAnalyzingAI] = useState(false);
|
||||
@@ -344,7 +343,6 @@ export default function ControlPanel({
|
||||
};
|
||||
|
||||
// Utility function available but not used in current render
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return "text-accent-black";
|
||||
if (score >= 60) return "text-accent-black";
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function HeroScrapingTag({
|
||||
};
|
||||
|
||||
animate();
|
||||
}, []);
|
||||
}, [label]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
||||
@@ -257,7 +257,7 @@ export const encryptText = (
|
||||
export default function HomeHeroTitle() {
|
||||
return (
|
||||
<h1 className="text-title-h1 mx-auto text-center [&_span]:text-heat-100 mb-12 lg:mb-16">
|
||||
Open Lovable <span>v2</span>
|
||||
Open Lovable <span>v3</span>
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { appConfig } from "@/config/app.config";
|
||||
|
||||
interface SidebarInputProps {
|
||||
onSubmit: (url: string, style: string, model: string, instructions?: string) => void;
|
||||
@@ -11,7 +12,7 @@ interface SidebarInputProps {
|
||||
export default function SidebarInput({ onSubmit, disabled = false }: SidebarInputProps) {
|
||||
const [url, setUrl] = useState<string>("");
|
||||
const [selectedStyle, setSelectedStyle] = useState<string>("1");
|
||||
const [selectedModel, setSelectedModel] = useState<string>("moonshotai/kimi-k2-instruct-0905");
|
||||
const [selectedModel, setSelectedModel] = useState<string>(appConfig.ai.defaultModel);
|
||||
const [additionalInstructions, setAdditionalInstructions] = useState<string>("");
|
||||
const [isValidUrl, setIsValidUrl] = useState<boolean>(false);
|
||||
|
||||
@@ -33,12 +34,10 @@ export default function SidebarInput({ onSubmit, disabled = false }: SidebarInpu
|
||||
{ id: "8", name: "Retro Wave", description: "80s inspired" },
|
||||
];
|
||||
|
||||
const models = [
|
||||
{ id: "moonshotai/kimi-k2-instruct-0905", name: "Kimi K2 0905 on Groq" },
|
||||
{ id: "openai/gpt-5", name: "GPT-5" },
|
||||
{ id: "anthropic/claude-sonnet-4-20250514", name: "Sonnet 4" },
|
||||
{ id: "google/gemini-2.0-flash-exp", name: "Gemini 2.0" },
|
||||
];
|
||||
const models = appConfig.ai.availableModels.map(model => ({
|
||||
id: model,
|
||||
name: appConfig.ai.modelDisplayNames[model] || model,
|
||||
}));
|
||||
|
||||
const handleSubmit = (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { HTMLAttributes, useEffect, useRef } from "react";
|
||||
import { HTMLAttributes, useEffect, useRef, memo } from "react";
|
||||
|
||||
import { cn } from "@/utils/cn";
|
||||
import { setIntervalOnVisible } from "@/utils/set-timeout-on-visible";
|
||||
|
||||
import data from "./explosion-data.json";
|
||||
|
||||
export function AsciiExplosion(attrs: HTMLAttributes<HTMLDivElement>) {
|
||||
function AsciiExplosion(attrs: HTMLAttributes<HTMLDivElement>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -21,7 +21,9 @@ export function AsciiExplosion(attrs: HTMLAttributes<HTMLDivElement>) {
|
||||
if (index >= data.length) index = -40;
|
||||
if (index < 0) return;
|
||||
|
||||
ref.current!.innerHTML = data[index];
|
||||
if (ref.current) {
|
||||
ref.current.innerHTML = data[index];
|
||||
}
|
||||
},
|
||||
interval: 40,
|
||||
});
|
||||
@@ -52,5 +54,11 @@ export function AsciiExplosion(attrs: HTMLAttributes<HTMLDivElement>) {
|
||||
);
|
||||
}
|
||||
|
||||
// Memoized version to prevent re-renders on parent state changes
|
||||
const MemoizedAsciiExplosion = memo(AsciiExplosion);
|
||||
|
||||
// Named export
|
||||
export { AsciiExplosion };
|
||||
|
||||
// Default export for backward compatibility
|
||||
export default AsciiExplosion;
|
||||
export default MemoizedAsciiExplosion;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, memo } from "react";
|
||||
|
||||
import { setIntervalOnVisible } from "@/utils/set-timeout-on-visible";
|
||||
import data from "./hero-flame-data.json";
|
||||
|
||||
export default function HeroFlame() {
|
||||
function HeroFlame() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const ref2 = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
@@ -16,16 +16,13 @@ export default function HeroFlame() {
|
||||
const interval = setIntervalOnVisible({
|
||||
element: wrapperRef.current,
|
||||
callback: () => {
|
||||
if (!ref.current || !ref2.current) return;
|
||||
|
||||
index++;
|
||||
if (index >= data.length) index = 0;
|
||||
|
||||
if (ref.current) {
|
||||
ref.current.innerHTML = data[index];
|
||||
}
|
||||
|
||||
if (ref2.current) {
|
||||
ref2.current.innerHTML = data[index];
|
||||
}
|
||||
ref.current.innerHTML = data[index];
|
||||
ref2.current.innerHTML = data[index];
|
||||
},
|
||||
interval: 85,
|
||||
});
|
||||
@@ -66,3 +63,5 @@ export default function HeroFlame() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(HeroFlame);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React, { useEffect, useRef, useMemo } from "react";
|
||||
import { setIntervalOnVisible } from "@/utils/set-timeout-on-visible";
|
||||
|
||||
export default function SubtleAsciiAnimation({
|
||||
@@ -11,7 +11,7 @@ export default function SubtleAsciiAnimation({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Simple ASCII pattern for subtle animation
|
||||
const asciiFrames = [
|
||||
const asciiFrames = useMemo(() => [
|
||||
"░░░░░░░░░░░░░░░░",
|
||||
"▒░░░░░░░░░░░░░░░",
|
||||
"▒▒░░░░░░░░░░░░░░",
|
||||
@@ -30,7 +30,7 @@ export default function SubtleAsciiAnimation({
|
||||
"░░░░░░░░░░░░░▒▒░",
|
||||
"░░░░░░░░░░░░░░▒▒",
|
||||
"░░░░░░░░░░░░░░░▒",
|
||||
];
|
||||
], []);
|
||||
|
||||
useEffect(() => {
|
||||
let frameIndex = 0;
|
||||
@@ -55,7 +55,7 @@ export default function SubtleAsciiAnimation({
|
||||
return () => {
|
||||
cleanup?.();
|
||||
};
|
||||
}, []);
|
||||
}, [asciiFrames]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -79,7 +79,7 @@ const withChartIconAnimation = (IconComponent: React.ComponentType<any>) => {
|
||||
}) => {
|
||||
const controls = useAnimation();
|
||||
|
||||
const handleHoverStart = async () => {
|
||||
const handleHoverStart = useCallback(async () => {
|
||||
await controls.start((i) => ({
|
||||
pathLength: 0,
|
||||
opacity: 0,
|
||||
@@ -90,7 +90,11 @@ const withChartIconAnimation = (IconComponent: React.ComponentType<any>) => {
|
||||
opacity: 1,
|
||||
transition: { delay: i * 0.1, duration: 0.3 },
|
||||
}));
|
||||
};
|
||||
}, [controls]);
|
||||
|
||||
const handleHoverEnd = useCallback(() => {
|
||||
controls.start("normal");
|
||||
}, [controls]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHovered) {
|
||||
@@ -98,11 +102,7 @@ const withChartIconAnimation = (IconComponent: React.ComponentType<any>) => {
|
||||
} else {
|
||||
handleHoverEnd();
|
||||
}
|
||||
}, [isHovered, handleHoverStart]);
|
||||
|
||||
const handleHoverEnd = () => {
|
||||
controls.start("normal");
|
||||
};
|
||||
}, [isHovered, handleHoverStart, handleHoverEnd]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function SlackNotification({
|
||||
@@ -32,10 +33,12 @@ export default function SlackNotification({
|
||||
>
|
||||
<div className="flex flex-row items-center max-w-xs cursor-pointer">
|
||||
<div className="flex min-w-16 mr-2 mt-1">
|
||||
<img
|
||||
<Image
|
||||
className="w-16 h-16"
|
||||
src="/images/slack_logo_icon.png"
|
||||
alt="Slack Logo"
|
||||
width={64}
|
||||
height={64}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from "@/utils/cn";
|
||||
import React, { useRef, useState, useEffect } from "react";
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||
|
||||
export function JsonErrorHighlighter({
|
||||
value,
|
||||
@@ -31,7 +31,7 @@ export function JsonErrorHighlighter({
|
||||
const errorLineIdx = (error?.line ?? 1) - 1;
|
||||
|
||||
// Calculate visible lines on scroll or resize
|
||||
const recalcVisibleLines = () => {
|
||||
const recalcVisibleLines = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 24;
|
||||
@@ -49,7 +49,7 @@ export function JsonErrorHighlighter({
|
||||
lineHeight,
|
||||
clientHeight,
|
||||
});
|
||||
};
|
||||
}, [lines.length]);
|
||||
|
||||
useEffect(() => {
|
||||
recalcVisibleLines();
|
||||
@@ -57,7 +57,7 @@ export function JsonErrorHighlighter({
|
||||
const handleResize = () => recalcVisibleLines();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [value]);
|
||||
}, [value, recalcVisibleLines]);
|
||||
|
||||
// Attach scroll handler
|
||||
const handleScroll = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
export default function LivePreviewFrame({
|
||||
sessionId,
|
||||
@@ -19,6 +19,7 @@ export default function LivePreviewFrame({
|
||||
const idleStartTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const idleMoveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
const [isConnecting, setIsConnecting] = useState(true);
|
||||
const [cursorPosition, setCursorPosition] = useState<{
|
||||
x: number;
|
||||
@@ -31,7 +32,7 @@ export default function LivePreviewFrame({
|
||||
const [isIdle, setIsIdle] = useState(false);
|
||||
|
||||
// Function to start the random idle movement sequence
|
||||
const scheduleNextIdleMove = () => {
|
||||
const scheduleNextIdleMove = useCallback(() => {
|
||||
if (idleMoveTimerRef.current) {
|
||||
clearTimeout(idleMoveTimerRef.current);
|
||||
}
|
||||
@@ -49,7 +50,7 @@ export default function LivePreviewFrame({
|
||||
scheduleNextIdleMove(); // Schedule the next one
|
||||
}
|
||||
}, randomDelay);
|
||||
};
|
||||
}, [isIdle]);
|
||||
|
||||
// Effect to handle starting/stopping idle movement sequence
|
||||
useEffect(() => {
|
||||
@@ -66,7 +67,7 @@ export default function LivePreviewFrame({
|
||||
clearTimeout(idleMoveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isIdle]);
|
||||
}, [isIdle, scheduleNextIdleMove]);
|
||||
|
||||
// Main Animation effect (runs continuously)
|
||||
useEffect(() => {
|
||||
@@ -122,7 +123,7 @@ export default function LivePreviewFrame({
|
||||
clearTimeout(idleStartTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [targetPosition]); // Re-run main loop logic if targetPosition changes
|
||||
}, [targetPosition, isIdle]); // Re-run main loop logic if targetPosition changes
|
||||
|
||||
const cleanupConnection = () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
@@ -150,7 +151,7 @@ export default function LivePreviewFrame({
|
||||
}
|
||||
}, [onScrapeComplete]);
|
||||
|
||||
const connect = () => {
|
||||
const connect = useCallback(() => {
|
||||
setIsConnecting(true);
|
||||
// Clear any existing connection
|
||||
if (wsRef.current) {
|
||||
@@ -182,10 +183,8 @@ export default function LivePreviewFrame({
|
||||
typeof event.data === "string" &&
|
||||
event.data.startsWith("data:image")
|
||||
) {
|
||||
if (imgRef.current) {
|
||||
imgRef.current.src = event.data;
|
||||
return;
|
||||
}
|
||||
setImageSrc(event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not direct image data, try parsing as JSON
|
||||
@@ -232,19 +231,15 @@ export default function LivePreviewFrame({
|
||||
}
|
||||
}
|
||||
|
||||
if (imgRef.current && data.frame) {
|
||||
if (data.frame) {
|
||||
const img = "data:image/jpeg;base64," + data.frame;
|
||||
localStorage.setItem("browserImageData", img);
|
||||
imgRef.current.src = img;
|
||||
setImageSrc(img);
|
||||
}
|
||||
} catch (e) {
|
||||
// Try to use raw data as fallback if JSON parsing fails
|
||||
if (typeof event.data === "string" && imgRef.current) {
|
||||
try {
|
||||
imgRef.current.src = event.data;
|
||||
} catch (imgError) {
|
||||
console.error("Failed to set image source directly:", imgError);
|
||||
}
|
||||
if (typeof event.data === "string") {
|
||||
setImageSrc(event.data);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -270,7 +265,7 @@ export default function LivePreviewFrame({
|
||||
console.error("Failed to create connection");
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
}, [sessionId, isIdle]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only connect if we have a sessionId
|
||||
@@ -289,7 +284,7 @@ export default function LivePreviewFrame({
|
||||
cleanupConnection();
|
||||
};
|
||||
}
|
||||
}, [sessionId]); // Re-run effect when sessionId changes
|
||||
}, [sessionId, connect]); // Re-run effect when sessionId changes
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -322,21 +317,25 @@ export default function LivePreviewFrame({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Preview image */}
|
||||
<img
|
||||
ref={imgRef}
|
||||
id="live-frame"
|
||||
onLoad={() => {
|
||||
setImageLoaded(true);
|
||||
if (onScrapeComplete) onScrapeComplete();
|
||||
}}
|
||||
className={`w-auto h-auto max-w-full max-h-full object-contain transform-gpu ${
|
||||
!imageLoaded ? "opacity-0 scale-95" : "opacity-100 scale-100"
|
||||
} transition-all duration-300 ease-out`}
|
||||
style={{
|
||||
backgroundColor: "#f0f0f0",
|
||||
}}
|
||||
/>
|
||||
{/* Preview image - Using regular img tag for dynamic WebSocket stream */}
|
||||
{imageSrc && (
|
||||
<img
|
||||
ref={imgRef}
|
||||
id="live-frame"
|
||||
src={imageSrc}
|
||||
alt="Live preview"
|
||||
onLoad={() => {
|
||||
setImageLoaded(true);
|
||||
if (onScrapeComplete) onScrapeComplete();
|
||||
}}
|
||||
className={`w-auto h-auto max-w-full max-h-full object-contain transform-gpu ${
|
||||
!imageLoaded ? "opacity-0 scale-95" : "opacity-100 scale-100"
|
||||
} transition-all duration-300 ease-out`}
|
||||
style={{
|
||||
backgroundColor: "#f0f0f0",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import WebBrowser from "./web-browser";
|
||||
|
||||
@@ -29,14 +29,7 @@ export default function MultipleWebBrowsers({
|
||||
const SCALE_FACTOR = scaleFactor || 0.06;
|
||||
const [activeBrowsers, setActiveBrowsers] = useState<BrowserData[]>(browsers);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRotate) {
|
||||
startRotation();
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRotate, rotationInterval]);
|
||||
|
||||
const startRotation = () => {
|
||||
const startRotation = useCallback(() => {
|
||||
interval = setInterval(() => {
|
||||
setActiveBrowsers((prevBrowsers: BrowserData[]) => {
|
||||
const newArray = [...prevBrowsers];
|
||||
@@ -44,7 +37,14 @@ export default function MultipleWebBrowsers({
|
||||
return newArray;
|
||||
});
|
||||
}, rotationInterval);
|
||||
};
|
||||
}, [rotationInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRotate) {
|
||||
startRotation();
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRotate, startRotation]);
|
||||
|
||||
return (
|
||||
<div className="relative h-[600px] w-full">
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function Spinner({ className = '', size = 'md', finished = false
|
||||
}
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
md: 'h-20 w-20',
|
||||
lg: 'h-8 w-8'
|
||||
};
|
||||
|
||||
|
||||
@@ -51,14 +51,14 @@ export const appConfig = {
|
||||
// AI Model Configuration
|
||||
ai: {
|
||||
// Default AI model
|
||||
defaultModel: 'moonshotai/kimi-k2-instruct-0905',
|
||||
defaultModel: 'google/gemini-3-pro-preview',
|
||||
|
||||
// Available models
|
||||
availableModels: [
|
||||
'openai/gpt-5',
|
||||
'moonshotai/kimi-k2-instruct-0905',
|
||||
'anthropic/claude-sonnet-4-20250514',
|
||||
'google/gemini-2.0-flash-exp'
|
||||
'google/gemini-3-pro-preview'
|
||||
],
|
||||
|
||||
// Model display names
|
||||
@@ -66,7 +66,7 @@ export const appConfig = {
|
||||
'openai/gpt-5': 'GPT-5',
|
||||
'moonshotai/kimi-k2-instruct-0905': 'Kimi K2 (Groq)',
|
||||
'anthropic/claude-sonnet-4-20250514': 'Sonnet 4',
|
||||
'google/gemini-2.0-flash-exp': 'Gemini 2.0 Flash (Experimental)'
|
||||
'google/gemini-3-pro-preview': 'Gemini 3 Pro (Preview)'
|
||||
} as Record<string, string>,
|
||||
|
||||
// Model API configuration
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
# Package Detection and Installation Guide
|
||||
|
||||
This document explains how to use the XML-based package detection and installation mechanism in the Vercel Sandbox environment.
|
||||
|
||||
## Overview
|
||||
|
||||
The Vercel Sandbox can automatically detect and install packages from XML tags in AI-generated code responses. This mechanism works alongside the existing file detection system.
|
||||
|
||||
## XML Tag Formats
|
||||
|
||||
### Individual Package Tags
|
||||
Use `<package>` tags for individual packages:
|
||||
|
||||
```xml
|
||||
<package>react-router-dom</package>
|
||||
<package>axios</package>
|
||||
<package>@heroicons/react</package>
|
||||
```
|
||||
|
||||
### Multiple Packages Tag
|
||||
Use `<packages>` tag for multiple packages (comma or newline separated):
|
||||
|
||||
```xml
|
||||
<packages>
|
||||
react-router-dom
|
||||
axios
|
||||
@heroicons/react
|
||||
framer-motion
|
||||
</packages>
|
||||
```
|
||||
|
||||
Or comma-separated:
|
||||
|
||||
```xml
|
||||
<packages>react-router-dom, axios, @heroicons/react, framer-motion</packages>
|
||||
```
|
||||
|
||||
### Command Execution
|
||||
Use `<command>` tags to execute shell commands in the sandbox:
|
||||
|
||||
```xml
|
||||
<command>npm run build</command>
|
||||
<command>npm run test</command>
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a complete example of an AI response with files, packages, and commands:
|
||||
|
||||
```xml
|
||||
<explanation>
|
||||
Creating a React application with routing and API integration.
|
||||
</explanation>
|
||||
|
||||
<packages>
|
||||
react-router-dom
|
||||
axios
|
||||
@heroicons/react
|
||||
</packages>
|
||||
|
||||
<file path="src/App.jsx">
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { HomeIcon } from '@heroicons/react/24/solid';
|
||||
import HomePage from './pages/HomePage';
|
||||
import AboutPage from './pages/AboutPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<nav className="bg-white shadow-lg p-4">
|
||||
<HomeIcon className="h-6 w-6" />
|
||||
</nav>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
</file>
|
||||
|
||||
<file path="src/pages/HomePage.jsx">
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
function HomePage() {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
axios.get('/api/data')
|
||||
.then(response => setData(response.data))
|
||||
.catch(error => console.error('Error fetching data:', error));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-8">
|
||||
<h1 className="text-3xl font-bold">Home Page</h1>
|
||||
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HomePage;
|
||||
</file>
|
||||
|
||||
<file path="src/pages/AboutPage.jsx">
|
||||
import React from 'react';
|
||||
|
||||
function AboutPage() {
|
||||
return (
|
||||
<div className="container mx-auto p-8">
|
||||
<h1 className="text-3xl font-bold">About Page</h1>
|
||||
<p>This is the about page of our application.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AboutPage;
|
||||
</file>
|
||||
|
||||
<command>npm run dev</command>
|
||||
</xml>
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Parsing**: The `parseAIResponse` function in `/app/api/apply-ai-code/route.ts` extracts:
|
||||
- Files from `<file>` tags
|
||||
- Packages from `<package>` and `<packages>` tags
|
||||
- Commands from `<command>` tags
|
||||
|
||||
2. **Package Installation**:
|
||||
- Packages are automatically installed using npm
|
||||
- Both scoped packages (e.g., `@heroicons/react`) and regular packages are supported
|
||||
- The system checks if packages are already installed to avoid redundant installations
|
||||
|
||||
3. **File Creation**: Files are created in the sandbox after packages are installed
|
||||
|
||||
4. **Command Execution**: Commands are executed in the sandbox environment
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### `/api/apply-ai-code`
|
||||
Main endpoint that processes AI responses containing XML tags.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"response": "<AI response with XML tags>",
|
||||
"isEdit": false,
|
||||
"packages": [] // Optional array of packages
|
||||
}
|
||||
```
|
||||
|
||||
### `/api/detect-and-install-packages`
|
||||
Detects packages from import statements in code files.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"files": {
|
||||
"src/App.jsx": "import React from 'react'...",
|
||||
"src/utils.js": "import axios from 'axios'..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `/api/install-packages`
|
||||
Directly installs packages in the sandbox.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"packages": ["react-router-dom", "axios", "@heroicons/react"]
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Automatic Package Detection**: Extracts packages from import statements
|
||||
- **Duplicate Prevention**: Avoids installing already-installed packages
|
||||
- **Scoped Package Support**: Handles packages like `@heroicons/react`
|
||||
- **Built-in Module Filtering**: Skips Node.js built-in modules
|
||||
- **Real-time Feedback**: Provides installation progress updates
|
||||
- **Error Handling**: Reports failed installations
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Specify packages explicitly** using XML tags when possible
|
||||
2. **Group related packages** in a single `<packages>` tag
|
||||
3. **Order matters**: Packages are installed before files are created
|
||||
4. **Use commands** for post-installation tasks like building or testing
|
||||
|
||||
## Integration with Vercel Sandbox
|
||||
|
||||
The package detection mechanism integrates seamlessly with the Vercel Sandbox:
|
||||
|
||||
1. Packages are installed in the sandbox's working directory
|
||||
2. The development server is automatically restarted after package installation
|
||||
3. All npm operations run within the sandbox environment
|
||||
4. Package.json is automatically updated with new dependencies
|
||||
|
||||
## Vercel Sandbox Command Execution Methods
|
||||
|
||||
### Using runCommand() (Recommended)
|
||||
```javascript
|
||||
// Direct command execution using Vercel Sandbox API
|
||||
const result = await global.activeSandbox.runCommand({
|
||||
cmd: 'npm',
|
||||
args: ['install', 'axios']
|
||||
});
|
||||
const stdout = await result.stdout();
|
||||
const stderr = await result.stderr();
|
||||
console.log(stdout);
|
||||
```
|
||||
|
||||
### Command Execution Options
|
||||
|
||||
When using `sandbox.runCommand()`, you can specify:
|
||||
- `cmd`: The command to execute
|
||||
- `args`: Array of arguments
|
||||
- `detached`: Run in background (for long-running processes)
|
||||
- `stdout`: Stream for capturing stdout
|
||||
- `stderr`: Stream for capturing stderr
|
||||
- `cmd`: Command string to execute
|
||||
- `background`: Run in background (true) or wait for completion (false)
|
||||
- `envs`: Environment variables as key-value pairs
|
||||
- `user`: User to run command as (default: "user")
|
||||
- `cwd`: Working directory
|
||||
- `on_stdout`: Callback for stdout output
|
||||
- `on_stderr`: Callback for stderr output
|
||||
- `timeout`: Command timeout in seconds (default: 60)
|
||||
|
||||
### Example: Installing packages with commands.run()
|
||||
```javascript
|
||||
// Install multiple packages
|
||||
const packages = ['react-router-dom', 'axios', '@heroicons/react'];
|
||||
const result = await global.activeSandbox.commands.run(
|
||||
`npm install ${packages.join(' ')}`,
|
||||
{
|
||||
cwd: '/home/user/app',
|
||||
timeout: 120,
|
||||
on_stdout: (data) => console.log('npm:', data),
|
||||
on_stderr: (data) => console.error('npm error:', data)
|
||||
}
|
||||
);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
console.log('Packages installed successfully');
|
||||
} else {
|
||||
console.error('Installation failed:', result.stderr);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
# Streaming API Fixes Summary
|
||||
|
||||
## Issues Fixed
|
||||
|
||||
### 1. "Cannot read properties of undefined (reading 'split')"
|
||||
**Location**: `/api/install-packages/route.ts` line 119
|
||||
**Cause**: `installResult.output` was undefined
|
||||
**Fix**: Added fallback to handle different output formats:
|
||||
```typescript
|
||||
const output = installResult?.output || installResult?.logs?.stdout?.join('\n') || '';
|
||||
```
|
||||
|
||||
### 2. "Cannot read properties of undefined (reading 'push')"
|
||||
**Location**: `/api/apply-ai-code-stream/route.ts` various lines
|
||||
**Causes**:
|
||||
- Arrays not properly initialized
|
||||
- Results object properties accessed without checks
|
||||
|
||||
**Fixes**:
|
||||
- Added array checks before operations:
|
||||
```typescript
|
||||
const packagesArray = Array.isArray(packages) ? packages : [];
|
||||
const parsedPackages = Array.isArray(parsed.packages) ? parsed.packages : [];
|
||||
const filesArray = Array.isArray(parsed.files) ? parsed.files : [];
|
||||
const commandsArray = Array.isArray(parsed.commands) ? parsed.commands : [];
|
||||
```
|
||||
|
||||
- Added null checks before push operations:
|
||||
```typescript
|
||||
if (results.filesCreated) results.filesCreated.push(normalizedPath);
|
||||
if (results.errors) results.errors.push(`Failed to create ${file.path}`);
|
||||
```
|
||||
|
||||
### 3. Improved Error Handling
|
||||
- Added checks for undefined chunks in streaming
|
||||
- Added proper error messages for all failure cases
|
||||
- Ensured all arrays are initialized before use
|
||||
|
||||
## Current Status
|
||||
|
||||
✅ Package detection working via XML tags
|
||||
✅ Real-time streaming feedback operational
|
||||
✅ File creation/update tracking functional
|
||||
✅ Command execution with output streaming
|
||||
✅ Error messages properly displayed
|
||||
|
||||
## Known Issues
|
||||
|
||||
1. **NPM Resolution Errors**: When packages have conflicting dependencies, npm may show ERESOLVE errors. This is expected behavior and doesn't break the functionality.
|
||||
|
||||
2. **Package Installation Verification**: The current implementation tries to verify package installation by checking the filesystem. This might not always work for all package types.
|
||||
|
||||
## UI Feedback Flow
|
||||
|
||||
Users now see:
|
||||
1. 🔍 Analyzing code and detecting dependencies
|
||||
2. 📦 Starting code application
|
||||
3. Step 1: Installing X packages (with real-time npm output)
|
||||
4. Step 2: Creating Y files (with progress indicators)
|
||||
5. Step 3: Executing Z commands (with output streaming)
|
||||
6. ✅ Success message with summary
|
||||
|
||||
All errors are displayed inline with context, making debugging easier.
|
||||
@@ -1,67 +0,0 @@
|
||||
# Tool Call Validation Fix Summary
|
||||
|
||||
## Issue
|
||||
The error message "tool call validation failed: parameters for tool installPackage did not match schema" was occurring when the AI tried to install packages.
|
||||
|
||||
## Root Cause
|
||||
The Groq models (including `moonshotai/kimi-k2-instruct`) do not support function/tool calling. This is a limitation of most Groq models - only specific models like `llama3-groq-70b-8192-tool-use-preview` support tools.
|
||||
|
||||
## Solution
|
||||
Instead of using the Vercel AI SDK's tool calling feature, we switched to XML-based package detection:
|
||||
|
||||
### 1. Removed Tool Support
|
||||
- Removed the `tool` import and `installPackage` tool definition
|
||||
- Removed the `tools` configuration from the `streamText` call
|
||||
|
||||
### 2. Updated System Prompt
|
||||
Changed from:
|
||||
```
|
||||
Use the installPackage tool with parameters: {name: "package-name", reason: "why you need it"}
|
||||
```
|
||||
|
||||
To:
|
||||
```
|
||||
You MUST specify packages using <package> tags BEFORE using them in your code.
|
||||
For example: <package>three</package> or <package>@heroicons/react</package>
|
||||
```
|
||||
|
||||
### 3. Implemented XML Tag Detection
|
||||
- Added streaming detection for `<package>` tags during response generation
|
||||
- Implemented buffering to handle tags split across chunks
|
||||
- Added support for both individual `<package>` tags and grouped `<packages>` tags
|
||||
|
||||
### 4. Real-time Package Detection
|
||||
Packages are now detected in real-time as the AI generates the response:
|
||||
```javascript
|
||||
// Buffer incomplete tags across chunks
|
||||
const searchText = tagBuffer + text;
|
||||
const packageRegex = /<package>([^<]+)<\/package>/g;
|
||||
|
||||
while ((packageMatch = packageRegex.exec(searchText)) !== null) {
|
||||
const packageName = packageMatch[1].trim();
|
||||
if (packageName && !packagesToInstall.includes(packageName)) {
|
||||
packagesToInstall.push(packageName);
|
||||
await sendProgress({
|
||||
type: 'package',
|
||||
name: packageName,
|
||||
message: `📦 Package detected: ${packageName}`
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Results
|
||||
- ✅ Package detection now works reliably
|
||||
- ✅ Real-time UI feedback shows packages as they're detected
|
||||
- ✅ No more tool validation errors
|
||||
- ✅ Compatible with all Groq models
|
||||
|
||||
## UI Feedback
|
||||
Users now see:
|
||||
```
|
||||
📦 Package detected: three
|
||||
📦 Package detected: @react-three/fiber
|
||||
📦 Package detected: @react-three/drei
|
||||
```
|
||||
|
||||
As packages are detected in the AI's response, providing immediate feedback about dependencies that will be installed.
|
||||
@@ -1,133 +0,0 @@
|
||||
# UI Feedback Demonstration
|
||||
|
||||
This document demonstrates the new real-time feedback mechanism for package installation and command execution in the E2B sandbox UI.
|
||||
|
||||
## What's New
|
||||
|
||||
### 1. Real-time Package Installation Feedback
|
||||
|
||||
When packages are detected and installed from XML tags, users now see:
|
||||
|
||||
- 🔍 **Initial Analysis**: "Analyzing code and detecting dependencies..."
|
||||
- 📦 **Package Detection**: "Step 1: Installing X packages..."
|
||||
- **NPM Output**: Real-time npm install output with proper formatting
|
||||
- Blue text for commands (`$ npm install react-router-dom`)
|
||||
- Gray text for standard output
|
||||
- Red text for errors
|
||||
- ✅ **Success Messages**: Clear confirmation when packages are installed
|
||||
|
||||
### 2. File Creation Progress
|
||||
|
||||
- 📝 **File Creation Start**: "Creating X files..."
|
||||
- **Individual File Updates**: Progress for each file being created/updated
|
||||
- ✅ **Completion Status**: Visual confirmation for each file
|
||||
|
||||
### 3. Command Execution Feedback
|
||||
|
||||
When `<command>` tags are executed:
|
||||
|
||||
- ⚡ **Command Start**: Shows the command being executed
|
||||
- **Real-time Output**: Displays stdout/stderr as it happens
|
||||
- ✅/❌ **Exit Status**: Clear success/failure indicators
|
||||
|
||||
## Example Flow
|
||||
|
||||
Here's what users see when applying code with packages and commands:
|
||||
|
||||
```
|
||||
🔍 Analyzing code and detecting dependencies...
|
||||
📦 Starting code application...
|
||||
Step 1: Installing 3 packages...
|
||||
$ npm install react-router-dom
|
||||
> added 3 packages in 2.3s
|
||||
$ npm install axios
|
||||
> added 1 package in 1.1s
|
||||
$ npm install @heroicons/react
|
||||
> added 1 package in 0.9s
|
||||
✅ Successfully installed: react-router-dom, axios, @heroicons/react
|
||||
|
||||
Step 2: Creating 5 files...
|
||||
📝 Creating 5 files...
|
||||
|
||||
Step 3: Executing 1 commands...
|
||||
⚡ executing command: npm run dev
|
||||
> app@0.0.0 dev
|
||||
> vite
|
||||
> VITE ready in 523ms
|
||||
✅ Command completed successfully
|
||||
```
|
||||
|
||||
## UI Components
|
||||
|
||||
### Chat Message Types
|
||||
|
||||
The UI now supports these message types with distinct styling:
|
||||
|
||||
1. **System Messages** (`bg-[#36322F] text-white text-sm`)
|
||||
- General information and status updates
|
||||
|
||||
2. **Command Messages** (`bg-gray-100 text-gray-600 font-mono text-sm`)
|
||||
- Input commands: Blue prefix (`$`)
|
||||
- Output: Gray text
|
||||
- Errors: Red text
|
||||
- Success: Green text
|
||||
|
||||
3. **User Messages** (`bg-[#36322F] text-white`)
|
||||
- User input and queries
|
||||
|
||||
4. **AI Messages** (`bg-secondary text-foreground`)
|
||||
- AI responses
|
||||
|
||||
### Visual Indicators
|
||||
|
||||
- 🔍 Analyzing/Detection phase
|
||||
- 📦 Package operations
|
||||
- 📝 File operations
|
||||
- ⚡ Command execution
|
||||
- ✅ Success states
|
||||
- ❌ Error states
|
||||
- ⚠️ Warnings
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Streaming Response Format
|
||||
|
||||
The new `/api/apply-ai-code-stream` endpoint sends Server-Sent Events:
|
||||
|
||||
```typescript
|
||||
data: {"type": "start", "message": "Starting code application...", "totalSteps": 3}
|
||||
data: {"type": "step", "step": 1, "message": "Installing 3 packages..."}
|
||||
data: {"type": "package-progress", "type": "output", "message": "added 3 packages"}
|
||||
data: {"type": "file-progress", "current": 1, "total": 5, "fileName": "App.jsx"}
|
||||
data: {"type": "command-output", "command": "npm run dev", "output": "VITE ready", "stream": "stdout"}
|
||||
data: {"type": "complete", "results": {...}, "message": "Success"}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Errors are displayed inline with context:
|
||||
|
||||
- Package installation failures
|
||||
- File creation errors
|
||||
- Command execution failures
|
||||
|
||||
Each error includes the specific operation that failed and helpful error messages.
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Transparency**: Users see exactly what's happening in real-time
|
||||
2. **Debugging**: Easy to identify where issues occur
|
||||
3. **Progress Tracking**: Clear indication of progress through multi-step operations
|
||||
4. **Professional Feel**: Terminal-like output for technical operations
|
||||
5. **Accessibility**: Color-coded output for quick scanning
|
||||
|
||||
## Usage
|
||||
|
||||
The feedback system automatically activates when:
|
||||
|
||||
1. Code with `<package>` or `<packages>` tags is applied
|
||||
2. Files are created or updated
|
||||
3. Commands from `<command>` tags are executed
|
||||
4. Packages are auto-detected from import statements
|
||||
|
||||
No additional configuration is required - the UI automatically provides rich feedback for all operations.
|
||||
@@ -19,6 +19,12 @@ const eslintConfig = [
|
||||
"react/no-unescaped-entities": "off",
|
||||
"prefer-const": "warn"
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["**/live-preview-frame.tsx"],
|
||||
rules: {
|
||||
"@next/next/no-img-element": "off" // Dynamic WebSocket stream images require regular img tag
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { appConfig } from '@/config/app.config';
|
||||
import { createGroq } from '@ai-sdk/groq';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
|
||||
type ProviderName = 'openai' | 'anthropic' | 'groq' | 'google';
|
||||
|
||||
// Client function type returned by @ai-sdk providers
|
||||
export type ProviderClient =
|
||||
| ReturnType<typeof createOpenAI>
|
||||
| ReturnType<typeof createAnthropic>
|
||||
| ReturnType<typeof createGroq>
|
||||
| ReturnType<typeof createGoogleGenerativeAI>;
|
||||
|
||||
export interface ProviderResolution {
|
||||
client: ProviderClient;
|
||||
actualModel: string;
|
||||
}
|
||||
|
||||
const aiGatewayApiKey = process.env.AI_GATEWAY_API_KEY;
|
||||
const aiGatewayBaseURL = 'https://ai-gateway.vercel.sh/v1';
|
||||
const isUsingAIGateway = !!aiGatewayApiKey;
|
||||
|
||||
// Cache provider clients by a stable key to avoid recreating
|
||||
const clientCache = new Map<string, ProviderClient>();
|
||||
|
||||
function getEnvDefaults(provider: ProviderName): { apiKey?: string; baseURL?: string } {
|
||||
if (isUsingAIGateway) {
|
||||
return { apiKey: aiGatewayApiKey, baseURL: aiGatewayBaseURL };
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
return { apiKey: process.env.OPENAI_API_KEY, baseURL: process.env.OPENAI_BASE_URL };
|
||||
case 'anthropic':
|
||||
// Default Anthropic base URL mirrors existing routes
|
||||
return { apiKey: process.env.ANTHROPIC_API_KEY, baseURL: process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com/v1' };
|
||||
case 'groq':
|
||||
return { apiKey: process.env.GROQ_API_KEY, baseURL: process.env.GROQ_BASE_URL };
|
||||
case 'google':
|
||||
return { apiKey: process.env.GEMINI_API_KEY, baseURL: process.env.GEMINI_BASE_URL };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateClient(provider: ProviderName, apiKey?: string, baseURL?: string): ProviderClient {
|
||||
const effective = isUsingAIGateway
|
||||
? { apiKey: aiGatewayApiKey, baseURL: aiGatewayBaseURL }
|
||||
: { apiKey, baseURL };
|
||||
|
||||
const cacheKey = `${provider}:${effective.apiKey || ''}:${effective.baseURL || ''}`;
|
||||
const cached = clientCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
let client: ProviderClient;
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
client = createOpenAI({ apiKey: effective.apiKey || getEnvDefaults('openai').apiKey, baseURL: effective.baseURL ?? getEnvDefaults('openai').baseURL });
|
||||
break;
|
||||
case 'anthropic':
|
||||
client = createAnthropic({ apiKey: effective.apiKey || getEnvDefaults('anthropic').apiKey, baseURL: effective.baseURL ?? getEnvDefaults('anthropic').baseURL });
|
||||
break;
|
||||
case 'groq':
|
||||
client = createGroq({ apiKey: effective.apiKey || getEnvDefaults('groq').apiKey, baseURL: effective.baseURL ?? getEnvDefaults('groq').baseURL });
|
||||
break;
|
||||
case 'google':
|
||||
client = createGoogleGenerativeAI({ apiKey: effective.apiKey || getEnvDefaults('google').apiKey, baseURL: effective.baseURL ?? getEnvDefaults('google').baseURL });
|
||||
break;
|
||||
default:
|
||||
client = createGroq({ apiKey: effective.apiKey || getEnvDefaults('groq').apiKey, baseURL: effective.baseURL ?? getEnvDefaults('groq').baseURL });
|
||||
}
|
||||
|
||||
clientCache.set(cacheKey, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
export function getProviderForModel(modelId: string): ProviderResolution {
|
||||
// 1) Check explicit model configuration in app config (custom models)
|
||||
const configured = appConfig.ai.modelApiConfig?.[modelId as keyof typeof appConfig.ai.modelApiConfig];
|
||||
if (configured) {
|
||||
const { provider, apiKey, baseURL, model } = configured as { provider: ProviderName; apiKey?: string; baseURL?: string; model: string };
|
||||
const client = getOrCreateClient(provider, apiKey, baseURL);
|
||||
return { client, actualModel: model };
|
||||
}
|
||||
|
||||
// 2) Fallback logic based on prefixes and special cases
|
||||
const isAnthropic = modelId.startsWith('anthropic/');
|
||||
const isOpenAI = modelId.startsWith('openai/');
|
||||
const isGoogle = modelId.startsWith('google/');
|
||||
const isKimiGroq = modelId === 'moonshotai/kimi-k2-instruct-0905';
|
||||
|
||||
if (isKimiGroq) {
|
||||
const client = getOrCreateClient('groq');
|
||||
return { client, actualModel: 'moonshotai/kimi-k2-instruct-0905' };
|
||||
}
|
||||
|
||||
if (isAnthropic) {
|
||||
const client = getOrCreateClient('anthropic');
|
||||
return { client, actualModel: modelId.replace('anthropic/', '') };
|
||||
}
|
||||
|
||||
if (isOpenAI) {
|
||||
const client = getOrCreateClient('openai');
|
||||
return { client, actualModel: modelId.replace('openai/', '') };
|
||||
}
|
||||
|
||||
if (isGoogle) {
|
||||
const client = getOrCreateClient('google');
|
||||
return { client, actualModel: modelId.replace('google/', '') };
|
||||
}
|
||||
|
||||
// Default: use Groq with modelId as-is
|
||||
const client = getOrCreateClient('groq');
|
||||
return { client, actualModel: modelId };
|
||||
}
|
||||
|
||||
export default getProviderForModel;
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
export interface BuildValidation {
|
||||
success: boolean;
|
||||
errors: string[];
|
||||
isRendering: boolean;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the sandbox build was successful
|
||||
* Checks compilation status and verifies app is rendering
|
||||
*/
|
||||
export async function validateBuild(sandboxUrl: string, sandboxId: string): Promise<BuildValidation> {
|
||||
try {
|
||||
// Step 1: Wait for Vite to process files (give it time to compile)
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
// Step 2: Check if the sandbox is actually serving content
|
||||
const response = await fetch(sandboxUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'OpenLovable-Validator',
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [`Sandbox returned ${response.status}`],
|
||||
isRendering: false
|
||||
};
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// Step 3: Check if it's the default page or actual app
|
||||
const isDefaultPage =
|
||||
html.includes('Vercel Sandbox Ready') ||
|
||||
html.includes('Start building your React app with Vite') ||
|
||||
html.includes('Vite + React') ||
|
||||
!html.includes('id="root"');
|
||||
|
||||
if (isDefaultPage) {
|
||||
return {
|
||||
success: false,
|
||||
errors: ['Sandbox showing default page, app not rendered'],
|
||||
isRendering: false
|
||||
};
|
||||
}
|
||||
|
||||
// Step 4: Check for Vite error overlay in HTML
|
||||
const hasViteError = html.includes('vite-error-overlay');
|
||||
if (hasViteError) {
|
||||
// Try to extract error message
|
||||
const errorMatch = html.match(/Failed to resolve import "([^"]+)"/);
|
||||
const error = errorMatch
|
||||
? `Missing package: ${errorMatch[1]}`
|
||||
: 'Vite compilation error detected';
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errors: [error],
|
||||
isRendering: false
|
||||
};
|
||||
}
|
||||
|
||||
// Success! App is rendering
|
||||
return {
|
||||
success: true,
|
||||
errors: [],
|
||||
isRendering: true
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[validateBuild] Error during validation:', error);
|
||||
return {
|
||||
success: false,
|
||||
errors: [error instanceof Error ? error.message : 'Validation failed'],
|
||||
isRendering: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts missing package names from error messages
|
||||
*/
|
||||
export function extractMissingPackages(error: any): string[] {
|
||||
const message = error?.message || String(error);
|
||||
const packages: string[] = [];
|
||||
|
||||
// Pattern 1: "Failed to resolve import 'package-name'"
|
||||
const importMatches = message.matchAll(/Failed to resolve import ["']([^"']+)["']/g);
|
||||
for (const match of importMatches) {
|
||||
packages.push(match[1]);
|
||||
}
|
||||
|
||||
// Pattern 2: "Cannot find module 'package-name'"
|
||||
const moduleMatches = message.matchAll(/Cannot find module ["']([^"']+)["']/g);
|
||||
for (const match of moduleMatches) {
|
||||
packages.push(match[1]);
|
||||
}
|
||||
|
||||
// Pattern 3: "Package 'package-name' not found"
|
||||
const packageMatches = message.matchAll(/Package ["']([^"']+)["'] not found/g);
|
||||
for (const match of packageMatches) {
|
||||
packages.push(match[1]);
|
||||
}
|
||||
|
||||
return [...new Set(packages)]; // Remove duplicates
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies error type for targeted recovery
|
||||
*/
|
||||
export type ErrorType = 'missing-package' | 'syntax-error' | 'sandbox-timeout' | 'not-rendered' | 'vite-error' | 'unknown';
|
||||
|
||||
export function classifyError(error: any): ErrorType {
|
||||
const message = (error?.message || String(error)).toLowerCase();
|
||||
|
||||
if (message.includes('failed to resolve import') ||
|
||||
message.includes('cannot find module') ||
|
||||
message.includes('missing package')) {
|
||||
return 'missing-package';
|
||||
}
|
||||
|
||||
if (message.includes('syntax error') ||
|
||||
message.includes('unexpected token') ||
|
||||
message.includes('parsing error')) {
|
||||
return 'syntax-error';
|
||||
}
|
||||
|
||||
if (message.includes('timeout') ||
|
||||
message.includes('not responding') ||
|
||||
message.includes('timed out')) {
|
||||
return 'sandbox-timeout';
|
||||
}
|
||||
|
||||
if (message.includes('not rendered') ||
|
||||
message.includes('sandbox ready') ||
|
||||
message.includes('default page')) {
|
||||
return 'not-rendered';
|
||||
}
|
||||
|
||||
if (message.includes('vite') ||
|
||||
message.includes('compilation')) {
|
||||
return 'vite-error';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates retry delay based on attempt number and error type
|
||||
*/
|
||||
export function calculateRetryDelay(attempt: number, errorType: ErrorType): number {
|
||||
const baseDelay = 2000; // 2 seconds
|
||||
|
||||
// Different strategies for different errors
|
||||
switch (errorType) {
|
||||
case 'missing-package':
|
||||
// Packages need time to install
|
||||
return baseDelay * 2 * attempt; // 4s, 8s, 12s
|
||||
|
||||
case 'not-rendered':
|
||||
// Vite needs time to compile
|
||||
return baseDelay * 3 * attempt; // 6s, 12s, 18s
|
||||
|
||||
case 'vite-error':
|
||||
// Vite restart needed
|
||||
return baseDelay * 2 * attempt;
|
||||
|
||||
case 'sandbox-timeout':
|
||||
// Sandbox might be slow
|
||||
return baseDelay * 4 * attempt; // 8s, 16s, 24s
|
||||
|
||||
default:
|
||||
// Standard exponential backoff
|
||||
return baseDelay * attempt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// Using direct fetch to Morph's OpenAI-compatible API to avoid SDK type issues
|
||||
|
||||
export interface MorphEditBlock {
|
||||
targetFile: string;
|
||||
instructions: string;
|
||||
update: string;
|
||||
}
|
||||
|
||||
export interface MorphApplyResult {
|
||||
success: boolean;
|
||||
normalizedPath?: string;
|
||||
mergedCode?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Normalize project-relative paths to sandbox layout
|
||||
export function normalizeProjectPath(inputPath: string): { normalizedPath: string; fullPath: string } {
|
||||
let normalizedPath = inputPath.trim();
|
||||
if (normalizedPath.startsWith('/')) normalizedPath = normalizedPath.slice(1);
|
||||
|
||||
const configFiles = new Set([
|
||||
'tailwind.config.js',
|
||||
'vite.config.js',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'tsconfig.json',
|
||||
'postcss.config.js'
|
||||
]);
|
||||
|
||||
const fileName = normalizedPath.split('/').pop() || '';
|
||||
if (!normalizedPath.startsWith('src/') &&
|
||||
!normalizedPath.startsWith('public/') &&
|
||||
normalizedPath !== 'index.html' &&
|
||||
!configFiles.has(fileName)) {
|
||||
normalizedPath = 'src/' + normalizedPath;
|
||||
}
|
||||
|
||||
const fullPath = `/home/user/app/${normalizedPath}`;
|
||||
return { normalizedPath, fullPath };
|
||||
}
|
||||
|
||||
async function morphChatCompletionsCreate(payload: any) {
|
||||
if (!process.env.MORPH_API_KEY) throw new Error('MORPH_API_KEY is not set');
|
||||
const res = await fetch('https://api.morphllm.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${process.env.MORPH_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Morph API error ${res.status}: ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Parse <edit> blocks from LLM output
|
||||
export function parseMorphEdits(text: string): MorphEditBlock[] {
|
||||
const edits: MorphEditBlock[] = [];
|
||||
const editRegex = /<edit\s+target_file="([^"]+)">([\s\S]*?)<\/edit>/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = editRegex.exec(text)) !== null) {
|
||||
const targetFile = match[1].trim();
|
||||
const inner = match[2];
|
||||
const instrMatch = inner.match(/<instructions>([\s\S]*?)<\/instructions>/);
|
||||
const updateMatch = inner.match(/<update>([\s\S]*?)<\/update>/);
|
||||
const instructions = instrMatch ? instrMatch[1].trim() : '';
|
||||
const update = updateMatch ? updateMatch[1].trim() : '';
|
||||
if (targetFile && update) {
|
||||
edits.push({ targetFile, instructions, update });
|
||||
}
|
||||
}
|
||||
return edits;
|
||||
}
|
||||
|
||||
// Read a file from sandbox: prefers cache, then sandbox.files, then commands.run("cat ...")
|
||||
async function readFileFromSandbox(sandbox: any, normalizedPath: string, fullPath: string): Promise<string> {
|
||||
// Try backend cache first
|
||||
if ((global as any).sandboxState?.fileCache?.files?.[normalizedPath]?.content) {
|
||||
return (global as any).sandboxState.fileCache.files[normalizedPath].content as string;
|
||||
}
|
||||
|
||||
// Try E2B files API
|
||||
if (sandbox?.files?.read) {
|
||||
return await sandbox.files.read(fullPath);
|
||||
}
|
||||
|
||||
// Try provider runCommand (preferred for provider pattern)
|
||||
if (typeof sandbox?.runCommand === 'function') {
|
||||
try {
|
||||
const res = await sandbox.runCommand(`cat ${normalizedPath}`);
|
||||
if (res && typeof res.stdout === 'string') {
|
||||
return res.stdout as string;
|
||||
}
|
||||
} catch {}
|
||||
// fallback to absolute path
|
||||
try {
|
||||
const resAbs = await sandbox.runCommand(`cat ${fullPath}`);
|
||||
if (resAbs && typeof resAbs.stdout === 'string') {
|
||||
return resAbs.stdout as string;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Try shell cat via commands.run
|
||||
if (sandbox?.commands?.run) {
|
||||
const result = await sandbox.commands.run(`cat ${fullPath}`, { cwd: '/home/user/app', timeout: 30 });
|
||||
if (result?.exitCode === 0 && typeof result?.stdout === 'string') {
|
||||
return result.stdout as string;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to read file: ${normalizedPath}`);
|
||||
}
|
||||
|
||||
// Write a file to sandbox and update cache
|
||||
async function writeFileToSandbox(sandbox: any, normalizedPath: string, fullPath: string, content: string): Promise<void> {
|
||||
// Provider pattern (writeFile)
|
||||
if (typeof sandbox?.writeFile === 'function') {
|
||||
await sandbox.writeFile(normalizedPath, content);
|
||||
return;
|
||||
}
|
||||
|
||||
// Provider pattern (runCommand redirect)
|
||||
if (typeof sandbox?.runCommand === 'function') {
|
||||
// Ensure directory exists
|
||||
const dir = normalizedPath.includes('/') ? normalizedPath.substring(0, normalizedPath.lastIndexOf('/')) : '';
|
||||
if (dir) {
|
||||
try { await sandbox.runCommand(`mkdir -p ${dir}`); } catch {}
|
||||
}
|
||||
// Write via heredoc with proper escaping
|
||||
const heredoc = `bash -lc 'cat > ${normalizedPath} <<\"EOF\"\n${content.replace(/\\/g, '\\\\').replace(/\n/g, '\n').replace(/\$/g, '\$')}\nEOF'`;
|
||||
const result = await sandbox.runCommand(heredoc);
|
||||
if (result?.stdout || result?.stderr) {
|
||||
// no-op
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer E2B files API
|
||||
if (sandbox?.files?.write) {
|
||||
await sandbox.files.write(fullPath, content);
|
||||
} else if (sandbox?.runCode) {
|
||||
// Use Python to write safely
|
||||
const escaped = content
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"""/g, '\"\"\"');
|
||||
await sandbox.runCode(`
|
||||
import os
|
||||
os.makedirs(os.path.dirname("${fullPath}"), exist_ok=True)
|
||||
with open("${fullPath}", 'w') as f:
|
||||
f.write("""${escaped}""")
|
||||
print("WROTE:${fullPath}")
|
||||
`);
|
||||
} else if (sandbox?.commands?.run) {
|
||||
// Shell redirection (fallback)
|
||||
// Note: beware of special chars; this is a last-resort path
|
||||
const result = await sandbox.commands.run(`bash -lc 'mkdir -p "$(dirname "${fullPath}")" && cat > "${fullPath}" << \EOF\n${content}\nEOF'`, { cwd: '/home/user/app', timeout: 60 });
|
||||
if (result?.exitCode !== 0) {
|
||||
throw new Error(`Failed to write file via shell: ${normalizedPath}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error('No available method to write files to sandbox');
|
||||
}
|
||||
|
||||
// Update backend cache if available
|
||||
if ((global as any).sandboxState?.fileCache) {
|
||||
(global as any).sandboxState.fileCache.files[normalizedPath] = {
|
||||
content,
|
||||
lastModified: Date.now()
|
||||
};
|
||||
}
|
||||
if ((global as any).existingFiles) {
|
||||
(global as any).existingFiles.add(normalizedPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyMorphEditToFile(params: {
|
||||
sandbox: any;
|
||||
targetPath: string;
|
||||
instructions: string;
|
||||
updateSnippet: string;
|
||||
}): Promise<MorphApplyResult> {
|
||||
try {
|
||||
if (!process.env.MORPH_API_KEY) {
|
||||
return { success: false, error: 'MORPH_API_KEY not set' };
|
||||
}
|
||||
|
||||
const { normalizedPath, fullPath } = normalizeProjectPath(params.targetPath);
|
||||
|
||||
// Read original code (existence validation happens here)
|
||||
const initialCode = await readFileFromSandbox(params.sandbox, normalizedPath, fullPath);
|
||||
|
||||
const resp = await morphChatCompletionsCreate({
|
||||
model: 'morph-v3-large',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `<instruction>${params.instructions || ''}</instruction>\n<code>${initialCode}</code>\n<update>${params.updateSnippet}</update>`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const mergedCode = (resp as any)?.choices?.[0]?.message?.content || '';
|
||||
if (!mergedCode) {
|
||||
return { success: false, error: 'Morph returned empty content', normalizedPath };
|
||||
}
|
||||
|
||||
await writeFileToSandbox(params.sandbox, normalizedPath, fullPath, mergedCode);
|
||||
|
||||
return { success: true, normalizedPath, mergedCode };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -1,7 +1,14 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'www.google.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Reference in New Issue
Block a user