IA generativa aplicada a los sistemas de diseño

Foto Abel Toledano Andrés

Abel Toledano Andrés Seguir

Tiempo de lectura: 13 min

Los modelos de lenguaje grandes (LLM) son una herramienta poderosa para generar código, pero también plantean una serie de retos. En esta entrada del blog, exploraremos cómo se pueden aplicar los LLM a los sistemas de diseño para ayudar a los desarrolladores y diseñadores a generar interfaces de usuario de manera eficiente.

Definición del objetivo

Nuestro objetivo es crear una herramienta que genere código de interfaz de usuario basándose en una descripción textual. La interfaz de usuario generada debe construirse utilizando componentes de nuestro sistema de diseño. En este ejemplo, utilizaremos componentes React, pero el mismo concepto se aplica a otros marcos de interfaz de usuario declarativos. Lo ideal sería algo como esto:

const code = generateUI('Create a login screen');
console.log(code);

The expected output should be JSX code that correctly implements a login screen using our Design System components, like this:

<ScreenLayout>
<Navbar>Login</Navbar>
<Form id="loginForm">
<Stack space={16}>
<TextField label="Email" name="email" />
<PasswordField label="Password" name="password" />
</Stack>
</Form>
<FixedFooter>
<Button submit form="loginForm">
Log in
</Button>
</FixedFooter>
</ScreenLayout>

Aunque los LLM son bastante competentes a la hora de generar código React gracias a su entrenamiento con bases de código disponibles públicamente, no comprenden de forma inherente nuestro sistema de diseño específico y sus componentes. Esto significa que debemos proporcionar explícitamente esta información en la entrada del LLM.

Comencemos con un script sencillo que se conecta a la API de OpenAI utilizando el SDK de IA de Vercel y solicita al modelo:

import {generateText} from 'ai';
import {openai} from '@ai-sdk/openai';

const model = openai('gpt-4o-mini');

const {text} = await generateText({
model,
prompt: 'Create a login screen',
});

console.log(text);

Ahora, refinemos el mensaje añadiendo información sobre los componentes de nuestro sistema de diseño. En lugar de pasar un simple mensaje de cadena, utilizamos una matriz de mensajes. Los mensajes del sistema son priorizados por la IA, lo que los convierte en un lugar ideal para definir el comportamiento y proporcionar detalles sobre los componentes:

const {text} = await generateText({
model,
messages: [
{
role: 'system',
content: `
You are a helpful assistant that generates UIs using a Design System
The Design System components are:
- Button
- TextField
- PasswordField
- Checkbox
- ScreenLayout
- Navbar
- Form
- FixedFooter
- Stack
`,
},
{
role: 'user',
content: 'Create a login screen',
},
],
});

Mejora de la precisión con ingeniería de indicaciones

Incluso con esta configuración, el LLM podría producir texto superfluo junto con el código JSX o generar propiedades de componentes no válidas. Para mitigar estos problemas, refinamos el mensaje del sistema:

Con unos pocos cambios sencillos podemos obtener resultados mucho mejores. Pero podemos ir más allá.

Salida estructurada con JSON

Un enfoque más sólido consiste en indicar al LLM que genere JSON estructurado en lugar de JSX sin procesar. Esto reduce los errores y nos permite transformar el resultado en JSX mediante programación.

Una interfaz de usuario se puede representar como un árbol JSON. Por ejemplo:

{
"component": "ScreenLayout",
"props": {
"children": [
{"component": "Navbar", "props": {"children": "Login"}},
{"component": "Form", "props": {"id": "loginForm", "children": []}}
]
}
}

El SDK de IA de Vercel tiene soporte integrado para datos estructurados con la función generateObject, que recibe un esquema que se puede crear con zod. Sin embargo, aquí estamos siguiendo un enfoque diferente, ya que seguimos utilizando generateText y pasando una definición de TypeScript al LLM a través de un prompt. La ventaja de este enfoque sobre generateObject es que tendremos un control más preciso. Por ejemplo, podemos utilizar la misma definición TypeScript para validar la salida del LLM y encontrar errores, como veremos más adelante.

