Merge branch 'v0.8.0-new' of github.com:ZSeven-W/openpencil into v0.8.0-new
This commit is contained in:
parent
cb9a0ae692
commit
8a39bc7b03
|
|
@ -1,192 +0,0 @@
|
|||
/**
|
||||
* Design Code Generator (Stage 1 of visual reference pipeline).
|
||||
*
|
||||
* Generates self-contained HTML/CSS code using the model's strongest design
|
||||
* capability. The output is a visual reference that guides PenNode generation.
|
||||
* Design principles are included to ensure consistent visual quality.
|
||||
*/
|
||||
|
||||
import type { DesignSystem } from './ai-types';
|
||||
import type { AIProviderType } from '@/types/agent-settings';
|
||||
import { generateCompletion } from './ai-service';
|
||||
import { getSkillByName } from '@zseven-w/pen-ai-skills';
|
||||
import { designSystemToPromptContext } from './design-system-generator';
|
||||
|
||||
interface CodeGenOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
model?: string;
|
||||
provider?: AIProviderType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate self-contained HTML/CSS code for a design request.
|
||||
* The code is production-grade and serves as a visual blueprint.
|
||||
*/
|
||||
export async function generateDesignCode(
|
||||
prompt: string,
|
||||
designSystem: DesignSystem,
|
||||
options: CodeGenOptions,
|
||||
): Promise<string> {
|
||||
const designCodeSkill = getSkillByName('design-code')?.content ?? '';
|
||||
const principles = getSkillByName('design-principles')?.content ?? '';
|
||||
|
||||
// Build the system prompt with principles injected
|
||||
const systemPrompt = principles ? `${designCodeSkill}\n\n${principles}` : designCodeSkill;
|
||||
|
||||
// Build the user prompt with design system context
|
||||
const dsContext = designSystemToPromptContext(designSystem);
|
||||
const userPrompt = buildCodeGenUserPrompt(prompt, dsContext, options.width, options.height);
|
||||
|
||||
const response = await generateCompletion(
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
options.model,
|
||||
options.provider,
|
||||
);
|
||||
|
||||
return extractHtmlFromResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the HTML content from an AI response.
|
||||
* Handles responses with code fences, markdown, or bare HTML.
|
||||
*/
|
||||
function extractHtmlFromResponse(response: string): string {
|
||||
const trimmed = response.trim();
|
||||
|
||||
// Check for code fence wrapped HTML
|
||||
const fenceMatch = trimmed.match(/```(?:html)?\s*\n?([\s\S]*?)\n?```/);
|
||||
if (fenceMatch) {
|
||||
const content = fenceMatch[1].trim();
|
||||
if (content.includes('<!DOCTYPE') || content.includes('<html')) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the response itself starts with HTML
|
||||
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Try to find HTML document in the response
|
||||
const htmlMatch = trimmed.match(/(<!DOCTYPE[\s\S]*<\/html>)/i);
|
||||
if (htmlMatch) {
|
||||
return htmlMatch[1];
|
||||
}
|
||||
|
||||
// Last resort: wrap bare content
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Design</title></head>
|
||||
<body>${trimmed}</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a structural summary from HTML for use as sub-agent reference.
|
||||
* Produces a concise text description of the HTML structure.
|
||||
*/
|
||||
export function extractStructureSummary(html: string): string {
|
||||
const lines: string[] = ['DESIGN REFERENCE STRUCTURE:'];
|
||||
|
||||
// Extract section-level elements
|
||||
const sectionPattern =
|
||||
/<(?:section|header|footer|nav|main|div)\s+[^>]*(?:class|id)="([^"]*)"[^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = sectionPattern.exec(html)) !== null) {
|
||||
const classOrId = match[1];
|
||||
if (classOrId && !classOrId.includes('__')) {
|
||||
lines.push(`- Section: ${classOrId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract heading content for structure hints
|
||||
const headingPattern = /<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi;
|
||||
while ((match = headingPattern.exec(html)) !== null) {
|
||||
const level = match[1];
|
||||
const content = match[2]
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim()
|
||||
.slice(0, 60);
|
||||
if (content) {
|
||||
lines.push(`- H${level}: "${content}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract button/CTA text
|
||||
const buttonPattern =
|
||||
/<(?:button|a)\s+[^>]*class="[^"]*(?:btn|button|cta)[^"]*"[^>]*>([\s\S]*?)<\/(?:button|a)>/gi;
|
||||
while ((match = buttonPattern.exec(html)) !== null) {
|
||||
const text = match[1]
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim()
|
||||
.slice(0, 30);
|
||||
if (text) {
|
||||
lines.push(`- CTA: "${text}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't extract structure, provide a generic summary
|
||||
if (lines.length <= 1) {
|
||||
lines.push('(HTML structure extracted — use as visual layout reference)');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the HTML section relevant to a specific subtask label.
|
||||
* Uses heuristic matching on section/div IDs, classes, and heading content.
|
||||
*/
|
||||
export function extractHtmlSection(html: string, subtaskLabel: string): string | null {
|
||||
const labelLower = subtaskLabel.toLowerCase();
|
||||
|
||||
// Try to find a matching section by common keywords
|
||||
const keywords = labelLower
|
||||
.replace(/[((].+[))]/g, '')
|
||||
.split(/[\s,/]+/)
|
||||
.filter((w) => w.length > 2);
|
||||
|
||||
if (keywords.length === 0) return null;
|
||||
|
||||
// Build a regex to match section containers
|
||||
const keywordPattern = keywords.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
||||
const sectionRegex = new RegExp(
|
||||
`<(?:section|div|header|footer|nav)[^>]*(?:class|id)="[^"]*(?:${keywordPattern})[^"]*"[^>]*>[\\s\\S]*?(?=<(?:section|div|header|footer|nav)[^>]*(?:class|id)="|$)`,
|
||||
'i',
|
||||
);
|
||||
|
||||
const match = sectionRegex.exec(html);
|
||||
if (match) {
|
||||
// Truncate to reasonable length for context
|
||||
const section = match[0].slice(0, 1500);
|
||||
return `HTML reference for "${subtaskLabel}":\n${section}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the user prompt for HTML/CSS code generation.
|
||||
* Includes the design system tokens and viewport constraints.
|
||||
*/
|
||||
function buildCodeGenUserPrompt(
|
||||
userPrompt: string,
|
||||
designSystemContext: string,
|
||||
width: number,
|
||||
height: number,
|
||||
): string {
|
||||
const heightInstruction =
|
||||
height > 0
|
||||
? `Height: ${height}px (fixed viewport).`
|
||||
: `Height: auto (content determines height, estimate based on sections).`;
|
||||
|
||||
return `Design request: ${userPrompt}
|
||||
|
||||
Viewport: Width ${width}px. ${heightInstruction}
|
||||
|
||||
${designSystemContext}
|
||||
|
||||
Generate the complete HTML file now.`;
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
/**
|
||||
* Design System Generator (Stage 0 of visual reference pipeline).
|
||||
*
|
||||
* Generates structured design tokens (colors, typography, spacing) from
|
||||
* a user's design request. The tokens serve dual purpose:
|
||||
* 1. Guide HTML code generation for consistent design
|
||||
* 2. Map to PenDocument.variables for design system integration
|
||||
*/
|
||||
|
||||
import type { DesignSystem } from './ai-types';
|
||||
import type { AIProviderType } from '@/types/agent-settings';
|
||||
import type { VariableDefinition } from '@/types/variables';
|
||||
import { generateCompletion } from './ai-service';
|
||||
import { getSkillByName } from '@zseven-w/pen-ai-skills';
|
||||
|
||||
/**
|
||||
* Generate a design system from a user's prompt.
|
||||
* Uses a fast model (Haiku-class) for speed since output is small JSON.
|
||||
*/
|
||||
export async function generateDesignSystem(
|
||||
prompt: string,
|
||||
model?: string,
|
||||
provider?: AIProviderType,
|
||||
): Promise<DesignSystem> {
|
||||
const designSystemPrompt = getSkillByName('design-system')?.content ?? '';
|
||||
const response = await generateCompletion(designSystemPrompt, prompt, model, provider);
|
||||
|
||||
return parseDesignSystem(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a design system from AI response text.
|
||||
* Tolerant of code fences and surrounding text.
|
||||
*/
|
||||
function parseDesignSystem(text: string): DesignSystem {
|
||||
const trimmed = text.trim();
|
||||
|
||||
// Try direct parse
|
||||
const direct = tryParseDS(trimmed);
|
||||
if (direct) return direct;
|
||||
|
||||
// Try extracting from code fences
|
||||
const fenceMatch = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
||||
if (fenceMatch) {
|
||||
const fenced = tryParseDS(fenceMatch[1].trim());
|
||||
if (fenced) return fenced;
|
||||
}
|
||||
|
||||
// Try extracting first { ... } block
|
||||
const firstBrace = trimmed.indexOf('{');
|
||||
const lastBrace = trimmed.lastIndexOf('}');
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
const braced = tryParseDS(trimmed.slice(firstBrace, lastBrace + 1));
|
||||
if (braced) return braced;
|
||||
}
|
||||
|
||||
// Fallback: return default design system
|
||||
return DEFAULT_DESIGN_SYSTEM;
|
||||
}
|
||||
|
||||
function tryParseDS(json: string): DesignSystem | null {
|
||||
try {
|
||||
const obj = JSON.parse(json) as Record<string, unknown>;
|
||||
if (!obj.palette || typeof obj.palette !== 'object') return null;
|
||||
if (!obj.typography || typeof obj.typography !== 'object') return null;
|
||||
|
||||
const p = obj.palette as Record<string, string>;
|
||||
const t = obj.typography as Record<string, unknown>;
|
||||
const s = (obj.spacing as Record<string, unknown>) ?? {
|
||||
unit: 8,
|
||||
scale: [8, 16, 24, 32, 48, 64],
|
||||
};
|
||||
|
||||
return {
|
||||
palette: {
|
||||
background: p.background ?? '#F8FAFC',
|
||||
surface: p.surface ?? '#FFFFFF',
|
||||
text: p.text ?? '#0F172A',
|
||||
textSecondary: p.textSecondary ?? '#475569',
|
||||
primary: p.primary ?? '#2563EB',
|
||||
primaryLight: p.primaryLight ?? '#DBEAFE',
|
||||
accent: p.accent ?? '#0EA5E9',
|
||||
border: p.border ?? '#E2E8F0',
|
||||
},
|
||||
typography: {
|
||||
headingFont: (t.headingFont as string) ?? 'Space Grotesk',
|
||||
bodyFont: (t.bodyFont as string) ?? 'Inter',
|
||||
scale: Array.isArray(t.scale) ? (t.scale as number[]) : [14, 16, 20, 28, 40, 56],
|
||||
},
|
||||
spacing: {
|
||||
unit: (s.unit as number) ?? 8,
|
||||
scale: Array.isArray(s.scale) ? (s.scale as number[]) : [8, 16, 24, 32, 48, 64],
|
||||
},
|
||||
radius: Array.isArray(obj.radius) ? (obj.radius as number[]) : [8, 12, 16],
|
||||
aesthetic: (obj.aesthetic as string) ?? 'clean modern',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_DESIGN_SYSTEM: DesignSystem = {
|
||||
palette: {
|
||||
background: '#F8FAFC',
|
||||
surface: '#FFFFFF',
|
||||
text: '#0F172A',
|
||||
textSecondary: '#475569',
|
||||
primary: '#2563EB',
|
||||
primaryLight: '#DBEAFE',
|
||||
accent: '#0EA5E9',
|
||||
border: '#E2E8F0',
|
||||
},
|
||||
typography: {
|
||||
headingFont: 'Space Grotesk',
|
||||
bodyFont: 'Inter',
|
||||
scale: [14, 16, 20, 28, 40, 56],
|
||||
},
|
||||
spacing: {
|
||||
unit: 8,
|
||||
scale: [8, 16, 24, 32, 48, 64],
|
||||
},
|
||||
radius: [8, 12, 16],
|
||||
aesthetic: 'clean modern blue',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map design system → PenDocument.variables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a DesignSystem into PenDocument variable definitions.
|
||||
* These are stored in the document and referenced as $variable-name in nodes.
|
||||
*/
|
||||
export function designSystemToVariables(ds: DesignSystem): Record<string, VariableDefinition> {
|
||||
const vars: Record<string, VariableDefinition> = {};
|
||||
|
||||
// Colors
|
||||
for (const [key, value] of Object.entries(ds.palette)) {
|
||||
const name = `color-${kebab(key)}`;
|
||||
vars[name] = { type: 'color', value };
|
||||
}
|
||||
|
||||
// Spacing
|
||||
const spacingNames = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl'];
|
||||
for (let i = 0; i < ds.spacing.scale.length && i < spacingNames.length; i++) {
|
||||
vars[`spacing-${spacingNames[i]}`] = { type: 'number', value: ds.spacing.scale[i] };
|
||||
}
|
||||
|
||||
// Radius
|
||||
const radiusNames = ['sm', 'md', 'lg', 'xl'];
|
||||
for (let i = 0; i < ds.radius.length && i < radiusNames.length; i++) {
|
||||
vars[`radius-${radiusNames[i]}`] = { type: 'number', value: ds.radius[i] };
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a concise design system context string for AI prompts.
|
||||
*/
|
||||
export function designSystemToPromptContext(ds: DesignSystem): string {
|
||||
const p = ds.palette;
|
||||
return `DESIGN SYSTEM (use these values consistently):
|
||||
Colors: bg ${p.background}, surface ${p.surface}, text ${p.text}, muted ${p.textSecondary}, primary ${p.primary}, primaryLight ${p.primaryLight}, accent ${p.accent}, border ${p.border}
|
||||
Fonts: heading "${ds.typography.headingFont}", body "${ds.typography.bodyFont}"
|
||||
Type scale: ${ds.typography.scale.join(', ')}px
|
||||
Spacing: ${ds.spacing.scale.join(', ')}px (${ds.spacing.unit}px grid)
|
||||
Radius: ${ds.radius.join(', ')}px
|
||||
Style: ${ds.aesthetic}`;
|
||||
}
|
||||
|
||||
function kebab(str: string): string {
|
||||
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
||||
}
|
||||
|
|
@ -18,7 +18,6 @@ import {
|
|||
} from './ai-runtime-config';
|
||||
import type { PenNode } from '@/types/pen';
|
||||
import type { AIProviderType } from '@/types/agent-settings';
|
||||
import { getCurrentVisualReference, clearVisualReference } from './visual-ref-orchestrator';
|
||||
import { resolveSkills } from '@zseven-w/pen-ai-skills';
|
||||
import { runPreValidationFixesDetailed } from './design-pre-validation';
|
||||
|
||||
|
|
@ -310,7 +309,6 @@ export async function runPostGenerationValidation(options?: {
|
|||
|
||||
// If LLM validation is disabled, stop after pre-checks
|
||||
if (!VALIDATION_ENABLED) {
|
||||
clearVisualReference();
|
||||
const breakdown = formatCategoryBreakdown(preFix.byCategory);
|
||||
emit(
|
||||
'done',
|
||||
|
|
@ -328,7 +326,6 @@ export async function runPostGenerationValidation(options?: {
|
|||
// outputs (one badge, one chart) are not worth the round-trip.
|
||||
const nodeCount = countNodesInActivePage();
|
||||
if (nodeCount < VALIDATION_NODE_COUNT_THRESHOLD) {
|
||||
clearVisualReference();
|
||||
const breakdown = formatCategoryBreakdown(preFix.byCategory);
|
||||
emit(
|
||||
'done',
|
||||
|
|
@ -369,7 +366,6 @@ export async function runPostGenerationValidation(options?: {
|
|||
console.warn(`[Validation] Round ${round}: could not capture screenshot — stopping`);
|
||||
if (isFirstRound) {
|
||||
emit('done', '[error] Screenshot failed');
|
||||
clearVisualReference();
|
||||
return { applied: 0, skipped: true };
|
||||
}
|
||||
break;
|
||||
|
|
@ -386,17 +382,14 @@ export async function runPostGenerationValidation(options?: {
|
|||
console.log(`[Validation] Node tree dump:\n${nodeTreeDump}`);
|
||||
}
|
||||
|
||||
// Reference comparison only on first round
|
||||
const visualRef = isFirstRound ? getCurrentVisualReference() : null;
|
||||
const hasReference = visualRef?.screenshot && visualRef.screenshot.length > 0;
|
||||
|
||||
// Reference-comparison path was deleted with the visual-ref pipeline
|
||||
// (no caller ever populated `currentReference`). The Rust port keeps
|
||||
// the `referenceScreenshot` parameter on `validateDesignScreenshot`
|
||||
// so a future visual-ref source can be plumbed back in without
|
||||
// touching this control flow.
|
||||
emit(
|
||||
'streaming',
|
||||
hasReference && isFirstRound
|
||||
? '[pending] Comparing with design reference...'
|
||||
: isFirstRound
|
||||
? '[pending] Analyzing design...'
|
||||
: `[pending] Analyzing (round ${round})...`,
|
||||
isFirstRound ? '[pending] Analyzing design...' : `[pending] Analyzing (round ${round})...`,
|
||||
);
|
||||
|
||||
const result = await validateDesignScreenshot(
|
||||
|
|
@ -404,7 +397,7 @@ export async function runPostGenerationValidation(options?: {
|
|||
nodeTreeDump,
|
||||
options?.model,
|
||||
options?.provider,
|
||||
hasReference ? visualRef!.screenshot : undefined,
|
||||
undefined,
|
||||
round,
|
||||
);
|
||||
|
||||
|
|
@ -420,7 +413,6 @@ export async function runPostGenerationValidation(options?: {
|
|||
: 'timeout or provider error';
|
||||
log[log.length - 1] = `[error] Analysis skipped (${reasonShort})`;
|
||||
if (isFirstRound) {
|
||||
clearVisualReference();
|
||||
emit('done');
|
||||
return { applied: 0, skipped: true };
|
||||
}
|
||||
|
|
@ -504,9 +496,6 @@ export async function runPostGenerationValidation(options?: {
|
|||
emit('streaming');
|
||||
}
|
||||
|
||||
// Cleanup visual reference after all rounds
|
||||
clearVisualReference();
|
||||
|
||||
// Final summary line
|
||||
const qualityInfo = lastQualityScore > 0 ? ` — quality: ${lastQualityScore}/10` : '';
|
||||
if (totalApplied > 0) {
|
||||
|
|
|
|||
|
|
@ -1,125 +0,0 @@
|
|||
/**
|
||||
* HTML Renderer (Stage 2 of visual reference pipeline).
|
||||
*
|
||||
* Renders generated HTML/CSS to a screenshot using a hidden iframe + html2canvas.
|
||||
* Runs entirely client-side — no external browser process needed.
|
||||
*/
|
||||
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
/**
|
||||
* Render an HTML string to a base64 PNG screenshot.
|
||||
* Creates a hidden iframe, writes the HTML, and captures with html2canvas.
|
||||
*
|
||||
* @param html - Complete HTML document string
|
||||
* @param width - Viewport width in pixels
|
||||
* @param height - Viewport height in pixels (0 = auto based on content)
|
||||
* @returns Base64 PNG string (without data: URL prefix)
|
||||
*/
|
||||
export async function renderHtmlToScreenshot(
|
||||
html: string,
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<string> {
|
||||
// Safety check — only runs in browser
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('renderHtmlToScreenshot requires a browser environment');
|
||||
}
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
|
||||
try {
|
||||
// Position off-screen
|
||||
iframe.style.cssText = `
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
width: ${width}px;
|
||||
height: ${height > 0 ? `${height}px` : '4000px'};
|
||||
border: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
`;
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
const iframeDoc = iframe.contentDocument;
|
||||
if (!iframeDoc) {
|
||||
throw new Error('Could not access iframe document');
|
||||
}
|
||||
|
||||
// Write the HTML into the iframe (same-origin blob)
|
||||
iframeDoc.open();
|
||||
iframeDoc.write(html);
|
||||
iframeDoc.close();
|
||||
|
||||
// Wait for fonts and rendering to settle
|
||||
await waitForRender(iframeDoc);
|
||||
|
||||
// Determine actual content height if height was auto
|
||||
const captureHeight = height > 0 ? height : Math.min(iframeDoc.body.scrollHeight || 4000, 6000);
|
||||
|
||||
// Resize iframe to actual content height
|
||||
if (height <= 0) {
|
||||
iframe.style.height = `${captureHeight}px`;
|
||||
// Wait one more frame for resize to apply
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Capture with html2canvas
|
||||
const canvas = await html2canvas(iframeDoc.body, {
|
||||
width,
|
||||
height: captureHeight,
|
||||
windowWidth: width,
|
||||
windowHeight: captureHeight,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
scale: 1, // 1x is sufficient for reference (saves memory/bandwidth)
|
||||
logging: false,
|
||||
backgroundColor: null, // Preserve transparency
|
||||
});
|
||||
|
||||
// Convert to base64 PNG (strip the data:image/png;base64, prefix)
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
const base64 = dataUrl.replace(/^data:image\/png;base64,/, '');
|
||||
|
||||
return base64;
|
||||
} finally {
|
||||
// Cleanup
|
||||
if (iframe.parentNode) {
|
||||
document.body.removeChild(iframe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the iframe document to finish rendering.
|
||||
* Waits for fonts, images, and layout to stabilize.
|
||||
*/
|
||||
async function waitForRender(doc: Document): Promise<void> {
|
||||
// Wait for fonts to load (if the document's fonts API is available)
|
||||
try {
|
||||
if (doc.fonts && typeof doc.fonts.ready === 'object') {
|
||||
await Promise.race([
|
||||
doc.fonts.ready,
|
||||
new Promise<void>((r) => setTimeout(r, 3000)), // Max 3s for fonts
|
||||
]);
|
||||
}
|
||||
} catch {
|
||||
// Fonts API not available in iframe — continue anyway
|
||||
}
|
||||
|
||||
// Wait for general rendering to stabilize (2 animation frames + small delay)
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}, 300); // 300ms for CSS transitions and layout
|
||||
});
|
||||
}
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
/**
|
||||
* Visual Reference Orchestrator — full A+B+C pipeline.
|
||||
*
|
||||
* Orchestrates the visual reference pipeline:
|
||||
* Stage 0: Generate design system tokens (Phase B)
|
||||
* Stage 1: Generate HTML/CSS code with skill-enhanced prompts (Phase A+C)
|
||||
* Stage 2: Render HTML to screenshot (Phase C)
|
||||
* Stage 3: Run PenNode generation with visual reference context
|
||||
* Stage 4: Validate against reference screenshot
|
||||
*
|
||||
* The key insight: separating "what looks good" (Stages 0-2) from
|
||||
* "how to encode it" (Stage 3) lets each LLM call focus on what it's best at.
|
||||
*/
|
||||
|
||||
import type { PenNode } from '@/types/pen';
|
||||
import type { AIDesignRequest, DesignSystem, VisualReference } from './ai-types';
|
||||
import {
|
||||
generateDesignSystem,
|
||||
designSystemToVariables,
|
||||
designSystemToPromptContext,
|
||||
} from './design-system-generator';
|
||||
import {
|
||||
generateDesignCode,
|
||||
extractStructureSummary,
|
||||
extractHtmlSection,
|
||||
} from './design-code-generator';
|
||||
import { renderHtmlToScreenshot } from './html-renderer';
|
||||
import { executeOrchestration } from './orchestrator';
|
||||
import { useDocumentStore } from '@/stores/document-store';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module state — reference data for the current generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let currentReference: VisualReference | null = null;
|
||||
|
||||
export function getCurrentVisualReference(): VisualReference | null {
|
||||
return currentReference;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function executeVisualRefOrchestration(
|
||||
request: AIDesignRequest,
|
||||
callbacks?: {
|
||||
onApplyPartial?: (count: number) => void;
|
||||
onTextUpdate?: (text: string) => void;
|
||||
animated?: boolean;
|
||||
},
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<{ nodes: PenNode[]; rawResponse: string }> {
|
||||
currentReference = null;
|
||||
|
||||
const emitStatus = (title: string, detail: string) => {
|
||||
callbacks?.onTextUpdate?.(`<step title="${title}" status="streaming">${detail}</step>`);
|
||||
};
|
||||
|
||||
try {
|
||||
// -- Stage 0: Generate Design System --
|
||||
emitStatus('Crafting design system', 'Selecting colors, typography, and spacing...');
|
||||
|
||||
if (abortSignal?.aborted) throw new Error('Aborted');
|
||||
|
||||
let designSystem: DesignSystem;
|
||||
try {
|
||||
designSystem = await generateDesignSystem(request.prompt, request.model, request.provider);
|
||||
} catch (err) {
|
||||
console.warn('[VisualRef] Design system generation failed, using defaults:', err);
|
||||
designSystem = getDefaultDesignSystem();
|
||||
}
|
||||
|
||||
// Write design tokens to document variables
|
||||
const variables = designSystemToVariables(designSystem);
|
||||
const store = useDocumentStore.getState();
|
||||
for (const [name, def] of Object.entries(variables)) {
|
||||
store.setVariable(name, def);
|
||||
}
|
||||
|
||||
emitStatus('Crafting design system', `Style: ${designSystem.aesthetic}`);
|
||||
|
||||
if (abortSignal?.aborted) throw new Error('Aborted');
|
||||
|
||||
// -- Stage 1: Generate HTML/CSS Code --
|
||||
emitStatus('Generating design reference', 'Creating high-fidelity HTML blueprint...');
|
||||
|
||||
// Determine viewport from request context or defaults
|
||||
const width = request.context?.canvasSize?.width ?? 1200;
|
||||
const height = request.context?.canvasSize?.height ?? 0;
|
||||
|
||||
let html: string;
|
||||
try {
|
||||
html = await generateDesignCode(request.prompt, designSystem, {
|
||||
width,
|
||||
height,
|
||||
model: request.model,
|
||||
provider: request.provider,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[VisualRef] Code generation failed, falling back to direct pipeline:', err);
|
||||
return executeOrchestration(request, callbacks, abortSignal);
|
||||
}
|
||||
|
||||
emitStatus('Generating design reference', 'HTML blueprint ready');
|
||||
|
||||
if (abortSignal?.aborted) throw new Error('Aborted');
|
||||
|
||||
// -- Stage 2: Render to Screenshot --
|
||||
emitStatus('Rendering reference', 'Capturing visual reference...');
|
||||
|
||||
let screenshot: string;
|
||||
try {
|
||||
screenshot = await renderHtmlToScreenshot(html, width, height);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[VisualRef] Screenshot rendering failed, continuing without visual reference:',
|
||||
err,
|
||||
);
|
||||
// Continue without screenshot — sub-agents still get the HTML structure
|
||||
screenshot = '';
|
||||
}
|
||||
|
||||
if (screenshot) {
|
||||
emitStatus('Rendering reference', 'Visual reference captured');
|
||||
}
|
||||
|
||||
// Build structure summary for orchestrator context
|
||||
const structureSummary = extractStructureSummary(html);
|
||||
|
||||
// Store the reference for validation phase
|
||||
currentReference = {
|
||||
html,
|
||||
screenshot,
|
||||
designSystem,
|
||||
structureSummary,
|
||||
};
|
||||
|
||||
if (abortSignal?.aborted) throw new Error('Aborted');
|
||||
|
||||
// -- Stage 3: PenNode Generation with Reference --
|
||||
// Enhance the request prompt with design reference context
|
||||
const dsContext = designSystemToPromptContext(designSystem);
|
||||
const enhancedPrompt = buildEnhancedPrompt(request.prompt, structureSummary, dsContext);
|
||||
|
||||
const enhancedRequest: AIDesignRequest = {
|
||||
...request,
|
||||
prompt: enhancedPrompt,
|
||||
// Pass through existing context, adding our variables
|
||||
context: {
|
||||
...request.context,
|
||||
variables: {
|
||||
...request.context?.variables,
|
||||
...variables,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Run the existing orchestration pipeline with enhanced context
|
||||
const result = await executeOrchestration(enhancedRequest, callbacks, abortSignal);
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
// Don't clear currentReference here — validation needs it
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt enhancement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildEnhancedPrompt(
|
||||
originalPrompt: string,
|
||||
structureSummary: string,
|
||||
designSystemContext: string,
|
||||
): string {
|
||||
return `${originalPrompt}
|
||||
|
||||
${structureSummary}
|
||||
|
||||
${designSystemContext}
|
||||
|
||||
IMPORTANT: Follow the design reference structure closely. The design system colors, fonts, and spacing have already been determined — use them consistently. The reference structure shows the intended layout — match its section order and composition.`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML section extraction for sub-agents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Enrich subtasks with HTML reference snippets from the current visual reference.
|
||||
* Called after orchestrator planning to give each sub-agent structural context.
|
||||
*/
|
||||
export function enrichSubtasksWithHtmlReference(
|
||||
subtasks: Array<{ id: string; label: string; htmlReference?: string }>,
|
||||
): void {
|
||||
if (!currentReference) return;
|
||||
|
||||
for (const subtask of subtasks) {
|
||||
const section = extractHtmlSection(currentReference.html, subtask.label);
|
||||
if (section) {
|
||||
subtask.htmlReference = section;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function clearVisualReference(): void {
|
||||
currentReference = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallback design system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getDefaultDesignSystem(): DesignSystem {
|
||||
return {
|
||||
palette: {
|
||||
background: '#F8FAFC',
|
||||
surface: '#FFFFFF',
|
||||
text: '#0F172A',
|
||||
textSecondary: '#475569',
|
||||
primary: '#2563EB',
|
||||
primaryLight: '#DBEAFE',
|
||||
accent: '#0EA5E9',
|
||||
border: '#E2E8F0',
|
||||
},
|
||||
typography: {
|
||||
headingFont: 'Space Grotesk',
|
||||
bodyFont: 'Inter',
|
||||
scale: [14, 16, 20, 28, 40, 56],
|
||||
},
|
||||
spacing: {
|
||||
unit: 8,
|
||||
scale: [8, 16, 24, 32, 48, 64],
|
||||
},
|
||||
radius: [8, 12, 16],
|
||||
aesthetic: 'clean modern',
|
||||
};
|
||||
}
|
||||
|
|
@ -5,12 +5,7 @@
|
|||
"severity": "warning",
|
||||
"property": "padding",
|
||||
"currentValue": null,
|
||||
"suggestedValue": [
|
||||
0,
|
||||
16,
|
||||
0,
|
||||
16
|
||||
],
|
||||
"suggestedValue": [0, 16, 0, 16],
|
||||
"reason": "mobile root has 0 horizontal padding while 2 content section(s) glue text/icon to screen edge"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,12 +4,7 @@
|
|||
"category": "stacked-horizontal-padding",
|
||||
"severity": "info",
|
||||
"property": "padding",
|
||||
"currentValue": [
|
||||
0,
|
||||
24,
|
||||
0,
|
||||
24
|
||||
],
|
||||
"currentValue": [0, 24, 0, 24],
|
||||
"suggestedValue": null,
|
||||
"reason": "section h-padding [24/24] stacks with root h-padding [16/16] — combined gutter 40/40"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,11 +186,12 @@ pub async fn run_design_request(
|
|||
model,
|
||||
provider: provider_id,
|
||||
design_md: state.doc.design_md.clone(),
|
||||
// S3b-2 / S3b-4 / S3c additions — host 暂无路由,统一保守值。
|
||||
// S3b-2 / S3b-4 / S3c / S4 additions — host 暂无路由,统一保守值。
|
||||
// 真实接线在 task #27 走 chat_runtime intent gate 时定。
|
||||
append_context: None,
|
||||
concurrency: 1,
|
||||
validation_enabled: false,
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
let mut sink = DesktopDocSink::new(state);
|
||||
// Stub validation providers — production-visible, no-op. Host can
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ fn make_req() -> crate::types::DesignRequest {
|
|||
concurrency: 2,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ fn make_req() -> crate::types::DesignRequest {
|
|||
concurrency: 2,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
478
crates/op-orchestrator/src/design_system.rs
Normal file
478
crates/op-orchestrator/src/design_system.rs
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
//! `design_system.rs` — S4 A1 + B1: `DesignSystem` struct, parse, defaults,
|
||||
//! LLM generator, variable seeding, and prompt context.
|
||||
//!
|
||||
//! Port of `design-system-generator.ts` (deleted in commit `0f12b6e9`):
|
||||
//! - L20-29: `generateDesignSystem` entry (→ `generate_design_system`).
|
||||
//! - L40-100: `parseDesignSystem` + `tryParseDS` 4-stage fallback chain.
|
||||
//! - L102-124: `DEFAULT_DESIGN_SYSTEM` constant values.
|
||||
//! - L134-156: `designSystemToVariables` (→ `design_system_to_seed_commands`).
|
||||
//! - L161-170: `designSystemToPromptContext` (→ `design_system_to_prompt_context`).
|
||||
//! - L59-81: `DesignSystem` interface (via `ai-types.ts`).
|
||||
|
||||
use futures::StreamExt;
|
||||
use op_editor_core::{EditorCommand, VariableScalarPayload};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::types::{AbortFlag, CallRequest, LlmChunk, LlmClient};
|
||||
|
||||
// ── DesignSystem struct (mirrors TS ai-types.ts:59-81) ────────────────────────
|
||||
|
||||
/// Typography section of the design system.
|
||||
///
|
||||
/// Port of `DesignSystem.typography` in `ai-types.ts`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Typography {
|
||||
pub heading_font: String,
|
||||
pub body_font: String,
|
||||
pub scale: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Spacing section of the design system.
|
||||
///
|
||||
/// Port of `DesignSystem.spacing` in `ai-types.ts`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Spacing {
|
||||
pub unit: f64,
|
||||
pub scale: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Structured design tokens (colors, typography, spacing, radius, aesthetic).
|
||||
///
|
||||
/// Port of the `DesignSystem` interface in `ai-types.ts:59-81`.
|
||||
///
|
||||
/// ```text
|
||||
/// palette — keyed color tokens (background, surface, text, textSecondary,
|
||||
/// primary, primaryLight, accent, border)
|
||||
/// typography — heading/body font names + type scale (px)
|
||||
/// spacing — unit grid size + scale steps (px)
|
||||
/// radius — corner-radius steps (px)
|
||||
/// aesthetic — prose adjectives describing the visual style
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DesignSystem {
|
||||
/// Color palette tokens. Keys match TS camelCase names
|
||||
/// (`background`, `surface`, `text`, `textSecondary`, `primary`,
|
||||
/// `primaryLight`, `accent`, `border`).
|
||||
pub palette: BTreeMap<String, String>,
|
||||
pub typography: Typography,
|
||||
pub spacing: Spacing,
|
||||
/// Corner-radius scale in pixels. Port of `radius: number[]` in TS.
|
||||
pub radius: Vec<f64>,
|
||||
/// Short aesthetic description (e.g. "clean modern blue").
|
||||
pub aesthetic: String,
|
||||
}
|
||||
|
||||
// ── DEFAULT_DESIGN_SYSTEM (mirrors TS L102-124) ───────────────────────────────
|
||||
|
||||
fn build_default() -> DesignSystem {
|
||||
let mut palette = BTreeMap::new();
|
||||
palette.insert("background".into(), "#F8FAFC".into());
|
||||
palette.insert("surface".into(), "#FFFFFF".into());
|
||||
palette.insert("text".into(), "#0F172A".into());
|
||||
palette.insert("textSecondary".into(), "#475569".into());
|
||||
palette.insert("primary".into(), "#2563EB".into());
|
||||
palette.insert("primaryLight".into(), "#DBEAFE".into());
|
||||
palette.insert("accent".into(), "#0EA5E9".into());
|
||||
palette.insert("border".into(), "#E2E8F0".into());
|
||||
|
||||
DesignSystem {
|
||||
palette,
|
||||
typography: Typography {
|
||||
heading_font: "Space Grotesk".into(),
|
||||
body_font: "Inter".into(),
|
||||
scale: vec![14.0, 16.0, 20.0, 28.0, 40.0, 56.0],
|
||||
},
|
||||
spacing: Spacing {
|
||||
unit: 8.0,
|
||||
scale: vec![8.0, 16.0, 24.0, 32.0, 48.0, 64.0],
|
||||
},
|
||||
radius: vec![8.0, 12.0, 16.0],
|
||||
aesthetic: "clean modern blue".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazily-initialized default design system.
|
||||
///
|
||||
/// Mirrors `DEFAULT_DESIGN_SYSTEM` in `design-system-generator.ts:102-124`.
|
||||
/// Using `OnceLock` gives a `&'static DesignSystem` that callers can deref
|
||||
/// without cloning in the common case; callers that need an owned copy call
|
||||
/// `.clone()`.
|
||||
pub static DEFAULT_DESIGN_SYSTEM: OnceLock<DesignSystem> = OnceLock::new();
|
||||
|
||||
/// Convenience accessor — initialises on first call.
|
||||
pub fn default_design_system() -> &'static DesignSystem {
|
||||
DEFAULT_DESIGN_SYSTEM.get_or_init(build_default)
|
||||
}
|
||||
|
||||
// ── parse_design_system: 4-stage fallback chain (mirrors TS L40-100) ─────────
|
||||
|
||||
/// Parse a `DesignSystem` from LLM response text.
|
||||
///
|
||||
/// Tolerant 4-stage fallback chain — faithfully mirrors `parseDesignSystem`
|
||||
/// in `design-system-generator.ts:40-100`:
|
||||
///
|
||||
/// 1. Direct `serde_json::from_str` of trimmed input.
|
||||
/// 2. Strip code fences (` ```json ... ``` ` or ` ``` ... ``` `).
|
||||
/// 3. Find first `{` and last `}`, substring, retry parse.
|
||||
/// 4. Return `DEFAULT_DESIGN_SYSTEM` (cloned).
|
||||
pub fn parse_design_system(text: &str) -> DesignSystem {
|
||||
let trimmed = text.trim();
|
||||
|
||||
// Stage 1: direct parse
|
||||
if let Some(ds) = try_parse_ds(trimmed) {
|
||||
return ds;
|
||||
}
|
||||
|
||||
// Stage 2: strip code fences (```json ... ``` or ``` ... ```)
|
||||
// Regex pattern: ```(?:json)?\s*\n?([\s\S]*?)\n?```
|
||||
if let Some(inner) = extract_code_fence(trimmed) {
|
||||
if let Some(ds) = try_parse_ds(inner.trim()) {
|
||||
return ds;
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 3: first `{` … last `}`
|
||||
if let (Some(first), Some(last)) = (trimmed.find('{'), trimmed.rfind('}')) {
|
||||
if last > first {
|
||||
if let Some(ds) = try_parse_ds(&trimmed[first..=last]) {
|
||||
return ds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 4: fallback
|
||||
default_design_system().clone()
|
||||
}
|
||||
|
||||
/// Try to parse JSON into a `DesignSystem`, returning `None` on any failure.
|
||||
///
|
||||
/// Port of `tryParseDS` in `design-system-generator.ts:62-100`:
|
||||
/// validates that `palette` and `typography` fields are present objects,
|
||||
/// then fills missing sub-fields with defaults.
|
||||
fn try_parse_ds(json: &str) -> Option<DesignSystem> {
|
||||
let obj: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
|
||||
// Must have palette object and typography object
|
||||
let p = obj.get("palette").and_then(|v| v.as_object())?;
|
||||
let t = obj.get("typography").and_then(|v| v.as_object())?;
|
||||
|
||||
let default = default_design_system();
|
||||
|
||||
// Build palette — fill missing keys with defaults
|
||||
let mut palette = BTreeMap::new();
|
||||
let dp = &default.palette;
|
||||
let palette_keys = [
|
||||
"background",
|
||||
"surface",
|
||||
"text",
|
||||
"textSecondary",
|
||||
"primary",
|
||||
"primaryLight",
|
||||
"accent",
|
||||
"border",
|
||||
];
|
||||
for key in palette_keys {
|
||||
let val = p
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_else(|| dp.get(key).map(|s| s.as_str()).unwrap_or(""))
|
||||
.to_string();
|
||||
palette.insert(key.to_string(), val);
|
||||
}
|
||||
|
||||
// Typography — fill missing fields with defaults
|
||||
let heading_font = t
|
||||
.get("headingFont")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&default.typography.heading_font)
|
||||
.to_string();
|
||||
let body_font = t
|
||||
.get("bodyFont")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&default.typography.body_font)
|
||||
.to_string();
|
||||
let type_scale: Vec<f64> = t
|
||||
.get("scale")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| arr.iter().map(|v| v.as_f64()).collect::<Option<Vec<_>>>())
|
||||
.unwrap_or_else(|| default.typography.scale.clone());
|
||||
|
||||
// Spacing — optional, fill with defaults
|
||||
let s_obj = obj.get("spacing").and_then(|v| v.as_object());
|
||||
let spacing_unit = s_obj
|
||||
.and_then(|s| s.get("unit"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(default.spacing.unit);
|
||||
let spacing_scale: Vec<f64> = s_obj
|
||||
.and_then(|s| s.get("scale"))
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| arr.iter().map(|v| v.as_f64()).collect::<Option<Vec<_>>>())
|
||||
.unwrap_or_else(|| default.spacing.scale.clone());
|
||||
|
||||
// Radius — optional array
|
||||
let radius: Vec<f64> = obj
|
||||
.get("radius")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| arr.iter().map(|v| v.as_f64()).collect::<Option<Vec<_>>>())
|
||||
.unwrap_or_else(|| default.radius.clone());
|
||||
|
||||
// Aesthetic — optional string
|
||||
let aesthetic = obj
|
||||
.get("aesthetic")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&default.aesthetic)
|
||||
.to_string();
|
||||
|
||||
Some(DesignSystem {
|
||||
palette,
|
||||
typography: Typography {
|
||||
heading_font,
|
||||
body_font,
|
||||
scale: type_scale,
|
||||
},
|
||||
spacing: Spacing {
|
||||
unit: spacing_unit,
|
||||
scale: spacing_scale,
|
||||
},
|
||||
radius,
|
||||
aesthetic,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the content between code fences (` ```json ... ``` ` or ` ``` ... ``` `).
|
||||
/// Returns `None` if no fence is found.
|
||||
fn extract_code_fence(text: &str) -> Option<&str> {
|
||||
// Find opening fence: ``` optionally followed by "json"
|
||||
let fence_start = text.find("```")?;
|
||||
let after_open = &text[fence_start + 3..];
|
||||
|
||||
// Skip optional "json" language tag and the following newline
|
||||
let content_start = after_open.strip_prefix("json").unwrap_or(after_open);
|
||||
|
||||
// Skip leading newline
|
||||
let content_start = content_start.strip_prefix('\n').unwrap_or(content_start);
|
||||
|
||||
// Find closing fence
|
||||
let fence_end = content_start.find("```")?;
|
||||
|
||||
// Strip trailing newline before closing fence
|
||||
let inner = &content_start[..fence_end];
|
||||
let inner = inner.strip_suffix('\n').unwrap_or(inner);
|
||||
|
||||
Some(inner)
|
||||
}
|
||||
|
||||
// ── B1: generate_design_system (port of TS L20-29) ───────────────────────────
|
||||
|
||||
/// Generate a `DesignSystem` from a user prompt via a single LLM call.
|
||||
///
|
||||
/// Port of `generateDesignSystem` in `design-system-generator.ts:20-29`:
|
||||
/// 1. Loads the `design-system` skill as system prompt via
|
||||
/// `op_ai_skills::get_skill_by_name`.
|
||||
/// 2. Makes a single (non-streaming) `LlmClient::call` with the user prompt.
|
||||
/// 3. Collects all text chunks, then parses via `parse_design_system`.
|
||||
/// 4. Falls back to `DEFAULT_DESIGN_SYSTEM` on any parse/LLM failure.
|
||||
///
|
||||
/// The `model` / `provider` / `abort` params match the existing `CallRequest`
|
||||
/// shape used by `subagent.rs` and `prompt.rs`.
|
||||
pub async fn generate_design_system(
|
||||
prompt: &str,
|
||||
llm: &dyn LlmClient,
|
||||
model: Option<&str>,
|
||||
provider: Option<&str>,
|
||||
abort: &AbortFlag,
|
||||
) -> DesignSystem {
|
||||
// Load the design-system skill content as system prompt.
|
||||
let system_prompt = op_ai_skills::get_skill_by_name("design-system")
|
||||
.map(|e| e.content.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let req = CallRequest {
|
||||
system_prompt,
|
||||
user_prompt: prompt.to_string(),
|
||||
model: model.map(|s| s.to_string()),
|
||||
provider: provider.map(|s| s.to_string()),
|
||||
timeout: std::time::Duration::from_secs(30),
|
||||
abort: abort.clone(),
|
||||
no_text_timeout: None,
|
||||
first_text_timeout: None,
|
||||
};
|
||||
|
||||
// Collect all text chunks.
|
||||
let mut stream = llm.call(req);
|
||||
let mut text = String::new();
|
||||
while let Some(item) = stream.next().await {
|
||||
match item {
|
||||
Ok(LlmChunk::Text(t)) => text.push_str(&t),
|
||||
Ok(LlmChunk::Thinking(_)) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and return; falls back to DEFAULT on failure.
|
||||
parse_design_system(&text)
|
||||
}
|
||||
|
||||
// ── B1: design_system_to_seed_commands (port of TS L134-156) ─────────────────
|
||||
|
||||
/// Convert a `DesignSystem` into `EditorCommand::SetVariable*` commands.
|
||||
///
|
||||
/// Faithful port of `designSystemToVariables` in
|
||||
/// `design-system-generator.ts:134-156`. Emits ONLY:
|
||||
/// - Palette: `color-{kebab(key)}` → `SetVariableColor` (one per palette entry)
|
||||
/// - Spacing scale: `spacing-{xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl}` →
|
||||
/// `SetVariableScalar::Number` (capped at `spacingNames.length`)
|
||||
/// - Radius: `radius-{sm|md|lg|xl}` → `SetVariableScalar::Number` (capped at
|
||||
/// `radiusNames.length`)
|
||||
///
|
||||
/// Typography is NOT seeded into document variables — the TS source feeds
|
||||
/// heading/body fonts + type scale into the LLM via
|
||||
/// `design_system_to_prompt_context` only.
|
||||
///
|
||||
/// DEFAULT_DESIGN_SYSTEM emits exactly 17 commands: 8 palette + 6 spacing + 3 radius.
|
||||
pub fn design_system_to_seed_commands(ds: &DesignSystem) -> Vec<EditorCommand> {
|
||||
let mut cmds = Vec::new();
|
||||
|
||||
// Colors: palette → SetVariableColor with kebab-case name.
|
||||
// TS: `for (const [key, value] of Object.entries(ds.palette))`
|
||||
// Note: BTreeMap iterates in sorted key order, which is fine for determinism.
|
||||
for (key, value) in &ds.palette {
|
||||
let name = format!("color-{}", camel_to_kebab(key));
|
||||
cmds.push(EditorCommand::SetVariableColor {
|
||||
name,
|
||||
hex: value.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Spacing scale → spacing-xs/sm/md/lg/xl/2xl/3xl/4xl/5xl/6xl
|
||||
// TS: `const spacingNames = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl']`
|
||||
const SPACING_NAMES: &[&str] = &[
|
||||
"xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl",
|
||||
];
|
||||
for (i, &label) in SPACING_NAMES
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(ds.spacing.scale.len())
|
||||
{
|
||||
let name = format!("spacing-{label}");
|
||||
cmds.push(EditorCommand::SetVariableScalar {
|
||||
name,
|
||||
scalar: VariableScalarPayload::Number(ds.spacing.scale[i]),
|
||||
});
|
||||
}
|
||||
|
||||
// Radius → radius-sm/md/lg/xl
|
||||
// TS: `const radiusNames = ['sm', 'md', 'lg', 'xl']`
|
||||
const RADIUS_NAMES: &[&str] = &["sm", "md", "lg", "xl"];
|
||||
for (i, &label) in RADIUS_NAMES.iter().enumerate().take(ds.radius.len()) {
|
||||
let name = format!("radius-{label}");
|
||||
cmds.push(EditorCommand::SetVariableScalar {
|
||||
name,
|
||||
scalar: VariableScalarPayload::Number(ds.radius[i]),
|
||||
});
|
||||
}
|
||||
|
||||
cmds
|
||||
}
|
||||
|
||||
/// Convert a camelCase string to kebab-case.
|
||||
///
|
||||
/// Port of `kebab` in `design-system-generator.ts`:
|
||||
/// `str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()`
|
||||
fn camel_to_kebab(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 4);
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
for i in 0..chars.len() {
|
||||
let c = chars[i];
|
||||
if i > 0 && c.is_uppercase() && chars[i - 1].is_lowercase() {
|
||||
out.push('-');
|
||||
}
|
||||
out.push(c.to_ascii_lowercase());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ── B1: design_system_to_prompt_context (port of TS L161-170) ────────────────
|
||||
|
||||
/// Build a fixed-form design-system context string for AI prompts.
|
||||
///
|
||||
/// Port of `designSystemToPromptContext` in `design-system-generator.ts:161-170`:
|
||||
/// ```text
|
||||
/// DESIGN SYSTEM (use these values consistently):
|
||||
/// Colors: bg {bg}, surface {surface}, text {text}, muted {muted}, ...
|
||||
/// Fonts: heading "{headingFont}", body "{bodyFont}"
|
||||
/// Type scale: {scale}px
|
||||
/// Spacing: {scale}px ({unit}px grid)
|
||||
/// Radius: {radius}px
|
||||
/// Style: {aesthetic}
|
||||
/// ```
|
||||
pub fn design_system_to_prompt_context(ds: &DesignSystem) -> String {
|
||||
let p = &ds.palette;
|
||||
let bg = p.get("background").map(|s| s.as_str()).unwrap_or("");
|
||||
let surface = p.get("surface").map(|s| s.as_str()).unwrap_or("");
|
||||
let text = p.get("text").map(|s| s.as_str()).unwrap_or("");
|
||||
let muted = p.get("textSecondary").map(|s| s.as_str()).unwrap_or("");
|
||||
let primary = p.get("primary").map(|s| s.as_str()).unwrap_or("");
|
||||
let primary_light = p.get("primaryLight").map(|s| s.as_str()).unwrap_or("");
|
||||
let accent = p.get("accent").map(|s| s.as_str()).unwrap_or("");
|
||||
let border = p.get("border").map(|s| s.as_str()).unwrap_or("");
|
||||
|
||||
// Type scale: "14, 16, 20, 28, 40, 56px"
|
||||
let type_scale = ds
|
||||
.typography
|
||||
.scale
|
||||
.iter()
|
||||
.map(|v| format_num(*v))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
// Spacing scale: "8, 16, 24, 32, 48, 64px"
|
||||
let spacing_scale = ds
|
||||
.spacing
|
||||
.scale
|
||||
.iter()
|
||||
.map(|v| format_num(*v))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let spacing_unit = format_num(ds.spacing.unit);
|
||||
|
||||
// Radius: "8, 12, 16px"
|
||||
let radius = ds
|
||||
.radius
|
||||
.iter()
|
||||
.map(|v| format_num(*v))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
format!(
|
||||
"DESIGN SYSTEM (use these values consistently):\n\
|
||||
Colors: bg {bg}, surface {surface}, text {text}, muted {muted}, primary {primary}, primaryLight {primary_light}, accent {accent}, border {border}\n\
|
||||
Fonts: heading \"{heading}\", body \"{body}\"\n\
|
||||
Type scale: {type_scale}px\n\
|
||||
Spacing: {spacing_scale}px ({spacing_unit}px grid)\n\
|
||||
Radius: {radius}px\n\
|
||||
Style: {aesthetic}",
|
||||
heading = ds.typography.heading_font,
|
||||
body = ds.typography.body_font,
|
||||
aesthetic = ds.aesthetic,
|
||||
)
|
||||
}
|
||||
|
||||
/// Format a float as an integer if it has no fractional part, or with 1
|
||||
/// decimal place otherwise. Matches the JS number-to-string behaviour for
|
||||
/// the values in the design system (all are whole numbers in practice).
|
||||
fn format_num(v: f64) -> String {
|
||||
if v.fract() == 0.0 {
|
||||
format!("{}", v as i64)
|
||||
} else {
|
||||
format!("{v:.1}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "design_system_tests.rs"]
|
||||
mod tests;
|
||||
560
crates/op-orchestrator/src/design_system_tests.rs
Normal file
560
crates/op-orchestrator/src/design_system_tests.rs
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
//! Tests for `design_system.rs` — A1 step 1 (failing tests first, TDD).
|
||||
//! B1 tests appended at the bottom.
|
||||
|
||||
use crate::design_system::{default_design_system, parse_design_system, DesignSystem};
|
||||
use crate::types::{DesignRequest, Progress};
|
||||
|
||||
// ── parse_design_system: direct JSON round-trip ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_design_system_direct_json() {
|
||||
let ds = default_design_system();
|
||||
let json = serde_json::to_string(ds).expect("serialize default");
|
||||
let parsed = parse_design_system(&json);
|
||||
assert_eq!(parsed.palette, ds.palette);
|
||||
assert_eq!(parsed.aesthetic, ds.aesthetic);
|
||||
}
|
||||
|
||||
// ── parse_design_system: code-fence stripping ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_design_system_strips_json_code_fence() {
|
||||
let ds = default_design_system();
|
||||
let inner = serde_json::to_string(ds).expect("serialize");
|
||||
let fenced = format!("```json\n{inner}\n```");
|
||||
let parsed = parse_design_system(&fenced);
|
||||
assert_eq!(parsed.palette, ds.palette);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_design_system_strips_bare_code_fence() {
|
||||
let ds = default_design_system();
|
||||
let inner = serde_json::to_string(ds).expect("serialize");
|
||||
let fenced = format!("```\n{inner}\n```");
|
||||
let parsed = parse_design_system(&fenced);
|
||||
assert_eq!(parsed.palette, ds.palette);
|
||||
}
|
||||
|
||||
// ── parse_design_system: brace extraction ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_design_system_brace_extract() {
|
||||
let ds = default_design_system();
|
||||
let inner = serde_json::to_string(ds).expect("serialize");
|
||||
let wrapped = format!("Some preamble text\n{inner}\nSome trailing text.");
|
||||
let parsed = parse_design_system(&wrapped);
|
||||
assert_eq!(parsed.palette, ds.palette);
|
||||
}
|
||||
|
||||
// ── parse_design_system: fallback to DEFAULT on garbage ──────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_design_system_fallback_on_garbage() {
|
||||
let ds = default_design_system();
|
||||
let parsed = parse_design_system("not json at all");
|
||||
assert_eq!(parsed.palette, ds.palette);
|
||||
assert_eq!(parsed.aesthetic, ds.aesthetic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_design_system_fallback_on_missing_palette() {
|
||||
// Valid JSON but missing palette field → fallback
|
||||
let ds = default_design_system();
|
||||
let parsed = parse_design_system(r#"{"aesthetic": "flat"}"#);
|
||||
assert_eq!(parsed.palette, ds.palette);
|
||||
}
|
||||
|
||||
// ── DEFAULT_DESIGN_SYSTEM round-trips via serde ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_design_system_serde_round_trip() {
|
||||
let ds = default_design_system();
|
||||
let json = serde_json::to_string(ds).expect("serialize");
|
||||
let back: DesignSystem = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back.palette, ds.palette);
|
||||
assert_eq!(back.typography.heading_font, ds.typography.heading_font);
|
||||
assert_eq!(back.typography.body_font, ds.typography.body_font);
|
||||
assert_eq!(back.typography.scale, ds.typography.scale);
|
||||
assert_eq!(back.spacing.unit, ds.spacing.unit);
|
||||
assert_eq!(back.spacing.scale, ds.spacing.scale);
|
||||
assert_eq!(back.radius, ds.radius);
|
||||
assert_eq!(back.aesthetic, ds.aesthetic);
|
||||
}
|
||||
|
||||
// ── DEFAULT_DESIGN_SYSTEM exact values (port faithful to TS) ─────────────
|
||||
|
||||
#[test]
|
||||
fn default_design_system_palette_values() {
|
||||
let p = &default_design_system().palette;
|
||||
assert_eq!(p["background"], "#F8FAFC");
|
||||
assert_eq!(p["surface"], "#FFFFFF");
|
||||
assert_eq!(p["text"], "#0F172A");
|
||||
assert_eq!(p["textSecondary"], "#475569");
|
||||
assert_eq!(p["primary"], "#2563EB");
|
||||
assert_eq!(p["primaryLight"], "#DBEAFE");
|
||||
assert_eq!(p["accent"], "#0EA5E9");
|
||||
assert_eq!(p["border"], "#E2E8F0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_design_system_typography_values() {
|
||||
let t = &default_design_system().typography;
|
||||
assert_eq!(t.heading_font, "Space Grotesk");
|
||||
assert_eq!(t.body_font, "Inter");
|
||||
assert_eq!(t.scale, vec![14.0, 16.0, 20.0, 28.0, 40.0, 56.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_design_system_spacing_values() {
|
||||
let s = &default_design_system().spacing;
|
||||
assert_eq!(s.unit, 8.0);
|
||||
assert_eq!(s.scale, vec![8.0, 16.0, 24.0, 32.0, 48.0, 64.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_design_system_radius_values() {
|
||||
assert_eq!(default_design_system().radius, vec![8.0, 12.0, 16.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_design_system_aesthetic_value() {
|
||||
assert_eq!(default_design_system().aesthetic, "clean modern blue");
|
||||
}
|
||||
|
||||
// ── DesignRequest.visual_ref_enabled defaults to false ────────────────────
|
||||
|
||||
#[test]
|
||||
fn design_request_visual_ref_enabled_defaults_false() {
|
||||
// JSON without `visualRefEnabled` field
|
||||
let json = r#"{"prompt":"test","concurrency":1}"#;
|
||||
let req: DesignRequest = serde_json::from_str(json).expect("deserialize");
|
||||
assert!(
|
||||
!req.visual_ref_enabled,
|
||||
"visual_ref_enabled should default to false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_request_visual_ref_enabled_can_be_set_true() {
|
||||
let json = r#"{"prompt":"test","concurrency":1,"visualRefEnabled":true}"#;
|
||||
let req: DesignRequest = serde_json::from_str(json).expect("deserialize");
|
||||
assert!(req.visual_ref_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_request_visual_ref_enabled_literal_compiles() {
|
||||
// Verify that the field can be written in struct literal form
|
||||
let req = DesignRequest {
|
||||
prompt: "test".into(),
|
||||
model: None,
|
||||
provider: None,
|
||||
design_md: None,
|
||||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
assert!(!req.visual_ref_enabled);
|
||||
}
|
||||
|
||||
// ── Progress::VisualRef* variants compile and pattern-match ──────────────
|
||||
|
||||
#[test]
|
||||
fn progress_visual_ref_variants_compile() {
|
||||
let variants = vec![
|
||||
Progress::VisualRefStarted,
|
||||
Progress::VisualRefDesignSystem { var_count: 25 },
|
||||
Progress::VisualRefHtmlGenerated { byte_len: 4096 },
|
||||
Progress::VisualRefScreenshotReady { skipped: false },
|
||||
Progress::VisualRefFallback {
|
||||
reason: "LLM returned empty HTML".into(),
|
||||
},
|
||||
];
|
||||
for v in variants {
|
||||
match v {
|
||||
Progress::VisualRefStarted => {}
|
||||
Progress::VisualRefDesignSystem { var_count } => {
|
||||
assert_eq!(var_count, 25);
|
||||
}
|
||||
Progress::VisualRefHtmlGenerated { byte_len } => {
|
||||
assert_eq!(byte_len, 4096);
|
||||
}
|
||||
Progress::VisualRefScreenshotReady { skipped } => {
|
||||
assert!(!skipped);
|
||||
}
|
||||
Progress::VisualRefFallback { reason } => {
|
||||
assert!(!reason.is_empty());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DesignSystem struct fields exist and are accessible ───────────────────
|
||||
|
||||
#[test]
|
||||
fn design_system_struct_fields_accessible() {
|
||||
let ds = DesignSystem {
|
||||
palette: {
|
||||
let mut m = std::collections::BTreeMap::new();
|
||||
m.insert("background".to_string(), "#F8FAFC".to_string());
|
||||
m
|
||||
},
|
||||
typography: crate::design_system::Typography {
|
||||
heading_font: "Space Grotesk".into(),
|
||||
body_font: "Inter".into(),
|
||||
scale: vec![14.0, 16.0],
|
||||
},
|
||||
spacing: crate::design_system::Spacing {
|
||||
unit: 8.0,
|
||||
scale: vec![8.0, 16.0],
|
||||
},
|
||||
radius: vec![8.0],
|
||||
aesthetic: "clean".into(),
|
||||
};
|
||||
assert_eq!(ds.palette["background"], "#F8FAFC");
|
||||
assert_eq!(ds.typography.heading_font, "Space Grotesk");
|
||||
assert_eq!(ds.spacing.unit, 8.0);
|
||||
}
|
||||
|
||||
// ── Task B1: generate_design_system ──────────────────────────────────────
|
||||
|
||||
/// Scripted LLM returning valid JSON design-system → parsed DesignSystem
|
||||
/// (not the default — the LLM-provided values win).
|
||||
#[tokio::test]
|
||||
async fn generate_design_system_happy_path() {
|
||||
use crate::design_system::generate_design_system;
|
||||
use crate::test_support::{ScriptResponse, ScriptedLlm};
|
||||
use crate::types::AbortFlag;
|
||||
|
||||
// Craft a valid JSON that differs from DEFAULT_DESIGN_SYSTEM so we
|
||||
// can confirm the LLM value was used.
|
||||
// Build the JSON string using serde_json to avoid raw-string delimiter conflicts.
|
||||
let custom_json = serde_json::json!({
|
||||
"palette": {
|
||||
"background": "\u{23}111111",
|
||||
"surface": "\u{23}222222",
|
||||
"text": "\u{23}FFFFFF",
|
||||
"textSecondary": "\u{23}AAAAAA",
|
||||
"primary": "\u{23}FF0000",
|
||||
"primaryLight": "\u{23}FF9999",
|
||||
"accent": "\u{23}00FF00",
|
||||
"border": "\u{23}333333"
|
||||
},
|
||||
"typography": {
|
||||
"headingFont": "Roboto",
|
||||
"bodyFont": "Open Sans",
|
||||
"scale": [12.0_f64, 14.0, 18.0, 24.0, 36.0, 48.0]
|
||||
},
|
||||
"spacing": { "unit": 4.0_f64, "scale": [4.0_f64, 8.0, 12.0, 16.0, 24.0, 32.0] },
|
||||
"radius": [4.0_f64, 8.0, 12.0],
|
||||
"aesthetic": "dark minimal"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let llm = ScriptedLlm::new(vec![ScriptResponse::Text(custom_json.to_string())]);
|
||||
let abort = AbortFlag::new();
|
||||
let ds = generate_design_system("a dark app", &llm, None, None, &abort).await;
|
||||
|
||||
// LLM-provided values must win over default
|
||||
assert_eq!(ds.palette["background"], "#111111");
|
||||
assert_eq!(ds.typography.heading_font, "Roboto");
|
||||
assert_eq!(ds.typography.body_font, "Open Sans");
|
||||
assert_eq!(ds.aesthetic, "dark minimal");
|
||||
assert_eq!(ds.radius, vec![4.0, 8.0, 12.0]);
|
||||
}
|
||||
|
||||
/// Scripted LLM returning garbage → fallback to DEFAULT_DESIGN_SYSTEM.
|
||||
#[tokio::test]
|
||||
async fn generate_design_system_garbage_falls_back_to_default() {
|
||||
use crate::design_system::generate_design_system;
|
||||
use crate::test_support::{ScriptResponse, ScriptedLlm};
|
||||
use crate::types::AbortFlag;
|
||||
|
||||
let llm = ScriptedLlm::new(vec![ScriptResponse::Text(
|
||||
"not valid json at all!".to_string(),
|
||||
)]);
|
||||
let abort = AbortFlag::new();
|
||||
let ds = generate_design_system("any prompt", &llm, None, None, &abort).await;
|
||||
let default = default_design_system();
|
||||
|
||||
assert_eq!(ds.palette, default.palette);
|
||||
assert_eq!(ds.aesthetic, default.aesthetic);
|
||||
}
|
||||
|
||||
/// LLM returning JSON wrapped in code fence → parsed correctly.
|
||||
#[tokio::test]
|
||||
async fn generate_design_system_code_fence_response() {
|
||||
use crate::design_system::generate_design_system;
|
||||
use crate::test_support::{ScriptResponse, ScriptedLlm};
|
||||
use crate::types::AbortFlag;
|
||||
|
||||
let ds_default = default_design_system();
|
||||
let inner = serde_json::to_string(ds_default).unwrap();
|
||||
let fenced = format!("```json\n{inner}\n```");
|
||||
|
||||
let llm = ScriptedLlm::new(vec![ScriptResponse::Text(fenced)]);
|
||||
let abort = AbortFlag::new();
|
||||
let ds = generate_design_system("prompt", &llm, None, None, &abort).await;
|
||||
assert_eq!(ds.palette, ds_default.palette);
|
||||
}
|
||||
|
||||
// ── Task B1: design_system_to_seed_commands ───────────────────────────────
|
||||
|
||||
/// DEFAULT_DESIGN_SYSTEM → expected number of SetVariable* commands.
|
||||
/// Faithful to TS `designSystemToVariables` (L134-156): typography is NOT
|
||||
/// seeded into document variables, only colors + spacing + radius.
|
||||
/// 8 palette (color) + 6 spacing scale + 3 radius = 17.
|
||||
#[test]
|
||||
fn seed_commands_default_count() {
|
||||
use crate::design_system::design_system_to_seed_commands;
|
||||
let ds = default_design_system();
|
||||
let cmds = design_system_to_seed_commands(ds);
|
||||
assert_eq!(
|
||||
cmds.len(),
|
||||
17,
|
||||
"expected 17 seed commands (8 palette + 6 spacing + 3 radius), got {}",
|
||||
cmds.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// DEFAULT_DESIGN_SYSTEM → no typography variables emitted.
|
||||
/// Faithful to TS — typography reaches the LLM via prompt context, not vars.
|
||||
#[test]
|
||||
fn seed_commands_no_typography_variables() {
|
||||
use crate::design_system::design_system_to_seed_commands;
|
||||
use op_editor_core::EditorCommand;
|
||||
let ds = default_design_system();
|
||||
let cmds = design_system_to_seed_commands(ds);
|
||||
|
||||
let has_font_var = cmds.iter().any(|c| match c {
|
||||
EditorCommand::SetVariableColor { name, .. }
|
||||
| EditorCommand::SetVariableScalar { name, .. } => {
|
||||
name.starts_with("font-") || name.starts_with("typography-")
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
assert!(
|
||||
!has_font_var,
|
||||
"typography MUST NOT be seeded into document variables (faithful to TS)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Palette colors → SetVariableColor with kebab-case names.
|
||||
#[test]
|
||||
fn seed_commands_palette_color_names() {
|
||||
use crate::design_system::design_system_to_seed_commands;
|
||||
use op_editor_core::EditorCommand;
|
||||
|
||||
let ds = default_design_system();
|
||||
let cmds = design_system_to_seed_commands(ds);
|
||||
|
||||
// Collect all SetVariableColor names
|
||||
let color_names: Vec<String> = cmds
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
if let EditorCommand::SetVariableColor { name, .. } = c {
|
||||
Some(name.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Verify all 8 palette keys are present in kebab-case
|
||||
assert!(
|
||||
color_names.contains(&"color-background".to_string()),
|
||||
"missing color-background"
|
||||
);
|
||||
assert!(
|
||||
color_names.contains(&"color-text".to_string()),
|
||||
"missing color-text"
|
||||
);
|
||||
assert!(
|
||||
color_names.contains(&"color-text-secondary".to_string()),
|
||||
"missing color-text-secondary (textSecondary → text-secondary)"
|
||||
);
|
||||
assert!(
|
||||
color_names.contains(&"color-primary-light".to_string()),
|
||||
"missing color-primary-light (primaryLight → primary-light)"
|
||||
);
|
||||
assert_eq!(color_names.len(), 8, "expected 8 color variables");
|
||||
}
|
||||
|
||||
/// Palette color value is correctly mapped.
|
||||
#[test]
|
||||
fn seed_commands_palette_color_value() {
|
||||
use crate::design_system::design_system_to_seed_commands;
|
||||
use op_editor_core::EditorCommand;
|
||||
|
||||
let ds = default_design_system();
|
||||
let cmds = design_system_to_seed_commands(ds);
|
||||
|
||||
let bg_cmd = cmds.iter().find(
|
||||
|c| matches!(c, EditorCommand::SetVariableColor { name, .. } if name == "color-background"),
|
||||
);
|
||||
assert!(bg_cmd.is_some(), "missing color-background command");
|
||||
if let Some(EditorCommand::SetVariableColor { hex, .. }) = bg_cmd {
|
||||
assert_eq!(hex, "#F8FAFC", "wrong color-background value");
|
||||
}
|
||||
}
|
||||
|
||||
/// Spacing scale → SetVariableScalar::Number with spacing-xs/sm/... names.
|
||||
#[test]
|
||||
fn seed_commands_spacing_scale_names() {
|
||||
use crate::design_system::design_system_to_seed_commands;
|
||||
use op_editor_core::{EditorCommand, VariableScalarPayload};
|
||||
|
||||
let ds = default_design_system();
|
||||
let cmds = design_system_to_seed_commands(ds);
|
||||
|
||||
let spacing_names: Vec<String> = cmds
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
if let EditorCommand::SetVariableScalar {
|
||||
name,
|
||||
scalar: VariableScalarPayload::Number(_),
|
||||
} = c
|
||||
{
|
||||
if name.starts_with("spacing-") {
|
||||
return Some(name.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
spacing_names.contains(&"spacing-xs".to_string()),
|
||||
"missing spacing-xs"
|
||||
);
|
||||
assert!(
|
||||
spacing_names.contains(&"spacing-sm".to_string()),
|
||||
"missing spacing-sm"
|
||||
);
|
||||
assert!(
|
||||
spacing_names.contains(&"spacing-md".to_string()),
|
||||
"missing spacing-md"
|
||||
);
|
||||
assert!(
|
||||
spacing_names.contains(&"spacing-lg".to_string()),
|
||||
"missing spacing-lg"
|
||||
);
|
||||
assert!(
|
||||
spacing_names.contains(&"spacing-xl".to_string()),
|
||||
"missing spacing-xl"
|
||||
);
|
||||
assert!(
|
||||
spacing_names.contains(&"spacing-2xl".to_string()),
|
||||
"missing spacing-2xl"
|
||||
);
|
||||
assert_eq!(spacing_names.len(), 6, "expected 6 spacing variables");
|
||||
}
|
||||
|
||||
/// Radius steps → SetVariableScalar::Number with radius-sm/md/lg names.
|
||||
#[test]
|
||||
fn seed_commands_radius_names() {
|
||||
use crate::design_system::design_system_to_seed_commands;
|
||||
use op_editor_core::{EditorCommand, VariableScalarPayload};
|
||||
|
||||
let ds = default_design_system();
|
||||
let cmds = design_system_to_seed_commands(ds);
|
||||
|
||||
let radius_names: Vec<String> = cmds
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
if let EditorCommand::SetVariableScalar {
|
||||
name,
|
||||
scalar: VariableScalarPayload::Number(_),
|
||||
} = c
|
||||
{
|
||||
if name.starts_with("radius-") {
|
||||
return Some(name.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
radius_names.contains(&"radius-sm".to_string()),
|
||||
"missing radius-sm"
|
||||
);
|
||||
assert!(
|
||||
radius_names.contains(&"radius-md".to_string()),
|
||||
"missing radius-md"
|
||||
);
|
||||
assert!(
|
||||
radius_names.contains(&"radius-lg".to_string()),
|
||||
"missing radius-lg"
|
||||
);
|
||||
assert_eq!(radius_names.len(), 3, "expected 3 radius variables");
|
||||
}
|
||||
|
||||
// ── Task B1: design_system_to_prompt_context ─────────────────────────────
|
||||
|
||||
/// `design_system_to_prompt_context` produces the exact TS template.
|
||||
#[test]
|
||||
fn prompt_context_exact_template() {
|
||||
use crate::design_system::design_system_to_prompt_context;
|
||||
|
||||
let ds = default_design_system();
|
||||
let ctx = design_system_to_prompt_context(ds);
|
||||
|
||||
// Verify structural lines (port of TS L161-170 format)
|
||||
assert!(
|
||||
ctx.starts_with("DESIGN SYSTEM (use these values consistently):"),
|
||||
"wrong header: {ctx}"
|
||||
);
|
||||
assert!(ctx.contains("Colors: bg #F8FAFC"), "missing Colors line");
|
||||
assert!(ctx.contains("surface #FFFFFF"), "missing surface");
|
||||
assert!(ctx.contains("text #0F172A"), "missing text");
|
||||
assert!(ctx.contains("muted #475569"), "missing muted");
|
||||
assert!(ctx.contains("primary #2563EB"), "missing primary");
|
||||
assert!(ctx.contains("primaryLight #DBEAFE"), "missing primaryLight");
|
||||
assert!(ctx.contains("accent #0EA5E9"), "missing accent");
|
||||
assert!(ctx.contains("border #E2E8F0"), "missing border");
|
||||
assert!(
|
||||
ctx.contains(r#"Fonts: heading "Space Grotesk""#),
|
||||
"missing heading font"
|
||||
);
|
||||
assert!(ctx.contains(r#"body "Inter""#), "missing body font");
|
||||
assert!(
|
||||
ctx.contains("Type scale: 14, 16, 20, 28, 40, 56px"),
|
||||
"wrong type scale line: {ctx}"
|
||||
);
|
||||
assert!(
|
||||
ctx.contains("Spacing: 8, 16, 24, 32, 48, 64px (8px grid)"),
|
||||
"wrong spacing line: {ctx}"
|
||||
);
|
||||
assert!(
|
||||
ctx.contains("Radius: 8, 12, 16px"),
|
||||
"wrong radius line: {ctx}"
|
||||
);
|
||||
assert!(
|
||||
ctx.contains("Style: clean modern blue"),
|
||||
"wrong style line: {ctx}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Byte-exact match of the TS template for DEFAULT_DESIGN_SYSTEM.
|
||||
#[test]
|
||||
fn prompt_context_byte_exact() {
|
||||
use crate::design_system::design_system_to_prompt_context;
|
||||
|
||||
let ds = default_design_system();
|
||||
let ctx = design_system_to_prompt_context(ds);
|
||||
|
||||
let expected = "DESIGN SYSTEM (use these values consistently):\n\
|
||||
Colors: bg #F8FAFC, surface #FFFFFF, text #0F172A, muted #475569, primary #2563EB, primaryLight #DBEAFE, accent #0EA5E9, border #E2E8F0\n\
|
||||
Fonts: heading \"Space Grotesk\", body \"Inter\"\n\
|
||||
Type scale: 14, 16, 20, 28, 40, 56px\n\
|
||||
Spacing: 8, 16, 24, 32, 48, 64px (8px grid)\n\
|
||||
Radius: 8, 12, 16px\n\
|
||||
Style: clean modern blue";
|
||||
|
||||
assert_eq!(
|
||||
ctx, expected,
|
||||
"prompt context does not match TS template byte-exactly"
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ pub mod compact_prompt;
|
|||
pub mod compact_skills;
|
||||
pub mod dashboard_columns;
|
||||
pub mod design_md_policy;
|
||||
pub mod design_system;
|
||||
pub mod design_type;
|
||||
pub mod intent;
|
||||
pub mod model_profile;
|
||||
|
|
@ -39,6 +40,7 @@ pub mod run_dashboard;
|
|||
pub mod scaffold;
|
||||
pub mod scaffold_dashboard;
|
||||
pub mod subagent;
|
||||
pub mod visual_ref;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
|
|
@ -47,10 +49,21 @@ pub use compact_prompt::{build_compact_planning_prompt, CompactPlanningPrompt};
|
|||
pub use design_md_policy::{
|
||||
build_design_md_style_policy, guess_neutral_background_from_theme, infer_design_md_background,
|
||||
};
|
||||
pub use design_system::{
|
||||
default_design_system, design_system_to_prompt_context, design_system_to_seed_commands,
|
||||
generate_design_system, parse_design_system, DesignSystem, Spacing, Typography,
|
||||
};
|
||||
pub use design_type::{detect_design_type, DesignType, DesignTypePreset};
|
||||
pub use intent::classify_intent;
|
||||
pub use model_profile::{resolve_model_profile, ModelProfile, ModelTier};
|
||||
pub use prompt::build_orchestrator_prompt;
|
||||
pub use run::Orchestrator;
|
||||
pub use stub_providers::{SkippedPreValidator, SkippedScreenshotProvider, SkippedVisionLlmClient};
|
||||
pub use stub_providers::{
|
||||
SkippedPreValidator, SkippedScreenshotProvider, SkippedVisionLlmClient,
|
||||
SkippedVisualRefProvider,
|
||||
};
|
||||
pub use types::*;
|
||||
pub use visual_ref::{
|
||||
build_enhanced_prompt, execute_visual_ref_orchestration, extract_structure_summary,
|
||||
generate_design_code,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -219,6 +219,8 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -177,6 +179,8 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -245,6 +245,8 @@ fn req(prompt: &str) -> DesignRequest {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -270,6 +272,8 @@ fn req_with_design_md(prompt: &str) -> DesignRequest {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -252,6 +252,8 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -398,6 +400,8 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
let full_cr =
|
||||
build_subagent_prompt(&st, &plan(), &basic_req, AbortFlag::new(), false, false);
|
||||
|
|
@ -488,6 +492,8 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
let long_prompt = "x".repeat(5000); // >= 4200 chars
|
||||
let long_req = DesignRequest {
|
||||
|
|
@ -498,6 +504,8 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
let short_pp = build_orchestrator_prompt(&short_req, PlanningMode::Rich, AbortFlag::new());
|
||||
let long_pp = build_orchestrator_prompt(&long_req, PlanningMode::Rich, AbortFlag::new());
|
||||
|
|
@ -519,6 +527,8 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
let pp = build_orchestrator_prompt(&ds_req, PlanningMode::Rich, AbortFlag::new());
|
||||
// Short bucket base: 300_000ms × 2.0 = 600_000ms
|
||||
|
|
@ -577,6 +587,8 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
let long_req = DesignRequest {
|
||||
prompt: "x".repeat(5000),
|
||||
|
|
@ -586,6 +598,8 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
let short_cr = build_subagent_prompt(
|
||||
&subtask(),
|
||||
|
|
@ -620,6 +634,8 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
let cr = build_subagent_prompt(
|
||||
&subtask(),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ fn req() -> DesignRequest {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +43,8 @@ fn req_standard() -> DesignRequest {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +59,8 @@ fn req_basic() -> DesignRequest {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ fn req_append(live_target_id: &str) -> DesignRequest {
|
|||
is_mobile: false,
|
||||
}),
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,6 +108,8 @@ fn req_append_concurrent(live_target_id: &str) -> DesignRequest {
|
|||
is_mobile: false,
|
||||
}),
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -353,6 +357,8 @@ fn non_append_mode_takes_normal_sequential_path() {
|
|||
concurrency: 1,
|
||||
append_context: None, // no append context
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
|
||||
let summary = futures::executor::block_on(Orchestrator::new().run(
|
||||
|
|
@ -446,6 +452,8 @@ fn append_mode_wins_over_dashboard_branch() {
|
|||
is_mobile: false,
|
||||
}),
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
|
||||
let llm = ScriptedLlm::new(vec![
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ fn req_concurrent() -> DesignRequest {
|
|||
concurrency: 2,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +87,8 @@ fn req_sequential() -> DesignRequest {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ fn req_dashboard() -> DesignRequest {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -82,6 +84,8 @@ fn req_non_dashboard() -> DesignRequest {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ fn req_validation_enabled() -> DesignRequest {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -103,6 +105,8 @@ fn req_validation_disabled() -> DesignRequest {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: false,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -321,6 +325,8 @@ fn dashboard_validation_enabled_emits_validation_done() {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
},
|
||||
&mut sink,
|
||||
&llm,
|
||||
|
|
@ -380,6 +386,8 @@ fn dashboard_validation_disabled_no_validation_events() {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: false,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
},
|
||||
&mut sink,
|
||||
&llm,
|
||||
|
|
@ -422,6 +430,8 @@ fn concurrent_validation_enabled_emits_validation_done() {
|
|||
concurrency: 2,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
},
|
||||
&mut sink,
|
||||
&llm,
|
||||
|
|
@ -481,6 +491,8 @@ fn concurrent_validation_disabled_no_validation_events() {
|
|||
concurrency: 2,
|
||||
append_context: None,
|
||||
validation_enabled: false,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
},
|
||||
&mut sink,
|
||||
&llm,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Production-visible stub provider implementations for the vision
|
||||
//! validation pipeline (S3c).
|
||||
//! validation pipeline (S3c) and visual-ref pipeline (S4).
|
||||
//!
|
||||
//! Hosts use these when the real implementations are not yet wired —
|
||||
//! `op-design-lint` integration is pending host wiring, screenshot
|
||||
|
|
@ -14,6 +14,8 @@
|
|||
//! loop exits before any vision round.
|
||||
//! - `SkippedVisionLlmClient::validate` → `VisionResponse::Skipped`
|
||||
//! (defensive in case `capture_root_frame` ever returns `Some`).
|
||||
//! - `SkippedVisualRefProvider::render_html_to_screenshot` → `None`,
|
||||
//! so the visual-ref pipeline skips immediately to plain orchestration.
|
||||
//!
|
||||
//! Net result: the Progress stream emits `ValidationStarted` →
|
||||
//! `ValidationPreCheckDone { applied: 0, .. }` →
|
||||
|
|
@ -23,7 +25,7 @@
|
|||
|
||||
use crate::types::{
|
||||
DocSink, PreValidationResult, PreValidator, ScreenshotProvider, VisionCallRequest,
|
||||
VisionLlmClient, VisionResponse,
|
||||
VisionLlmClient, VisionResponse, VisualRefProvider,
|
||||
};
|
||||
|
||||
/// Stub `PreValidator` — always returns zero fixes; no side effects.
|
||||
|
|
@ -53,3 +55,13 @@ impl VisionLlmClient for SkippedVisionLlmClient {
|
|||
VisionResponse::Skipped { reason: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub `VisualRefProvider` — always returns `None`. Visual-ref pipeline
|
||||
/// short-circuits to plain orchestration without rendering any HTML.
|
||||
pub struct SkippedVisualRefProvider;
|
||||
|
||||
impl VisualRefProvider for SkippedVisualRefProvider {
|
||||
fn render_html_to_screenshot(&self, _html: &str, _width: f64, _height: f64) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -160,6 +160,23 @@ pub struct ValidationProviders<'a> {
|
|||
pub system_prompt: String,
|
||||
}
|
||||
|
||||
// ── S4: VisualRefProvider ─────────────────────────────────────────────────────
|
||||
|
||||
/// Visual-reference rendering outlet.
|
||||
///
|
||||
/// host 实现把 HTML 字符串渲染成 base64 PNG 截图;stub 返回 `None`
|
||||
/// 表示"跳过视觉参考阶段"。
|
||||
///
|
||||
/// Port of the `renderHtmlToScreenshot` call-site shape in
|
||||
/// `visual-ref-orchestrator.ts:108-122` + spec §4.5.
|
||||
pub trait VisualRefProvider: Send + Sync {
|
||||
/// 将 HTML 字符串渲染为给定像素尺寸的截图,返回 base64 PNG;
|
||||
/// `None` 表示不可用 / 跳过。
|
||||
fn render_html_to_screenshot(&self, html: &str, width: f64, height: f64) -> Option<String>;
|
||||
}
|
||||
|
||||
// ── S4 end ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── S3c end ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 廉价可克隆的中止句柄(`Arc<AtomicBool>` 语义)。
|
||||
|
|
@ -246,6 +263,40 @@ pub enum Progress {
|
|||
ValidationDone {
|
||||
total_applied: usize,
|
||||
},
|
||||
// ── S4: Visual-Ref pipeline progress variants ─────────────────────────────
|
||||
/// Visual-ref pipeline started (before design-system generation).
|
||||
///
|
||||
/// Port of the entry point in `visual-ref-orchestrator.ts:62`.
|
||||
VisualRefStarted,
|
||||
/// Design system generated and variables seeded.
|
||||
///
|
||||
/// Port of stage 1 in `visual-ref-orchestrator.ts:74-90`.
|
||||
VisualRefDesignSystem {
|
||||
/// Number of `SetVariable*` commands emitted (one per palette/spacing/radius token).
|
||||
var_count: usize,
|
||||
},
|
||||
/// HTML code generated from the design system.
|
||||
///
|
||||
/// Port of stage 2 in `visual-ref-orchestrator.ts:92-106`.
|
||||
VisualRefHtmlGenerated {
|
||||
/// Byte length of the generated HTML string.
|
||||
byte_len: usize,
|
||||
},
|
||||
/// Screenshot step complete (or skipped when `VisualRefProvider` returns `None`).
|
||||
///
|
||||
/// Port of stage 3 in `visual-ref-orchestrator.ts:108-122`.
|
||||
VisualRefScreenshotReady {
|
||||
/// `true` when the screenshot was skipped (provider returned `None`).
|
||||
skipped: bool,
|
||||
},
|
||||
/// Visual-ref pipeline fell back to plain orchestration.
|
||||
///
|
||||
/// Emitted when any stage fails or the provider skips. Port of the
|
||||
/// fallback path in `visual-ref-orchestrator.ts:124-140`.
|
||||
VisualRefFallback {
|
||||
/// Human-readable reason for the fallback.
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 单个 subtask 的执行结果。`error` 带值但 `node_count > 0` 表示
|
||||
|
|
@ -333,12 +384,22 @@ pub struct DesignRequest {
|
|||
/// Port of `VALIDATION_ENABLED` in `ai-runtime-config.ts:109`.
|
||||
#[serde(default = "default_validation_enabled")]
|
||||
pub validation_enabled: bool,
|
||||
/// 是否在编排器执行前运行视觉参考(visual-ref)流水线(S4)。
|
||||
/// 默认 `false`(host 明确选择才启用)。
|
||||
/// Port of the `executeVisualRefOrchestration` vs `executeOrchestration`
|
||||
/// dispatch pattern in `visual-ref-orchestrator.ts`.
|
||||
#[serde(default = "default_visual_ref_enabled")]
|
||||
pub visual_ref_enabled: bool,
|
||||
}
|
||||
|
||||
fn default_validation_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_visual_ref_enabled() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -407,6 +468,7 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
assert!(req.append_context.is_none());
|
||||
}
|
||||
|
|
@ -428,6 +490,7 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: Some(ctx),
|
||||
validation_enabled: true,
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
assert!(req.append_context.is_some());
|
||||
}
|
||||
|
|
@ -443,6 +506,7 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
let json = serde_json::to_string(&req).expect("serialize");
|
||||
assert!(
|
||||
|
|
@ -517,6 +581,42 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Task A2: VisualRefProvider trait + SkippedVisualRefProvider stub ─────────
|
||||
|
||||
/// `SkippedVisualRefProvider` returns `None` for any input.
|
||||
#[test]
|
||||
fn skipped_visual_ref_provider_returns_none() {
|
||||
use crate::stub_providers::SkippedVisualRefProvider;
|
||||
let p = SkippedVisualRefProvider;
|
||||
assert!(p
|
||||
.render_html_to_screenshot("<html></html>", 1280.0, 800.0)
|
||||
.is_none());
|
||||
assert!(p.render_html_to_screenshot("", 0.0, 0.0).is_none());
|
||||
assert!(p
|
||||
.render_html_to_screenshot("<html><body>Hello</body></html>", 390.0, 844.0)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
/// `VisualRefProvider` trait is `Send + Sync`.
|
||||
#[test]
|
||||
fn visual_ref_provider_is_send_sync() {
|
||||
use crate::types::VisualRefProvider;
|
||||
fn assert_send_sync<T: Send + Sync + ?Sized>() {}
|
||||
assert_send_sync::<dyn VisualRefProvider>();
|
||||
}
|
||||
|
||||
/// `op_orchestrator::SkippedVisualRefProvider` resolves from a host-style import.
|
||||
#[test]
|
||||
fn skipped_visual_ref_provider_resolves_from_crate_root() {
|
||||
use crate::{SkippedVisualRefProvider, VisualRefProvider};
|
||||
let p: &dyn VisualRefProvider = &SkippedVisualRefProvider;
|
||||
assert!(p
|
||||
.render_html_to_screenshot("<p>test</p>", 800.0, 600.0)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
// ── Task A2 end ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// `DesignRequest.validation_enabled` defaults to `true` when omitted from JSON.
|
||||
#[test]
|
||||
fn design_request_validation_enabled_defaults_true() {
|
||||
|
|
@ -540,6 +640,7 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: false,
|
||||
visual_ref_enabled: false,
|
||||
};
|
||||
let json = serde_json::to_string(&req).expect("serialize");
|
||||
let back: DesignRequest = serde_json::from_str(&json).expect("deserialize");
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ fn make_request(validation_enabled: bool) -> DesignRequest {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled,
|
||||
visual_ref_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ mod tests {
|
|||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
|
||||
visual_ref_enabled: false,
|
||||
});
|
||||
assert!(seed_commands(&plan).is_empty());
|
||||
let sink = VecDocSink::new();
|
||||
|
|
|
|||
758
crates/op-orchestrator/src/visual_ref.rs
Normal file
758
crates/op-orchestrator/src/visual_ref.rs
Normal file
|
|
@ -0,0 +1,758 @@
|
|||
//! `visual_ref.rs` — S4 B2+C1: HTML helpers + main orchestration for the
|
||||
//! visual-reference pipeline.
|
||||
//!
|
||||
//! Functions ported from the deleted TS source (`commit 0f12b6e9^`):
|
||||
//!
|
||||
//! - [`generate_design_code`] — LLM call using `design-code` +
|
||||
//! `design-principles` skills as the system prompt. Returns HTML verbatim.
|
||||
//! Port of `design-code-generator.ts:26-49`.
|
||||
//!
|
||||
//! - [`extract_structure_summary`] — hand-rolled HTML scanner. Extracts
|
||||
//! section containers (by class/id), headings (`<h1>`-`<h6>`), and CTA
|
||||
//! elements (buttons/anchors whose class contains `btn`, `button`, or `cta`).
|
||||
//! Returns an indented text summary. Port of `design-code-generator.ts:90-136`.
|
||||
//!
|
||||
//! - [`build_enhanced_prompt`] — string concatenation producing the exact
|
||||
//! prompt template from `visual-ref-orchestrator.ts:172-184`.
|
||||
//!
|
||||
//! - [`execute_visual_ref_orchestration`] — 5-stage visual-ref pipeline.
|
||||
//! Port of `executeVisualRefOrchestration` in
|
||||
//! `visual-ref-orchestrator.ts:45-166`.
|
||||
//!
|
||||
//! No `regex` crate dependency (not in workspace Cargo.toml). The scanner uses
|
||||
//! a hand-rolled byte-level approach consistent with the S3b-4 precedent.
|
||||
|
||||
use futures::StreamExt;
|
||||
|
||||
use crate::design_system::{
|
||||
design_system_to_prompt_context, design_system_to_seed_commands, generate_design_system,
|
||||
DesignSystem,
|
||||
};
|
||||
use crate::run::Orchestrator;
|
||||
use crate::types::{
|
||||
AbortFlag, CallRequest, DesignRequest, DocSink, LlmChunk, LlmClient, OrchestratorError,
|
||||
Progress, RunSummary, ValidationProviders, VisualRefProvider,
|
||||
};
|
||||
|
||||
// ── generate_design_code ──────────────────────────────────────────────────────
|
||||
|
||||
/// Generate self-contained HTML/CSS code for a design request.
|
||||
///
|
||||
/// Port of `generateDesignCode` in `design-code-generator.ts:26-49`.
|
||||
///
|
||||
/// System prompt = `design-code` skill content + `\n\n` + `design-principles`
|
||||
/// skill content (when principles are non-empty).
|
||||
/// User prompt = `buildCodeGenUserPrompt(prompt, dsContext, width, height)`.
|
||||
/// Returns the raw LLM output (HTML verbatim — no post-processing here;
|
||||
/// the TS `extractHtmlFromResponse` is intentionally omitted to keep this
|
||||
/// function a minimal faithful port of the I/O shape only).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn generate_design_code(
|
||||
prompt: &str,
|
||||
ds: &DesignSystem,
|
||||
width: f64,
|
||||
height: f64,
|
||||
llm: &dyn LlmClient,
|
||||
model: Option<&str>,
|
||||
provider: Option<&str>,
|
||||
abort: &AbortFlag,
|
||||
) -> String {
|
||||
let design_code = op_ai_skills::get_skill_by_name("design-code")
|
||||
.map(|e| e.content.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let principles = op_ai_skills::get_skill_by_name("design-principles")
|
||||
.map(|e| e.content.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// TS: `const systemPrompt = principles ? \`${designCodeSkill}\n\n${principles}\` : designCodeSkill`
|
||||
let system_prompt = if principles.is_empty() {
|
||||
design_code
|
||||
} else {
|
||||
format!("{design_code}\n\n{principles}")
|
||||
};
|
||||
|
||||
let ds_context = design_system_to_prompt_context(ds);
|
||||
let user_prompt = build_code_gen_user_prompt(prompt, &ds_context, width, height);
|
||||
|
||||
let req = CallRequest {
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
model: model.map(|s| s.to_string()),
|
||||
provider: provider.map(|s| s.to_string()),
|
||||
timeout: std::time::Duration::from_secs(60),
|
||||
abort: abort.clone(),
|
||||
no_text_timeout: None,
|
||||
first_text_timeout: None,
|
||||
};
|
||||
|
||||
let mut stream = llm.call(req);
|
||||
let mut text = String::new();
|
||||
while let Some(item) = stream.next().await {
|
||||
match item {
|
||||
Ok(LlmChunk::Text(t)) => text.push_str(&t),
|
||||
Ok(LlmChunk::Thinking(_)) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
// Empty text = LLM error or truly-empty response. We can't distinguish
|
||||
// here (no exceptions in Rust streams). Return empty so the caller's
|
||||
// `html.is_empty()` fall-back path fires — TS catches the LLM error via
|
||||
// try/catch and falls back; this is the equivalent shortcut. Non-empty
|
||||
// text gets the full 4-stage normalization (TS extractHtmlFromResponse).
|
||||
if text.is_empty() {
|
||||
text
|
||||
} else {
|
||||
extract_html_from_response(&text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize an LLM response into clean HTML.
|
||||
///
|
||||
/// Port of `extractHtmlFromResponse` in `design-code-generator.ts:54-87`.
|
||||
/// Faithful 4-stage chain — without this, code-fence-wrapped HTML
|
||||
/// (the most common LLM output shape) leaks ``` markers into the
|
||||
/// downstream `extract_structure_summary` scan.
|
||||
///
|
||||
/// 1. ` ```(html)?\s*\n?…\n?``` ` fenced block — if the inner content
|
||||
/// contains `<!DOCTYPE` or `<html`, return the inner content (trimmed).
|
||||
/// 2. Trimmed response itself starts with `<!DOCTYPE` or `<html` — return as-is.
|
||||
/// 3. Case-insensitive find of `<!DOCTYPE…</html>` substring — return slice.
|
||||
/// 4. Wrap bare content in a default HTML document scaffold.
|
||||
pub(crate) fn extract_html_from_response(response: &str) -> String {
|
||||
let trimmed = response.trim();
|
||||
|
||||
// Stage 1: code fence with HTML inside
|
||||
if let Some(content) = extract_html_fenced_content(trimmed) {
|
||||
let inner = content.trim();
|
||||
if inner.contains("<!DOCTYPE") || inner.contains("<html") {
|
||||
return inner.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 2: trimmed starts with DOCTYPE / html
|
||||
if trimmed.starts_with("<!DOCTYPE") || trimmed.starts_with("<html") {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
// Stage 3: embedded <!DOCTYPE…</html>
|
||||
if let Some(html) = extract_doctype_to_html_close(trimmed) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Stage 4: wrap bare content in default scaffold
|
||||
format!(
|
||||
"<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Design</title></head>\n<body>{trimmed}</body>\n</html>"
|
||||
)
|
||||
}
|
||||
|
||||
/// Find content inside ` ```(html)?…``` `. Returns raw inner (caller trims).
|
||||
/// Matches TS regex `/```(?:html)?\s*\n?([\s\S]*?)\n?```/`.
|
||||
fn extract_html_fenced_content(text: &str) -> Option<&str> {
|
||||
let start = text.find("```")?;
|
||||
let after = &text[start + 3..];
|
||||
|
||||
// Optional "html" language tag
|
||||
let after = after.strip_prefix("html").unwrap_or(after);
|
||||
// \s* — strip any leading whitespace (incl. \n)
|
||||
let after = after.trim_start();
|
||||
|
||||
// Closing ```
|
||||
let end = after.find("```")?;
|
||||
Some(&after[..end])
|
||||
}
|
||||
|
||||
/// ASCII case-insensitive find of `<!DOCTYPE…</html>` in `text`. Returns
|
||||
/// the slice from `text` (preserving original case) between those markers.
|
||||
///
|
||||
/// **UTF-8 safety**: we scan byte windows with `eq_ignore_ascii_case`
|
||||
/// instead of `text.to_lowercase()`. The `to_lowercase()` approach can
|
||||
/// change byte lengths for non-ASCII (e.g. Turkish `İ` 2 bytes →
|
||||
/// `i\u{0307}` 3 bytes), making lower-string indices unsafe to use on
|
||||
/// the original `text` — slicing on a non-char-boundary panics. ASCII
|
||||
/// needles (`<!doctype` / `</html>`) always land on byte positions that
|
||||
/// are valid UTF-8 char boundaries (any byte ≤ 0x7F is a char start in
|
||||
/// UTF-8), so direct byte-index slicing on `text` is safe.
|
||||
fn extract_doctype_to_html_close(text: &str) -> Option<String> {
|
||||
let bytes = text.as_bytes();
|
||||
let doc_needle: &[u8] = b"<!doctype";
|
||||
let close_needle: &[u8] = b"</html>";
|
||||
|
||||
let doc_start = bytes.windows(doc_needle.len()).position(|w| {
|
||||
w.iter()
|
||||
.zip(doc_needle)
|
||||
.all(|(a, b)| a.eq_ignore_ascii_case(b))
|
||||
})?;
|
||||
|
||||
let close_rel = bytes[doc_start..]
|
||||
.windows(close_needle.len())
|
||||
.position(|w| {
|
||||
w.iter()
|
||||
.zip(close_needle)
|
||||
.all(|(a, b)| a.eq_ignore_ascii_case(b))
|
||||
})?;
|
||||
|
||||
let end = doc_start + close_rel + close_needle.len();
|
||||
Some(text[doc_start..end].to_string())
|
||||
}
|
||||
|
||||
/// Build the user prompt for HTML/CSS code generation.
|
||||
///
|
||||
/// Port of `buildCodeGenUserPrompt` in `design-code-generator.ts:168-185`.
|
||||
fn build_code_gen_user_prompt(
|
||||
user_prompt: &str,
|
||||
design_system_context: &str,
|
||||
width: f64,
|
||||
height: f64,
|
||||
) -> String {
|
||||
let height_instruction = if height > 0.0 {
|
||||
format!("Height: {}px (fixed viewport).", height as i64)
|
||||
} else {
|
||||
"Height: auto (content determines height, estimate based on sections).".to_string()
|
||||
};
|
||||
|
||||
format!(
|
||||
"Design request: {user_prompt}\n\nViewport: Width {width}px. {height_instruction}\n\n{design_system_context}\n\nGenerate the complete HTML file now.",
|
||||
width = width as i64,
|
||||
)
|
||||
}
|
||||
|
||||
// ── extract_structure_summary ─────────────────────────────────────────────────
|
||||
|
||||
/// Extract a structural summary from HTML for use as sub-agent reference.
|
||||
///
|
||||
/// Port of `extractStructureSummary` in `design-code-generator.ts:90-136`.
|
||||
///
|
||||
/// Scans the HTML string for:
|
||||
/// 1. Section-level containers (`<section|header|footer|nav|main|div>`) with a
|
||||
/// `class` or `id` attribute — emits `- Section: {classOrId}` (skips
|
||||
/// values containing `__` to filter BEM modifiers).
|
||||
/// 2. Headings `<h1>`-`<h6>` — emits `- H{n}: "{content}"` (inner text
|
||||
/// stripped of tags, truncated to 60 chars).
|
||||
/// 3. CTAs: `<button|a>` whose `class` attribute contains `btn`, `button`, or
|
||||
/// `cta` — emits `- CTA: "{text}"` (inner text stripped, truncated to 30 chars).
|
||||
///
|
||||
/// Returns a single string with `DESIGN REFERENCE STRUCTURE:` as the first
|
||||
/// line. If nothing was extracted, appends a generic fallback line.
|
||||
pub fn extract_structure_summary(html: &str) -> String {
|
||||
let mut lines: Vec<String> = vec!["DESIGN REFERENCE STRUCTURE:".to_string()];
|
||||
|
||||
// ── 1. Section-level containers ──────────────────────────────────────────
|
||||
// TS regex: /<(?:section|header|footer|nav|main|div)\s+[^>]*(?:class|id)="([^"]*)"[^>]*>/gi
|
||||
extract_section_containers(html, &mut lines);
|
||||
|
||||
// ── 2. Headings ──────────────────────────────────────────────────────────
|
||||
// TS regex: /<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi
|
||||
extract_headings(html, &mut lines);
|
||||
|
||||
// ── 3. CTA buttons / links ───────────────────────────────────────────────
|
||||
// TS regex: /<(?:button|a)\s+[^>]*class="[^"]*(?:btn|button|cta)[^"]*"[^>]*>([\s\S]*?)<\/(?:button|a)>/gi
|
||||
extract_ctas(html, &mut lines);
|
||||
|
||||
// Fallback when nothing was extracted
|
||||
if lines.len() <= 1 {
|
||||
lines.push("(HTML structure extracted — use as visual layout reference)".to_string());
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Strip HTML tags from a string, returning plain text.
|
||||
fn strip_tags(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut in_tag = false;
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'<' => in_tag = true,
|
||||
'>' => in_tag = false,
|
||||
_ if !in_tag => out.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Extract the value of `class` or `id` attribute from an opening tag string.
|
||||
///
|
||||
/// Returns the first match found (class takes precedence over id).
|
||||
/// `tag_inner` is the content between `<` and `>` (exclusive).
|
||||
fn extract_class_or_id(tag_inner: &str) -> Option<&str> {
|
||||
for attr in ["class", "id"] {
|
||||
// Search for `class="..."` or `id="..."`
|
||||
if let Some(pos) = find_attr(tag_inner, attr) {
|
||||
let rest = &tag_inner[pos..];
|
||||
// Skip `attr="`
|
||||
let start = attr.len() + 2; // `attr="` length
|
||||
if rest.len() > start {
|
||||
let value_start = start;
|
||||
if let Some(end) = rest[value_start..].find('"') {
|
||||
let val = &rest[value_start..value_start + end];
|
||||
if !val.is_empty() {
|
||||
return Some(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find a word-boundary attribute name within `tag_inner` followed by `="`.
|
||||
///
|
||||
/// Returns the byte offset of the start of `attr="` in `tag_inner`.
|
||||
fn find_attr(tag_inner: &str, attr: &str) -> Option<usize> {
|
||||
let pattern = format!("{attr}=\"");
|
||||
let bytes = tag_inner.as_bytes();
|
||||
let pat_bytes = pattern.as_bytes();
|
||||
let pat_len = pat_bytes.len();
|
||||
|
||||
let mut i = 0usize;
|
||||
while i + pat_len <= bytes.len() {
|
||||
if bytes[i..i + pat_len] == *pat_bytes {
|
||||
// Check word boundary: previous char must be whitespace or start
|
||||
if i == 0 || bytes[i - 1] == b' ' || bytes[i - 1] == b'\t' || bytes[i - 1] == b'\n' {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Scan for section-level container opening tags and extract class/id values.
|
||||
fn extract_section_containers(html: &str, lines: &mut Vec<String>) {
|
||||
// Container tag names (lowercase)
|
||||
const CONTAINERS: &[&str] = &["section", "header", "footer", "nav", "main", "div"];
|
||||
|
||||
let bytes = html.as_bytes();
|
||||
let len = bytes.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < len {
|
||||
// Look for '<'
|
||||
if bytes[i] != b'<' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Skip '<'
|
||||
let tag_start = i + 1;
|
||||
if tag_start >= len {
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to match one of our container tag names (case-insensitive)
|
||||
let mut matched_tag: Option<&str> = None;
|
||||
for &tag in CONTAINERS {
|
||||
let tag_len = tag.len();
|
||||
if tag_start + tag_len <= len {
|
||||
let slice = &html[tag_start..tag_start + tag_len];
|
||||
if slice.eq_ignore_ascii_case(tag) {
|
||||
// Must be followed by whitespace (has attributes) or '>'
|
||||
let after = tag_start + tag_len;
|
||||
if after < len
|
||||
&& (bytes[after] == b' '
|
||||
|| bytes[after] == b'\t'
|
||||
|| bytes[after] == b'\n'
|
||||
|| bytes[after] == b'\r')
|
||||
{
|
||||
matched_tag = Some(tag);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(_tag) = matched_tag {
|
||||
// Find the end of this opening tag
|
||||
if let Some(end_offset) = html[i..].find('>') {
|
||||
let tag_inner = &html[tag_start..i + end_offset];
|
||||
if let Some(class_or_id) = extract_class_or_id(tag_inner) {
|
||||
// TS: `if (classOrId && !classOrId.includes('__'))`
|
||||
if !class_or_id.contains("__") {
|
||||
lines.push(format!("- Section: {class_or_id}"));
|
||||
}
|
||||
}
|
||||
i = i + end_offset + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan for `<h1>`-`<h6>` elements and extract their text content.
|
||||
fn extract_headings(html: &str, lines: &mut Vec<String>) {
|
||||
let bytes = html.as_bytes();
|
||||
let len = bytes.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < len {
|
||||
if bytes[i] != b'<' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let tag_start = i + 1;
|
||||
if tag_start >= len {
|
||||
break;
|
||||
}
|
||||
|
||||
// Match `h` followed by digit 1-6
|
||||
if bytes[tag_start] != b'h' && bytes[tag_start] != b'H' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if tag_start + 1 >= len {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let digit = bytes[tag_start + 1];
|
||||
if !(b'1'..=b'6').contains(&digit) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let level = digit - b'0';
|
||||
// Must be followed by '>' or whitespace (no other chars)
|
||||
let after = tag_start + 2;
|
||||
if after < len
|
||||
&& bytes[after] != b'>'
|
||||
&& bytes[after] != b' '
|
||||
&& bytes[after] != b'\t'
|
||||
&& bytes[after] != b'\n'
|
||||
&& bytes[after] != b'\r'
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find end of opening tag
|
||||
let Some(end_offset) = html[i..].find('>') else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let after_open_tag = i + end_offset + 1;
|
||||
|
||||
// Build closing tag `</h{level}>`
|
||||
let closing = format!("</h{level}>");
|
||||
let closing_lower = format!("</h{level}>");
|
||||
|
||||
// Find closing tag (case-insensitive: just check both cases since h is ASCII)
|
||||
let rest = &html[after_open_tag..];
|
||||
let inner_end = rest
|
||||
.find(&closing)
|
||||
.or_else(|| rest.find(&closing_lower))
|
||||
.or_else(|| {
|
||||
// Try uppercase H
|
||||
let closing_upper = format!("</H{level}>");
|
||||
rest.find(&closing_upper)
|
||||
});
|
||||
|
||||
if let Some(inner_len) = inner_end {
|
||||
let inner_html = &rest[..inner_len];
|
||||
let content = strip_tags(inner_html).trim().to_string();
|
||||
// Truncate to 60 chars (TS: `.slice(0, 60)`)
|
||||
let content: String = content.chars().take(60).collect();
|
||||
if !content.is_empty() {
|
||||
lines.push(format!("- H{level}: \"{content}\""));
|
||||
}
|
||||
i = after_open_tag + inner_len + closing.len();
|
||||
continue;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan for `<button>` and `<a>` elements whose class contains `btn`, `button`,
|
||||
/// or `cta`, and extract their text content.
|
||||
fn extract_ctas(html: &str, lines: &mut Vec<String>) {
|
||||
let bytes = html.as_bytes();
|
||||
let len = bytes.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < len {
|
||||
if bytes[i] != b'<' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let tag_start = i + 1;
|
||||
if tag_start >= len {
|
||||
break;
|
||||
}
|
||||
|
||||
// Match `button` or `a` (case-insensitive)
|
||||
let matched_tag: Option<(&str, &str)> = if html[tag_start..].len() >= 6
|
||||
&& html[tag_start..tag_start + 6].eq_ignore_ascii_case("button")
|
||||
&& tag_start + 6 < len
|
||||
&& (bytes[tag_start + 6] == b' '
|
||||
|| bytes[tag_start + 6] == b'\t'
|
||||
|| bytes[tag_start + 6] == b'\n'
|
||||
|| bytes[tag_start + 6] == b'\r')
|
||||
{
|
||||
Some(("button", "</button>"))
|
||||
} else if !html[tag_start..].is_empty()
|
||||
&& html[tag_start..tag_start + 1].eq_ignore_ascii_case("a")
|
||||
&& tag_start + 1 < len
|
||||
&& (bytes[tag_start + 1] == b' '
|
||||
|| bytes[tag_start + 1] == b'\t'
|
||||
|| bytes[tag_start + 1] == b'\n'
|
||||
|| bytes[tag_start + 1] == b'\r')
|
||||
{
|
||||
Some(("a", "</a>"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some((_tag_name, closing)) = matched_tag {
|
||||
// Find end of opening tag
|
||||
let Some(end_offset) = html[i..].find('>') else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let tag_inner = &html[tag_start..i + end_offset];
|
||||
|
||||
// Check class contains btn/button/cta
|
||||
// TS regex: class="[^"]*(?:btn|button|cta)[^"]*"
|
||||
let has_cta_class = if let Some(class_pos) = find_attr(tag_inner, "class") {
|
||||
let rest = &tag_inner[class_pos + 7..]; // skip `class="`
|
||||
if let Some(end) = rest.find('"') {
|
||||
let class_val = &rest[..end];
|
||||
let cv_lower = class_val.to_ascii_lowercase();
|
||||
cv_lower.contains("btn")
|
||||
|| cv_lower.contains("button")
|
||||
|| cv_lower.contains("cta")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if has_cta_class {
|
||||
let after_open_tag = i + end_offset + 1;
|
||||
let rest = &html[after_open_tag..];
|
||||
|
||||
// Find closing tag (case-insensitive check: try lowercase and uppercase A)
|
||||
let inner_end = rest.find(closing).or_else(|| {
|
||||
let closing_upper = closing.to_ascii_uppercase();
|
||||
rest.find(&closing_upper)
|
||||
});
|
||||
|
||||
if let Some(inner_len) = inner_end {
|
||||
let inner_html = &rest[..inner_len];
|
||||
let text = strip_tags(inner_html).trim().to_string();
|
||||
// Truncate to 30 chars (TS: `.slice(0, 30)`)
|
||||
let text: String = text.chars().take(30).collect();
|
||||
if !text.is_empty() {
|
||||
lines.push(format!("- CTA: \"{text}\""));
|
||||
}
|
||||
i = after_open_tag + inner_len + closing.len();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
i = i + end_offset + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── build_enhanced_prompt ─────────────────────────────────────────────────────
|
||||
|
||||
/// Build the enhanced orchestration prompt that incorporates the visual reference.
|
||||
///
|
||||
/// Port of `buildEnhancedPrompt` in `visual-ref-orchestrator.ts:172-184`.
|
||||
///
|
||||
/// Template (byte-exact from TS):
|
||||
/// ```text
|
||||
/// {originalPrompt}
|
||||
///
|
||||
/// {structureSummary}
|
||||
///
|
||||
/// {designSystemContext}
|
||||
///
|
||||
/// IMPORTANT: Follow the design reference structure closely. The design system
|
||||
/// colors, fonts, and spacing have already been determined — use them
|
||||
/// consistently. The reference structure shows the intended layout — match its
|
||||
/// section order and composition.
|
||||
/// ```
|
||||
pub fn build_enhanced_prompt(original: &str, structure: &str, ds_context: &str) -> String {
|
||||
format!(
|
||||
"{original}\n\n{structure}\n\n{ds_context}\n\nIMPORTANT: Follow the design reference structure closely. The design system colors, fonts, and spacing have already been determined — use them consistently. The reference structure shows the intended layout — match its section order and composition."
|
||||
)
|
||||
}
|
||||
|
||||
// ── execute_visual_ref_orchestration ─────────────────────────────────────────
|
||||
|
||||
/// 5-stage visual-reference pipeline.
|
||||
///
|
||||
/// Port of `executeVisualRefOrchestration` in
|
||||
/// `visual-ref-orchestrator.ts:45-166`.
|
||||
///
|
||||
/// ## Stage flow
|
||||
///
|
||||
/// 1. Emit `Progress::VisualRefStarted`. Check abort. Call
|
||||
/// `generate_design_system`, apply `design_system_to_seed_commands` to
|
||||
/// `sink`, emit `Progress::VisualRefDesignSystem { var_count }`.
|
||||
///
|
||||
/// 2. Check abort. Call `generate_design_code`. If the result is empty,
|
||||
/// emit `Progress::VisualRefFallback` and fall back to `Orchestrator::run`
|
||||
/// with the original (un-enhanced) request.
|
||||
/// Otherwise emit `Progress::VisualRefHtmlGenerated { byte_len }`.
|
||||
///
|
||||
/// 3. Check abort. Call `visual_ref.render_html_to_screenshot`. Emit
|
||||
/// `Progress::VisualRefScreenshotReady { skipped: screenshot.is_none() }`.
|
||||
/// Stage 3 **does not fall back** — the pipeline continues to stages 4+5
|
||||
/// regardless of whether a screenshot was returned. The screenshot is an
|
||||
/// optional reference for the downstream vision-validation loop
|
||||
/// (currently always `None` in Rust S3c; future plumb-through to
|
||||
/// `validation.rs::reference_screenshot` is out of S4 scope).
|
||||
///
|
||||
/// 4. Build `enhanced_request` with prompt replaced by the output of
|
||||
/// `build_enhanced_prompt(original_prompt, extract_structure_summary(&html),
|
||||
/// design_system_to_prompt_context(&ds))`.
|
||||
///
|
||||
/// 5. Call `Orchestrator::new().run(enhanced_request, ...)` and return its
|
||||
/// `Result<RunSummary, OrchestratorError>`.
|
||||
///
|
||||
/// ## Abort handling
|
||||
///
|
||||
/// `abort.is_set()` is checked before each stage. If set, returns
|
||||
/// `Err(OrchestratorError::Aborted)` immediately.
|
||||
///
|
||||
/// ## Fallback semantics
|
||||
///
|
||||
/// Fallback to plain `Orchestrator::run(original_request, ...)` happens
|
||||
/// ONLY on:
|
||||
/// - Stage 2 failure (`generate_design_code` returns empty string).
|
||||
///
|
||||
/// Stage 1 (`generate_design_system`) already has internal fallback to
|
||||
/// `DEFAULT_DESIGN_SYSTEM` on any LLM/parse failure, so it never
|
||||
/// short-circuits. Stage 3 (`render_html_to_screenshot` returning `None`)
|
||||
/// is informational only — the pipeline continues to stages 4+5 without
|
||||
/// a screenshot.
|
||||
///
|
||||
/// ## Canvas size
|
||||
///
|
||||
/// The Rust `DesignRequest` does not carry a `canvas_size` field. The TS
|
||||
/// source used `request.context?.canvasSize?.width ?? 1200` and
|
||||
/// `?? 0` for height, so this function uses the same defaults:
|
||||
/// `width = 1200.0, height = 0.0`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute_visual_ref_orchestration(
|
||||
sink: &mut dyn DocSink,
|
||||
llm: &dyn LlmClient,
|
||||
providers: &ValidationProviders<'_>,
|
||||
visual_ref: &dyn VisualRefProvider,
|
||||
request: DesignRequest,
|
||||
on_progress: &mut dyn FnMut(Progress),
|
||||
abort: &AbortFlag,
|
||||
) -> Result<RunSummary, OrchestratorError> {
|
||||
// Default canvas dimensions: matches TS `request.context?.canvasSize?.{width,height} ?? default`
|
||||
const PLAN_WIDTH: f64 = 1200.0;
|
||||
const PLAN_HEIGHT: f64 = 0.0;
|
||||
|
||||
// Keep a clone of the original request for fallback paths.
|
||||
let original_request = request.clone();
|
||||
|
||||
// ── Stage 0: emit started, check abort ────────────────────────────────────
|
||||
on_progress(Progress::VisualRefStarted);
|
||||
|
||||
if abort.is_set() {
|
||||
return Err(OrchestratorError::Aborted);
|
||||
}
|
||||
|
||||
// ── Stage 1: design system ────────────────────────────────────────────────
|
||||
|
||||
// generate_design_system falls back to DEFAULT on any LLM/parse failure.
|
||||
let ds = generate_design_system(
|
||||
&request.prompt,
|
||||
llm,
|
||||
request.model.as_deref(),
|
||||
request.provider.as_deref(),
|
||||
abort,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Seed design-system variables into the document.
|
||||
let seed_cmds = design_system_to_seed_commands(&ds);
|
||||
let var_count = seed_cmds.len();
|
||||
for cmd in seed_cmds {
|
||||
sink.apply(cmd);
|
||||
}
|
||||
|
||||
on_progress(Progress::VisualRefDesignSystem { var_count });
|
||||
|
||||
if abort.is_set() {
|
||||
return Err(OrchestratorError::Aborted);
|
||||
}
|
||||
|
||||
// ── Stage 2: generate HTML/CSS ────────────────────────────────────────────
|
||||
|
||||
let html = generate_design_code(
|
||||
&request.prompt,
|
||||
&ds,
|
||||
PLAN_WIDTH,
|
||||
PLAN_HEIGHT,
|
||||
llm,
|
||||
request.model.as_deref(),
|
||||
request.provider.as_deref(),
|
||||
abort,
|
||||
)
|
||||
.await;
|
||||
|
||||
if html.is_empty() {
|
||||
on_progress(Progress::VisualRefFallback {
|
||||
reason: "design-code failed".into(),
|
||||
});
|
||||
return Orchestrator::new()
|
||||
.run(original_request, sink, llm, on_progress, abort, providers)
|
||||
.await;
|
||||
}
|
||||
|
||||
on_progress(Progress::VisualRefHtmlGenerated {
|
||||
byte_len: html.len(),
|
||||
});
|
||||
|
||||
if abort.is_set() {
|
||||
return Err(OrchestratorError::Aborted);
|
||||
}
|
||||
|
||||
// ── Stage 3: render screenshot ─────────────────────────────────────────────
|
||||
//
|
||||
// Informational stage: a `None` screenshot is OK — we still proceed to
|
||||
// stages 4+5 with the HTML structure summary + design system context.
|
||||
// The screenshot is reserved for a future plumb-through to S3c's
|
||||
// `validation.rs::reference_screenshot` (currently always `None`).
|
||||
let screenshot = visual_ref.render_html_to_screenshot(&html, PLAN_WIDTH, PLAN_HEIGHT);
|
||||
on_progress(Progress::VisualRefScreenshotReady {
|
||||
skipped: screenshot.is_none(),
|
||||
});
|
||||
let _ = screenshot; // currently unused downstream; see doc-comment
|
||||
|
||||
if abort.is_set() {
|
||||
return Err(OrchestratorError::Aborted);
|
||||
}
|
||||
|
||||
// ── Stage 4 + 5: build enhanced request → run Orchestrator ───────────────
|
||||
|
||||
let structure_summary = extract_structure_summary(&html);
|
||||
let ds_context = design_system_to_prompt_context(&ds);
|
||||
let enhanced_prompt = build_enhanced_prompt(&request.prompt, &structure_summary, &ds_context);
|
||||
|
||||
let enhanced_request = DesignRequest {
|
||||
prompt: enhanced_prompt,
|
||||
..request
|
||||
};
|
||||
|
||||
Orchestrator::new()
|
||||
.run(enhanced_request, sink, llm, on_progress, abort, providers)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "visual_ref_tests.rs"]
|
||||
mod tests;
|
||||
794
crates/op-orchestrator/src/visual_ref_tests.rs
Normal file
794
crates/op-orchestrator/src/visual_ref_tests.rs
Normal file
|
|
@ -0,0 +1,794 @@
|
|||
//! Tests for `visual_ref.rs` — S4 B2 + C1.
|
||||
|
||||
use crate::design_system::default_design_system;
|
||||
use crate::test_support::{
|
||||
ScriptResponse, ScriptedLlm, SkippedPreValidator, SkippedScreenshotProvider,
|
||||
SkippedVisionLlmClient, VecDocSink,
|
||||
};
|
||||
use crate::types::{
|
||||
AbortFlag, CallRequest, DesignRequest, LlmChunk, LlmClient, LlmError, OrchestratorError,
|
||||
Progress, ValidationProviders, VisualRefProvider,
|
||||
};
|
||||
use crate::visual_ref::{
|
||||
build_enhanced_prompt, execute_visual_ref_orchestration, extract_html_from_response,
|
||||
extract_structure_summary, generate_design_code,
|
||||
};
|
||||
use futures::stream::BoxStream;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// ── generate_design_code ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_design_code_returns_llm_output_verbatim() {
|
||||
let expected = "<html><body>Hello</body></html>";
|
||||
let llm = ScriptedLlm::new(vec![ScriptResponse::Text(expected.to_string())]);
|
||||
let abort = AbortFlag::new();
|
||||
let ds = default_design_system();
|
||||
|
||||
let result =
|
||||
generate_design_code("a login page", ds, 1440.0, 900.0, &llm, None, None, &abort).await;
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_design_code_returns_empty_on_llm_error() {
|
||||
use crate::types::LlmError;
|
||||
let llm = ScriptedLlm::new(vec![ScriptResponse::Fail(LlmError {
|
||||
message: "timeout".into(),
|
||||
aborted: false,
|
||||
})]);
|
||||
let abort = AbortFlag::new();
|
||||
let ds = default_design_system();
|
||||
|
||||
let result =
|
||||
generate_design_code("a dashboard", ds, 1440.0, 900.0, &llm, None, None, &abort).await;
|
||||
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_design_code_respects_model_and_provider() {
|
||||
// This test just checks the call doesn't panic with model/provider set.
|
||||
let llm = ScriptedLlm::new(vec![ScriptResponse::Text("<!DOCTYPE html>".to_string())]);
|
||||
let abort = AbortFlag::new();
|
||||
let ds = default_design_system();
|
||||
|
||||
let result = generate_design_code(
|
||||
"a settings page",
|
||||
ds,
|
||||
375.0,
|
||||
812.0,
|
||||
&llm,
|
||||
Some("claude-3-5-sonnet"),
|
||||
Some("anthropic"),
|
||||
&abort,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!result.is_empty());
|
||||
}
|
||||
|
||||
// ── extract_structure_summary ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_structure_summary_header_line() {
|
||||
let result = extract_structure_summary("<div></div>");
|
||||
assert_eq!(
|
||||
result.lines().next().unwrap(),
|
||||
"DESIGN REFERENCE STRUCTURE:"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_structure_summary_section_with_class() {
|
||||
let html = r#"<section class="hero"><h1>Welcome</h1></section>"#;
|
||||
let result = extract_structure_summary(html);
|
||||
assert!(
|
||||
result.contains("- Section: hero"),
|
||||
"expected Section: hero in:\n{result}"
|
||||
);
|
||||
assert!(
|
||||
result.contains("- H1: \"Welcome\""),
|
||||
"expected H1: Welcome in:\n{result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_structure_summary_section_h1_cta() {
|
||||
// This is the byte-exact test from the plan:
|
||||
// `extract_structure_summary("<section><h1>Hero</h1><a>CTA</a></section>")` → exact format
|
||||
// Note: <section> has no class/id → no Section line.
|
||||
// <h1> → H1 line.
|
||||
// <a> has no class → no CTA line.
|
||||
let html = "<section><h1>Hero</h1><a>CTA</a></section>";
|
||||
let result = extract_structure_summary(html);
|
||||
let lines: Vec<&str> = result.lines().collect();
|
||||
assert_eq!(lines[0], "DESIGN REFERENCE STRUCTURE:");
|
||||
assert_eq!(lines[1], "- H1: \"Hero\"");
|
||||
// Only 2 lines (header + H1) — no section/CTA since no class attrs
|
||||
assert_eq!(lines.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_structure_summary_skips_bem_modifier_classes() {
|
||||
// classOrId containing `__` must be skipped
|
||||
let html = r#"<div class="hero__inner">content</div>"#;
|
||||
let result = extract_structure_summary(html);
|
||||
assert!(
|
||||
!result.contains("hero__inner"),
|
||||
"BEM modifier should be skipped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_structure_summary_cta_with_btn_class() {
|
||||
let html = r#"<button class="btn-primary">Get Started</button>"#;
|
||||
let result = extract_structure_summary(html);
|
||||
assert!(
|
||||
result.contains("- CTA: \"Get Started\""),
|
||||
"expected CTA in:\n{result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_structure_summary_cta_with_cta_class() {
|
||||
let html = "<a class=\"cta-link\" href=\"#\">Learn More</a>";
|
||||
let result = extract_structure_summary(html);
|
||||
assert!(
|
||||
result.contains("- CTA: \"Learn More\""),
|
||||
"expected CTA in:\n{result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_structure_summary_cta_with_button_class() {
|
||||
let html = "<a class=\"button-primary\" href=\"#\">Sign Up</a>";
|
||||
let result = extract_structure_summary(html);
|
||||
assert!(
|
||||
result.contains("- CTA: \"Sign Up\""),
|
||||
"expected CTA in:\n{result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_structure_summary_headings_truncated_to_60_chars() {
|
||||
let long_title = "A".repeat(80);
|
||||
let html = format!("<h2>{long_title}</h2>");
|
||||
let result = extract_structure_summary(&html);
|
||||
// Should be truncated to 60 chars
|
||||
let expected_content: String = "A".repeat(60);
|
||||
assert!(
|
||||
result.contains(&format!("- H2: \"{expected_content}\"")),
|
||||
"should truncate to 60 chars:\n{result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_structure_summary_cta_text_truncated_to_30_chars() {
|
||||
let long_text = "B".repeat(50);
|
||||
let html = format!(r#"<button class="btn">{long_text}</button>"#);
|
||||
let result = extract_structure_summary(&html);
|
||||
let expected_text: String = "B".repeat(30);
|
||||
assert!(
|
||||
result.contains(&format!("- CTA: \"{expected_text}\"")),
|
||||
"should truncate to 30 chars:\n{result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_structure_summary_fallback_when_no_structure() {
|
||||
let html = "<div><span>plain</span></div>";
|
||||
let result = extract_structure_summary(html);
|
||||
assert!(
|
||||
result.contains("(HTML structure extracted"),
|
||||
"expected fallback line:\n{result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_structure_summary_section_with_id_attribute() {
|
||||
let html = r#"<section id="about">content</section>"#;
|
||||
let result = extract_structure_summary(html);
|
||||
assert!(
|
||||
result.contains("- Section: about"),
|
||||
"expected Section: about in:\n{result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_structure_summary_all_heading_levels() {
|
||||
let html = "<h1>One</h1><h2>Two</h2><h3>Three</h3><h4>Four</h4><h5>Five</h5><h6>Six</h6>";
|
||||
let result = extract_structure_summary(html);
|
||||
for (level, text) in [
|
||||
(1, "One"),
|
||||
(2, "Two"),
|
||||
(3, "Three"),
|
||||
(4, "Four"),
|
||||
(5, "Five"),
|
||||
(6, "Six"),
|
||||
] {
|
||||
assert!(
|
||||
result.contains(&format!("- H{level}: \"{text}\"")),
|
||||
"missing H{level} in:\n{result}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_structure_summary_strips_tags_from_headings() {
|
||||
let html = "<h1><span>Hello</span> <em>World</em></h1>";
|
||||
let result = extract_structure_summary(html);
|
||||
assert!(
|
||||
result.contains("- H1: \"Hello World\""),
|
||||
"should strip tags:\n{result}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── build_enhanced_prompt ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn build_enhanced_prompt_exact_template() {
|
||||
let original = "Design a login screen";
|
||||
let structure = "DESIGN REFERENCE STRUCTURE:\n- H1: \"Login\"";
|
||||
let ds_context = "DESIGN SYSTEM (use these values consistently):\nColors: bg #F8FAFC";
|
||||
|
||||
let result = build_enhanced_prompt(original, structure, ds_context);
|
||||
|
||||
let expected = format!(
|
||||
"{original}\n\n{structure}\n\n{ds_context}\n\nIMPORTANT: Follow the design reference structure closely. The design system colors, fonts, and spacing have already been determined — use them consistently. The reference structure shows the intended layout — match its section order and composition."
|
||||
);
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_enhanced_prompt_contains_important_instruction() {
|
||||
let result = build_enhanced_prompt("p", "s", "d");
|
||||
assert!(result.contains("IMPORTANT: Follow the design reference structure closely."));
|
||||
assert!(result.contains("use them consistently"));
|
||||
assert!(result.contains("match its section order and composition."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_enhanced_prompt_double_newline_separators() {
|
||||
let result = build_enhanced_prompt("p", "s", "d");
|
||||
// Check the exact separators: p\n\ns\n\nd\n\nIMPORTANT...
|
||||
assert!(result.starts_with("p\n\ns\n\nd\n\nIMPORTANT:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_enhanced_prompt_empty_inputs() {
|
||||
let result = build_enhanced_prompt("", "", "");
|
||||
// Should still produce the IMPORTANT line
|
||||
assert!(result.contains("IMPORTANT:"));
|
||||
assert_eq!(result, "\n\n\n\n\n\nIMPORTANT: Follow the design reference structure closely. The design system colors, fonts, and spacing have already been determined — use them consistently. The reference structure shows the intended layout — match its section order and composition.");
|
||||
}
|
||||
|
||||
// ── execute_visual_ref_orchestration — C1 tests ───────────────────────────
|
||||
|
||||
// Shared test fixtures ---------------------------------------------------
|
||||
|
||||
fn make_request() -> DesignRequest {
|
||||
DesignRequest {
|
||||
prompt: "a landing page".into(),
|
||||
model: None,
|
||||
provider: None,
|
||||
design_md: None,
|
||||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: false,
|
||||
visual_ref_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn stub_providers() -> ValidationProviders<'static> {
|
||||
ValidationProviders {
|
||||
pre_validator: &SkippedPreValidator,
|
||||
screenshot: &SkippedScreenshotProvider,
|
||||
vision: &SkippedVisionLlmClient,
|
||||
system_prompt: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal valid plan JSON for the Orchestrator.
|
||||
const PLAN_JSON: &str = r##"{
|
||||
"rootFrame": { "id": "root", "name": "Page", "width": 1200, "height": 800,
|
||||
"layout": "vertical", "gap": 0,
|
||||
"fill": [{ "type": "solid", "color": "#FFFFFF" }] },
|
||||
"subtasks": [
|
||||
{ "id": "hero", "label": "Hero", "region": { "width": 1200, "height": 400 } }
|
||||
]
|
||||
}"##;
|
||||
|
||||
fn node_json(prefix: &str) -> String {
|
||||
format!(
|
||||
r#"[{{"type":"frame","id":"{prefix}-1","name":"Sec","x":0,"y":0,"width":1200,"height":300,"children":[]}}]"#
|
||||
)
|
||||
}
|
||||
|
||||
fn default_ds_json() -> String {
|
||||
let ds = default_design_system();
|
||||
serde_json::to_string(ds).expect("serialize default DS")
|
||||
}
|
||||
|
||||
/// An `LlmClient` that records every `CallRequest` it sees while still
|
||||
/// returning scripted responses in order. Used to verify the enhanced
|
||||
/// prompt reaches the underlying orchestrator.
|
||||
struct RecordingLlm {
|
||||
responses: Mutex<std::collections::VecDeque<ScriptResponse>>,
|
||||
recorded: Mutex<Vec<CallRequest>>,
|
||||
}
|
||||
|
||||
impl RecordingLlm {
|
||||
fn new(responses: Vec<ScriptResponse>) -> Self {
|
||||
Self {
|
||||
responses: Mutex::new(responses.into()),
|
||||
recorded: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> Vec<CallRequest> {
|
||||
self.recorded.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl LlmClient for RecordingLlm {
|
||||
fn call(&self, req: CallRequest) -> BoxStream<'static, Result<LlmChunk, LlmError>> {
|
||||
self.recorded.lock().unwrap().push(req);
|
||||
let next = self.responses.lock().unwrap().pop_front();
|
||||
let items: Vec<Result<LlmChunk, LlmError>> = match next {
|
||||
Some(ScriptResponse::Text(t)) => vec![Ok(LlmChunk::Text(t))],
|
||||
Some(ScriptResponse::Fail(e)) => vec![Err(e)],
|
||||
None => vec![Err(LlmError {
|
||||
message: "RecordingLlm exhausted".into(),
|
||||
aborted: false,
|
||||
})],
|
||||
};
|
||||
Box::pin(futures::stream::iter(items))
|
||||
}
|
||||
}
|
||||
|
||||
// A `VisualRefProvider` that returns Some(base64) for any call.
|
||||
struct MockVisualRefProvider;
|
||||
impl VisualRefProvider for MockVisualRefProvider {
|
||||
fn render_html_to_screenshot(&self, _html: &str, _w: f64, _h: f64) -> Option<String> {
|
||||
Some("base64screenshot==".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// A `VisualRefProvider` that always returns None.
|
||||
struct NoneVisualRefProvider;
|
||||
impl VisualRefProvider for NoneVisualRefProvider {
|
||||
fn render_html_to_screenshot(&self, _html: &str, _w: f64, _h: f64) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 1: None screenshot → still runs enhanced orchestration ──────────
|
||||
|
||||
/// When `VisualRefProvider` returns `None`, the pipeline does NOT fall back
|
||||
/// to plain orchestration — it emits
|
||||
/// `Progress::VisualRefScreenshotReady { skipped: true }` and continues
|
||||
/// to the enhanced `Orchestrator::run`. No `VisualRefFallback` event is
|
||||
/// emitted. Matches TS `visual-ref-orchestrator.ts:109-122` semantics.
|
||||
#[tokio::test]
|
||||
async fn execute_visual_ref_none_screenshot_still_runs_enhanced_orchestration() {
|
||||
// LLM call order:
|
||||
// [0] generate_design_system → DS JSON
|
||||
// [1] generate_design_code → HTML (contains <h1>Hero</h1>)
|
||||
// [2] Orchestrator planning → PLAN_JSON
|
||||
// [3] Orchestrator subtask → node_json
|
||||
let llm = RecordingLlm::new(vec![
|
||||
ScriptResponse::Text(default_ds_json()),
|
||||
ScriptResponse::Text("<html><body><h1>Hero</h1></body></html>".into()),
|
||||
ScriptResponse::Text(PLAN_JSON.into()),
|
||||
ScriptResponse::Text(node_json("hero")),
|
||||
]);
|
||||
let mut sink = VecDocSink::new();
|
||||
let mut events: Vec<Progress> = Vec::new();
|
||||
let providers = stub_providers();
|
||||
|
||||
let result = execute_visual_ref_orchestration(
|
||||
&mut sink,
|
||||
&llm,
|
||||
&providers,
|
||||
&NoneVisualRefProvider,
|
||||
make_request(),
|
||||
&mut |p| events.push(p),
|
||||
&AbortFlag::new(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
|
||||
|
||||
// All four pre-orchestrator events
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, Progress::VisualRefStarted)),
|
||||
"missing VisualRefStarted in {:?}",
|
||||
events
|
||||
);
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, Progress::VisualRefDesignSystem { .. })),
|
||||
"missing VisualRefDesignSystem in {:?}",
|
||||
events
|
||||
);
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, Progress::VisualRefHtmlGenerated { .. })),
|
||||
"missing VisualRefHtmlGenerated in {:?}",
|
||||
events
|
||||
);
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, Progress::VisualRefScreenshotReady { skipped: true })),
|
||||
"expected VisualRefScreenshotReady{{skipped:true}} in {:?}",
|
||||
events
|
||||
);
|
||||
|
||||
// CRITICAL: no VisualRefFallback should be emitted when screenshot is None.
|
||||
let no_fallback = events
|
||||
.iter()
|
||||
.all(|e| !matches!(e, Progress::VisualRefFallback { .. }));
|
||||
assert!(
|
||||
no_fallback,
|
||||
"stage 3 None → must NOT emit VisualRefFallback; got {:?}",
|
||||
events
|
||||
);
|
||||
|
||||
// Orchestrator emits Planning when it runs.
|
||||
assert!(
|
||||
events.iter().any(|e| matches!(e, Progress::Planning)),
|
||||
"expected Planning event from enhanced Orchestrator::run, got {:?}",
|
||||
events
|
||||
);
|
||||
|
||||
// CRITICAL: the planning call (call index 2, after DS + codegen) must
|
||||
// have received the ENHANCED prompt (not the original "a landing page").
|
||||
// The enhanced prompt contains the IMPORTANT-instruction tail.
|
||||
let calls = llm.calls();
|
||||
assert!(
|
||||
calls.len() >= 3,
|
||||
"expected ≥3 LLM calls (DS + codegen + planning), got {}",
|
||||
calls.len()
|
||||
);
|
||||
let planning_user_prompt = &calls[2].user_prompt;
|
||||
assert!(
|
||||
planning_user_prompt.contains("IMPORTANT: Follow the design reference structure closely."),
|
||||
"planning user prompt should carry the enhanced-prompt instruction tail; got:\n{}",
|
||||
planning_user_prompt
|
||||
);
|
||||
// It should also carry the structure summary marker (since HTML had <h1>Hero</h1>).
|
||||
assert!(
|
||||
planning_user_prompt.contains("DESIGN REFERENCE STRUCTURE:"),
|
||||
"planning user prompt should carry the structure summary header; got:\n{}",
|
||||
planning_user_prompt
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 2: MockVisualRefProvider → all 5 stages + Orchestrator::run ──────
|
||||
|
||||
/// When `VisualRefProvider` returns `Some(base64)`, all 5 stages run and
|
||||
/// `Orchestrator::run` is called with the enhanced prompt.
|
||||
#[tokio::test]
|
||||
async fn execute_visual_ref_with_screenshot_runs_all_stages() {
|
||||
// LLM call order:
|
||||
// [0] generate_design_system → DS JSON
|
||||
// [1] generate_design_code → HTML
|
||||
// [2] Orchestrator planning → PLAN_JSON
|
||||
// [3] Orchestrator subtask → node_json
|
||||
let llm = ScriptedLlm::new(vec![
|
||||
ScriptResponse::Text(default_ds_json()),
|
||||
ScriptResponse::Text("<html><body><h1>Hero</h1></body></html>".into()),
|
||||
ScriptResponse::Text(PLAN_JSON.into()),
|
||||
ScriptResponse::Text(node_json("hero")),
|
||||
]);
|
||||
let mut sink = VecDocSink::new();
|
||||
let mut events: Vec<Progress> = Vec::new();
|
||||
let providers = stub_providers();
|
||||
|
||||
let result = execute_visual_ref_orchestration(
|
||||
&mut sink,
|
||||
&llm,
|
||||
&providers,
|
||||
&MockVisualRefProvider,
|
||||
make_request(),
|
||||
&mut |p| events.push(p),
|
||||
&AbortFlag::new(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
|
||||
|
||||
// VisualRefStarted
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, Progress::VisualRefStarted)),
|
||||
"missing VisualRefStarted"
|
||||
);
|
||||
// VisualRefDesignSystem
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, Progress::VisualRefDesignSystem { .. })),
|
||||
"missing VisualRefDesignSystem"
|
||||
);
|
||||
// VisualRefHtmlGenerated
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, Progress::VisualRefHtmlGenerated { .. })),
|
||||
"missing VisualRefHtmlGenerated"
|
||||
);
|
||||
// VisualRefScreenshotReady { skipped: false }
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, Progress::VisualRefScreenshotReady { skipped: false })),
|
||||
"missing VisualRefScreenshotReady{{skipped:false}}"
|
||||
);
|
||||
// No fallback
|
||||
let no_fallback = events
|
||||
.iter()
|
||||
.all(|e| !matches!(e, Progress::VisualRefFallback { .. }));
|
||||
assert!(no_fallback, "unexpected VisualRefFallback in {:?}", events);
|
||||
|
||||
// Orchestrator events present (Planning at minimum)
|
||||
let has_planning = events.iter().any(|e| matches!(e, Progress::Planning));
|
||||
assert!(has_planning, "expected Planning in {:?}", events);
|
||||
}
|
||||
|
||||
// ── Test 3: generate_design_code returns empty → fallback ─────────────────
|
||||
|
||||
/// When `generate_design_code` returns an empty string (LLM fails or returns
|
||||
/// nothing), the function emits `VisualRefFallback` and falls back.
|
||||
#[tokio::test]
|
||||
async fn execute_visual_ref_empty_html_falls_back() {
|
||||
// LLM call order:
|
||||
// [0] generate_design_system → DS JSON
|
||||
// [1] generate_design_code → empty (simulated via LLM error)
|
||||
// [2] Orchestrator planning → PLAN_JSON (fallback runs Orchestrator)
|
||||
// [3] Orchestrator subtask → node_json
|
||||
use crate::types::LlmError;
|
||||
let llm = ScriptedLlm::new(vec![
|
||||
ScriptResponse::Text(default_ds_json()),
|
||||
ScriptResponse::Fail(LlmError {
|
||||
message: "codegen timeout".into(),
|
||||
aborted: false,
|
||||
}),
|
||||
ScriptResponse::Text(PLAN_JSON.into()),
|
||||
ScriptResponse::Text(node_json("hero")),
|
||||
]);
|
||||
let mut sink = VecDocSink::new();
|
||||
let mut events: Vec<Progress> = Vec::new();
|
||||
let providers = stub_providers();
|
||||
|
||||
let result = execute_visual_ref_orchestration(
|
||||
&mut sink,
|
||||
&llm,
|
||||
&providers,
|
||||
&MockVisualRefProvider,
|
||||
make_request(),
|
||||
&mut |p| events.push(p),
|
||||
&AbortFlag::new(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
|
||||
|
||||
let has_fallback = events
|
||||
.iter()
|
||||
.any(|e| matches!(e, Progress::VisualRefFallback { .. }));
|
||||
assert!(
|
||||
has_fallback,
|
||||
"expected VisualRefFallback for empty HTML in {:?}",
|
||||
events
|
||||
);
|
||||
// No VisualRefHtmlGenerated (never got past code gen)
|
||||
let no_html_event = events
|
||||
.iter()
|
||||
.all(|e| !matches!(e, Progress::VisualRefHtmlGenerated { .. }));
|
||||
assert!(
|
||||
no_html_event,
|
||||
"unexpected VisualRefHtmlGenerated when HTML empty"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 4: abort before stage 1 → Err(Aborted) ──────────────────────────
|
||||
|
||||
/// When the abort flag is set before the call, the function returns
|
||||
/// `Err(OrchestratorError::Aborted)`.
|
||||
#[tokio::test]
|
||||
async fn execute_visual_ref_abort_before_stage_1_returns_aborted() {
|
||||
let llm = ScriptedLlm::new(vec![]);
|
||||
let mut sink = VecDocSink::new();
|
||||
let providers = stub_providers();
|
||||
let abort = AbortFlag::new();
|
||||
abort.set(); // fire before any call
|
||||
|
||||
let result = execute_visual_ref_orchestration(
|
||||
&mut sink,
|
||||
&llm,
|
||||
&providers,
|
||||
&NoneVisualRefProvider,
|
||||
make_request(),
|
||||
&mut |_| {},
|
||||
&abort,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(OrchestratorError::Aborted)),
|
||||
"expected Aborted, got {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 5: DesignSystem var_count matches seeded commands ─────────────────
|
||||
|
||||
/// `VisualRefDesignSystem.var_count` equals the number of commands emitted
|
||||
/// by `design_system_to_seed_commands(default)` = 17.
|
||||
#[tokio::test]
|
||||
async fn execute_visual_ref_ds_progress_reports_correct_var_count() {
|
||||
// Use NoneVisualRefProvider to keep test simple (fallback after screenshot)
|
||||
let llm = ScriptedLlm::new(vec![
|
||||
ScriptResponse::Text(default_ds_json()),
|
||||
ScriptResponse::Text("<html><body>page</body></html>".into()),
|
||||
ScriptResponse::Text(PLAN_JSON.into()),
|
||||
ScriptResponse::Text(node_json("hero")),
|
||||
]);
|
||||
let mut sink = VecDocSink::new();
|
||||
let mut events: Vec<Progress> = Vec::new();
|
||||
let providers = stub_providers();
|
||||
|
||||
let _ = execute_visual_ref_orchestration(
|
||||
&mut sink,
|
||||
&llm,
|
||||
&providers,
|
||||
&NoneVisualRefProvider,
|
||||
make_request(),
|
||||
&mut |p| events.push(p),
|
||||
&AbortFlag::new(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Find the VisualRefDesignSystem event and check var_count
|
||||
let ds_event = events.iter().find_map(|e| {
|
||||
if let Progress::VisualRefDesignSystem { var_count } = e {
|
||||
Some(*var_count)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
assert!(
|
||||
ds_event.is_some(),
|
||||
"missing VisualRefDesignSystem event in {:?}",
|
||||
events
|
||||
);
|
||||
// DEFAULT_DESIGN_SYSTEM: 8 palette + 6 spacing + 3 radius = 17
|
||||
assert_eq!(
|
||||
ds_event.unwrap(),
|
||||
17,
|
||||
"expected 17 vars from DEFAULT_DESIGN_SYSTEM"
|
||||
);
|
||||
}
|
||||
|
||||
// ── extract_html_from_response ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_html_fenced_with_doctype_returns_inner() {
|
||||
// Stage 1: ```html ... ``` wraps a full HTML document → return inner (trimmed).
|
||||
let resp =
|
||||
"Sure here's the HTML:\n```html\n<!DOCTYPE html>\n<html><body>Hi</body></html>\n```\nDone.";
|
||||
let html = extract_html_from_response(resp);
|
||||
assert!(html.starts_with("<!DOCTYPE html>"));
|
||||
assert!(html.ends_with("</html>"));
|
||||
assert!(!html.contains("```"));
|
||||
assert!(!html.contains("Done."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_html_fenced_no_doctype_marker_falls_through_to_wrap() {
|
||||
// Stage 1 falls through (inner has no <!DOCTYPE/<html); stage 2-3 don't match;
|
||||
// stage 4 wraps the ORIGINAL trimmed response (fence chars and all).
|
||||
let resp = "```\n<div>not a full doc</div>\n```";
|
||||
let html = extract_html_from_response(resp);
|
||||
assert!(html.starts_with("<!DOCTYPE html>"));
|
||||
assert!(html.contains("<body>```"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_html_starts_with_doctype_returns_trimmed_verbatim() {
|
||||
// Stage 2: trimmed starts with <!DOCTYPE → return as-is.
|
||||
let resp = " <!DOCTYPE html>\n<html><body>X</body></html> ";
|
||||
let html = extract_html_from_response(resp);
|
||||
assert_eq!(html, "<!DOCTYPE html>\n<html><body>X</body></html>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_html_starts_with_html_returns_verbatim() {
|
||||
// Stage 2: trimmed starts with <html → return as-is.
|
||||
let resp = "<html><body>Y</body></html>";
|
||||
let html = extract_html_from_response(resp);
|
||||
assert_eq!(html, "<html><body>Y</body></html>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_html_embedded_doctype_to_close_extracted() {
|
||||
// Stage 3: chatty preamble + trailing text → slice out <!DOCTYPE…</html>.
|
||||
let resp = "Here's the design:\n<!DOCTYPE html>\n<html><body>Hi</body></html>\nLet me know.";
|
||||
let html = extract_html_from_response(resp);
|
||||
assert!(html.starts_with("<!DOCTYPE html>"));
|
||||
assert!(html.ends_with("</html>"));
|
||||
assert!(!html.contains("Let me know"));
|
||||
assert!(!html.contains("Here's the design"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_html_case_insensitive_doctype_via_stage3() {
|
||||
// Lowercase <!doctype doesn't pass case-sensitive stage 2 startsWith;
|
||||
// stage 3 case-insensitive find catches it. Original case preserved.
|
||||
let resp = "<!doctype html>\n<HTML><body>Z</body></HTML>";
|
||||
let html = extract_html_from_response(resp);
|
||||
assert_eq!(html, "<!doctype html>\n<HTML><body>Z</body></HTML>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_html_bare_text_wrapped_in_default_scaffold() {
|
||||
// Stage 4: no fence, no <!DOCTYPE/<html → wrap in default scaffold.
|
||||
let resp = "Hello world.";
|
||||
let html = extract_html_from_response(resp);
|
||||
assert!(html.starts_with("<!DOCTYPE html>"));
|
||||
assert!(html.contains("<body>Hello world.</body>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_html_empty_response_wrapped_as_empty_body() {
|
||||
// Stage 4 with empty trimmed content.
|
||||
let html = extract_html_from_response("");
|
||||
assert!(html.starts_with("<!DOCTYPE html>"));
|
||||
assert!(html.contains("<body></body>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_html_utf8_preamble_does_not_panic_stage3() {
|
||||
// Regression guard: `text.to_lowercase()` would change byte length for
|
||||
// some non-ASCII chars (e.g. Turkish İ → i\u{0307}, German ẞ in some
|
||||
// locales), making indices derived from the lowercased string unsafe
|
||||
// on the original UTF-8 text. The ASCII-byte-scan implementation lets
|
||||
// any UTF-8 preamble pass through cleanly. Asserts no panic + correct
|
||||
// slice extraction.
|
||||
let resp = "解释一下:İ\n<!DOCTYPE html>\n<html><body>UTF-8 OK</body></html>\n注释";
|
||||
let html = extract_html_from_response(resp);
|
||||
assert!(html.starts_with("<!DOCTYPE html>"));
|
||||
assert!(html.ends_with("</html>"));
|
||||
assert!(!html.contains("解释一下"));
|
||||
assert!(!html.contains("注释"));
|
||||
assert!(!html.contains("İ"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_html_utf8_inside_body_preserved() {
|
||||
// Multi-byte chars INSIDE the extracted HTML must round-trip intact.
|
||||
let resp = "Here:\n<!DOCTYPE html>\n<html><body>你好 İstanbul</body></html>\n done";
|
||||
let html = extract_html_from_response(resp);
|
||||
assert!(html.contains("你好"));
|
||||
assert!(html.contains("İstanbul"));
|
||||
assert!(!html.contains("Here:"));
|
||||
assert!(!html.contains("done"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_html_mixed_case_doctype_with_utf8_preamble() {
|
||||
// Combine case-insensitivity + UTF-8 preamble. ASCII-byte scan handles
|
||||
// both without to_lowercase() byte-shift.
|
||||
let resp = "Note: ß and İ are tricky.\n<!DocType html>\n<HTML><body>OK</body></HTML>";
|
||||
let html = extract_html_from_response(resp);
|
||||
assert_eq!(html, "<!DocType html>\n<HTML><body>OK</body></HTML>");
|
||||
}
|
||||
Loading…
Reference in a new issue