Estaban modelados desde la fase 1 y no existían en el panel: `modelo_ia`,
`animo` y `prompt`, cero filas las tres y ni siquiera una línea en la portada.
Y la primera versión no respetaba el modelo. `modelo_ia.esquema` dice esto:
> JSON Schema con la forma que toman los prompts de este modelo. Se validan
> contra él al guardar, y el panel puede pintar el formulario a partir de él sin
> una pantalla por cada IA.
Había puesto un `textarea` único guardando `{ texto }` y forzado
`estado: 'publicado'` al crear, cuando el modelo dice borrador por defecto. Las
dos cosas van ahora como estaban escritas: el formulario del prompt sale del
esquema de su modelo —un campo por propiedad, con su etiqueta, su ayuda, su
lista cerrada y su rango— y lo que se guarda se comprueba contra él.
`$lib/esquema-prompt` es el lector, y dice en su cabecera qué subconjunto
entiende: objeto en la raíz con `properties` y `required`; por campo string,
number, integer o boolean, más `enum`, `maxLength`, `minimum`, `maximum`,
`title` y `description`. Lo que no esté se ignora; un esquema roto se rechaza al
guardarlo, no al usarlo. Un modelo sin esquema sigue funcionando con un bloque
de texto.
Y `x-order`, que no estaba previsto y hace falta: la base guarda el esquema como
`jsonb` y ahí Postgres reordena las claves por longitud, así que el campo
obligatorio salía el último del formulario. Se vio en la captura.
Los prompts van **dentro de la ficha del género**, no en una pestaña aparte: un
prompt es de ese género y escribir la ficha y sacar la música son la misma
sesión. Fuera de su formulario, porque cada prompt se guarda por su cuenta.
Los modelos se dan de alta a mano, con su versión: Suno v4 y v3.5 son dos
modelos, y una lista sembrada en un script envejecería en un mes. Los nueve
ánimos de la GEMS sí se siembran, con `npm run db:animos`.
Comprobado contra el panel de verdad: esquema aceptado y esquema roto
rechazado, formulario pintado desde el esquema —lista cerrada, número con
rango, letra en bloque—, campo obligatorio reclamado, un solo preferido por
combinación y el preferido el primero de la lista.
Falta `prompt.ejemplo_id`, el audio del resultado: pide los medios, que todavía
no se editan aquí.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
parent
3c09592e11
commit
1d287e3788
@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { camposDe, comoTexto, revisarContra, revisarEsquema } from './esquema-prompt';
|
||||
|
||||
const ESQUEMA = {
|
||||
type: 'object',
|
||||
required: ['estilo'],
|
||||
'x-order': ['estilo', 'titulo', 'letra', 'voz', 'peso', 'instrumental'],
|
||||
properties: {
|
||||
instrumental: { type: 'boolean', title: 'Sin voz' },
|
||||
peso: { type: 'number', title: 'Influencia', minimum: 0, maximum: 1 },
|
||||
voz: { type: 'string', title: 'Voz', enum: ['masculina', 'femenina'] },
|
||||
letra: { type: 'string', title: 'Letra' },
|
||||
titulo: { type: 'string', title: 'Título', maxLength: 60 },
|
||||
estilo: { type: 'string', title: 'Estilo', maxLength: 200, description: 'Con comas.' }
|
||||
}
|
||||
};
|
||||
|
||||
describe('camposDe', () => {
|
||||
it('lee los campos con su etiqueta, su ayuda y si hacen falta', () => {
|
||||
const campos = camposDe(ESQUEMA);
|
||||
const estilo = campos.find((c) => c.nombre === 'estilo')!;
|
||||
|
||||
expect(estilo.etiqueta).toBe('Estilo');
|
||||
expect(estilo.ayuda).toBe('Con comas.');
|
||||
expect(estilo.requerido).toBe(true);
|
||||
expect(estilo.maxLength).toBe(200);
|
||||
});
|
||||
|
||||
it('respeta el orden de «x-order»', () => {
|
||||
// Y hace falta: la base guarda el esquema como `jsonb`, que reordena las
|
||||
// claves por longitud. Sin esto, el campo obligatorio salía el último.
|
||||
expect(camposDe(ESQUEMA).map((c) => c.nombre)).toEqual([
|
||||
'estilo',
|
||||
'titulo',
|
||||
'letra',
|
||||
'voz',
|
||||
'peso',
|
||||
'instrumental'
|
||||
]);
|
||||
});
|
||||
|
||||
it('pinta en varias líneas lo que no cabe cómodo en una', () => {
|
||||
const campos = camposDe(ESQUEMA);
|
||||
const largo = (nombre: string) => campos.find((c) => c.nombre === nombre)!.largo;
|
||||
|
||||
// Sin `maxLength` puede ser una letra entera.
|
||||
expect(largo('letra')).toBe(true);
|
||||
// Doscientos caracteres caben en un `input`, pero no se ven: mejor bloque.
|
||||
expect(largo('estilo')).toBe(true);
|
||||
// Sesenta sí: un título es una línea.
|
||||
expect(largo('titulo')).toBe(false);
|
||||
// Y una lista cerrada nunca es un bloque de texto.
|
||||
expect(largo('voz')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignora lo que no entiende en vez de fallar', () => {
|
||||
const raro = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
bueno: { type: 'string' },
|
||||
anidado: { type: 'object', properties: {} },
|
||||
lista: { type: 'array' },
|
||||
sinTipo: { title: 'Sin tipo' }
|
||||
}
|
||||
};
|
||||
expect(camposDe(raro).map((c) => c.nombre)).toEqual(['bueno']);
|
||||
});
|
||||
|
||||
it('sin esquema no hay campos, que es lo que deja el bloque de texto', () => {
|
||||
expect(camposDe(null)).toEqual([]);
|
||||
expect(camposDe({})).toEqual([]);
|
||||
expect(camposDe('lo que sea')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revisarEsquema', () => {
|
||||
it('acepta uno bueno, y el vacío', () => {
|
||||
expect(revisarEsquema(JSON.stringify(ESQUEMA))).toBeNull();
|
||||
expect(revisarEsquema(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('rechaza lo que no es JSON', () => {
|
||||
expect(revisarEsquema('{ esto no')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rechaza uno sin campos aprovechables', () => {
|
||||
// Guardarlo dejaría el formulario de ese modelo sin nada que pintar, y el
|
||||
// fallo aparecería lejos de donde se cometió.
|
||||
expect(revisarEsquema('{"type":"object","properties":{}}')).toBeTruthy();
|
||||
expect(revisarEsquema('{"type":"array"}')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('revisarContra', () => {
|
||||
const campos = camposDe(ESQUEMA);
|
||||
|
||||
it('deja pasar lo que cuadra', () => {
|
||||
expect(revisarContra(campos, { estilo: 'bolero lento', peso: 0.7, voz: 'masculina' })).toEqual(
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('reclama lo obligatorio', () => {
|
||||
expect(revisarContra(campos, {}).estilo).toBeTruthy();
|
||||
});
|
||||
|
||||
it('respeta el largo máximo', () => {
|
||||
expect(revisarContra(campos, { estilo: 'x'.repeat(201) }).estilo).toBeTruthy();
|
||||
});
|
||||
|
||||
it('y el rango de los números', () => {
|
||||
expect(revisarContra(campos, { estilo: 'x', peso: 2 }).peso).toBeTruthy();
|
||||
expect(revisarContra(campos, { estilo: 'x', peso: -1 }).peso).toBeTruthy();
|
||||
});
|
||||
|
||||
it('y las opciones cerradas', () => {
|
||||
expect(revisarContra(campos, { estilo: 'x', voz: 'silbando' }).voz).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('comoTexto', () => {
|
||||
it('enseña una línea por campo con valor', () => {
|
||||
const texto = comoTexto(camposDe(ESQUEMA), { estilo: 'bolero', voz: 'masculina', letra: '' });
|
||||
expect(texto).toBe('Estilo: bolero\nVoz: masculina');
|
||||
});
|
||||
|
||||
it('sin esquema, el contenido es un bloque y se enseña tal cual', () => {
|
||||
expect(comoTexto([], { texto: 'un prompt suelto' })).toBe('un prompt suelto');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,220 @@
|
||||
/**
|
||||
* El esquema de los prompts de un modelo.
|
||||
*
|
||||
* `modelo_ia.esquema` guarda un JSON Schema con la forma que toman los prompts
|
||||
* de ese modelo, y el modelo de datos le da dos trabajos: **validar al guardar**
|
||||
* y **pintar el formulario**, para que añadir una IA nueva no cueste una
|
||||
* pantalla. Aquí están los dos.
|
||||
*
|
||||
* ## Qué subconjunto se entiende
|
||||
*
|
||||
* No es un validador de JSON Schema completo, y decirlo importa: un prompt de
|
||||
* Suno son cuatro campos —estilo, letra, exclusiones, un peso—, no un documento
|
||||
* anidado. Se entiende esto y nada más:
|
||||
*
|
||||
* - `type: "object"` en la raíz, con `properties` y `required`.
|
||||
* - Por campo, `type` `string`, `number`, `integer` o `boolean`.
|
||||
* - `enum` para elegir de una lista.
|
||||
* - `maxLength` en las cadenas; `minimum` y `maximum` en los números.
|
||||
* - `title` como etiqueta y `description` como ayuda, que son las dos claves
|
||||
* estándar para eso.
|
||||
*
|
||||
* Lo que no esté aquí —anidamiento, `oneOf`, `pattern`, `$ref`— se ignora en
|
||||
* vez de fallar: un esquema con algo de más sigue sirviendo para lo que sí
|
||||
* entiende. Lo que **no** se ignora es un esquema roto: ese se rechaza al
|
||||
* guardarlo, no al usarlo.
|
||||
*
|
||||
* Una cadena se pinta en varias líneas si no dice cuánto ocupa o si admite más
|
||||
* de ciento veinte caracteres. Una letra de canción no cabe en un `input`.
|
||||
*/
|
||||
|
||||
export type TipoDeCampo = 'string' | 'number' | 'integer' | 'boolean';
|
||||
|
||||
export interface CampoDeEsquema {
|
||||
nombre: string;
|
||||
tipo: TipoDeCampo;
|
||||
/** Lo que se lee encima del campo. */
|
||||
etiqueta: string;
|
||||
ayuda: string | null;
|
||||
requerido: boolean;
|
||||
opciones: string[] | null;
|
||||
maxLength: number | null;
|
||||
minimo: number | null;
|
||||
maximo: number | null;
|
||||
/** Si se pinta como `textarea` en vez de como `input`. */
|
||||
largo: boolean;
|
||||
}
|
||||
|
||||
/** A partir de aquí una cadena deja de caber cómoda en una línea. */
|
||||
const LARGO_DESDE = 120;
|
||||
|
||||
const esObjeto = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
|
||||
const TIPOS: TipoDeCampo[] = ['string', 'number', 'integer', 'boolean'];
|
||||
|
||||
/**
|
||||
* Los campos declarados por un esquema, en el orden que pida `x-order`.
|
||||
*
|
||||
* Hace falta decir el orden porque `jsonb` **no guarda el de las claves**:
|
||||
* Postgres las reordena por longitud y luego alfabéticamente, así que un
|
||||
* esquema escrito como estilo · letra · voz vuelve como voz · letra · estilo.
|
||||
* Se vio en pantalla, con el campo obligatorio al final del formulario.
|
||||
*
|
||||
* Sin `x-order` se respeta lo que devuelva la base, que es predecible aunque no
|
||||
* sea lo que se escribió. `x-` es el prefijo convenido para lo que un esquema
|
||||
* añade por su cuenta.
|
||||
*
|
||||
* Devuelve una lista vacía si el esquema no dice nada aprovechable, que es lo
|
||||
* que hace que un modelo sin esquema siga funcionando con un campo de texto.
|
||||
*/
|
||||
export function camposDe(esquema: unknown): CampoDeEsquema[] {
|
||||
if (!esObjeto(esquema) || !esObjeto(esquema.properties)) return [];
|
||||
|
||||
const requeridos = new Set(
|
||||
Array.isArray(esquema.required) ? esquema.required.filter((r) => typeof r === 'string') : []
|
||||
);
|
||||
|
||||
const campos: CampoDeEsquema[] = [];
|
||||
|
||||
for (const [nombre, bruto] of Object.entries(esquema.properties)) {
|
||||
if (!esObjeto(bruto)) continue;
|
||||
|
||||
const tipo = TIPOS.find((t) => t === bruto.type);
|
||||
if (!tipo) continue;
|
||||
|
||||
const maxLength = typeof bruto.maxLength === 'number' ? bruto.maxLength : null;
|
||||
const opciones = Array.isArray(bruto.enum)
|
||||
? bruto.enum.filter((o): o is string => typeof o === 'string')
|
||||
: null;
|
||||
|
||||
campos.push({
|
||||
nombre,
|
||||
tipo,
|
||||
etiqueta: typeof bruto.title === 'string' && bruto.title.trim() !== '' ? bruto.title : nombre,
|
||||
ayuda: typeof bruto.description === 'string' ? bruto.description : null,
|
||||
requerido: requeridos.has(nombre),
|
||||
opciones: opciones && opciones.length > 0 ? opciones : null,
|
||||
maxLength,
|
||||
minimo: typeof bruto.minimum === 'number' ? bruto.minimum : null,
|
||||
maximo: typeof bruto.maximum === 'number' ? bruto.maximum : null,
|
||||
largo: tipo === 'string' && !opciones && (maxLength === null || maxLength > LARGO_DESDE)
|
||||
});
|
||||
}
|
||||
|
||||
const orden = esquema['x-order'];
|
||||
if (Array.isArray(orden)) {
|
||||
const puesto = new Map(orden.map((n, i) => [String(n), i]));
|
||||
// Lo que `x-order` no nombra va detrás, en el orden que traía.
|
||||
const alFinal = campos.length;
|
||||
campos.sort((a, b) => (puesto.get(a.nombre) ?? alFinal) - (puesto.get(b.nombre) ?? alFinal));
|
||||
}
|
||||
|
||||
return campos;
|
||||
}
|
||||
|
||||
/** Qué está mal en el propio esquema. Vacío si se puede usar. */
|
||||
export function revisarEsquema(texto: string): string | null {
|
||||
if (texto.trim() === '') return null;
|
||||
|
||||
let leido: unknown;
|
||||
try {
|
||||
leido = JSON.parse(texto);
|
||||
} catch {
|
||||
return 'Eso no es JSON válido.';
|
||||
}
|
||||
|
||||
if (!esObjeto(leido)) return 'El esquema tiene que ser un objeto.';
|
||||
if (leido.type !== undefined && leido.type !== 'object') {
|
||||
return 'La raíz del esquema tiene que ser de tipo «object».';
|
||||
}
|
||||
if (!esObjeto(leido.properties)) {
|
||||
return 'Falta «properties»: es donde se declaran los campos del prompt.';
|
||||
}
|
||||
if (camposDe(leido).length === 0) {
|
||||
return 'Ningún campo es de un tipo que se entienda: string, number, integer o boolean.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Lo que se guarda: un valor por campo, ya con su tipo. */
|
||||
export type ContenidoDePrompt = Record<string, string | number | boolean>;
|
||||
|
||||
/**
|
||||
* Comprueba los valores contra el esquema.
|
||||
*
|
||||
* Devuelve un mensaje por campo. Un campo que no está en el esquema no se
|
||||
* comprueba y tampoco se guarda: el formulario solo pinta los declarados, y lo
|
||||
* que llegue de más viene de alguien trasteando.
|
||||
*/
|
||||
export function revisarContra(
|
||||
campos: CampoDeEsquema[],
|
||||
valores: ContenidoDePrompt
|
||||
): Record<string, string> {
|
||||
const errores: Record<string, string> = {};
|
||||
|
||||
for (const campo of campos) {
|
||||
const valor = valores[campo.nombre];
|
||||
const vacio = valor === undefined || valor === '' || valor === null;
|
||||
|
||||
if (campo.requerido && vacio && campo.tipo !== 'boolean') {
|
||||
errores[campo.nombre] = `«${campo.etiqueta}» hace falta.`;
|
||||
continue;
|
||||
}
|
||||
if (vacio) continue;
|
||||
|
||||
if (campo.tipo === 'string') {
|
||||
const texto = String(valor);
|
||||
if (campo.opciones && !campo.opciones.includes(texto)) {
|
||||
errores[campo.nombre] =
|
||||
`«${campo.etiqueta}» tiene que ser uno de: ${campo.opciones.join(', ')}.`;
|
||||
} else if (campo.maxLength !== null && texto.length > campo.maxLength) {
|
||||
errores[campo.nombre] =
|
||||
`«${campo.etiqueta}» admite ${campo.maxLength} caracteres y lleva ${texto.length}.`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (campo.tipo === 'number' || campo.tipo === 'integer') {
|
||||
const numero = Number(valor);
|
||||
if (!Number.isFinite(numero)) {
|
||||
errores[campo.nombre] = `«${campo.etiqueta}» es un número.`;
|
||||
} else if (campo.tipo === 'integer' && !Number.isInteger(numero)) {
|
||||
errores[campo.nombre] = `«${campo.etiqueta}» es un número entero.`;
|
||||
} else if (campo.minimo !== null && numero < campo.minimo) {
|
||||
errores[campo.nombre] = `«${campo.etiqueta}» no baja de ${campo.minimo}.`;
|
||||
} else if (campo.maximo !== null && numero > campo.maximo) {
|
||||
errores[campo.nombre] = `«${campo.etiqueta}» no pasa de ${campo.maximo}.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cómo se enseña un prompt guardado: una línea por campo con valor.
|
||||
*
|
||||
* Sirve para la tarjeta de la lista y para copiarlo entero. Los campos vacíos
|
||||
* no salen: un prompt con la mitad de las etiquetas en blanco se lee peor que
|
||||
* uno corto.
|
||||
*/
|
||||
export function comoTexto(campos: CampoDeEsquema[], contenido: unknown): string {
|
||||
if (!esObjeto(contenido)) return typeof contenido === 'string' ? contenido : '';
|
||||
|
||||
// Sin esquema, el contenido es `{ texto }` y se enseña tal cual.
|
||||
if (campos.length === 0) {
|
||||
return typeof contenido.texto === 'string'
|
||||
? contenido.texto
|
||||
: JSON.stringify(contenido, null, 2);
|
||||
}
|
||||
|
||||
return campos
|
||||
.filter((c) => {
|
||||
const v = contenido[c.nombre];
|
||||
return v !== undefined && v !== null && v !== '';
|
||||
})
|
||||
.map((c) => `${c.etiqueta}: ${contenido[c.nombre]}`)
|
||||
.join('\n');
|
||||
}
|
||||
@ -0,0 +1,376 @@
|
||||
/**
|
||||
* Los prompts de IA generativa, para el panel.
|
||||
*
|
||||
* Un prompt es de un **modelo concreto** —Suno v4 no es Suno v3.5: cambian la
|
||||
* sintaxis y lo que el modelo ignora—, de un género y de un ánimo. Y hay varios
|
||||
* por combinación con uno marcado como preferido, porque esto se itera: no se
|
||||
* acierta a la primera y el que funcionó hay que poder encontrarlo.
|
||||
*
|
||||
* El contenido va en `jsonb` y no en columnas: la forma la define un tercero y
|
||||
* cambia sin avisarnos. Pero abierto no es sin validar. Cada modelo declara en
|
||||
* `modelo_ia.esquema` un JSON Schema con la forma de sus prompts, y ese esquema
|
||||
* hace dos trabajos —validar al guardar y pintar el formulario—, que es lo que
|
||||
* evita una pantalla por cada IA. Están los dos en `$lib/esquema-prompt`.
|
||||
*/
|
||||
|
||||
import { and, asc, desc, eq, sql } from 'drizzle-orm';
|
||||
import { db } from './db';
|
||||
import { animo, genero, modeloIa, prompt } from './db/schema';
|
||||
import {
|
||||
camposDe,
|
||||
revisarContra,
|
||||
revisarEsquema,
|
||||
type CampoDeEsquema,
|
||||
type ContenidoDePrompt
|
||||
} from '$lib/esquema-prompt';
|
||||
|
||||
export type Visibilidad = 'privado' | 'taller' | 'publico';
|
||||
export type Estado = 'borrador' | 'publicado' | 'retirado';
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Modelos
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
export interface ModeloEnLista {
|
||||
id: string;
|
||||
codigo: string;
|
||||
familia: string;
|
||||
version: string;
|
||||
nombre: string;
|
||||
vigente: boolean;
|
||||
notaSintaxis: string | null;
|
||||
/** El JSON Schema tal cual, para pintarlo en su formulario. */
|
||||
esquema: unknown;
|
||||
/** Los campos que declara, ya leídos: es lo que pinta el formulario. */
|
||||
campos: CampoDeEsquema[];
|
||||
prompts: number;
|
||||
}
|
||||
|
||||
export async function listaDeModelos(): Promise<ModeloEnLista[]> {
|
||||
const filas = await db
|
||||
.select({
|
||||
id: modeloIa.id,
|
||||
codigo: modeloIa.codigo,
|
||||
familia: modeloIa.familia,
|
||||
version: modeloIa.version,
|
||||
nombre: modeloIa.nombre,
|
||||
vigente: modeloIa.vigente,
|
||||
notaSintaxis: modeloIa.notaSintaxis,
|
||||
esquema: modeloIa.esquema,
|
||||
prompts: sql<number>`(
|
||||
select count(*)::int from prompt p where p.modelo_ia_id = ${modeloIa.id}
|
||||
)`
|
||||
})
|
||||
.from(modeloIa)
|
||||
.orderBy(asc(modeloIa.familia), asc(modeloIa.version));
|
||||
|
||||
return filas.map((f) => ({ ...f, campos: camposDe(f.esquema) }));
|
||||
}
|
||||
|
||||
export interface CambiosDelModelo {
|
||||
familia: string;
|
||||
version: string;
|
||||
nombre: string;
|
||||
notaSintaxis: string;
|
||||
/** El JSON Schema como texto, tal cual se escribe. */
|
||||
esquema: string;
|
||||
vigente: boolean;
|
||||
}
|
||||
|
||||
export type ErroresDelModelo = Partial<
|
||||
Record<'familia' | 'version' | 'nombre' | 'esquema', string>
|
||||
>;
|
||||
|
||||
export function revisarModelo(cambios: CambiosDelModelo): ErroresDelModelo {
|
||||
const errores: ErroresDelModelo = {};
|
||||
|
||||
if (cambios.familia.trim() === '') errores.familia = 'La familia es «Suno», «Udio», la que sea.';
|
||||
if (cambios.version.trim() === '') {
|
||||
errores.version = 'La versión importa: v4 no escribe como v3.5.';
|
||||
}
|
||||
if (cambios.nombre.trim() === '') errores.nombre = 'Ponle el nombre con el que lo reconoces.';
|
||||
|
||||
/*
|
||||
* Un esquema roto se rechaza aquí y no al usarlo. Guardarlo dejaría el
|
||||
* formulario de prompts de ese modelo sin campos, y el fallo aparecería
|
||||
* lejos de donde se cometió.
|
||||
*/
|
||||
const malEsquema = revisarEsquema(cambios.esquema);
|
||||
if (malEsquema) errores.esquema = malEsquema;
|
||||
|
||||
return errores;
|
||||
}
|
||||
|
||||
/** El código sale de familia y versión: `suno-v4`, `udio-1-5`. */
|
||||
export function codigoDeModelo(familia: string, version: string): string {
|
||||
const limpio = (t: string) =>
|
||||
t
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
return `${limpio(familia)}-${limpio(version)}`;
|
||||
}
|
||||
|
||||
/** Un esquema en blanco se guarda como nulo: «este modelo no declara forma». */
|
||||
const comoEsquema = (texto: string): unknown => (texto.trim() === '' ? null : JSON.parse(texto));
|
||||
|
||||
function valoresDelModelo(cambios: CambiosDelModelo) {
|
||||
return {
|
||||
codigo: codigoDeModelo(cambios.familia, cambios.version),
|
||||
familia: cambios.familia.trim(),
|
||||
version: cambios.version.trim(),
|
||||
nombre: cambios.nombre.trim(),
|
||||
notaSintaxis: cambios.notaSintaxis.trim() || null,
|
||||
esquema: comoEsquema(cambios.esquema),
|
||||
vigente: cambios.vigente
|
||||
};
|
||||
}
|
||||
|
||||
export async function crearModelo(cambios: CambiosDelModelo): Promise<string> {
|
||||
const [fila] = await db
|
||||
.insert(modeloIa)
|
||||
.values(valoresDelModelo(cambios))
|
||||
.returning({ id: modeloIa.id });
|
||||
return fila.id;
|
||||
}
|
||||
|
||||
export async function guardarModelo(id: string, cambios: CambiosDelModelo): Promise<boolean> {
|
||||
const hechas = await db
|
||||
.update(modeloIa)
|
||||
.set(valoresDelModelo(cambios))
|
||||
.where(eq(modeloIa.id, id))
|
||||
.returning({ id: modeloIa.id });
|
||||
return hechas.length > 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Prompts
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
export interface PromptEnLista {
|
||||
id: string;
|
||||
animoId: string | null;
|
||||
animoNombre: string | null;
|
||||
modeloId: string;
|
||||
modeloNombre: string;
|
||||
idioma: string;
|
||||
valoracion: number | null;
|
||||
preferido: boolean;
|
||||
visibilidad: Visibilidad;
|
||||
estado: Estado;
|
||||
notas: string | null;
|
||||
/** Los valores tal cual, para rellenar el formulario del modelo. */
|
||||
contenido: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Los de un género, ordenados por lo que funcionó. */
|
||||
export async function promptsDelGenero(generoId: string): Promise<PromptEnLista[]> {
|
||||
const filas = await db
|
||||
.select({
|
||||
id: prompt.id,
|
||||
animoId: prompt.animoId,
|
||||
animoNombre: animo.nombre,
|
||||
modeloId: prompt.modeloIaId,
|
||||
modeloNombre: modeloIa.nombre,
|
||||
idioma: prompt.idioma,
|
||||
valoracion: prompt.valoracion,
|
||||
preferido: prompt.preferido,
|
||||
visibilidad: prompt.visibilidad,
|
||||
estado: prompt.estado,
|
||||
notas: prompt.notas,
|
||||
contenido: prompt.contenido
|
||||
})
|
||||
.from(prompt)
|
||||
.leftJoin(animo, eq(prompt.animoId, animo.id))
|
||||
.innerJoin(modeloIa, eq(prompt.modeloIaId, modeloIa.id))
|
||||
.where(eq(prompt.generoId, generoId))
|
||||
/*
|
||||
* El preferido primero y luego el mejor valorado: al abrir un género lo
|
||||
* que se busca es «el que funcionó», no el último que se escribió.
|
||||
*/
|
||||
.orderBy(desc(prompt.preferido), desc(prompt.valoracion), asc(prompt.creadoEn));
|
||||
|
||||
return filas.map((f) => ({
|
||||
...f,
|
||||
contenido: (f.contenido ?? {}) as Record<string, unknown>
|
||||
}));
|
||||
}
|
||||
|
||||
/** Los ánimos, para el desplegable. De más energía a menos. */
|
||||
export async function animosParaElegir() {
|
||||
return db
|
||||
.select({ id: animo.id, nombre: animo.nombre, descripcion: animo.descripcion })
|
||||
.from(animo)
|
||||
.orderBy(desc(animo.energia));
|
||||
}
|
||||
|
||||
export interface CambiosDelPrompt {
|
||||
modeloIaId: string;
|
||||
generoId: string | null;
|
||||
animoId: string | null;
|
||||
idioma: string;
|
||||
/** Un valor por campo del esquema del modelo. */
|
||||
contenido: ContenidoDePrompt;
|
||||
notas: string;
|
||||
valoracion: number | null;
|
||||
preferido: boolean;
|
||||
visibilidad: Visibilidad;
|
||||
estado: Estado;
|
||||
}
|
||||
|
||||
export interface ErroresDelPrompt {
|
||||
/** Lo que está mal fuera del contenido. */
|
||||
general?: string;
|
||||
/** Y lo que está mal dentro, por campo del esquema. */
|
||||
campos?: Record<string, string>;
|
||||
}
|
||||
|
||||
const VISIBILIDADES: Visibilidad[] = ['privado', 'taller', 'publico'];
|
||||
const ESTADOS_PROMPT: Estado[] = ['borrador', 'publicado', 'retirado'];
|
||||
|
||||
/**
|
||||
* Comprueba el prompt contra el esquema de su modelo.
|
||||
*
|
||||
* `campos` sale del modelo elegido. Si está vacío —un modelo que no declara
|
||||
* forma— se comprueba el único campo que hay entonces, `texto`, y basta con que
|
||||
* no esté en blanco.
|
||||
*/
|
||||
export function revisarPrompt(
|
||||
cambios: CambiosDelPrompt,
|
||||
campos: CampoDeEsquema[]
|
||||
): ErroresDelPrompt {
|
||||
const errores: ErroresDelPrompt = {};
|
||||
|
||||
if (!cambios.modeloIaId) {
|
||||
errores.general = 'Un prompt es de un modelo concreto: elige cuál.';
|
||||
return errores;
|
||||
}
|
||||
|
||||
if (cambios.valoracion !== null) {
|
||||
if (!Number.isInteger(cambios.valoracion) || cambios.valoracion < 1 || cambios.valoracion > 5) {
|
||||
errores.general = 'La valoración va de 1 a 5, o se deja sin poner.';
|
||||
return errores;
|
||||
}
|
||||
}
|
||||
|
||||
if (!VISIBILIDADES.includes(cambios.visibilidad)) {
|
||||
errores.general = 'Esa visibilidad no existe.';
|
||||
return errores;
|
||||
}
|
||||
if (!ESTADOS_PROMPT.includes(cambios.estado)) {
|
||||
errores.general = 'Ese estado no existe.';
|
||||
return errores;
|
||||
}
|
||||
|
||||
if (campos.length === 0) {
|
||||
if (String(cambios.contenido.texto ?? '').trim() === '') {
|
||||
errores.campos = { texto: 'El prompt no puede estar vacío.' };
|
||||
}
|
||||
return errores;
|
||||
}
|
||||
|
||||
const malos = revisarContra(campos, cambios.contenido);
|
||||
if (Object.keys(malos).length > 0) errores.campos = malos;
|
||||
|
||||
/*
|
||||
* Un prompt con todos los campos en blanco pasa la validación del esquema
|
||||
* —si ninguno es obligatorio— y no es un prompt. Se comprueba aparte.
|
||||
*/
|
||||
const algoEscrito = campos.some((c) => {
|
||||
const v = cambios.contenido[c.nombre];
|
||||
return v !== undefined && v !== '' && v !== false;
|
||||
});
|
||||
if (!algoEscrito) errores.general = 'El prompt está vacío: rellena al menos un campo.';
|
||||
|
||||
return errores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solo un preferido por combinación de modelo, género y ánimo.
|
||||
*
|
||||
* Lo quita de los demás en la misma transacción. Sin esto, marcar el segundo
|
||||
* dejaría dos preferidos y la lista mentiría sobre cuál funcionó.
|
||||
*/
|
||||
async function soloUnPreferido(
|
||||
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
|
||||
cambios: CambiosDelPrompt,
|
||||
salvo: string
|
||||
) {
|
||||
await tx
|
||||
.update(prompt)
|
||||
.set({ preferido: false })
|
||||
.where(
|
||||
and(
|
||||
eq(prompt.modeloIaId, cambios.modeloIaId),
|
||||
cambios.generoId ? eq(prompt.generoId, cambios.generoId) : sql`genero_id is null`,
|
||||
cambios.animoId ? eq(prompt.animoId, cambios.animoId) : sql`animo_id is null`,
|
||||
sql`${prompt.id} <> ${salvo}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function crearPrompt(cambios: CambiosDelPrompt): Promise<string> {
|
||||
return db.transaction(async (tx) => {
|
||||
const [fila] = await tx
|
||||
.insert(prompt)
|
||||
.values({
|
||||
modeloIaId: cambios.modeloIaId,
|
||||
generoId: cambios.generoId,
|
||||
animoId: cambios.animoId,
|
||||
idioma: cambios.idioma,
|
||||
contenido: cambios.contenido,
|
||||
notas: cambios.notas.trim() || null,
|
||||
valoracion: cambios.valoracion,
|
||||
preferido: cambios.preferido,
|
||||
visibilidad: cambios.visibilidad,
|
||||
// Nace en borrador, como todo lo publicable de este modelo.
|
||||
estado: cambios.estado
|
||||
})
|
||||
.returning({ id: prompt.id });
|
||||
|
||||
if (cambios.preferido) await soloUnPreferido(tx, cambios, fila.id);
|
||||
return fila.id;
|
||||
});
|
||||
}
|
||||
|
||||
export async function guardarPrompt(id: string, cambios: CambiosDelPrompt): Promise<boolean> {
|
||||
return db.transaction(async (tx) => {
|
||||
const hechas = await tx
|
||||
.update(prompt)
|
||||
.set({
|
||||
modeloIaId: cambios.modeloIaId,
|
||||
animoId: cambios.animoId,
|
||||
idioma: cambios.idioma,
|
||||
contenido: cambios.contenido,
|
||||
notas: cambios.notas.trim() || null,
|
||||
valoracion: cambios.valoracion,
|
||||
preferido: cambios.preferido,
|
||||
visibilidad: cambios.visibilidad,
|
||||
estado: cambios.estado,
|
||||
actualizadoEn: new Date()
|
||||
})
|
||||
.where(eq(prompt.id, id))
|
||||
.returning({ id: prompt.id });
|
||||
|
||||
if (hechas.length === 0) return false;
|
||||
if (cambios.preferido) await soloUnPreferido(tx, cambios, id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function borrarPrompt(id: string): Promise<boolean> {
|
||||
const hechas = await db.delete(prompt).where(eq(prompt.id, id)).returning({ id: prompt.id });
|
||||
return hechas.length > 0;
|
||||
}
|
||||
|
||||
/** El identificador del género por su slug, que es lo que trae la dirección. */
|
||||
export async function idDelGenero(slug: string): Promise<string | null> {
|
||||
const [fila] = await db
|
||||
.select({ id: genero.id })
|
||||
.from(genero)
|
||||
.where(eq(genero.slug, slug))
|
||||
.limit(1);
|
||||
return fila?.id ?? null;
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import {
|
||||
crearModelo,
|
||||
guardarModelo,
|
||||
listaDeModelos,
|
||||
revisarModelo,
|
||||
type CambiosDelModelo,
|
||||
type ErroresDelModelo
|
||||
} from '$lib/server/panel-prompts';
|
||||
|
||||
/**
|
||||
* Todas las ramas de fallo devuelven lo mismo.
|
||||
*
|
||||
* Si cada una tuviera sus claves, la página tendría que comprobar cuál le ha
|
||||
* llegado antes de poder leer un error.
|
||||
*/
|
||||
const repetido: ErroresDelModelo = {
|
||||
version: 'Ya existe un modelo con esa familia y esa versión.'
|
||||
};
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return { modelos: await listaDeModelos() };
|
||||
};
|
||||
|
||||
function leer(datos: FormData): CambiosDelModelo {
|
||||
return {
|
||||
familia: String(datos.get('familia') ?? ''),
|
||||
version: String(datos.get('version') ?? ''),
|
||||
nombre: String(datos.get('nombre') ?? ''),
|
||||
notaSintaxis: String(datos.get('notaSintaxis') ?? ''),
|
||||
esquema: String(datos.get('esquema') ?? ''),
|
||||
vigente: datos.get('vigente') !== null
|
||||
};
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
crear: async ({ request }) => {
|
||||
const datos = await request.formData();
|
||||
const cambios = leer(datos);
|
||||
|
||||
const errores = revisarModelo(cambios);
|
||||
if (Object.keys(errores).length > 0) return fail(400, { errores, cambios, donde: 'nuevo' });
|
||||
|
||||
try {
|
||||
await crearModelo(cambios);
|
||||
} catch {
|
||||
/*
|
||||
* El código sale de familia y versión y es único: dar de alta «Suno v4»
|
||||
* dos veces choca en la base. Se cuenta como lo que es, en vez de
|
||||
* enseñar el error de Postgres.
|
||||
*/
|
||||
return fail(409, { errores: repetido, cambios, donde: 'nuevo' });
|
||||
}
|
||||
|
||||
return { guardado: true };
|
||||
},
|
||||
|
||||
guardar: async ({ request }) => {
|
||||
const datos = await request.formData();
|
||||
const id = String(datos.get('id') ?? '');
|
||||
const cambios = leer(datos);
|
||||
|
||||
const errores = revisarModelo(cambios);
|
||||
if (Object.keys(errores).length > 0) return fail(400, { errores, cambios, donde: id });
|
||||
|
||||
try {
|
||||
const guardado = await guardarModelo(id, cambios);
|
||||
if (!guardado) {
|
||||
const perdido: ErroresDelModelo = { nombre: 'Ese modelo ya no existe.' };
|
||||
return fail(404, { errores: perdido, cambios, donde: id });
|
||||
}
|
||||
} catch {
|
||||
return fail(409, { errores: repetido, cambios, donde: id });
|
||||
}
|
||||
|
||||
return { guardado: true };
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,424 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Los modelos de IA.
|
||||
*
|
||||
* **Un modelo es una versión concreta, no una marca.** Un prompt de Suno v4
|
||||
* no es uno de v3.5: cambian la sintaxis, las etiquetas y lo que el modelo
|
||||
* ignora. Si esto apuntara a «Suno» a secas, el día que salga la versión
|
||||
* siguiente quedaría una biblioteca de prompts sin saber para qué servía cada
|
||||
* uno.
|
||||
*
|
||||
* Por eso no vienen sembrados: los nombres y las versiones los pone quien los
|
||||
* usa, y una lista escrita aquí envejecería en un mes.
|
||||
*/
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let abierto = $state('');
|
||||
|
||||
const nuevo = $derived(form?.donde === 'nuevo' ? form.cambios : null);
|
||||
const erroresDe = (id: string) => (form?.donde === id ? (form?.errores ?? {}) : {});
|
||||
|
||||
/** El esquema guardado, otra vez como texto para poder editarlo. */
|
||||
const comoTexto = (esquema: unknown) => (esquema ? JSON.stringify(esquema, null, 2) : '');
|
||||
|
||||
/**
|
||||
* Un ejemplo en el marcador de posición.
|
||||
*
|
||||
* No se rellena con él: sería inventarle a alguien la forma de sus prompts, y
|
||||
* la sintaxis de cada modelo la sabe quien lo usa. Puesto de fondo enseña la
|
||||
* forma sin decidir por nadie.
|
||||
*/
|
||||
const EJEMPLO = `{
|
||||
"type": "object",
|
||||
"required": ["estilo"],
|
||||
"x-order": ["estilo", "letra", "excluir", "instrumental"],
|
||||
"properties": {
|
||||
"estilo": {
|
||||
"type": "string",
|
||||
"title": "Estilo",
|
||||
"maxLength": 200,
|
||||
"description": "Los descriptores separados por comas."
|
||||
},
|
||||
"letra": { "type": "string", "title": "Letra" },
|
||||
"excluir": { "type": "string", "title": "Qué evitar", "maxLength": 200 },
|
||||
"instrumental": { "type": "boolean", "title": "Sin voz" }
|
||||
}
|
||||
}`;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Modelos de IA · Panel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Se vuelve a los géneros: los prompts se escriben dentro de su ficha. -->
|
||||
<p class="volver">
|
||||
<a href={resolve('/(dentro)/generos')}>← Géneros</a>
|
||||
</p>
|
||||
|
||||
<header>
|
||||
<h1>Modelos de IA</h1>
|
||||
<p class="entradilla">
|
||||
Una fila por versión, no por marca: Suno v4 y Suno v3.5 son dos modelos. Retirar uno no borra
|
||||
sus prompts, solo lo saca de los que se ofrecen al escribir uno nuevo.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if data.modelos.length > 0}
|
||||
<ul class="modelos">
|
||||
{#each data.modelos as m (m.id)}
|
||||
<li class="modelo" class:retirado={!m.vigente}>
|
||||
<div class="cabecera">
|
||||
<p class="quees">
|
||||
<strong>{m.nombre}</strong>
|
||||
<span class="codigo muted">{m.codigo}</span>
|
||||
</p>
|
||||
|
||||
<p class="cuantos muted">
|
||||
{m.prompts}
|
||||
{m.prompts === 1 ? 'prompt' : 'prompts'}
|
||||
{#if !m.vigente}· retirado{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if m.notaSintaxis}<p class="nota muted">{m.notaSintaxis}</p>{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="enlace"
|
||||
onclick={() => (abierto = abierto === m.id ? '' : m.id)}
|
||||
>
|
||||
{abierto === m.id ? 'Cerrar' : 'Editar'}
|
||||
</button>
|
||||
|
||||
{#if abierto === m.id}
|
||||
<form method="POST" action="?/guardar" class="editor" use:enhance>
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
{@render campos(m.id, {
|
||||
familia: m.familia,
|
||||
version: m.version,
|
||||
nombre: m.nombre,
|
||||
notaSintaxis: m.notaSintaxis ?? '',
|
||||
esquema: comoTexto(m.esquema),
|
||||
vigente: m.vigente
|
||||
})}
|
||||
<button type="submit" class="btn btn--primary">Guardar</button>
|
||||
</form>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<section class="alta">
|
||||
<h2>{data.modelos.length === 0 ? 'El primero' : 'Añadir otro'}</h2>
|
||||
|
||||
<form method="POST" action="?/crear" use:enhance>
|
||||
{@render campos('nuevo', {
|
||||
familia: nuevo?.familia ?? '',
|
||||
version: nuevo?.version ?? '',
|
||||
nombre: nuevo?.nombre ?? '',
|
||||
notaSintaxis: nuevo?.notaSintaxis ?? '',
|
||||
esquema: nuevo?.esquema ?? '',
|
||||
vigente: nuevo?.vigente ?? true
|
||||
})}
|
||||
<button type="submit" class="btn btn--primary">Dar de alta</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{#snippet campos(
|
||||
id: string,
|
||||
v: {
|
||||
familia: string;
|
||||
version: string;
|
||||
nombre: string;
|
||||
notaSintaxis: string;
|
||||
esquema: string;
|
||||
vigente: boolean;
|
||||
}
|
||||
)}
|
||||
{@const e = erroresDe(id)}
|
||||
|
||||
<div class="rejilla">
|
||||
<p class="campo">
|
||||
<label for="familia-{id}">Familia</label>
|
||||
<input
|
||||
id="familia-{id}"
|
||||
name="familia"
|
||||
value={v.familia}
|
||||
placeholder="Suno"
|
||||
aria-invalid={!!e.familia}
|
||||
/>
|
||||
{#if e.familia}<span class="fallo">{e.familia}</span>{/if}
|
||||
</p>
|
||||
|
||||
<p class="campo">
|
||||
<label for="version-{id}">Versión</label>
|
||||
<input
|
||||
id="version-{id}"
|
||||
name="version"
|
||||
value={v.version}
|
||||
placeholder="v4"
|
||||
aria-invalid={!!e.version}
|
||||
/>
|
||||
{#if e.version}<span class="fallo">{e.version}</span>{/if}
|
||||
</p>
|
||||
|
||||
<p class="campo">
|
||||
<label for="nombre-{id}">Nombre</label>
|
||||
<span class="ayuda muted">Con el que lo reconoces en un desplegable.</span>
|
||||
<input
|
||||
id="nombre-{id}"
|
||||
name="nombre"
|
||||
value={v.nombre}
|
||||
placeholder="Suno v4"
|
||||
aria-invalid={!!e.nombre}
|
||||
/>
|
||||
{#if e.nombre}<span class="fallo">{e.nombre}</span>{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="campo">
|
||||
<label for="nota-{id}">Cómo escribe este modelo</label>
|
||||
<span class="ayuda muted">
|
||||
Lo que hay que recordar de su sintaxis: qué etiquetas entiende, qué ignora, cuánto texto
|
||||
admite. Es lo que se olvida entre una sesión y la siguiente.
|
||||
</span>
|
||||
<textarea id="nota-{id}" name="notaSintaxis" rows="3">{v.notaSintaxis}</textarea>
|
||||
</p>
|
||||
|
||||
<p class="campo">
|
||||
<label for="esquema-{id}">Qué campos tiene un prompt suyo</label>
|
||||
<span class="ayuda muted">
|
||||
Un JSON Schema. De aquí sale el formulario del prompt —un campo por propiedad— y con esto se
|
||||
comprueba lo que se guarda, que es lo que evita una pantalla por cada IA. En blanco, sus
|
||||
prompts se escriben en un bloque de texto.
|
||||
</span>
|
||||
<span class="ayuda muted">
|
||||
Se entiende <code>type: "object"</code> con <code>properties</code> y
|
||||
<code>required</code>; por campo, <code>type</code> string, number, integer o boolean, más
|
||||
<code>enum</code>, <code>maxLength</code>, <code>minimum</code>, <code>maximum</code>,
|
||||
<code>title</code> como etiqueta y <code>description</code> como ayuda. Lo demás se ignora.
|
||||
</span>
|
||||
<span class="ayuda muted">
|
||||
Y <code>x-order</code>, la lista de campos en el orden en que se quieren ver. Hace falta
|
||||
decirlo: la base guarda el esquema como <code>jsonb</code> y ahí las claves se reordenan solas,
|
||||
así que sin esto el campo obligatorio puede acabar el último del formulario.
|
||||
</span>
|
||||
<textarea
|
||||
id="esquema-{id}"
|
||||
name="esquema"
|
||||
rows="10"
|
||||
spellcheck="false"
|
||||
class="codigo"
|
||||
aria-invalid={!!e.esquema}
|
||||
placeholder={EJEMPLO}>{v.esquema}</textarea
|
||||
>
|
||||
{#if e.esquema}<span class="fallo">{e.esquema}</span>{/if}
|
||||
</p>
|
||||
|
||||
<p class="campo marcar">
|
||||
<label>
|
||||
<input type="checkbox" name="vigente" checked={v.vigente} />
|
||||
En uso
|
||||
</label>
|
||||
<span class="ayuda muted">
|
||||
Al desmarcarlo deja de ofrecerse para prompts nuevos, y los que ya tiene se quedan.
|
||||
</span>
|
||||
</p>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.volver {
|
||||
margin: 0 0 var(--sp-4);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
header {
|
||||
max-width: 62ch;
|
||||
padding-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--sp-2);
|
||||
font-size: var(--fs-2xl);
|
||||
}
|
||||
|
||||
.entradilla {
|
||||
margin: 0;
|
||||
color: var(--c-text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.modelos {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.modelo {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-tarjeta);
|
||||
background: var(--c-bg-raised);
|
||||
}
|
||||
|
||||
/* Retirado: sigue estando, pero no se ofrece. Se ve sin gritar. */
|
||||
.modelo.retirado {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.cabecera {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quees,
|
||||
.cuantos,
|
||||
.nota {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.codigo {
|
||||
margin-left: var(--sp-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.cuantos {
|
||||
font-size: var(--fs-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.nota {
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.55;
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.enlace {
|
||||
justify-self: start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--c-text-muted);
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.enlace:hover {
|
||||
color: var(--c-accent);
|
||||
}
|
||||
|
||||
.editor,
|
||||
.alta form {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
justify-items: start;
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.alta {
|
||||
padding-top: var(--sp-7);
|
||||
}
|
||||
|
||||
.alta h2 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xl);
|
||||
}
|
||||
|
||||
.rejilla {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
|
||||
gap: var(--sp-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.campo {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.marcar label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--c-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.ayuda {
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: var(--boton-relleno);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-boton);
|
||||
background: var(--c-bg-sunken);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: var(--boton-texto);
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--c-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
[aria-invalid='true'] {
|
||||
border-color: var(--c-danger);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* El esquema es JSON: se escribe y se lee con letra de ancho fijo. */
|
||||
.codigo {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.ayuda code {
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.fallo {
|
||||
color: var(--c-danger);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in new issue