added fast apply
if isEdit === true & Morph API Key is in .env: - Morph Fast Apply is activated.
This commit is contained in:
+5
-1
@@ -17,4 +17,8 @@ OPENAI_API_KEY=your_openai_api_key_here
|
||||
GEMINI_API_KEY=your_gemini_api_key_here
|
||||
|
||||
# Get yours at https://console.groq.com
|
||||
GROQ_API_KEY=your_groq_api_key_here
|
||||
GROQ_API_KEY=your_groq_api_key_here
|
||||
|
||||
# Optional Morph Fast Apply
|
||||
# Get yours at https://morphllm.com/
|
||||
MORPH_API_KEY=your_fast_apply_key
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { parseMorphEdits, applyMorphEditToFile } from '@/lib/morph-fast-apply';
|
||||
import { Sandbox } from '@e2b/code-interpreter';
|
||||
import type { SandboxState } from '@/types/sandbox';
|
||||
import type { ConversationState } from '@/types/conversation';
|
||||
@@ -278,6 +279,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:');
|
||||
@@ -392,6 +399,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 : [];
|
||||
@@ -463,7 +478,7 @@ export async function POST(request: NextRequest) {
|
||||
if (data.type === 'success' && data.installedPackages) {
|
||||
results.packagesInstalled = data.installedPackages;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
@@ -496,11 +511,60 @@ 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 && sandboxInstance) {
|
||||
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: sandboxInstance,
|
||||
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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -144,6 +145,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) {
|
||||
@@ -172,6 +179,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[],
|
||||
@@ -296,9 +311,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`);
|
||||
@@ -306,6 +358,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) {
|
||||
@@ -354,7 +421,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -491,7 +558,7 @@ with open(file_path, 'w') as f:
|
||||
print(f"Auto-generated: {file_path}")
|
||||
`);
|
||||
results.filesCreated.push('src/index.css (with Tailwind)');
|
||||
} catch (error) {
|
||||
} catch {
|
||||
results.errors.push('Failed to create index.css with Tailwind');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,7 +551,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:
|
||||
@@ -897,6 +897,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) {
|
||||
@@ -1140,6 +1158,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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// 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 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> {
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user