Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions apps/cli/ai/generation/generators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import fs from 'fs';
import path from 'path';
import { getSkillsRoot } from 'cli/ai/skills';
import { completeText, extractJson, stripCodeFences } from './llm';
import { parseManifest, type SiteManifest } from './manifest';

/**
* Loads and runs the generator prompt fragments bundled under
* `skills/site-generator/generators/`. Each fragment is a system-prompt body
* adapted from a Telex generator; the tool appends the site spec + chosen
* design + a per-call task. `_shared` is prepended to every call.
*/

function generatorsDir(): string {
return path.join( getSkillsRoot(), 'site-generator', 'generators' );
}

const fragmentCache = new Map< string, string >();

function loadFragment( name: string ): string {
const cached = fragmentCache.get( name );
if ( cached !== undefined ) {
return cached;
}
const file = path.join( generatorsDir(), `${ name }.md` );
if ( ! fs.existsSync( file ) ) {
throw new Error(
`Generator prompt fragment not found: ${ name } (${ file }). Is the wordpress-site-generator bundle installed?`
);
}
const content = fs.readFileSync( file, 'utf8' );
fragmentCache.set( name, content );
return content;
}

export interface RunGeneratorInput {
name: string;
specJson: string;
design?: string;
task?: string;
maxTokens?: number;
temperature?: number;
}

export async function runGenerator( input: RunGeneratorInput ): Promise< string > {
const shared = loadFragment( '_shared' );
const fragment = loadFragment( input.name );
const system = `${ shared }\n\n----------\n\n${ fragment }`;

let user = `SITE SPEC (JSON):\n${ input.specJson }`;
if ( input.design ) {
user += `\n\nCHOSEN DESIGN DIRECTION:\n${ input.design }`;
}
if ( input.task ) {
user += `\n\nTASK:\n${ input.task }`;
}

return stripCodeFences(
await completeText( {
system,
user,
maxTokens: input.maxTokens,
temperature: input.temperature,
} )
);
}

export async function runManifest( specJson: string ): Promise< SiteManifest > {
// Generous budget: the manifest carries a cinematic composition brief per
// page, so a small cap truncates the JSON mid-stream (no closing fence/brace)
// and the parse fails.
const raw = await runGenerator( {
name: 'manifest',
specJson,
maxTokens: 16_000,
temperature: 0.2,
} );
return parseManifest( raw );
}

export interface GeneratedBlockFiles {
files: Record< string, string >;
}

export async function runBlockGenerator(
specJson: string,
blockTask: string
): Promise< GeneratedBlockFiles > {
const raw = await runGenerator( {
name: 'block',
specJson,
task: blockTask,
maxTokens: 16_000,
temperature: 0.3,
} );
let data: unknown;
try {
data = JSON.parse( extractJson( raw ) );
} catch ( error ) {
throw new Error(
`Block generator did not return valid JSON: ${
error instanceof Error ? error.message : String( error )
}`
);
}
const filesValue = ( data as { files?: unknown } )?.files;
if ( ! filesValue || typeof filesValue !== 'object' ) {
throw new Error( 'Block generator JSON is missing a "files" object.' );
}
const files: Record< string, string > = {};
for ( const [ key, value ] of Object.entries( filesValue as Record< string, unknown > ) ) {
if ( typeof value === 'string' ) {
files[ key ] = value;
}
}
if ( ! Object.keys( files ).length ) {
throw new Error( 'Block generator produced no files.' );
}
return { files };
}
131 changes: 131 additions & 0 deletions apps/cli/ai/generation/images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { aspectFromHint, generateImageBytes, type ImageAspectRatio } from './wpcom-image';

/**
* Resolves the `AI_IMAGE:` placeholder convention. Generated markup uses
* <img src="..." alt="AI_IMAGE: <description> | <style> | <aspect>">
* so imagery can be filled in after structure is generated. These helpers find
* those placeholders, generate the image, hand the bytes to a caller-supplied
* persistence strategy (theme assets dir, or the media uploads dir), and
* rewrite the tag's `src`/`alt` in place.
*/

