1983 lines
68 KiB
TypeScript
1983 lines
68 KiB
TypeScript
import { GooseUltraComputerDriver, GooseUltraBrowserDriver, GooseUltraServerDriver, Project } from '../types';
|
||
import { runVibeGuard, loadCurrentState, saveCurrentState, recordInteraction, ExecutionMode, saveSnapshot } from './ContextEngine';
|
||
import { SafeGenStreamer } from './StreamHandler';
|
||
|
||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||
|
||
// Global model accessor - reads from localStorage to support Ollama integration
|
||
// This is updated by the React orchestrator when the model changes
|
||
export const getActiveModel = (): string => {
|
||
try {
|
||
const stored = localStorage.getItem('goose-active-model');
|
||
return stored || 'qwen-coder-plus';
|
||
} catch {
|
||
return 'qwen-coder-plus';
|
||
}
|
||
};
|
||
|
||
export const setActiveModel = (model: string): void => {
|
||
try {
|
||
localStorage.setItem('goose-active-model', model);
|
||
} catch {
|
||
// Ignore storage errors
|
||
}
|
||
};
|
||
|
||
export const CANVAS_ISOLATION_ARCHITECTURE = `
|
||
### CANVAS ISOLATION ARCHITECTURE
|
||
1. **Base Canvas**: Existing code is immutable. Avoid touching unrelated blocks.
|
||
2. **Isolation Chamber**: Extract ONLY specific sections mentioned.
|
||
3. **Precision Merge**: Merge back using surgical anchors. 100% preservation.
|
||
4. **Boundary Mapping**: Identify exact coordinates. No cascading changes.
|
||
`;
|
||
|
||
// --- GEMINI 3 PRO / VIBE CODING TEMPLATE ---
|
||
export const FRAMEWORK_TEMPLATE_PROMPT = `
|
||
You are an expert Frontend Engineer.
|
||
Your task is to implement the User's Plan into a SINGLE, HIGH-FIDELITY HTML FILE using the REQUESTED FRAMEWORK.
|
||
|
||
### TECHNICAL REQUIREMENTS:
|
||
1. ** Single File **: Everything(HTML, CSS, JS) must be in one file.
|
||
2. ** CDNs Only **: Use reputable CDNs(cdnjs, unpkg, esm.sh) for libraries.
|
||
3. ** React **: If React is requested, use React 18 + ReactDOM 18 + Babel Standalone(for JSX).
|
||
- <script src="https://unpkg.com/react@18/umd/react.development.js" > </script>
|
||
- <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" > </script>
|
||
- <script src="https://unpkg.com/@babel/standalone/babel.min.js" > </script>
|
||
- <script type="text/babel" > ... your code ... </script>
|
||
4. ** Vue **: If Vue is requested, use Vue 3 global build.
|
||
- <script src="https://unpkg.com/vue@3/dist/vue.global.js" > </script>
|
||
5. ** Aesthetics **: Unless the user specified a specific CSS framework(like Bootstrap), DEFAULT to ** TailwindCSS ** for styling to maintain "Vibe" quality.
|
||
- <script src="https://cdn.tailwindcss.com" > </script>
|
||
|
||
### CRITICAL: CLIENT - SIDE ONLY
|
||
- ** NO SERVER - SIDE TEMPLATES **: DO NOT use Jinja2, Liquid, PHP, etc.
|
||
- ** NO NODE.JS SPECIFIC **: No 'require', no 'process.env', no 'npm install'.
|
||
- ** Mock Data **: Create dummy data arrays inside the script.
|
||
|
||
### OUTPUT FORMAT:
|
||
- RETURN ** ONLY ** THE RAW HTML CODE.
|
||
- DO NOT USE MARKDOWN BLOCK.
|
||
- DO NOT include or display the plan / instructions in the UI.The plan is input - only.
|
||
`;
|
||
|
||
export const MODERN_TEMPLATE_PROMPT = `
|
||
You are an elite Frontend Architect simulating the reasoning and coding quality of Google Gemini 1.5 Pro.
|
||
|
||
### DESIGN & TECH STACK(NON - NEGOTIABLE)
|
||
1. ** Framework **: Vanilla HTML5 + JavaScript(ES6 +).NO React / Vue / Angular unless explicitly requested.
|
||
2. ** Styling **: Tailwind CSS(via CDN).Use modern utility classes(flex, grid, glassmorphism, gradients).Use slate / zinc / neutral for structural greys and vibrant indigo / violet / blue for accents.
|
||
3. ** Icons **: FontAwesome(via CDN).
|
||
4. ** Font **: 'Inter' or 'JetBrains Mono' via Google Fonts.
|
||
|
||
### ATOMIC OUTPUT PROTOCOL
|
||
1. You are strictly forbidden from 'chatting' your code.
|
||
2. You must output the code inside a standard XML block: <artifact_payload>.
|
||
3. Do not output internal tool logs(like << goose) inside this block.
|
||
|
||
Example Output:
|
||
<artifact_payload file="index.html" >
|
||
<!DOCTYPE html >
|
||
<html lang="en" >
|
||
...
|
||
</html>
|
||
</artifact_payload>
|
||
`;
|
||
|
||
|
||
|
||
export const MockComputerDriver: GooseUltraComputerDriver = {
|
||
checkArmed: () => true, // In real app, check env var or system flag
|
||
runAction: async (action, params) => {
|
||
console.log(`[Desktop] ${action} `, params);
|
||
await sleep(800);
|
||
return { status: 'success', screenshot: 'https://picsum.photos/800/600' };
|
||
}
|
||
};
|
||
|
||
export const MockBrowserDriver: GooseUltraBrowserDriver = {
|
||
navigate: async (url) => {
|
||
console.log(`[Browser] Navigate: ${url} `);
|
||
await sleep(1000);
|
||
},
|
||
assert: async (selector) => {
|
||
console.log(`[Browser] Assert: ${selector} `);
|
||
await sleep(500);
|
||
return true; // Always pass in mock
|
||
}
|
||
};
|
||
|
||
export const MockServerDriver: GooseUltraServerDriver = {
|
||
connect: async (host) => {
|
||
console.log(`[Server] Connecting to ${host}...`);
|
||
await sleep(1500);
|
||
return true;
|
||
},
|
||
runCommand: async (cmd, dryRun) => {
|
||
if (dryRun) return `[DryRun] Would execute: ${cmd} `;
|
||
console.log(`[Server] Executing: ${cmd} `);
|
||
await sleep(1000);
|
||
return `Success: ${cmd} executed.\nLogs: [System OK]`;
|
||
}
|
||
};
|
||
|
||
// P0-2: Task Match Gate
|
||
export interface TaskMatchResult {
|
||
matchesRequest: boolean;
|
||
why: string;
|
||
detectedAppType: string;
|
||
requestType: string;
|
||
}
|
||
|
||
const runTaskMatchCheck = async (
|
||
plan: string,
|
||
userRequest: string
|
||
): Promise<TaskMatchResult> => {
|
||
const electron = (window as any).electron;
|
||
if (!electron) return { matchesRequest: true, why: "Offline", detectedAppType: "unknown", requestType: "unknown" }; // Skip in offline mode
|
||
|
||
const prompt = `You are a strict QA Gatekeeper.
|
||
User Request: "${userRequest}"
|
||
Proposed Plan: "${plan.substring(0, 500)}..."
|
||
|
||
Analyze if the Plan matches the User Request category.
|
||
- If User asked for a Game and Plan is a Dashboard -> FAIL.
|
||
- If User asked for a Portfolio and Plan is a Chatbot -> FAIL.
|
||
- If they vaguely match(e.g., "App" and "Dashboard"), PASS.
|
||
|
||
OUTPUT ONLY JSON:
|
||
{
|
||
"matchesRequest": boolean,
|
||
"why": "string reason",
|
||
"detectedAppType": "string",
|
||
"requestType": "string"
|
||
} `;
|
||
|
||
return new Promise((resolve) => {
|
||
let buf = '';
|
||
const onChunk = (c: string) => buf += c;
|
||
const onComplete = (c: string) => {
|
||
electron.removeChatListeners();
|
||
const final = (c && c.length > buf.length ? c : buf).replace(/```json/gi, '').replace(/```/g, '').trim();
|
||
try {
|
||
const res = JSON.parse(final);
|
||
resolve(res);
|
||
} catch (e) {
|
||
resolve({ matchesRequest: true, why: "JSON Parse Error", detectedAppType: "unknown", requestType: "unknown" });
|
||
}
|
||
};
|
||
|
||
electron.onChatChunk(onChunk);
|
||
electron.onChatComplete(onComplete);
|
||
|
||
electron.startChat([
|
||
{ role: 'system', content: prompt }
|
||
], getActiveModel());
|
||
});
|
||
};
|
||
|
||
export const compilePlanToCode = async (
|
||
plan: string,
|
||
onChunk?: (code: string) => void,
|
||
projectId?: string,
|
||
preferredFramework?: string | null,
|
||
persona?: { name: string, prompt: string } | null,
|
||
skills?: string[] | null
|
||
): Promise<string> => {
|
||
const electron = (window as any).electron;
|
||
if (!electron) return "<!-- Electron Not Available --><h1 style='color:red'>Offline Mode</h1>";
|
||
|
||
// M1: Load Project Memory
|
||
let memoryContext = "";
|
||
if (projectId) {
|
||
const memories = await loadProjectMemories(projectId);
|
||
// M3: Retrieve Top-K
|
||
const relevant = retrieveRelevantMemories(memories, plan, 5); // Top-5 relevant to the plan
|
||
memoryContext = formatMemoriesForPrompt(relevant);
|
||
}
|
||
|
||
const MAX_RETRIES = 3;
|
||
let attempt = 0;
|
||
let lastQaReport: QualityReport | null = null;
|
||
|
||
// P0-2: Run Task Match Check before generating code (Pass 1)
|
||
// We infer the original request from the plan title or first few lines if not explicitly passed.
|
||
// Ideally, we passed the request explicitly, but for now we extract it from the plan header.
|
||
if (onChunk) onChunk("// Verifying Plan Alignment with User Request..."); // VISUAL FEEDBACK
|
||
|
||
const userRequestInferred = plan.split('\n')[0].replace('#', '').trim() || "App Build";
|
||
|
||
// Add Timeout to prevent stuck state
|
||
const matchResultPromise = runTaskMatchCheck(plan, userRequestInferred);
|
||
const timeoutPromise = new Promise<TaskMatchResult>(resolve =>
|
||
setTimeout(() => resolve({ matchesRequest: true, why: "Timeout (Fail Open)", detectedAppType: "unknown", requestType: "unknown" }), 5000)
|
||
);
|
||
|
||
const matchResult = await Promise.race([matchResultPromise, timeoutPromise]);
|
||
|
||
if (!matchResult.matchesRequest) {
|
||
console.error("[AutoService] BLOCKED: Plan matches " + matchResult.detectedAppType + " but User asked for " + matchResult.requestType);
|
||
// We could throw here, but the contract says "Auto-retry once with stronger instructions".
|
||
// We will inject this failure into the prompt.
|
||
}
|
||
|
||
while (attempt < MAX_RETRIES) {
|
||
attempt++;
|
||
console.log(`[AutoService] Compiling Plan to Code... Attempt ${attempt}/${MAX_RETRIES}`);
|
||
|
||
// VISUAL FEEDBACK
|
||
if (onChunk) onChunk(`// Initializing Build Engine (Attempt ${attempt})...`);
|
||
|
||
try {
|
||
const result = await new Promise<string>((resolve, reject) => {
|
||
let fullResponse = '';
|
||
|
||
// Ensure clean state BEFORE attaching new listeners
|
||
electron.removeChatListeners();
|
||
|
||
let lastSafeCode = '';
|
||
const streamer = new SafeGenStreamer();
|
||
|
||
// ... (handlers definition)
|
||
|
||
const onChunkHandler = (c: string) => {
|
||
const safeUpdate = streamer.processChunk(c);
|
||
if (safeUpdate) {
|
||
if (!safeUpdate.startsWith("<!-- Generating")) {
|
||
lastSafeCode = safeUpdate;
|
||
}
|
||
if (onChunk) onChunk(safeUpdate);
|
||
}
|
||
};
|
||
|
||
const onCompleteHandler = (c: string) => {
|
||
if (c && c.length > 5) {
|
||
const safeUpdate = streamer.processChunk(c);
|
||
if (safeUpdate && !safeUpdate.startsWith("<!-- Generating")) {
|
||
lastSafeCode = safeUpdate;
|
||
}
|
||
}
|
||
cleanup();
|
||
|
||
if (lastSafeCode) {
|
||
resolve(lastSafeCode);
|
||
} else {
|
||
console.warn("[SafeGen] Output was incomplete or invalid.");
|
||
resolve("<!-- Error: SafeGen failed to validate artifact. -->");
|
||
}
|
||
};
|
||
|
||
const onErrorHandler = (e: any) => {
|
||
cleanup();
|
||
reject(e);
|
||
};
|
||
|
||
const cleanup = () => {
|
||
electron.removeChatListeners();
|
||
};
|
||
|
||
electron.onChatChunk(onChunkHandler);
|
||
electron.onChatComplete(onCompleteHandler);
|
||
electron.onChatError(onErrorHandler);
|
||
|
||
// ... (Prompt Construction)
|
||
|
||
// Construct Prompt (F5: XML Artifact Bundle)
|
||
let systemPrompt = `You are a strict Build Engine.
|
||
|
||
${CANVAS_ISOLATION_ARCHITECTURE}
|
||
|
||
1. FIRST, output a <thinking> block. Discuss strategy/design (e.g. "Using dark theme...").
|
||
2. THEN, output the files using <goose_file> tags.
|
||
|
||
SCHEMA:
|
||
<thinking>
|
||
...
|
||
</thinking>
|
||
<goose_file path="index.html">
|
||
<!DOCTYPE html>
|
||
...
|
||
</goose_file>
|
||
<goose_file path="style.css">
|
||
body { ... }
|
||
</goose_file>
|
||
|
||
RULES:
|
||
1. Do NOT escape content (no need for \\n or \\").
|
||
2. Do NOT use markdown code blocks (\`\`\`) inside the tags.
|
||
3. index.html MUST be a complete file.
|
||
4. Output MUST start with <thinking>.
|
||
5. DO NOT generate a "Plan" or "Documentation" page. Generate the ACTUAL APP requested.
|
||
`;
|
||
let userContent = "IMPLEMENTATION PLAN (input-only):\n" + plan;
|
||
|
||
if (memoryContext) {
|
||
systemPrompt += "\n" + memoryContext;
|
||
}
|
||
|
||
if (preferredFramework) {
|
||
systemPrompt += `\n\n[USER PREFERENCE]: Use "${preferredFramework}".`;
|
||
}
|
||
|
||
// --- INJECT PERSONA & SKILLS ---
|
||
if (persona) {
|
||
systemPrompt += `\n\n[ACTIVE PERSONA]: You are acting as "${persona.name}".\n${persona.prompt}`;
|
||
}
|
||
if (skills && skills.length > 0) {
|
||
systemPrompt += `\n\n[AVAILABLE SKILLS]: You have the following capabilities/skills available: ${skills.join(', ')}. Ensure you leverage them if relevant to the implementation.`;
|
||
}
|
||
|
||
// P0-2: Task Mismatch Handling (Inject into Prompt)
|
||
if (!matchResult.matchesRequest && attempt === 1) {
|
||
systemPrompt += `\n\n⚠️ CRITICAL WARNING: Previous plan drifted. User asked for ${matchResult.requestType}. BUILD THAT.`;
|
||
}
|
||
|
||
// Retry Logic with Repair Prompt
|
||
if (attempt > 1 && lastQaReport) {
|
||
// We just reuse strict mode instructions
|
||
systemPrompt += `\n\n[REPAIR MODE] Fix these errors:\n${lastQaReport.gates.filter(g => !g.passed).map(g => g.errors.join(', ')).join('\n')}`;
|
||
}
|
||
|
||
// Just start chat - listeners are already attached (and cleaned before that)
|
||
electron.startChat([
|
||
{ role: 'system', content: systemPrompt },
|
||
{ role: 'user', content: userContent }
|
||
], getActiveModel());
|
||
});
|
||
|
||
// F5: Parse XML Bundle for QA
|
||
let filesForQa: Record<string, string> = {};
|
||
try {
|
||
// Strip thinking block for cleaner processing (optional, regex handles it but good for debug)
|
||
const regex = /<goose_file\s+path=["']([^"']+)["']\s*>([\s\S]*?)<\/goose_file>/g;
|
||
let m;
|
||
while ((m = regex.exec(result)) !== null) {
|
||
if (m[1] && m[2]) {
|
||
filesForQa[m[1]] = m[2].trim();
|
||
}
|
||
}
|
||
|
||
// Fallback: If no tags found, try legacy detection (plain HTML)
|
||
if (Object.keys(filesForQa).length === 0) {
|
||
if (result.includes('<!DOCTYPE') || result.includes('<html')) {
|
||
filesForQa['index.html'] = result;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Fallback or fail
|
||
if (result.includes('<!DOCTYPE') || result.includes('<html')) {
|
||
filesForQa['index.html'] = result;
|
||
}
|
||
}
|
||
|
||
const qaReport = runQualityGates(filesForQa);
|
||
|
||
if (qaReport.overallPass) {
|
||
console.log("[AutoService] ✓ Quality gates passed.");
|
||
// Return the RAW RESULT (JSON or HTML) - generateMockFiles will handle parsing again
|
||
return result;
|
||
} else {
|
||
console.warn(`[AutoService] Attempt ${attempt} failed QA:`, qaReport.gates.filter(g => !g.passed).map(g => g.gate));
|
||
lastQaReport = qaReport;
|
||
|
||
if (attempt >= MAX_RETRIES) {
|
||
console.error("[AutoService] QA failed after max retries. Returning best effort.");
|
||
return result;
|
||
}
|
||
await sleep(1000);
|
||
}
|
||
|
||
|
||
} catch (e) {
|
||
if (attempt >= MAX_RETRIES) throw e;
|
||
console.error(`[AutoService] Attempt ${attempt} error:`, e);
|
||
await sleep(1000);
|
||
}
|
||
}
|
||
|
||
throw new Error("Code generation failed unexpectedly.");
|
||
};
|
||
|
||
const PLAN_TAG_RE = /\[+\s*plan\s*\]+/gi;
|
||
const stripPlanTag = (text: string) => text.replace(PLAN_TAG_RE, '').replace(/^\s*plan\s*:\s*/i, '').trim();
|
||
|
||
// --- Fingerprint Helpers for Redesign Detection ---
|
||
const computeDomSignature = (html: string): string => {
|
||
// Extract tag sequence from body content
|
||
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
||
const body = bodyMatch ? bodyMatch[1] : html;
|
||
const tags = body.match(/<(\w+)[^>]*>/g) || [];
|
||
const tagNames = tags.slice(0, 50).map(t => t.match(/<(\w+)/)?.[1] || '').join(',');
|
||
// Simple hash
|
||
let hash = 0;
|
||
for (let i = 0; i < tagNames.length; i++) {
|
||
hash = ((hash << 5) - hash) + tagNames.charCodeAt(i);
|
||
hash |= 0;
|
||
}
|
||
return hash.toString(16);
|
||
};
|
||
|
||
const computeCssSignature = (html: string): string => {
|
||
// Extract color tokens and font-family
|
||
const colors = html.match(/(#[0-9a-fA-F]{3,8}|rgb\([^)]+\)|hsl\([^)]+\))/g) || [];
|
||
const fonts = html.match(/font-family:\s*([^;]+)/g) || [];
|
||
const signature = [...colors.slice(0, 10), ...fonts.slice(0, 3)].join('|');
|
||
let hash = 0;
|
||
for (let i = 0; i < signature.length; i++) {
|
||
hash = ((hash << 5) - hash) + signature.charCodeAt(i);
|
||
hash |= 0;
|
||
}
|
||
return hash.toString(16);
|
||
};
|
||
|
||
const computeLayoutSignature = (html: string): string => {
|
||
// Count major structural elements
|
||
const hasNav = /<nav|class="[^"]*nav/i.test(html);
|
||
const hasHero = /<section[^>]*hero|class="[^"]*hero/i.test(html);
|
||
const hasFooter = /<footer/i.test(html);
|
||
const sectionCount = (html.match(/<section/gi) || []).length;
|
||
const divCount = (html.match(/<div/gi) || []).length;
|
||
return `nav:${hasNav},hero:${hasHero},footer:${hasFooter},sections:${sectionCount},divs:${Math.floor(divCount / 5) * 5}`;
|
||
};
|
||
|
||
const compareSignatures = (before: { dom: string; css: string; layout: string }, after: { dom: string; css: string; layout: string }) => {
|
||
const domChanged = before.dom !== after.dom;
|
||
const cssChanged = before.css !== after.css;
|
||
const layoutChanged = before.layout !== after.layout;
|
||
// Layout change is most severe indicator of redesign
|
||
const isLikelyRedesign = layoutChanged || (domChanged && cssChanged);
|
||
return { domChanged, cssChanged, layoutChanged, isLikelyRedesign };
|
||
};
|
||
|
||
// --- Change Budget Calculator ---
|
||
const computeChangeBudget = (before: string, after: string): { addedLines: number; removedLines: number; changedLines: number; totalChanges: number } => {
|
||
const beforeLines = before.split('\n');
|
||
const afterLines = after.split('\n');
|
||
|
||
// Simple line-based diff approximation
|
||
const beforeSet = new Set(beforeLines.map(l => l.trim()).filter(l => l.length > 0));
|
||
const afterSet = new Set(afterLines.map(l => l.trim()).filter(l => l.length > 0));
|
||
|
||
let addedLines = 0;
|
||
let removedLines = 0;
|
||
|
||
for (const line of beforeSet) {
|
||
if (!afterSet.has(line)) removedLines++;
|
||
}
|
||
for (const line of afterSet) {
|
||
if (!beforeSet.has(line)) addedLines++;
|
||
}
|
||
|
||
return {
|
||
addedLines,
|
||
removedLines,
|
||
changedLines: Math.min(addedLines, removedLines),
|
||
totalChanges: addedLines + removedLines
|
||
};
|
||
};
|
||
|
||
// --- AI Self-Check Gate ---
|
||
interface SelfCheckVerdict {
|
||
preservedDesign: boolean;
|
||
onlyRequestedChanges: boolean;
|
||
forbiddenChangesDetected: string[];
|
||
summaryOfChanges: string;
|
||
}
|
||
|
||
const runAISelfCheck = async (
|
||
beforeHtml: string,
|
||
afterHtml: string,
|
||
userRequest: string
|
||
): Promise<SelfCheckVerdict> => {
|
||
const electron = (window as any).electron;
|
||
if (!electron) {
|
||
return { preservedDesign: true, onlyRequestedChanges: true, forbiddenChangesDetected: [], summaryOfChanges: 'Offline mode - skipped' };
|
||
}
|
||
|
||
const selfCheckPrompt = `You are a strict QA validator.
|
||
|
||
${CANVAS_ISOLATION_ARCHITECTURE}
|
||
|
||
Compare BEFORE and AFTER HTML based on the USER REQUEST: "${userRequest}"
|
||
|
||
GOAL: Verify that ONLY the requested changes were made and the overall design remains identical.
|
||
|
||
Return ONLY valid JSON (no markdown):
|
||
{
|
||
"preservedDesign": true/false (DID LAYOUT, COLORS, FONTS, AND BASIC STRUCTURE REMAIN UNCHANGED?),
|
||
"onlyRequestedChanges": true/false (DID THE AI AVOID ADDING UNREQUESTED FEATURES OR CHANGING UNTOUCHED BLOCKS?),
|
||
"forbiddenChangesDetected": ["list exactly what changed that wasn't asked for"],
|
||
"summaryOfChanges": "one sentence summary"
|
||
}`;
|
||
|
||
const userContent = `BEFORE (first 500 chars):\n${beforeHtml.substring(0, 500)}\n\nAFTER (first 500 chars):\n${afterHtml.substring(0, 500)}`;
|
||
|
||
return new Promise((resolve) => {
|
||
let buffer = '';
|
||
electron.removeChatListeners();
|
||
|
||
electron.onChatChunk((c: string) => { buffer += c; });
|
||
electron.onChatComplete((response: string) => {
|
||
electron.removeChatListeners();
|
||
let json = (response || buffer).trim();
|
||
json = json.replace(/```json/gi, '').replace(/```/g, '').trim();
|
||
const first = json.indexOf('{');
|
||
const last = json.lastIndexOf('}');
|
||
if (first !== -1 && last > first) {
|
||
json = json.substring(first, last + 1);
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(json);
|
||
resolve({
|
||
preservedDesign: parsed.preservedDesign ?? true,
|
||
onlyRequestedChanges: parsed.onlyRequestedChanges ?? true,
|
||
forbiddenChangesDetected: parsed.forbiddenChangesDetected ?? [],
|
||
summaryOfChanges: parsed.summaryOfChanges ?? ''
|
||
});
|
||
} catch {
|
||
console.warn('[SelfCheck] Parse failed, assuming pass');
|
||
resolve({ preservedDesign: true, onlyRequestedChanges: true, forbiddenChangesDetected: [], summaryOfChanges: 'Parse error - assumed pass' });
|
||
}
|
||
});
|
||
electron.onChatError(() => {
|
||
electron.removeChatListeners();
|
||
resolve({ preservedDesign: true, onlyRequestedChanges: true, forbiddenChangesDetected: [], summaryOfChanges: 'Error - assumed pass' });
|
||
});
|
||
|
||
electron.startChat([
|
||
{ role: 'system', content: selfCheckPrompt },
|
||
{ role: 'user', content: userContent }
|
||
], getActiveModel());
|
||
});
|
||
};
|
||
|
||
// --- Constants for Change Budget ---
|
||
const CHANGE_BUDGET = {
|
||
maxHtmlLineEdits: 80,
|
||
maxCssLineEdits: 120, // Not used since CSS is inline
|
||
maxJsLineEdits: 160 // Not used since JS is inline
|
||
};
|
||
|
||
// --- Patch Engine Types ---
|
||
export interface Patch {
|
||
file: string;
|
||
op: 'INSERT_AFTER' | 'INSERT_BEFORE' | 'REPLACE_BLOCK' | 'DELETE_BLOCK';
|
||
anchor: {
|
||
type: 'STRING' | 'REGEX';
|
||
value: string;
|
||
};
|
||
content?: string;
|
||
reason?: string;
|
||
}
|
||
|
||
export interface PatchPlan {
|
||
mode: 'MODIFY_EXISTING';
|
||
projectId: string;
|
||
request: string;
|
||
redesignRequested: boolean;
|
||
files: string[];
|
||
changeBudget: {
|
||
maxEdits: number;
|
||
maxNewLines: number;
|
||
};
|
||
patches: Patch[];
|
||
}
|
||
|
||
// --- Local Patch Applier ---
|
||
const applyPatches = (html: string, patches: Patch[]): string => {
|
||
let currentHtml = html;
|
||
|
||
for (const patch of patches) {
|
||
if (patch.file !== 'index.html' && patch.file !== 'file') continue; // Single file mode for now
|
||
|
||
// Locate anchor
|
||
let anchorIndex = -1;
|
||
let matchLength = 0;
|
||
|
||
if (patch.anchor.type === 'REGEX') {
|
||
try {
|
||
const re = new RegExp(patch.anchor.value);
|
||
const match = currentHtml.match(re);
|
||
if (match && match.index !== undefined) {
|
||
anchorIndex = match.index;
|
||
matchLength = match[0].length;
|
||
}
|
||
} catch (e) {
|
||
console.warn(`[Patch] Invalid Regex: ${patch.anchor.value}`);
|
||
}
|
||
} else {
|
||
anchorIndex = currentHtml.indexOf(patch.anchor.value);
|
||
matchLength = patch.anchor.value.length;
|
||
}
|
||
|
||
if (anchorIndex === -1) {
|
||
console.warn(`[Patch] Anchor not found: ${patch.anchor.value.substring(0, 50)}...`);
|
||
continue; // Skip failed patch
|
||
}
|
||
|
||
// Apply Op
|
||
const content = patch.content || '';
|
||
if (patch.op === 'REPLACE_BLOCK') {
|
||
currentHtml = currentHtml.substring(0, anchorIndex) + content + currentHtml.substring(anchorIndex + matchLength);
|
||
} else if (patch.op === 'DELETE_BLOCK') {
|
||
currentHtml = currentHtml.substring(0, anchorIndex) + currentHtml.substring(anchorIndex + matchLength);
|
||
} else if (patch.op === 'INSERT_AFTER') {
|
||
currentHtml = currentHtml.substring(0, anchorIndex + matchLength) + content + currentHtml.substring(anchorIndex + matchLength);
|
||
} else if (patch.op === 'INSERT_BEFORE') {
|
||
currentHtml = currentHtml.substring(0, anchorIndex) + content + currentHtml.substring(anchorIndex);
|
||
}
|
||
}
|
||
return currentHtml;
|
||
};
|
||
|
||
export const applyPlanToExistingHtml = async (
|
||
plan: string,
|
||
currentHtml: string,
|
||
onChunk?: (code: string) => void,
|
||
retryCount: number = 0,
|
||
projectId?: string,
|
||
persona?: { name: string, prompt: string } | null,
|
||
skills?: string[] | null
|
||
): Promise<string> => {
|
||
const electron = (window as any).electron;
|
||
if (!electron) return "<!-- Electron Not Available --><h1 style='color:red'>Offline Mode</h1>";
|
||
|
||
// M1: Injest Memory
|
||
let memoryContext = "";
|
||
if (projectId) {
|
||
const memories = await loadProjectMemories(projectId);
|
||
const relevant = retrieveRelevantMemories(memories, plan, 5);
|
||
memoryContext = formatMemoriesForPrompt(relevant);
|
||
}
|
||
|
||
// --- INJECT PERSONA & SKILLS ---
|
||
if (persona) {
|
||
memoryContext += `\n\n[ACTIVE PERSONA]: You are acting as "${persona.name}".\n${persona.prompt}`;
|
||
}
|
||
if (skills && skills.length > 0) {
|
||
memoryContext += `\n\n[AVAILABLE SKILLS]: You have the following capabilities/skills available: ${skills.join(', ')}. Ensure you leverage them if relevant to the implementation.`;
|
||
}
|
||
|
||
// PATCH PROMPT
|
||
const PATCH_PROMPT = `
|
||
You are an expert Frontend Engineer.
|
||
The User wants to MODIFY the existing application.
|
||
DO NOT REWRITE THE FULL FILE. Provide a JSON Patch Plan.
|
||
|
||
${CANVAS_ISOLATION_ARCHITECTURE}
|
||
|
||
### FILES
|
||
We are working on a single file 'index.html' which contains HTML, CSS, and JS.
|
||
|
||
### MEMORY & CONSTRAINTS
|
||
${memoryContext}
|
||
|
||
### INSTRUCTIONS
|
||
1. Analyze the CURRENT_HTML and the REQUEST.
|
||
2. Produce a JSON Patch Plan (Strict JSON only).
|
||
3. Use 'REPLACE_BLOCK' to modify existing code.
|
||
4. Use 'INSERT_AFTER' / 'INSERT_BEFORE' to add new features.
|
||
5. 'anchor' must be a UNIQUE string or regex in the file to locate where to apply the change.
|
||
6. Keep 'content' minimal. Do NOT include unchanged surrounding code in 'content'.
|
||
|
||
### JSON FORMAT
|
||
{
|
||
"mode": "MODIFY_EXISTING",
|
||
"projectId": "${projectId || 'unknown'}",
|
||
"request": "summary of request",
|
||
"redesignRequested": false,
|
||
"files": ["index.html"],
|
||
"changeBudget": { "maxEdits": 50, "maxNewLines": 200 },
|
||
"patches": [
|
||
{
|
||
"file": "index.html",
|
||
"op": "REPLACE_BLOCK",
|
||
"anchor": { "type": "STRING", "value": "<h1 class=\"title\">Old Title</h1>" },
|
||
"content": "<h1 class=\"title\">New Title</h1>"
|
||
}
|
||
]
|
||
}
|
||
|
||
If the request requires a FULL REDESIGN (changing >50% of layout or colors), set "redesignRequested": true.
|
||
`;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
let fullResponse = '';
|
||
const timeout = setTimeout(() => {
|
||
cleanup();
|
||
reject(new Error("Patch Generation Timeout (90s). The model took too long to respond."));
|
||
}, 90000);
|
||
|
||
const onChunkHandler = (c: string) => {
|
||
fullResponse += c;
|
||
// Throttled status updates
|
||
if (onChunk && fullResponse.length % 50 === 0) {
|
||
onChunk(JSON.stringify({ status: "Generating Patch...", size: fullResponse.length }));
|
||
}
|
||
};
|
||
|
||
const onCompleteHandler = async (c: string) => {
|
||
clearTimeout(timeout);
|
||
cleanup();
|
||
let rawJson = (c.length > fullResponse.length ? c : fullResponse).trim();
|
||
|
||
// Robust extraction: find first { and last }
|
||
const firstBrace = rawJson.indexOf('{');
|
||
const lastBrace = rawJson.lastIndexOf('}');
|
||
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
||
rawJson = rawJson.substring(firstBrace, lastBrace + 1);
|
||
}
|
||
|
||
// Parse JSON
|
||
let patchPlan: PatchPlan | null = null;
|
||
try {
|
||
const jsonStr = rawJson.replace(/```json/gi, '').replace(/```/g, '').trim();
|
||
patchPlan = JSON.parse(jsonStr);
|
||
} catch (e) {
|
||
console.error("Failed to parse patch JSON", e, rawJson.substring(0, 300));
|
||
if (retryCount < 1) {
|
||
return resolve(applyPlanToExistingHtml(plan, currentHtml, onChunk, retryCount + 1, projectId));
|
||
}
|
||
reject(new Error("Failed to parse patch plan: " + (e as Error).message));
|
||
return;
|
||
}
|
||
|
||
if (patchPlan?.redesignRequested) {
|
||
// Fallback to full rewrite if redesign requested
|
||
// We would ideally ask user confirmation here, but for now we throw/reject or handle upstream?
|
||
// The contract says: "Require explicit REDESIGN_OK confirmation".
|
||
// Since we can't show a modal easily from here without UI callback, we'll Reject with a special error.
|
||
reject(new Error("REDESIGN_REQUESTED: The model requesting a full redesign. Please confirm."));
|
||
return;
|
||
}
|
||
|
||
// CLIE: Save Snapshot (Before applying patches)
|
||
if (projectId && patchPlan) {
|
||
// We save the state *before* modifications are applied
|
||
const description = patchPlan.request ? `Before: ${patchPlan.request}` : 'Before update';
|
||
// For now, we assume index.html is the main file.
|
||
// In a real multi-file system we'd snapshot all files, but currentHtml implies single context here.
|
||
saveSnapshot(projectId, description, { 'index.html': currentHtml }).catch(e => console.error('[CLIE] Snapshot failed:', e));
|
||
}
|
||
|
||
// Apply Patches
|
||
let newHtml = currentHtml;
|
||
if (patchPlan && patchPlan.patches) {
|
||
newHtml = applyPatches(currentHtml, patchPlan.patches);
|
||
}
|
||
|
||
// CLIE: VIBE GUARD Check
|
||
// Only run if we have a projectId (so we can load context)
|
||
if (projectId && patchPlan && !patchPlan.redesignRequested) {
|
||
try {
|
||
const currentState = await loadCurrentState(projectId);
|
||
if (currentState) {
|
||
// Assume REPAIR_MODE if we are here (patching existing) without redesign
|
||
// Ideally we'd pass the mode from intent analysis, but let's default to strict safety
|
||
const mode: ExecutionMode = 'REPAIR_MODE';
|
||
|
||
const vibeCheck = runVibeGuard(mode, currentState, newHtml);
|
||
|
||
await recordInteraction(
|
||
projectId,
|
||
patchPlan.request || 'Unknown Patch',
|
||
mode,
|
||
`Applied ${patchPlan.patches?.length || 0} patches`,
|
||
vibeCheck.approved,
|
||
vibeCheck.domDrift
|
||
);
|
||
|
||
if (!vibeCheck.approved) {
|
||
console.error(`[CLIE] Vibe Guard Blocked Update: ${vibeCheck.reason}`);
|
||
reject(new Error(`VIBE GUARD BLOCKED: ${vibeCheck.reason}. The AI attempted a destructive change (` + vibeCheck.domDrift + '% structural change) during a repair task. Please explicitly request a "Redesign" if you want to change the layout.'));
|
||
return;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('[CLIE] Vibe Guard Error (Allowing update):', e);
|
||
}
|
||
}
|
||
|
||
// Validate
|
||
const qReport = runQualityGates(newHtml);
|
||
if (!qReport.overallPass) {
|
||
console.warn("Patched HTML failed QA", qReport);
|
||
// Auto-repair? Or just return original?
|
||
// We can try to repair the RESULTING html using the standard repair loop
|
||
// But maybe simpler to just return it and let the standard flow handle it?
|
||
// The contract says "on_validation_failure: Do not write files... Auto-retry".
|
||
if (retryCount < 1) {
|
||
return resolve(applyPlanToExistingHtml(plan, currentHtml, onChunk, retryCount + 1, projectId));
|
||
}
|
||
}
|
||
|
||
// CLIE: Update State
|
||
if (projectId) {
|
||
// We only save the structural state - CSS handling is simplified here (assuming inline or separate)
|
||
// If we had the CSS we would pass it. For now, passing empty string for CSS update, causing re-extraction from HTML if present.
|
||
saveCurrentState(projectId, newHtml, "").catch(e => console.error(e));
|
||
}
|
||
|
||
resolve(newHtml);
|
||
};
|
||
|
||
const onErrorHandler = (e: any) => {
|
||
cleanup();
|
||
reject(e);
|
||
};
|
||
|
||
const cleanup = () => {
|
||
electron.removeChatListeners();
|
||
};
|
||
|
||
electron.onChatChunk(onChunkHandler);
|
||
electron.onChatComplete(onCompleteHandler);
|
||
electron.onChatError(onErrorHandler);
|
||
|
||
electron.startChat(
|
||
[
|
||
{ role: 'system', content: PATCH_PROMPT },
|
||
{ role: 'user', content: `CURRENT_HTML (snippet):\n${currentHtml.substring(0, 5000) + "..."}\n\nREQUEST:\n${plan}` }
|
||
],
|
||
getActiveModel()
|
||
);
|
||
});
|
||
};
|
||
|
||
export const generateMockPlan = () => `
|
||
# Project Plan: Vibe Coder
|
||
## Objectives
|
||
- Create a slick React interface
|
||
- Implement state machine
|
||
- ensure darkness
|
||
|
||
## Files
|
||
- /src/App.tsx
|
||
- /src/components/Header.tsx
|
||
`;
|
||
|
||
// Helper to get electron API
|
||
const getElectron = () => (window as any).electron;
|
||
|
||
export const getUserDataRoot = async (): Promise<string | null> => {
|
||
const electron = getElectron();
|
||
if (!electron?.getAppPath) return null;
|
||
try {
|
||
return await electron.getAppPath();
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const safeJsonParse = <T,>(raw: string, fallback: T): T => {
|
||
try {
|
||
return JSON.parse(raw) as T;
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
};
|
||
|
||
export const ensureProjectOnDisk = async (project: Project): Promise<void> => {
|
||
const electron = getElectron();
|
||
if (!electron?.fs) return;
|
||
const userData = await getUserDataRoot();
|
||
if (!userData) return;
|
||
const base = `${userData}/projects/${project.id}`;
|
||
const metadataPath = `${base}/project.json`;
|
||
await electron.fs.write(metadataPath, JSON.stringify(project, null, 2));
|
||
};
|
||
|
||
export const writeLastActiveProjectId = async (projectId: string): Promise<void> => {
|
||
const electron = getElectron();
|
||
if (!electron?.fs) return;
|
||
const userData = await getUserDataRoot();
|
||
if (!userData) return;
|
||
await electron.fs.write(`${userData}/projects/.lastActive.json`, JSON.stringify({ projectId }, null, 2));
|
||
};
|
||
|
||
export const readLastActiveProjectId = async (): Promise<string | null> => {
|
||
const electron = getElectron();
|
||
if (!electron?.fs) return null;
|
||
const userData = await getUserDataRoot();
|
||
if (!userData) return null;
|
||
try {
|
||
const raw = await electron.fs.read(`${userData}/projects/.lastActive.json`);
|
||
const parsed = safeJsonParse<{ projectId?: string }>(raw, {});
|
||
return parsed.projectId || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
// --- Project Context Memory (for modification retention) ---
|
||
export interface ProjectContext {
|
||
projectId: string;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
designLockEnabled: boolean;
|
||
designIntent: string;
|
||
uiTokens: {
|
||
colors: string[];
|
||
fonts: string[];
|
||
borderRadiusHints: string[];
|
||
};
|
||
structureSummary: string;
|
||
goldenSnapshot: {
|
||
indexHtmlSha256: string;
|
||
styleCssSha256: string | null;
|
||
scriptJsSha256: string | null;
|
||
createdAt: string;
|
||
};
|
||
fingerprints: {
|
||
domSignature: string;
|
||
cssSignature: string;
|
||
layoutSignature: string;
|
||
};
|
||
modificationBudgetDefaults: {
|
||
maxHtmlLineEdits: number;
|
||
maxCssLineEdits: number;
|
||
maxJsLineEdits: number;
|
||
};
|
||
}
|
||
|
||
export const saveProjectContext = async (projectId: string, context: ProjectContext): Promise<void> => {
|
||
const electron = getElectron();
|
||
if (!electron?.fs) return;
|
||
const userData = await getUserDataRoot();
|
||
if (!userData) return;
|
||
const path = `${userData}/projects/${projectId}/goose-context.json`;
|
||
await electron.fs.write(path, JSON.stringify(context, null, 2));
|
||
};
|
||
|
||
export const loadProjectContext = async (projectId: string): Promise<ProjectContext | null> => {
|
||
const electron = getElectron();
|
||
if (!electron?.fs) return null;
|
||
const userData = await getUserDataRoot();
|
||
if (!userData) return null;
|
||
try {
|
||
const raw = await electron.fs.read(`${userData}/projects/${projectId}/goose-context.json`);
|
||
return safeJsonParse<ProjectContext>(raw, null as any);
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
export const extractProjectContext = (html: string, projectId: string): ProjectContext => {
|
||
// Extract colors (hex codes)
|
||
const colors = (html.match(/#[0-9a-fA-F]{3,8}/g) || []).slice(0, 10);
|
||
// Extract fonts
|
||
const fonts = (html.match(/font-family:\s*([^;]+)/gi) || []).map(f => f.replace(/font-family:\s*/i, '')).slice(0, 5);
|
||
// Extract structure summary
|
||
const hasNav = /<nav/i.test(html);
|
||
const hasHero = /hero|jumbotron/i.test(html);
|
||
const hasFeatures = /features|benefits/i.test(html);
|
||
const hasFooter = /<footer/i.test(html);
|
||
const sectionCount = (html.match(/<section/gi) || []).length;
|
||
|
||
const structureSummary = [
|
||
hasNav ? 'nav' : '',
|
||
hasHero ? 'hero' : '',
|
||
hasFeatures ? 'features' : '',
|
||
`${sectionCount} sections`,
|
||
hasFooter ? 'footer' : ''
|
||
].filter(Boolean).join(', ');
|
||
|
||
// Compute fingerprints
|
||
const domSignature = computeDomSignature(html);
|
||
const cssSignature = computeCssSignature(html);
|
||
const layoutSignature = computeLayoutSignature(html);
|
||
|
||
// Extract border radius hints
|
||
const borderRadiusHints = (html.match(/border-radius:\s*([^;]+)/gi) || [])
|
||
.map(r => r.replace(/border-radius:\s*/i, ''))
|
||
.slice(0, 5);
|
||
|
||
// Simple hash for snapshot
|
||
const simpleHash = (s: string): string => {
|
||
let hash = 0;
|
||
for (let i = 0; i < Math.min(s.length, 1000); i++) {
|
||
hash = ((hash << 5) - hash) + s.charCodeAt(i);
|
||
hash |= 0;
|
||
}
|
||
return hash.toString(16);
|
||
};
|
||
|
||
const now = new Date().toISOString();
|
||
|
||
return {
|
||
projectId,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
designLockEnabled: true, // Default ON as per contract
|
||
designIntent: 'Modern dark-mode web app with Tailwind styling',
|
||
uiTokens: { colors, fonts, borderRadiusHints },
|
||
structureSummary,
|
||
goldenSnapshot: {
|
||
indexHtmlSha256: simpleHash(html),
|
||
styleCssSha256: null,
|
||
scriptJsSha256: null,
|
||
createdAt: now
|
||
},
|
||
fingerprints: { domSignature, cssSignature, layoutSignature },
|
||
modificationBudgetDefaults: {
|
||
maxHtmlLineEdits: 80,
|
||
maxCssLineEdits: 120,
|
||
maxJsLineEdits: 160
|
||
}
|
||
};
|
||
};
|
||
|
||
// =========================================
|
||
// mem0-STYLE PROJECT MEMORY SYSTEM
|
||
// =========================================
|
||
|
||
export interface MemoryRecord {
|
||
memoryId: string;
|
||
scope: 'project' | 'session' | 'global';
|
||
type: 'fact' | 'constraint' | 'decision' | 'preference' | 'glossary';
|
||
key: string;
|
||
value: string;
|
||
confidence: number; // 0..1
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
source: string; // messageId or buildSessionId
|
||
supersedes?: string; // ID of memory this replaces
|
||
isActive: boolean;
|
||
}
|
||
|
||
// --- Memory Store CRUD ---
|
||
export const loadProjectMemories = async (projectId: string): Promise<MemoryRecord[]> => {
|
||
const electron = getElectron();
|
||
if (!electron?.fs) return [];
|
||
const userData = await getUserDataRoot();
|
||
if (!userData) return [];
|
||
|
||
try {
|
||
const raw = await electron.fs.read(`${userData}/projects/${projectId}/memory/memory.jsonl`);
|
||
const lines = raw.split('\n').filter(l => l.trim());
|
||
return lines.map(l => {
|
||
try { return JSON.parse(l) as MemoryRecord; }
|
||
catch { return null; }
|
||
}).filter(Boolean) as MemoryRecord[];
|
||
} catch {
|
||
return [];
|
||
}
|
||
};
|
||
|
||
export const saveProjectMemories = async (projectId: string, memories: MemoryRecord[]): Promise<void> => {
|
||
const electron = getElectron();
|
||
if (!electron?.fs) return;
|
||
const userData = await getUserDataRoot();
|
||
if (!userData) return;
|
||
|
||
const jsonl = memories.map(m => JSON.stringify(m)).join('\n');
|
||
await electron.fs.write(`${userData}/projects/${projectId}/memory/memory.jsonl`, jsonl);
|
||
};
|
||
|
||
export const addMemory = async (projectId: string, memory: Omit<MemoryRecord, 'memoryId' | 'createdAt' | 'updatedAt' | 'isActive'>): Promise<MemoryRecord> => {
|
||
const existing = await loadProjectMemories(projectId);
|
||
|
||
const newMemory: MemoryRecord = {
|
||
...memory,
|
||
memoryId: `mem_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
isActive: true
|
||
};
|
||
|
||
// M4: Dedup - check for existing similar memory
|
||
const duplicate = existing.find(m =>
|
||
m.isActive &&
|
||
m.key.toLowerCase() === newMemory.key.toLowerCase() &&
|
||
m.type === newMemory.type
|
||
);
|
||
|
||
if (duplicate) {
|
||
// Update existing instead of adding new
|
||
duplicate.value = newMemory.value;
|
||
duplicate.updatedAt = newMemory.updatedAt;
|
||
duplicate.confidence = Math.max(duplicate.confidence, newMemory.confidence);
|
||
await saveProjectMemories(projectId, existing);
|
||
return duplicate;
|
||
}
|
||
|
||
existing.push(newMemory);
|
||
await saveProjectMemories(projectId, existing);
|
||
return newMemory;
|
||
};
|
||
|
||
export const deleteMemory = async (projectId: string, memoryId: string): Promise<void> => {
|
||
const existing = await loadProjectMemories(projectId);
|
||
const memory = existing.find(m => m.memoryId === memoryId);
|
||
if (memory) {
|
||
memory.isActive = false;
|
||
memory.updatedAt = new Date().toISOString();
|
||
}
|
||
await saveProjectMemories(projectId, existing);
|
||
};
|
||
|
||
export const updateMemory = async (projectId: string, memoryId: string, updates: Partial<Pick<MemoryRecord, 'value' | 'confidence'>>): Promise<void> => {
|
||
const existing = await loadProjectMemories(projectId);
|
||
const memory = existing.find(m => m.memoryId === memoryId);
|
||
if (memory) {
|
||
if (updates.value !== undefined) memory.value = updates.value;
|
||
if (updates.confidence !== undefined) memory.confidence = updates.confidence;
|
||
memory.updatedAt = new Date().toISOString();
|
||
}
|
||
await saveProjectMemories(projectId, existing);
|
||
};
|
||
|
||
// --- M3: Top-K Retrieval with Keyword + Recency Scoring ---
|
||
export const retrieveRelevantMemories = (
|
||
memories: MemoryRecord[],
|
||
query: string,
|
||
topK: number = 5,
|
||
maxChars: number = 1500
|
||
): MemoryRecord[] => {
|
||
const queryLower = query.toLowerCase();
|
||
const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
|
||
const now = Date.now();
|
||
|
||
// Active memories only
|
||
const active = memories.filter(m => m.isActive);
|
||
|
||
// Score each memory
|
||
const scored = active.map(m => {
|
||
let score = 0;
|
||
const keyLower = m.key.toLowerCase();
|
||
const valueLower = m.value.toLowerCase();
|
||
|
||
// Keyword matching
|
||
for (const word of queryWords) {
|
||
if (keyLower.includes(word)) score += 3;
|
||
if (valueLower.includes(word)) score += 1;
|
||
}
|
||
|
||
// Boost by type priority
|
||
const typePriority: Record<string, number> = {
|
||
constraint: 5,
|
||
decision: 4,
|
||
preference: 3,
|
||
fact: 2,
|
||
glossary: 1
|
||
};
|
||
score += typePriority[m.type] || 0;
|
||
|
||
// Recency boost (memories from last 24h get +2, last week +1)
|
||
const age = now - new Date(m.updatedAt).getTime();
|
||
if (age < 24 * 60 * 60 * 1000) score += 2;
|
||
else if (age < 7 * 24 * 60 * 60 * 1000) score += 1;
|
||
|
||
// Confidence boost
|
||
score += m.confidence;
|
||
|
||
return { memory: m, score };
|
||
});
|
||
|
||
// Sort by score descending
|
||
scored.sort((a, b) => b.score - a.score);
|
||
|
||
// Take top-K within char budget
|
||
const result: MemoryRecord[] = [];
|
||
let totalChars = 0;
|
||
|
||
for (const { memory } of scored) {
|
||
const charCount = memory.key.length + memory.value.length + 20;
|
||
if (totalChars + charCount > maxChars) break;
|
||
result.push(memory);
|
||
totalChars += charCount;
|
||
if (result.length >= topK) break;
|
||
}
|
||
|
||
return result;
|
||
};
|
||
|
||
// --- M2: Memory Extraction from Chat/Builds ---
|
||
export const extractMemoriesFromText = async (
|
||
projectId: string,
|
||
text: string,
|
||
source: string
|
||
): Promise<MemoryRecord[]> => {
|
||
const electron = (window as any).electron;
|
||
if (!electron) return [];
|
||
|
||
const extractionPrompt = `You are a memory extraction agent. Extract durable facts, constraints, and decisions from the text.
|
||
|
||
Return ONLY valid JSON array (no markdown):
|
||
[
|
||
{"type": "constraint|decision|preference|fact|glossary", "key": "short_key", "value": "description", "confidence": 0.8}
|
||
]
|
||
|
||
Rules:
|
||
- Max 5 items
|
||
- Constraint = something that MUST be preserved (e.g., "dark mode", "use Tailwind")
|
||
- Decision = architectural choice (e.g., "use React 18", "localStorage for state")
|
||
- Preference = user style preference (e.g., "prefers dark glass UI")
|
||
- Fact = factual statement about the project
|
||
- Glossary = domain term definition
|
||
|
||
Only extract if clearly stated. Do not invent.`;
|
||
|
||
return new Promise((resolve) => {
|
||
let buffer = '';
|
||
electron.removeChatListeners();
|
||
|
||
electron.onChatChunk((c: string) => { buffer += c; });
|
||
electron.onChatComplete((response: string) => {
|
||
electron.removeChatListeners();
|
||
let json = (response || buffer).trim();
|
||
json = json.replace(/```json/gi, '').replace(/```/g, '').trim();
|
||
|
||
// Extract JSON array
|
||
const first = json.indexOf('[');
|
||
const last = json.lastIndexOf(']');
|
||
if (first !== -1 && last > first) {
|
||
json = json.substring(first, last + 1);
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(json) as Array<{ type: string; key: string; value: string; confidence: number }>;
|
||
const newMemories: MemoryRecord[] = [];
|
||
|
||
for (const item of parsed.slice(0, 5)) {
|
||
if (item.key && item.value) {
|
||
const mem: MemoryRecord = {
|
||
memoryId: `mem_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`,
|
||
scope: 'project',
|
||
type: (item.type as MemoryRecord['type']) || 'fact',
|
||
key: item.key,
|
||
value: item.value,
|
||
confidence: item.confidence || 0.7,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
source,
|
||
isActive: true
|
||
};
|
||
newMemories.push(mem);
|
||
}
|
||
}
|
||
|
||
resolve(newMemories);
|
||
} catch {
|
||
console.warn('[MemoryExtract] Parse failed');
|
||
resolve([]);
|
||
}
|
||
});
|
||
|
||
electron.onChatError(() => {
|
||
electron.removeChatListeners();
|
||
resolve([]);
|
||
});
|
||
|
||
electron.startChat([
|
||
{ role: 'system', content: extractionPrompt },
|
||
{ role: 'user', content: `Extract memories from:\n${text.substring(0, 2000)}` }
|
||
], getActiveModel());
|
||
});
|
||
};
|
||
|
||
// --- Format memories for prompt injection ---
|
||
export const formatMemoriesForPrompt = (memories: MemoryRecord[]): string => {
|
||
if (memories.length === 0) return '';
|
||
|
||
const lines = memories.map(m => `• [${m.type.toUpperCase()}] ${m.key}: ${m.value}`);
|
||
return `\n[PROJECT MEMORY - MUST RESPECT]\n${lines.join('\n')}\n`;
|
||
};
|
||
|
||
export const listProjectsFromDisk = async (): Promise<Project[]> => {
|
||
const electron = getElectron();
|
||
if (!electron?.fs) return [];
|
||
const userData = await getUserDataRoot();
|
||
if (!userData) return [];
|
||
|
||
const root = `${userData}/projects`;
|
||
let entries: Array<{ name: string; isDirectory: boolean; path: string }> = [];
|
||
try {
|
||
entries = await electron.fs.list(root);
|
||
} catch {
|
||
return [];
|
||
}
|
||
|
||
const projects: Project[] = [];
|
||
for (const entry of entries) {
|
||
if (!entry.isDirectory) continue;
|
||
const id = entry.name;
|
||
try {
|
||
const raw = await electron.fs.read(`${root}/${id}/project.json`);
|
||
const parsed = safeJsonParse<Project>(raw, null as any);
|
||
if (parsed?.id) {
|
||
projects.push(parsed);
|
||
continue;
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
projects.push({ id, name: `Project ${id}`, slug: id, createdAt: 0 });
|
||
}
|
||
|
||
projects.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||
return projects;
|
||
};
|
||
|
||
export const loadProjectFilesFromDisk = async (projectId: string): Promise<Record<string, string>> => {
|
||
const electron = getElectron();
|
||
if (!electron?.fs) return {};
|
||
const userData = await getUserDataRoot();
|
||
if (!userData) return {};
|
||
|
||
const base = `${userData}/projects/${projectId}`;
|
||
const result: Record<string, string> = {};
|
||
|
||
const tryRead = async (rel: string) => {
|
||
try {
|
||
return await electron.fs.read(`${base}/${rel}`);
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const html = await tryRead('index.html');
|
||
const css = await tryRead('style.css');
|
||
const js = await tryRead('script.js');
|
||
|
||
if (html != null) result['index.html'] = html;
|
||
if (css != null) result['style.css'] = css;
|
||
if (js != null) result['script.js'] = js;
|
||
|
||
return result;
|
||
};
|
||
|
||
export const deleteProjectFromDisk = async (projectId: string): Promise<void> => {
|
||
const electron = getElectron();
|
||
if (!electron?.fs) return;
|
||
const userData = await getUserDataRoot();
|
||
if (!userData) return;
|
||
await electron.fs.delete(`${userData}/projects/${projectId}`);
|
||
|
||
try {
|
||
const raw = await electron.fs.read(`${userData}/projects/.lastActive.json`);
|
||
const parsed = safeJsonParse<{ projectId?: string }>(raw, {});
|
||
if (parsed.projectId === projectId) {
|
||
await electron.fs.write(`${userData}/projects/.lastActive.json`, JSON.stringify({ projectId: null }, null, 2));
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
};
|
||
|
||
// Helper to extract code blocks
|
||
const extractCode = (markdown: string, lang: string) => {
|
||
// Try precise language match
|
||
let regex = new RegExp(`\`\`\`${lang}([\\s\\S]*?)\`\`\``, 'i');
|
||
let match = markdown.match(regex);
|
||
if (match) return match[1].trim();
|
||
|
||
// Fallback: If looking for HTML, try to find generic blocks containing specific tags
|
||
if (lang === 'html') {
|
||
const genericRegex = /```([\s\S]*?)```/g;
|
||
let m;
|
||
while ((m = genericRegex.exec(markdown)) !== null) {
|
||
const content = m[1];
|
||
if (content.includes('<!DOCTYPE html>') || content.includes('<html')) {
|
||
return content.trim();
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// =========================================
|
||
// UI QUALITY GATES SYSTEM
|
||
// =========================================
|
||
|
||
export interface QualityGateResult {
|
||
passed: boolean;
|
||
gate: string;
|
||
errors: string[];
|
||
warnings: string[];
|
||
}
|
||
|
||
export interface QualityReport {
|
||
overallPass: boolean;
|
||
artifactType: 'HTML_APP' | 'PLAN_TEXT' | 'UNKNOWN';
|
||
gates: QualityGateResult[];
|
||
repairHints: string[];
|
||
}
|
||
|
||
// Gate 1: Detect artifact type and block plan/prose
|
||
const gate1_artifactType = (files: Record<string, string>): QualityGateResult => {
|
||
const content = files['index.html'] || '';
|
||
const errors: string[] = [];
|
||
const warnings: string[] = [];
|
||
|
||
// Check for plan markers
|
||
// Check for plan markers (Aggressive Update)
|
||
if (/\[PLAN\]/i.test(content) ||
|
||
content.includes('<title>Implementation Plan</title>') ||
|
||
content.includes('<h1>Architecture Overview</h1>') ||
|
||
(content.includes('<h2>Core Features</h2>') && content.includes('<ul>')) ||
|
||
// New Specific Checks for the user's case
|
||
content.match(/<div class=["']phase["']>/i) ||
|
||
content.match(/<h3>Phase \d+:/i) ||
|
||
content.includes('Timeline Summary') ||
|
||
content.includes('Implementation Plan') // Catch-all for title variations
|
||
) {
|
||
errors.push('Output appears to be a formatted HTML Plan, not the App itself [PLAN]');
|
||
}
|
||
|
||
// Check for markdown headings without HTML
|
||
if (/^#{1,3}\s+/m.test(content) && !/<html/i.test(content)) {
|
||
errors.push('Contains markdown headings but no <html> tag');
|
||
}
|
||
|
||
// Check for bullet lists at start
|
||
if (/^[\s]*[-*]\s+/m.test(content.substring(0, 500)) && !/<html/i.test(content)) {
|
||
errors.push('Starts with bullet list (prose/plan) but no <html> tag');
|
||
}
|
||
|
||
// Must have HTML tags
|
||
if (!/<html/i.test(content) && !/<body/i.test(content) && !/<div/i.test(content)) {
|
||
errors.push('No HTML tags detected - appears to be plain text');
|
||
}
|
||
|
||
return {
|
||
passed: errors.length === 0,
|
||
gate: 'Gate 1: Artifact Type',
|
||
errors,
|
||
warnings
|
||
};
|
||
};
|
||
|
||
// Gate 2: HTML validity
|
||
const gate2_htmlValidity = (files: Record<string, string>): QualityGateResult => {
|
||
const content = files['index.html'] || '';
|
||
const errors: string[] = [];
|
||
const warnings: string[] = [];
|
||
|
||
// Must have doctype
|
||
if (!/<!DOCTYPE html>/i.test(content)) {
|
||
errors.push('Missing <!DOCTYPE html>');
|
||
}
|
||
|
||
// Must have <html> tag
|
||
if (!/<html/i.test(content)) {
|
||
errors.push('Missing <html> tag');
|
||
}
|
||
|
||
// Must have <head>
|
||
if (!/<head/i.test(content)) {
|
||
warnings.push('Missing <head> section');
|
||
}
|
||
|
||
// Must have <body>
|
||
if (!/<body/i.test(content)) {
|
||
errors.push('Missing <body> tag');
|
||
}
|
||
|
||
// Must not have escaped HTML entities as content (sign of encoding issue)
|
||
if (/<html|<body|<div/i.test(content)) {
|
||
errors.push('Contains escaped HTML entities (<) - encoding issue');
|
||
}
|
||
|
||
// Check for matching closing tags
|
||
if (/<html/i.test(content) && !/<\/html>/i.test(content)) {
|
||
errors.push('Missing closing </html> tag');
|
||
}
|
||
|
||
return {
|
||
passed: errors.length === 0,
|
||
gate: 'Gate 2: HTML Validity',
|
||
errors,
|
||
warnings
|
||
};
|
||
};
|
||
|
||
// Gate 3: Styling presence
|
||
const gate3_stylingPresence = (files: Record<string, string>): QualityGateResult => {
|
||
const content = files['index.html'] || '';
|
||
const cssContent = files['style.css'] || '';
|
||
|
||
const errors: string[] = [];
|
||
const warnings: string[] = [];
|
||
|
||
const hasInlineStyle = /<style[\s\S]*?>[\s\S]*?<\/style>/i.test(content);
|
||
const hasStyleAttribute = /style\s*=\s*["'][^"']+["']/i.test(content);
|
||
const hasTailwindCDN = /cdn\.tailwindcss\.com/i.test(content);
|
||
const hasExternalCSS = /<link[^>]+stylesheet/i.test(content);
|
||
const hasBootstrapCDN = /bootstrap/i.test(content);
|
||
const hasLocalCSS = cssContent.length > 50; // Arbitrary significant size
|
||
|
||
// Count CSS rules if inline style exists
|
||
let cssRuleCount = 0;
|
||
if (hasInlineStyle) {
|
||
const styleMatch = content.match(/<style[\s\S]*?>([\s\S]*?)<\/style>/i);
|
||
if (styleMatch) {
|
||
cssRuleCount = (styleMatch[1].match(/[{]/g) || []).length;
|
||
}
|
||
}
|
||
|
||
// Count local CSS rules
|
||
if (hasLocalCSS) {
|
||
cssRuleCount += (cssContent.match(/[{]/g) || []).length;
|
||
}
|
||
|
||
// Determine if styled
|
||
const isVanillaHighQuality = (hasInlineStyle || hasLocalCSS) && cssRuleCount >= 5;
|
||
const isTailwindUsed = hasTailwindCDN && (content.match(/class="[^"]*"/g) || []).length > 3;
|
||
const isBootstrapUsed = hasBootstrapCDN;
|
||
const isExternalUsed = hasExternalCSS;
|
||
const isInlineHeavy = hasStyleAttribute && (content.match(/style\s*=/gi) || []).length >= 5;
|
||
|
||
const isStyled = isVanillaHighQuality || isTailwindUsed || isBootstrapUsed || isExternalUsed || isInlineHeavy;
|
||
|
||
if (!isStyled) {
|
||
warnings.push('Low styling detected (Gate 3 Warning).');
|
||
warnings.push('Expected significant CSS, Tailwind, or inline styles.');
|
||
if (hasInlineStyle) warnings.push(`Found <style> with ${cssRuleCount} rules.`);
|
||
}
|
||
|
||
// Check for potential CDN issues
|
||
if (hasTailwindCDN && !hasInlineStyle && !hasStyleAttribute && !hasLocalCSS) {
|
||
warnings.push('Relies solely on Tailwind CDN - ensure network access');
|
||
}
|
||
|
||
return {
|
||
passed: errors.length === 0,
|
||
gate: 'Gate 3: Styling Presence',
|
||
errors,
|
||
warnings
|
||
};
|
||
};
|
||
|
||
// Gate 4: Runtime sanity (basic checks)
|
||
const gate4_runtimeSanity = (files: Record<string, string>): QualityGateResult => {
|
||
const content = files['index.html'] || '';
|
||
const jsContent = files['script.js'] || '';
|
||
|
||
const errors: string[] = [];
|
||
const warnings: string[] = [];
|
||
|
||
// Combine JS
|
||
let combinedJS = jsContent;
|
||
const scriptMatches = content.match(/<script[^>]*>([\s\S]*?)<\/script>/gi) || [];
|
||
for (const script of scriptMatches) {
|
||
combinedJS += '\n' + script.replace(/<\/?script[^>]*>/gi, '');
|
||
}
|
||
|
||
// Check for unclosed strings or brackets (very basic)
|
||
const openBraces = (combinedJS.match(/{/g) || []).length;
|
||
const closeBraces = (combinedJS.match(/}/g) || []).length;
|
||
if (Math.abs(openBraces - closeBraces) > 2) {
|
||
warnings.push('Possible JS syntax error: mismatched braces');
|
||
}
|
||
|
||
// Check body isn't just text dump
|
||
const bodyMatch = content.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
||
if (bodyMatch) {
|
||
const bodyContent = bodyMatch[1].replace(/<[^>]+>/g, '').trim();
|
||
// If body is mostly text with very few tags, might be a dump
|
||
const tagCount = (bodyMatch[1].match(/<[a-z]/gi) || []).length;
|
||
if (bodyContent.length > 1000 && tagCount < 5) {
|
||
warnings.push('Body contains large text block with few HTML tags - possible prose dump');
|
||
}
|
||
}
|
||
|
||
// Check for undefined references (basic)
|
||
if (/undefined|null\s*\./.test(combinedJS)) {
|
||
warnings.push('Potential runtime error: undefined/null reference');
|
||
}
|
||
|
||
return {
|
||
passed: errors.length === 0,
|
||
gate: 'Gate 4: Runtime Sanity',
|
||
errors,
|
||
warnings
|
||
};
|
||
};
|
||
|
||
// Gate 5: Accessibility minimum
|
||
const gate5_accessibilityMinimum = (files: Record<string, string>): QualityGateResult => {
|
||
const content = files['index.html'] || '';
|
||
const errors: string[] = [];
|
||
const warnings: string[] = [];
|
||
|
||
// Check for viewport meta
|
||
if (!/<meta[^>]+viewport/i.test(content)) {
|
||
warnings.push('Missing viewport meta tag for responsive design');
|
||
}
|
||
|
||
// Check for at least one interactive element
|
||
const hasButton = /<button/i.test(content);
|
||
const hasLink = /<a[^>]+href/i.test(content);
|
||
const hasInput = /<input/i.test(content);
|
||
if (!hasButton && !hasLink && !hasInput) {
|
||
warnings.push('No interactive elements (button, link, input) detected');
|
||
}
|
||
|
||
// Check for lang attribute
|
||
if (!/<html[^>]+lang\s*=/i.test(content)) {
|
||
warnings.push('Missing lang attribute on <html>');
|
||
}
|
||
|
||
return {
|
||
passed: true, // Accessibility is warning-only, doesn't block
|
||
gate: 'Gate 5: Accessibility',
|
||
errors,
|
||
warnings
|
||
};
|
||
};
|
||
|
||
// =========================================
|
||
// LAYER 4: TASK MATCH GATE
|
||
// =========================================
|
||
// Detect when AI generates wrong type of app (e.g., asked for game, got dashboard)
|
||
|
||
interface TaskMatchContext {
|
||
originalPrompt: string;
|
||
projectType?: string;
|
||
}
|
||
|
||
// Store context for task matching (set before generation)
|
||
let taskMatchContext: TaskMatchContext | null = null;
|
||
|
||
export const setTaskMatchContext = (context: TaskMatchContext) => {
|
||
taskMatchContext = context;
|
||
};
|
||
|
||
export const clearTaskMatchContext = () => {
|
||
taskMatchContext = null;
|
||
};
|
||
|
||
// Extract key intent keywords from prompt
|
||
const extractIntentKeywords = (prompt: string): string[] => {
|
||
const keywords: string[] = [];
|
||
const lower = prompt.toLowerCase();
|
||
|
||
// App types
|
||
if (lower.includes('game') || lower.includes('play') || lower.includes('score')) keywords.push('game');
|
||
if (lower.includes('dashboard') || lower.includes('analytics') || lower.includes('chart')) keywords.push('dashboard');
|
||
if (lower.includes('form') || lower.includes('survey') || lower.includes('input')) keywords.push('form');
|
||
if (lower.includes('portfolio') || lower.includes('resume') || lower.includes('cv')) keywords.push('portfolio');
|
||
if (lower.includes('blog') || lower.includes('article') || lower.includes('post')) keywords.push('blog');
|
||
if (lower.includes('shop') || lower.includes('store') || lower.includes('cart') || lower.includes('product')) keywords.push('ecommerce');
|
||
if (lower.includes('cbt') || lower.includes('therapy') || lower.includes('mental') || lower.includes('stress')) keywords.push('wellness');
|
||
if (lower.includes('todo') || lower.includes('task') || lower.includes('checklist')) keywords.push('productivity');
|
||
if (lower.includes('chat') || lower.includes('message') || lower.includes('conversation')) keywords.push('messaging');
|
||
if (lower.includes('landing') || lower.includes('hero') || lower.includes('call to action')) keywords.push('landing');
|
||
|
||
return keywords;
|
||
};
|
||
|
||
// Check if output matches expected intent
|
||
const gate6_taskMatch = (files: Record<string, string>): QualityGateResult => {
|
||
const content = files['index.html'] || '';
|
||
const errors: string[] = [];
|
||
const warnings: string[] = [];
|
||
|
||
if (!taskMatchContext?.originalPrompt) {
|
||
// No context set, skip this gate
|
||
return { passed: true, gate: 'Gate 6: Task Match', errors, warnings };
|
||
}
|
||
|
||
const requestedKeywords = extractIntentKeywords(taskMatchContext.originalPrompt);
|
||
const outputKeywords = extractIntentKeywords(content);
|
||
|
||
// Also check the <title> and headings
|
||
const titleMatch = content.match(/<title>([^<]+)<\/title>/i);
|
||
const h1Match = content.match(/<h1[^>]*>([^<]+)<\/h1>/i);
|
||
const titleText = (titleMatch?.[1] || '') + ' ' + (h1Match?.[1] || '');
|
||
const titleKeywords = extractIntentKeywords(titleText);
|
||
|
||
// Merge detected keywords
|
||
const allOutputKeywords = [...new Set([...outputKeywords, ...titleKeywords])];
|
||
|
||
// Check for mismatch
|
||
if (requestedKeywords.length > 0) {
|
||
const hasMatch = requestedKeywords.some(k => allOutputKeywords.includes(k));
|
||
|
||
if (!hasMatch && allOutputKeywords.length > 0) {
|
||
// Definite mismatch
|
||
errors.push(`Task mismatch: Requested "${requestedKeywords.join(', ')}" but output appears to be "${allOutputKeywords.join(', ')}"`);
|
||
} else if (!hasMatch) {
|
||
// Can't determine output type, just warn
|
||
warnings.push(`Could not verify output matches requested "${requestedKeywords.join(', ')}"`);
|
||
}
|
||
}
|
||
|
||
return {
|
||
passed: errors.length === 0,
|
||
gate: 'Gate 6: Task Match',
|
||
errors,
|
||
warnings
|
||
};
|
||
};
|
||
|
||
// Run all quality gates
|
||
export const runQualityGates = (files: Record<string, string> | string): QualityReport => {
|
||
// Normalize input to files object to support legacy calls if any
|
||
let normalizedFiles: Record<string, string> = {};
|
||
if (typeof files === 'string') {
|
||
normalizedFiles = { 'index.html': files };
|
||
} else {
|
||
normalizedFiles = files;
|
||
}
|
||
|
||
const gates = [
|
||
gate1_artifactType(normalizedFiles),
|
||
gate2_htmlValidity(normalizedFiles),
|
||
gate3_stylingPresence(normalizedFiles),
|
||
gate4_runtimeSanity(normalizedFiles),
|
||
gate5_accessibilityMinimum(normalizedFiles),
|
||
gate6_taskMatch(normalizedFiles) // LAYER 4: Task Match Gate
|
||
];
|
||
|
||
// Gates 1-4 and 6 are blocking; Gate 5 (accessibility) is warning-only
|
||
const blockingGates = [gates[0], gates[1], gates[2], gates[3], gates[5]];
|
||
const overallPass = blockingGates.every(g => g.passed);
|
||
|
||
// Determine artifact type
|
||
let artifactType: 'HTML_APP' | 'PLAN_TEXT' | 'UNKNOWN' = 'UNKNOWN';
|
||
if (gates[0].passed && gates[1].passed) {
|
||
artifactType = 'HTML_APP';
|
||
} else if (gates[0].errors.some(e => e.includes('[PLAN]') || e.includes('markdown'))) {
|
||
artifactType = 'PLAN_TEXT';
|
||
}
|
||
|
||
// Generate repair hints
|
||
const repairHints: string[] = [];
|
||
for (const gate of gates) {
|
||
for (const error of gate.errors) {
|
||
if (error.includes('plan text') || error.includes('[PLAN]')) {
|
||
repairHints.push('Return a full valid HTML document, not plan text.');
|
||
}
|
||
if (error.includes('Missing <!DOCTYPE')) {
|
||
repairHints.push('Start with <!DOCTYPE html><html>...');
|
||
}
|
||
if (error.includes('No significant styling')) {
|
||
repairHints.push('Add a <style> block with CSS rules, or use inline styles.');
|
||
}
|
||
if (error.includes('escaped HTML entities')) {
|
||
repairHints.push('Output raw HTML, not escaped entities.');
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
overallPass,
|
||
artifactType,
|
||
gates,
|
||
repairHints: [...new Set(repairHints)] // Dedupe
|
||
};
|
||
};
|
||
|
||
// Auto-repair prompt generator
|
||
export const generateRepairPrompt = (originalPrompt: string, qaReport: QualityReport, attemptNumber: number): string => {
|
||
const strictMode = attemptNumber >= 2;
|
||
|
||
let repairPrompt = `⚠️ QA GATE FAILURE - ATTEMPT ${attemptNumber}/2
|
||
|
||
Your previous output FAILED quality checks:
|
||
${qaReport.gates.filter(g => !g.passed).map(g => `• ${g.gate}: ${g.errors.join(', ')}`).join('\n')}
|
||
|
||
REPAIR INSTRUCTIONS:
|
||
${qaReport.repairHints.map(h => `• ${h}`).join('\n')}
|
||
|
||
${strictMode ? `
|
||
⚡ STRICT MODE (Final Attempt):
|
||
- Use ONLY embedded <style> block, NO external CDNs
|
||
- Keep JavaScript minimal
|
||
- Output a complete, self-contained HTML file
|
||
- DO NOT include any text explanations
|
||
` : ''}
|
||
|
||
ORIGINAL REQUEST:
|
||
${originalPrompt}
|
||
|
||
OUTPUT: Return ONLY the corrected HTML file. No markdown. No explanations.`;
|
||
|
||
return repairPrompt;
|
||
};
|
||
|
||
export const generateMockFiles = async (projectPath: string = 'projects/latest', planContent: string = '') => {
|
||
const electron = getElectron();
|
||
const buildId = Date.now().toString();
|
||
|
||
// =========================================
|
||
// SAFE-GEN PIPELINE INTEGRATION
|
||
// =========================================
|
||
|
||
let files: Record<string, string> = {};
|
||
|
||
// F5: XML Parsing Logic (v2 Protocol)
|
||
const regex = /<goose_file\s+path=["']([^"']+)["']\s*>([\s\S]*?)<\/goose_file>/g;
|
||
let hasXmlMatch = false;
|
||
let m;
|
||
while ((m = regex.exec(planContent)) !== null) {
|
||
if (m[1] && m[2]) {
|
||
files[m[1]] = m[2].trim();
|
||
hasXmlMatch = true;
|
||
}
|
||
}
|
||
|
||
if (hasXmlMatch) {
|
||
console.log('[MockFiles] Successfully parsed XML Artifact Bundle');
|
||
}
|
||
|
||
// Direct HTML assignment (primary path fallback)
|
||
if (Object.keys(files).length === 0) {
|
||
// Only accept if strictly HTML to avoid JSON-dump bug
|
||
const trimmed = planContent.trim();
|
||
if (trimmed.startsWith('<!DOCTYPE html') || trimmed.startsWith('<html')) {
|
||
files['index.html'] = trimmed;
|
||
} else {
|
||
// FALLBACK: Legacy regex extraction
|
||
let html = extractCode(planContent, 'html') || extractCode(planContent, 'xml') || '';
|
||
let css = extractCode(planContent, 'css');
|
||
let js = extractCode(planContent, 'javascript') || extractCode(planContent, 'js');
|
||
|
||
// Fallback: Check for raw HTML if extraction failed
|
||
if (!html && planContent.trim().includes('<!DOCTYPE html>')) {
|
||
const match = planContent.match(/<!DOCTYPE html>[\s\S]*<\/html>/i);
|
||
html = match ? match[0] : null;
|
||
}
|
||
|
||
// Last resort: Check for <html> tag without DOCTYPE
|
||
if (!html && planContent.includes('<html')) {
|
||
const match = planContent.match(/<html[\s\S]*<\/html>/i);
|
||
if (match) {
|
||
html = '<!DOCTYPE html>\n' + match[0];
|
||
}
|
||
}
|
||
|
||
if (html) {
|
||
files['index.html'] = html;
|
||
if (css) files['style.css'] = css;
|
||
if (js) files['script.js'] = js;
|
||
}
|
||
}
|
||
}
|
||
|
||
// If no files extracted at all, create simple stub so QA fails properly
|
||
if (!files['index.html']) {
|
||
files['index.html'] = `<!DOCTYPE html><html><body><h1>Code Generation Failed</h1></body></html>`;
|
||
}
|
||
|
||
// =========================================
|
||
// RUN QUALITY GATES (Non-Destructive)
|
||
// =========================================
|
||
const qaReport = runQualityGates(files);
|
||
console.log('[QA Gates] Report:', qaReport);
|
||
|
||
const home = electron && electron.fs ? await electron.getAppPath() : '';
|
||
const fullBasePath = `${home}/${projectPath}`;
|
||
const buildsPath = `${fullBasePath}/.builds/${buildId}`;
|
||
|
||
// STAGING: Always save raw artifacts to history (F1)
|
||
if (electron && electron.fs) {
|
||
try {
|
||
// Create directory if needed (assuming fs handles it or we rely on write to create)
|
||
await electron.fs.write(`${buildsPath}/raw/index.html`, files['index.html'] || '');
|
||
if (files['style.css']) await electron.fs.write(`${buildsPath}/raw/style.css`, files['style.css']);
|
||
if (files['script.js']) await electron.fs.write(`${buildsPath}/raw/script.js`, files['script.js']);
|
||
|
||
await electron.fs.write(`${buildsPath}/qa/report.json`, JSON.stringify(qaReport, null, 2));
|
||
} catch (e) {
|
||
console.error('[QA Gates] Failed to write history:', e);
|
||
}
|
||
}
|
||
|
||
if (!qaReport.overallPass) {
|
||
console.error('[QA Gates] FAILED! Errors:', qaReport.gates.filter(g => !g.passed).map(g => ({ gate: g.gate, errors: g.errors })));
|
||
|
||
// F1: NON-DESTRUCTIVE - Do NOT write to project root.
|
||
// Instead, return the failure overlay content for the UI/Preview to show, but DO NOT save it as index.html
|
||
|
||
const failureHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>QA Check Failed</title><style>
|
||
body { background: linear-gradient(135deg, #1a1a1a 0%, #2d1a1a 100%); color: #e8e8e8; padding: 40px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||
.container { max-width: 700px; text-align: center; }
|
||
h1 { color: #ff5555; font-size: 28px; margin-bottom: 10px; }
|
||
.subtitle { color: #888; margin-bottom: 30px; font-size: 14px; }
|
||
.error-box { background: rgba(255,85,85,0.1); border: 1px solid rgba(255,85,85,0.3); border-radius: 12px; padding: 20px; margin: 20px 0; text-align: left; }
|
||
.error-item { margin: 12px 0; font-size: 14px; color: #ffaaaa; }
|
||
.error-item strong { color: #ff8888; }
|
||
.rebuild-btn { display: inline-flex; align-items: center; gap: 8px; margin-top: 30px; padding: 14px 28px; background: linear-gradient(135deg, #34d399 0%, #10b981 100%); color: #000; font-weight: bold; font-size: 14px; border: none; border-radius: 12px; cursor: pointer; box-shadow: 0 0 30px rgba(52,211,153,0.3); transition: all 0.3s ease; }
|
||
.rebuild-btn:hover { transform: scale(1.05); box-shadow: 0 0 40px rgba(52,211,153,0.5); }
|
||
</style></head><body>
|
||
<div class="container">
|
||
<h1>⚠️ QA Check Failed</h1>
|
||
<p class="subtitle">The generated app did not meet quality standards. (Build ID: ${buildId})</p>
|
||
<div class="error-box">
|
||
<p style="margin: 0 0 12px 0; font-size: 12px; color: #ff6666; text-transform: uppercase;">Issues Detected:</p>
|
||
${qaReport.gates.filter(g => !g.passed).map(g => `
|
||
<div class="error-item"><strong>Gate ${g.gate.split(':')[0]}:</strong> ${g.errors.join(', ')}</div>
|
||
`).join('')}
|
||
</div>
|
||
<button class="rebuild-btn" onclick="window.parent.postMessage({type:'REBUILD_REQUEST'},'*')">
|
||
🔄 Retry / Repair
|
||
</button>
|
||
</div>
|
||
<script>
|
||
document.querySelector('.rebuild-btn').addEventListener('click', function() {
|
||
try { window.parent.dispatchEvent(new CustomEvent('goose-rebuild-request')); } catch(e) {}
|
||
});
|
||
</script>
|
||
</body></html>`;
|
||
|
||
// Return files object with _qaFailed flag but also the 'virtual' index.html for the iframe to render
|
||
return {
|
||
'index.html': failureHtml,
|
||
'style.css': '',
|
||
'script.js': '',
|
||
_qaFailed: true,
|
||
_qaReport: qaReport,
|
||
_buildId: buildId
|
||
};
|
||
}
|
||
|
||
// Log any warnings (non-blocking)
|
||
const warnings = qaReport.gates.flatMap(g => g.warnings);
|
||
if (warnings.length > 0) {
|
||
console.warn('[QA Gates] Warnings:', warnings);
|
||
}
|
||
|
||
// Write all extracted files to disk (ATOMIC SWAP: Success only)
|
||
if (electron && electron.fs) {
|
||
console.log(`[QA Gates] PASSED ✓ Writing ${Object.keys(files).length} files to ${fullBasePath}...`);
|
||
for (const [relPath, content] of Object.entries(files)) {
|
||
if (content) await electron.fs.write(`${fullBasePath}/${relPath}`, content);
|
||
}
|
||
return files;
|
||
} else {
|
||
console.warn("Electron FS not available. Returning mock data.");
|
||
return files;
|
||
}
|
||
};
|
||
|
||
// --- Skills Service ---
|
||
// --- Skills Service ---
|
||
// Superseded by src/services/skillsService.ts
|
||
|
||
|
||
|
||
export const savePersonasToDisk = async (personas: import('../types').Persona[]) => {
|
||
const electron = getElectron();
|
||
if (!electron?.fs) return;
|
||
const userData = await getUserDataRoot();
|
||
if (!userData) return;
|
||
const personasDir = `${userData}/personas`;
|
||
await electron.fs.write(`${personasDir}/index.json`, JSON.stringify(personas, null, 2));
|
||
};
|
||
|
||
export const loadPersonasFromDisk = async (): Promise<import('../types').Persona[]> => {
|
||
const electron = getElectron();
|
||
if (!electron?.fs) return [];
|
||
const userData = await getUserDataRoot();
|
||
if (!userData) return [];
|
||
try {
|
||
const personasDir = `${userData}/personas`;
|
||
const raw = await electron.fs.read(`${personasDir}/index.json`);
|
||
return JSON.parse(raw);
|
||
} catch {
|
||
return [];
|
||
}
|
||
};
|