Comencemos por definir el esquema TypeScript que representa nuestro sistema de diseño de ejemplo:

type ComponentPropsMap = {
Button: {
submit?: boolean;
form?: string;
children: string;
};
TextField: {
label: string;
name: string;
defaultValue?: string;
};
PasswordField: {
label: string;
name: string;
defaultValue?: string;
};
Checkbox: {
label: string;
name: string;
defaultChecked?: boolean;
};
ScreenLayout: {
children: Array<Element>;
};
Navbar: {
children: string;
};
Form: {
id: string;
children: Array<Element>;
};
FixedFooter: {
children: Array<Element>;
};
Stack: {
space: number;
children: Array<Element>;
};
};

type ComponentName = keyof ComponentPropsMap;

type Element = {
[CN in ComponentName]: {component: CN; props: ComponentPropsMap[CN]};
}[ComponentName];
export type AiResponse = {component: string; props: unknown} & Element;

A continuación, pasa esta definición como parte del mensaje del sistema:

const typeDefinition = readFileSync(path.join(__dirname, 'type-definition.ts'), 'utf-8');

const systemPrompt = `
You are a helpful assistant that generates UIs using a Design System.
Output should be only JSX code, in plain text, without markdown.
The generated UIs should use the components of a Design System.
Output should be a JSON object of type AiResponse, following this TypeScript definition:

${typeDefinition}
`;

Después, tenemos que analizar la salida JSON:

const parseJSONResponse = (text: string) => {
const jsonStart = text.indexOf('{');
const jsonEnd = text.lastIndexOf('}');

if (jsonStart === -1 || jsonEnd === -1) {
return {success: false, error: 'Invalid JSON'} as const;
}

try {
const jsonText = text.slice(jsonStart, jsonEnd + 1);
const parsedJson = JSON.parse(jsonText) as AiResponse;
return {success: true, data: parsedJson} as const;
} catch (err) {
return {success: false, error: 'Invalid JSON'} as const;
}
};

El siguiente paso es transformar el objeto JSON en código JSX. Pero dejaremos esta tarea para el lector.

Implementación de un bucle de retroalimentación

Con la ingeniería de prompts que hemos realizado, podemos estar bastante seguros de que el LLM generará el código que queremos, pero aún así existe una buena probabilidad de que genere algún código no válido. Podemos utilizar la técnica del bucle de retroalimentación para mejorar los resultados. Podemos verificar el código generado por el LLM con nuestra definición de TypeScript y comprobar si es válido. Si no lo es, proporcionamos el error de TypeScript como retroalimentación al LLM y le pedimos que genere una nueva versión del código. Podemos hacer esto en un bucle hasta obtener un código válido.

const generateUI = async (userPrompt: string) => {
const messages: Array<{role: 'system' | 'user' | 'assistant'; content: string}> = [
{
role: 'system',
content: systemPrompt,
},
{
role: 'user',
content: userPrompt,
},
];

let isValid = false;
let retries = 3;

while (!isValid && retries > 0) {
const {text} = await generateText({model, messages});
messages.push({
role: 'assistant',
content: text,
});

const parseResponse = parseJSONResponse(text);
if (!parseResponse.success) {
messages.push({
role: 'user',
content: 'The provided code is invalid JSON, please fix it',
});
retries--;
continue;
}

const tsResponse = validateResponse(parseResponse.data, typeDefinition);
if (!tsResponse.success) {
messages.push({
role: 'user',
content: `The provided code has the following errors: ${tsResponse.error}`,
});
retries--;
continue;
}

isValid = true;
}

if (isValid) {
return {success: true, data: jsonToJsx(messages[messages.length - 1]?.content)};
} else {
return {success: false, error: 'Failed to generate valid code'};
}
};

const ui = await generateUI('Create a login screen');

Mejorar el contexto con documentación y ejemplos

Para proporcionar al LLM más contexto sobre los componentes del sistema de diseño, es recomendable incluir una pequeña documentación sobre los atributos de los componentes.