export interface AiImageMatch {
tag: string;
description: string;
style?: string;
aspect?: string;
}

const IMG_TAG_RE = /<img\b[^>]*>/gi;

export function getAttr( tag: string, name: string ): string | undefined {
const match =
tag.match( new RegExp( `\\b${ name }\\s*=\\s*"([^"]*)"`, 'i' ) ) ??
tag.match( new RegExp( `\\b${ name }\\s*=\\s*'([^']*)'`, 'i' ) );
return match ? match[ 1 ] : undefined;
}

function setAttr( tag: string, name: string, value: string ): string {
const escaped = value.replace( /&/g, '&amp;' ).replace( /"/g, '&quot;' );
const re = new RegExp( `\\s${ name }\\s*=\\s*("[^"]*"|'[^']*')`, 'i' );
if ( re.test( tag ) ) {
return tag.replace( re, ` ${ name }="${ escaped }"` );
}
return tag.replace( /(\s*\/?>)\s*$/, ` ${ name }="${ escaped }"$1` );
}

export function parseAiImageAlt(
alt: string
): { description: string; style?: string; aspect?: string } | null {
const match = alt.match( /^\s*AI_IMAGE:\s*(.+)$/i );
if ( ! match ) {
return null;
}
const parts = match[ 1 ].split( '|' ).map( ( s ) => s.trim() );
return {
description: parts[ 0 ] || 'abstract textured background',
style: parts[ 1 ],
aspect: parts[ 2 ],
};
}

export function findAiImages( html: string ): AiImageMatch[] {
const out: AiImageMatch[] = [];
for ( const tag of html.match( IMG_TAG_RE ) ?? [] ) {
const alt = getAttr( tag, 'alt' );
if ( ! alt ) {
continue;
}
const parsed = parseAiImageAlt( alt );
if ( parsed ) {
out.push( { tag, ...parsed } );
}
}
return out;
}

// Removes AI_IMAGE placeholder <img> tags (used when imagery can't be
// generated) while leaving real <img> tags intact, so a page renders as
// intentional sections rather than broken images.
export function stripAiImagePlaceholders( html: string ): string {
return html.replace( /<img\b[^>]*\bAI_IMAGE:[^>]*>/gi, '' );
}

export function buildImagePrompt( description: string, style?: string ): string {
const styleClause = style ? ` Style: ${ style }.` : '';
return `${ description }.${ styleClause } High quality, professional, clean composition. No text, no lettering, no watermark, no logo.`;
}

export interface PersistImageContext {
description: string;
aspect: ImageAspectRatio;
index: number;
}

export type PersistImage = ( bytes: Buffer, ctx: PersistImageContext ) => Promise< string | null >;

export interface ImageResolution {
html: string;
generated: number;
failed: number;
total: number;
}

/**
* Best-effort: any image that fails to generate or persist is left as-is (the
* placeholder stays) so a single failure never aborts the whole build.
*/
export async function resolveAiImagesInHtml(
html: string,
persist: PersistImage
): Promise< ImageResolution > {
const matches = findAiImages( html );
let out = html;
let generated = 0;
let failed = 0;
let index = 0;

for ( const match of matches ) {
index++;
try {
const aspect = aspectFromHint( match.aspect );
const bytes = await generateImageBytes(
buildImagePrompt( match.description, match.style ),
aspect
);
const url = await persist( bytes, { description: match.description, aspect, index } );
if ( ! url ) {
failed++;
continue;
}
let newTag = setAttr( match.tag, 'src', url );
newTag = setAttr( newTag, 'alt', match.description );
out = out.replace( match.tag, newTag );
generated++;
} catch {
failed++;
}
}

return { html: out, generated, failed, total: matches.length };
}
Loading