type ComponentPropsMap = {
Button: {
/**
* If true, the button will submit the form if it's inside one
*/
submit?: boolean;
/**
* The id of the form the button should submit
*/
form?: string;
children: string;
};

// ...

/**
* Use this component to add vertical space between its children
*/
Stack: {
/**
* The space between children
*/
space: number;
children: Array<Element>;
};
};

También puedes aplicar las restricciones del sistema de diseño en las definiciones de tipo. Por ejemplo, si queremos que el espaciado vertical sea un múltiplo de 8, podemos hacer lo siguiente:

type ComponentPropsMap = {
// ...

/**
* Use this component to add vertical space between its children
*/
Stack: {
/**
* The space between children
*/
space: 8 | 16 | 24 | 32 | 40 | 48 | 56 | 64 | 72 | 80;
children: Array<Element>;
};
};

También podemos incluir ejemplos de cómo se deben utilizar los componentes:

type ComponentPropsMap = {
/**
* use Form component to group form elements
*
* @example
* {
* component: 'Form',
* props: {
* id: 'myFormId',
* children: [
* {
* component: 'Stack',
* props: {
* space: 16,
* children: [
*
* // Form fields here
*
* ]
* }
* },
* ]
* }
* }
*/
Form: {
id: string;
children: Array<Element>;
};
};

Todo este contexto ayudará a optimizar la precisión del LLM.

Técnicas avanzadas: ajuste fino y RAG

Con las técnicas que hemos visto hasta ahora, podemos obtener resultados bastante buenos, pero aquí hay algunas ideas para mejorar:

  • Generación aumentada por recuperación (RAG): RAG combina la potencia de los LLM con la precisión de los motores de búsqueda. Al utilizar RAG, puede buscar ejemplos de interfaces de usuario creadas con su sistema de diseño y utilizarlas como entrada para el LLM, lo que da como resultado salidas más precisas.
  • Ajuste fino del modelo: el ajuste fino le permite crear un nuevo modelo basado en uno existente, entrenado con un conjunto específico de ejemplos de su dominio. Si dispone de un conjunto de datos de interfaces de usuario creadas con su sistema de diseño, puede ajustar el LLM con él. Esto alineará el LLM más estrechamente con su sistema de diseño y mejorará la calidad del código generado. La ventaja del ajuste es que permite entrenar con más ejemplos de los que caben en una solicitud, lo que reduce el uso de tokens y la latencia en las solicitudes posteriores al modelo ajustado.

Pista adicional: generar interfaces de usuario utilizando una imagen como entrada

Con los LLM multimodales, en lugar de utilizar texto como entrada («crear una pantalla de inicio de sesión»), se puede utilizar una imagen. Solo necesitamos unos pocos cambios en los mensajes de solicitud:

const messages = [
{
role: 'system',
content: systemPrompt,
},
{
role: 'user',
content: [
{type: 'text', text: 'Create a UI based on this image:'},
{
type: 'image',
image: readFileSync(path.join(__dirname, 'login.png')),
},
],
},
];

Ejemplo real

En Telefónica, estamos experimentando con todas estas técnicas en nuestro sistema de diseño (Mística). Lo estamos integrando en nuestras herramientas de prototipado con muy buenos resultados. Estos son algunos ejemplos:

Sugerencia de texto:

Build a streaming service platform called Movistar Plus+.
Show a list of movies, a list of shows, and a list to continue watching.
Add a Hero with a promoted show

Código de salida:

Renderizado:

Y utilizando una imagen como un prompt:

Conclusión

Hemos visto cómo se pueden aprovechar los LLM para generar interfaces de usuario que se ajusten a nuestro sistema de diseño. Aunque existen retos, refinar las indicaciones, aplicar una salida estructurada e implementar mecanismos de validación puede mejorar enormemente la fiabilidad. Definir mensajes claros del sistema, restringir las salidas utilizando esquemas TypeScript e iterar a través de bucles de retroalimentación ayuda a mantener el código generado preciso y coherente. Al aplicar estas técnicas, los desarrolladores o diseñadores pueden minimizar las correcciones manuales y dedicar más tiempo a crear interfaces de alta calidad de manera eficiente.

Compártelo en tus redes sociales

Medios de comunicación

Contacta con nuestro departamento de comunicación o solicita material adicional.