feat(app) : phase 3 — templates ComfyUI paramétriques + smoke tests
- Templates paramétrés (data/workflows/templates/) : substitution de variables
({{modele_checkpoint}}, {{resolution}}, {{resolution_largeur/hauteur}},
{{vram_profile}}) — jamais de réécriture de topologie (PRD §4.6)
- Génération de JSON concrets (/workflows) + régénération automatique à chaque
écriture du registre, commit git ; suppression si le modèle disparaît
- Champ comfyui_checkpoint ajouté au schéma et au formulaire registre ;
génération refusée tant qu'il est null (anti-hallucination)
- Smoke test : prompt canari sans modèle (EmptyImage→PreviewImage) posté sur
COMFYUI_URL/prompt, statut remonté dans l'UI, dégradation gracieuse
- Endpoint de téléchargement /workflows/fichier/[nom]
- Tests : 11 nouveaux cas (substitution, conversion numérique, variable
inconnue, paliers VRAM, résolution invalide, checkpoint null, smoke test
accepté/refusé/injoignable)
- Package YNH : question comfyui_url + COMFYUI_URL dans l'env applicatif
- Vérifié : check/lint/test (38)/build verts, autofixer propre, test fumée
HTTP complet (seed templates, refus sans checkpoint, génération, JSON
concret correct, téléchargement, smoke test dégradé, commits git)
This commit is contained in:
@@ -7,6 +7,9 @@
|
||||
# Endpoint Ollama (optionnel : l'app fonctionne sans, en mode dégradé)
|
||||
# OLLAMA_URL=http://127.0.0.1:11434
|
||||
|
||||
# Endpoint ComfyUI pour les smoke tests (optionnel : dégradation gracieuse)
|
||||
# COMFYUI_URL=http://127.0.0.1:8188
|
||||
|
||||
# Token Bearer protégeant POST /api/alerts (généré à l'install YunoHost — jamais commité)
|
||||
# ALERTS_TOKEN=
|
||||
|
||||
|
||||
@@ -171,6 +171,14 @@
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="champ">
|
||||
<span>{fr.registre.champ_checkpoint}</span>
|
||||
<input
|
||||
name="comfyui_checkpoint"
|
||||
value={s(capacites.comfyui_checkpoint)}
|
||||
placeholder="ex. modele_fp8.safetensors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export const fr = {
|
||||
profils: 'Profils',
|
||||
recommander: 'Recommander',
|
||||
exports: 'Exports',
|
||||
workflows: 'Workflows',
|
||||
alertes: 'Alertes',
|
||||
navigation_principale: 'Navigation principale',
|
||||
aller_au_contenu: 'Aller au contenu'
|
||||
@@ -38,6 +39,9 @@ export const fr = {
|
||||
carte_alertes_titre: 'Inbox alertes',
|
||||
carte_alertes_texte:
|
||||
'Événements classifiés (sorties, licences, classements) reçus depuis Node-RED.',
|
||||
carte_workflows_titre: 'Workflows ComfyUI',
|
||||
carte_workflows_texte:
|
||||
'Templates paramétrés, JSON concrets régénérés à chaque changement du registre, smoke test canari.',
|
||||
phase_a_venir: 'Phase à venir'
|
||||
},
|
||||
erreurs: {
|
||||
@@ -135,6 +139,7 @@ export const fr = {
|
||||
champ_resolution: 'Résolution max',
|
||||
champ_comfyui: 'Support natif ComfyUI',
|
||||
champ_outputs: 'Licence des outputs',
|
||||
champ_checkpoint: 'Checkpoint ComfyUI (nom de fichier)',
|
||||
outputs_commercialisables: 'commercialisables',
|
||||
outputs_non_commercial: 'non commercial',
|
||||
champ_scores: 'Scores (un par ligne : cle = valeur, ex. arena_image = 6)',
|
||||
@@ -190,6 +195,35 @@ export const fr = {
|
||||
genere_le: 'Généré le',
|
||||
tokens_aide: 'Tokens disponibles : {{date}}, {{tableau_registre}}, {{tableau_classements}}'
|
||||
},
|
||||
workflows: {
|
||||
titre: 'Workflows ComfyUI',
|
||||
intro:
|
||||
'Templates paramétrés : on substitue des variables, la topologie du graphe n’est jamais réécrite (PRD §4.6).',
|
||||
section_generer: 'Générer un workflow concret',
|
||||
champ_template: 'Template',
|
||||
champ_modele: 'Modèle du registre',
|
||||
champ_resolution: 'Résolution (LxH)',
|
||||
champ_vram: 'Profil VRAM',
|
||||
vram_auto: 'auto (depuis le registre)',
|
||||
generer: 'Générer',
|
||||
section_generes: 'Workflows générés',
|
||||
aucun_genere: 'Aucun workflow généré pour le moment.',
|
||||
colonne_fichier: 'Fichier',
|
||||
colonne_template: 'Template',
|
||||
colonne_modele: 'Modèle',
|
||||
colonne_genere_le: 'Généré le',
|
||||
telecharger: 'Télécharger',
|
||||
regenere_auto: 'Régénérés automatiquement à chaque modification du registre.',
|
||||
section_smoke: 'Smoke test ComfyUI',
|
||||
smoke_intro:
|
||||
'Envoie un prompt canari (graphe minimal sans modèle) sur l’API ComfyUI configurée.',
|
||||
lancer_smoke: 'Lancer le smoke test',
|
||||
smoke_ok: 'ComfyUI opérationnel',
|
||||
smoke_ko: 'Smoke test en échec',
|
||||
smoke_injoignable: 'ComfyUI injoignable',
|
||||
endpoint: 'Endpoint',
|
||||
erreur_generation: 'Génération impossible :'
|
||||
},
|
||||
alertes: {
|
||||
titre: 'Inbox alertes',
|
||||
intro: 'Événements classifiés reçus depuis Node-RED (POST /api/alerts).',
|
||||
|
||||
@@ -35,6 +35,8 @@ export const capacitesSchema = z.object({
|
||||
vram_gb: z.number().positive().nullable().default(null),
|
||||
resolution_max: z.string().nullable().default(null),
|
||||
comfyui_natif: z.boolean().nullable().default(null),
|
||||
/** Nom du fichier checkpoint tel qu'installé dans ComfyUI (ex. krea2turbo_fp8.safetensors). */
|
||||
comfyui_checkpoint: z.string().nullable().default(null),
|
||||
licence_outputs: z.enum(['commercialisables', 'non_commercial']).nullable().default(null)
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ export const config = {
|
||||
/** Endpoint Ollama. L'app doit fonctionner même s'il est injoignable (dégradation gracieuse). */
|
||||
ollamaUrl: env.OLLAMA_URL ?? 'http://127.0.0.1:11434',
|
||||
|
||||
/** Endpoint ComfyUI (smoke tests, phase 3). Dégradation gracieuse si injoignable. */
|
||||
comfyuiUrl: env.COMFYUI_URL ?? 'http://127.0.0.1:8188',
|
||||
|
||||
/** Token Bearer protégeant POST /api/alerts (généré à l'install YNH). Jamais de défaut. */
|
||||
alertsToken: env.ALERTS_TOKEN ?? null,
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export function modeleDepuisFormulaire(data: FormData): unknown {
|
||||
vram_gb: nombreOuNull(data.get('vram_gb')),
|
||||
resolution_max: chaineOuNull(data.get('resolution_max')),
|
||||
comfyui_natif: triEtat(data.get('comfyui_natif')),
|
||||
comfyui_checkpoint: chaineOuNull(data.get('comfyui_checkpoint')),
|
||||
licence_outputs: chaineOuNull(data.get('licence_outputs'))
|
||||
},
|
||||
scores,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Template de workflow ComfyUI par défaut (PRD §4.6) — écrit au premier
|
||||
* démarrage dans data/workflows/templates/, éditable ensuite sur disque.
|
||||
* Les templates sont des graphes API paramétrés : on substitue des variables,
|
||||
* jamais la topologie.
|
||||
*
|
||||
* Variables disponibles dans les valeurs texte :
|
||||
* {{modele_checkpoint}} fichier checkpoint du modèle (registre)
|
||||
* {{resolution}} ex. "1024x1024"
|
||||
* {{resolution_largeur}} ex. 1024 (nombre)
|
||||
* {{resolution_hauteur}} ex. 1024 (nombre)
|
||||
* {{vram_profile}} low | medium | high (dérivé de la VRAM du modèle)
|
||||
*/
|
||||
export const WORKFLOW_TEMPLATES_SEED: Record<string, string> = {
|
||||
'txt2img_basique.json': JSON.stringify(
|
||||
{
|
||||
'1': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: '{{modele_checkpoint}}' } },
|
||||
'2': { class_type: 'CLIPTextEncode', inputs: { text: 'a test image', clip: ['1', 1] } },
|
||||
'3': { class_type: 'CLIPTextEncode', inputs: { text: '', clip: ['1', 1] } },
|
||||
'4': {
|
||||
class_type: 'EmptyLatentImage',
|
||||
inputs: {
|
||||
width: '{{resolution_largeur}}',
|
||||
height: '{{resolution_hauteur}}',
|
||||
batch_size: 1
|
||||
}
|
||||
},
|
||||
'5': {
|
||||
class_type: 'KSampler',
|
||||
inputs: {
|
||||
model: ['1', 0],
|
||||
positive: ['2', 0],
|
||||
negative: ['3', 0],
|
||||
latent_image: ['4', 0],
|
||||
seed: 42,
|
||||
steps: 4,
|
||||
cfg: 1.0,
|
||||
sampler_name: 'euler',
|
||||
scheduler: 'simple',
|
||||
denoise: 1.0
|
||||
}
|
||||
},
|
||||
'6': { class_type: 'VAEDecode', inputs: { samples: ['5', 0], vae: ['1', 2] } },
|
||||
'7': { class_type: 'PreviewImage', inputs: { images: ['6', 0] } },
|
||||
'8': {
|
||||
class_type: 'Note',
|
||||
inputs: {
|
||||
text: 'Généré par veille-ia — modèle : {{modele_checkpoint}} · résolution : {{resolution}} · profil VRAM : {{vram_profile}}'
|
||||
}
|
||||
}
|
||||
},
|
||||
null,
|
||||
'\t'
|
||||
)
|
||||
};
|
||||
|
||||
export const WORKFLOW_INDEX_SEED = `# Index des templates de workflows ComfyUI (PRD §4.6).
|
||||
# Variables : {{modele_checkpoint}} {{resolution}} {{resolution_largeur}}
|
||||
# {{resolution_hauteur}} {{vram_profile}}
|
||||
- fichier: txt2img_basique.json
|
||||
nom: txt2img basique (canari)
|
||||
modalite: image
|
||||
description: Graphe minimal checkpoint → sampler → preview, utilisable comme canari de smoke test.
|
||||
`;
|
||||
@@ -9,6 +9,7 @@ import { modeleSchema, profilSchema, type Modele, type Profil } from '../schemas
|
||||
import { modelesSeed } from './seed/modeles';
|
||||
import { profilsSeed } from './seed/profils';
|
||||
import { TEMPLATES_SEED } from './seed/templates';
|
||||
import { WORKFLOW_INDEX_SEED, WORKFLOW_TEMPLATES_SEED } from './seed/workflows';
|
||||
|
||||
/**
|
||||
* Persistance : fichiers YAML dans le data_dir + commit git à chaque écriture.
|
||||
@@ -44,6 +45,20 @@ export function initialiserDonnees(): Promise<void> {
|
||||
fichiersSeedes.push(join(DOSSIER_TEMPLATES, nom));
|
||||
}
|
||||
}
|
||||
// Templates ComfyUI (PRD §4.6) : data/workflows/templates/ + data/workflows/generes/
|
||||
const dossierTemplatesWf = join(config.dataDir, 'workflows', 'templates');
|
||||
await mkdir(dossierTemplatesWf, { recursive: true });
|
||||
await mkdir(join(config.dataDir, 'workflows', 'generes'), { recursive: true });
|
||||
for (const [nom, contenu] of Object.entries(WORKFLOW_TEMPLATES_SEED)) {
|
||||
if (!existsSync(join(dossierTemplatesWf, nom))) {
|
||||
await writeFile(join(dossierTemplatesWf, nom), contenu, 'utf8');
|
||||
fichiersSeedes.push(join('workflows', 'templates', nom));
|
||||
}
|
||||
}
|
||||
if (!existsSync(join(dossierTemplatesWf, 'index.yaml'))) {
|
||||
await writeFile(join(dossierTemplatesWf, 'index.yaml'), WORKFLOW_INDEX_SEED, 'utf8');
|
||||
fichiersSeedes.push(join('workflows', 'templates', 'index.yaml'));
|
||||
}
|
||||
if (fichiersSeedes.length > 0) {
|
||||
await committer(
|
||||
fichiersSeedes,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
PROMPT_CANARI,
|
||||
profilVram,
|
||||
substituerWorkflow,
|
||||
testerComfyUI,
|
||||
variablesPourModele
|
||||
} from './workflows';
|
||||
import { modeleSchema } from '../schemas';
|
||||
|
||||
describe('substituerWorkflow', () => {
|
||||
it('remplace les variables et convertit les valeurs numériques', () => {
|
||||
const template = JSON.stringify({
|
||||
'1': {
|
||||
class_type: 'A',
|
||||
inputs: { ckpt: '{{modele_checkpoint}}', w: '{{resolution_largeur}}' }
|
||||
}
|
||||
});
|
||||
const resultat = JSON.parse(
|
||||
substituerWorkflow(template, {
|
||||
modele_checkpoint: 'flux.safetensors',
|
||||
resolution_largeur: 1024
|
||||
})
|
||||
);
|
||||
expect(resultat['1'].inputs.ckpt).toBe('flux.safetensors');
|
||||
expect(resultat['1'].inputs.w).toBe(1024);
|
||||
expect(typeof resultat['1'].inputs.w).toBe('number');
|
||||
});
|
||||
|
||||
it('ne touche pas à la topologie du graphe', () => {
|
||||
const graphe = {
|
||||
'1': { class_type: 'A', inputs: { x: '{{v}}' } },
|
||||
'2': { class_type: 'B', inputs: { in: ['1', 0], y: 3 } }
|
||||
};
|
||||
const resultat = JSON.parse(substituerWorkflow(JSON.stringify(graphe), { v: 'ok' }));
|
||||
expect(resultat['2'].inputs.in).toEqual(['1', 0]);
|
||||
expect(resultat['2'].inputs.y).toBe(3);
|
||||
});
|
||||
|
||||
it('rejette une variable inconnue (jamais de valeur inventée)', () => {
|
||||
expect(() => substituerWorkflow('{"a": "{{inconnue}}"}', {})).toThrow('inconnue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('profilVram', () => {
|
||||
it('mappe les paliers', () => {
|
||||
expect(profilVram(4)).toBe('low');
|
||||
expect(profilVram(12)).toBe('medium');
|
||||
expect(profilVram(24)).toBe('high');
|
||||
expect(profilVram(null)).toBe('medium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('variablesPourModele', () => {
|
||||
const base = {
|
||||
id: 'm1',
|
||||
nom: 'M1',
|
||||
editeur: 'E',
|
||||
modalites: ['image'],
|
||||
statut: 'production',
|
||||
sources: ['https://example.org']
|
||||
};
|
||||
|
||||
it('construit les variables depuis le registre', () => {
|
||||
const m = modeleSchema.parse({
|
||||
...base,
|
||||
capacites: { comfyui_checkpoint: 'm1.safetensors', vram_gb: 12 }
|
||||
});
|
||||
expect(variablesPourModele(m, '1024x768')).toEqual({
|
||||
modele_checkpoint: 'm1.safetensors',
|
||||
resolution: '1024x768',
|
||||
resolution_largeur: 1024,
|
||||
resolution_hauteur: 768,
|
||||
vram_profile: 'medium'
|
||||
});
|
||||
});
|
||||
|
||||
it('refuse de générer sans checkpoint renseigné (anti-hallucination)', () => {
|
||||
const m = modeleSchema.parse({ ...base, capacites: { comfyui_checkpoint: null } });
|
||||
expect(() => variablesPourModele(m, '1024x1024')).toThrow('comfyui_checkpoint');
|
||||
});
|
||||
|
||||
it('rejette une résolution malformée', () => {
|
||||
const m = modeleSchema.parse({ ...base, capacites: { comfyui_checkpoint: 'x' } });
|
||||
expect(() => variablesPourModele(m, '1024')).toThrow('Résolution invalide');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testerComfyUI (smoke test)', () => {
|
||||
it('canari accepté', async () => {
|
||||
const fetchMock = async () =>
|
||||
new Response(JSON.stringify({ prompt_id: 'abc' }), { status: 200 });
|
||||
const statut = await testerComfyUI('http://localhost:8188', fetchMock as typeof fetch);
|
||||
expect(statut).toEqual({
|
||||
joignable: true,
|
||||
accepte: true,
|
||||
detail: 'Prompt canari accepté (id abc).'
|
||||
});
|
||||
});
|
||||
|
||||
it('API joignable mais prompt refusé', async () => {
|
||||
const fetchMock = async () => new Response('bad prompt', { status: 400 });
|
||||
const statut = await testerComfyUI('http://localhost:8188', fetchMock as typeof fetch);
|
||||
expect(statut.joignable).toBe(true);
|
||||
expect(statut.accepte).toBe(false);
|
||||
expect(statut.detail).toContain('400');
|
||||
});
|
||||
|
||||
it('ComfyUI injoignable : dégradation gracieuse', async () => {
|
||||
const fetchMock = async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
};
|
||||
const statut = await testerComfyUI('http://localhost:8188', fetchMock as typeof fetch);
|
||||
expect(statut.joignable).toBe(false);
|
||||
expect(statut.accepte).toBe(false);
|
||||
expect(statut.detail).toContain('dégradation gracieuse');
|
||||
});
|
||||
|
||||
it('le canari est un graphe valide sans modèle', () => {
|
||||
expect(PROMPT_CANARI['1'].class_type).toBe('EmptyImage');
|
||||
expect(PROMPT_CANARI['2'].inputs.images).toEqual(['1', 0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { load as loadYaml, dump as dumpYaml } from 'js-yaml';
|
||||
import { z } from 'zod';
|
||||
import type { Modele } from '../schemas';
|
||||
import { committer } from './git';
|
||||
import { config } from './config';
|
||||
import { initialiserDonnees, lireRegistre } from './stockage';
|
||||
|
||||
/**
|
||||
* Templates ComfyUI paramétriques (PRD §4.6).
|
||||
* Principe : on PARAMÈTRE, on ne réécrit jamais la topologie du graphe.
|
||||
* Les templates vivent dans data/workflows/templates/ (JSON API ComfyUI avec
|
||||
* placeholders {{variable}}), les JSON concrets dans data/workflows/generes/.
|
||||
*/
|
||||
|
||||
const DOSSIER_WORKFLOWS = 'workflows';
|
||||
const DOSSIER_TEMPLATES = join(DOSSIER_WORKFLOWS, 'templates');
|
||||
const DOSSIER_GENERES = join(DOSSIER_WORKFLOWS, 'generes');
|
||||
const INDEX_TEMPLATES = join(DOSSIER_TEMPLATES, 'index.yaml');
|
||||
const INDEX_GENERES = join(DOSSIER_GENERES, 'index.yaml');
|
||||
|
||||
const templateInfoSchema = z.object({
|
||||
fichier: z.string().endsWith('.json'),
|
||||
nom: z.string().min(1),
|
||||
modalite: z.string().min(1),
|
||||
description: z.string().default('')
|
||||
});
|
||||
export type TemplateInfo = z.infer<typeof templateInfoSchema>;
|
||||
|
||||
const genereInfoSchema = z.object({
|
||||
fichier: z.string().endsWith('.json'),
|
||||
template: z.string(),
|
||||
modele_id: z.string(),
|
||||
resolution: z.string().regex(/^\d+x\d+$/),
|
||||
vram_profile: z.enum(['low', 'medium', 'high']),
|
||||
genere_le: z.string()
|
||||
});
|
||||
export type GenereInfo = z.infer<typeof genereInfoSchema>;
|
||||
|
||||
// ── Substitution (fonction pure, testable) ──
|
||||
|
||||
const PLACEHOLDER = /\{\{([a-z_]+)\}\}/g;
|
||||
|
||||
/**
|
||||
* Substitue les {{variables}} dans un template JSON (texte).
|
||||
* - Toute variable inconnue du template doit être fournie, sinon erreur explicite.
|
||||
* - Une valeur entièrement numérique après substitution devient un nombre JSON.
|
||||
* Ne touche JAMAIS à la structure du graphe (topologie inchangée).
|
||||
*/
|
||||
export function substituerWorkflow(
|
||||
templateJson: string,
|
||||
variables: Record<string, string | number>
|
||||
): string {
|
||||
const graphe: unknown = JSON.parse(templateJson);
|
||||
|
||||
const substitue = (valeur: unknown): unknown => {
|
||||
if (typeof valeur === 'string') {
|
||||
if (!valeur.includes('{{')) return valeur;
|
||||
const resultat = valeur.replace(PLACEHOLDER, (complet, cle: string) => {
|
||||
if (!(cle in variables)) {
|
||||
throw new Error(`Variable de template inconnue : {{${cle}}}`);
|
||||
}
|
||||
return String(variables[cle]);
|
||||
});
|
||||
// Conversion numérique : "{{resolution_largeur}}" → 1024 (nombre, pas chaîne)
|
||||
return /^\d+$/.test(resultat) ? Number(resultat) : resultat;
|
||||
}
|
||||
if (Array.isArray(valeur)) return valeur.map(substitue);
|
||||
if (valeur !== null && typeof valeur === 'object') {
|
||||
return Object.fromEntries(Object.entries(valeur).map(([k, v]) => [k, substitue(v)]));
|
||||
}
|
||||
return valeur;
|
||||
};
|
||||
|
||||
return JSON.stringify(substitue(graphe), null, '\t');
|
||||
}
|
||||
|
||||
/** Profil VRAM dérivé du registre (null → medium, documenté). */
|
||||
export function profilVram(vramGb: number | null): 'low' | 'medium' | 'high' {
|
||||
if (vramGb === null) return 'medium';
|
||||
if (vramGb <= 8) return 'low';
|
||||
if (vramGb <= 16) return 'medium';
|
||||
return 'high';
|
||||
}
|
||||
|
||||
/** Variables de substitution pour un modèle + une résolution « LxH ». */
|
||||
export function variablesPourModele(
|
||||
modele: Modele,
|
||||
resolution: string
|
||||
): Record<string, string | number> {
|
||||
const correspondance = resolution.match(/^(\d+)x(\d+)$/);
|
||||
if (!correspondance) {
|
||||
throw new Error(`Résolution invalide : « ${resolution} » (format attendu : 1024x1024)`);
|
||||
}
|
||||
// Règle anti-hallucination : jamais de nom de checkpoint inventé.
|
||||
if (!modele.capacites.comfyui_checkpoint) {
|
||||
throw new Error(
|
||||
`Le modèle « ${modele.id} » n'a pas de comfyui_checkpoint renseigné — ` +
|
||||
'compléter sa fiche registre avant de générer un workflow.'
|
||||
);
|
||||
}
|
||||
return {
|
||||
modele_checkpoint: modele.capacites.comfyui_checkpoint,
|
||||
resolution,
|
||||
resolution_largeur: Number(correspondance[1]),
|
||||
resolution_hauteur: Number(correspondance[2]),
|
||||
vram_profile: profilVram(modele.capacites.vram_gb)
|
||||
};
|
||||
}
|
||||
|
||||
// ── Persistance ──
|
||||
|
||||
export async function lireTemplates(): Promise<TemplateInfo[]> {
|
||||
await initialiserDonnees();
|
||||
const brut = await readFile(join(config.dataDir, INDEX_TEMPLATES), 'utf8');
|
||||
return z.array(templateInfoSchema).parse(loadYaml(brut));
|
||||
}
|
||||
|
||||
export async function lireGeneres(): Promise<GenereInfo[]> {
|
||||
await initialiserDonnees();
|
||||
const chemin = join(config.dataDir, INDEX_GENERES);
|
||||
if (!existsSync(chemin)) return [];
|
||||
const brut = await readFile(chemin, 'utf8');
|
||||
return z.array(genereInfoSchema).parse(loadYaml(brut) ?? []);
|
||||
}
|
||||
|
||||
export async function lireFichierGenere(fichier: string): Promise<string> {
|
||||
await initialiserDonnees();
|
||||
// Garde : pas de traversée de chemin.
|
||||
if (!/^[a-z0-9][a-z0-9._-]*\.json$/.test(fichier)) throw new Error('Nom de fichier invalide');
|
||||
return readFile(join(config.dataDir, DOSSIER_GENERES, fichier), 'utf8');
|
||||
}
|
||||
|
||||
async function ecrireIndexGeneres(entrees: GenereInfo[]): Promise<void> {
|
||||
await writeFile(
|
||||
join(config.dataDir, INDEX_GENERES),
|
||||
dumpYaml(entrees, { lineWidth: 100, noRefs: true }),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
/** Génère un workflow concret depuis un template + un modèle du registre. */
|
||||
export async function genererWorkflow(
|
||||
templateFichier: string,
|
||||
modele: Modele,
|
||||
resolution: string,
|
||||
vramProfileOverride: 'low' | 'medium' | 'high' | null = null
|
||||
): Promise<GenereInfo> {
|
||||
await initialiserDonnees();
|
||||
const variables = variablesPourModele(modele, resolution);
|
||||
if (vramProfileOverride) variables.vram_profile = vramProfileOverride;
|
||||
|
||||
const templateBrut = await readFile(
|
||||
join(config.dataDir, DOSSIER_TEMPLATES, templateFichier),
|
||||
'utf8'
|
||||
);
|
||||
const concret = substituerWorkflow(templateBrut, variables);
|
||||
|
||||
const base = templateFichier.replace(/\.json$/, '');
|
||||
const info: GenereInfo = {
|
||||
fichier: `${base}__${modele.id}.json`,
|
||||
template: templateFichier,
|
||||
modele_id: modele.id,
|
||||
resolution,
|
||||
vram_profile: variables.vram_profile as GenereInfo['vram_profile'],
|
||||
genere_le: new Date().toISOString()
|
||||
};
|
||||
|
||||
await mkdir(join(config.dataDir, DOSSIER_GENERES), { recursive: true });
|
||||
await writeFile(join(config.dataDir, DOSSIER_GENERES, info.fichier), concret, 'utf8');
|
||||
|
||||
const entrees = (await lireGeneres()).filter((e) => e.fichier !== info.fichier);
|
||||
entrees.push(info);
|
||||
await ecrireIndexGeneres(entrees);
|
||||
|
||||
await committer(
|
||||
[join(DOSSIER_GENERES, info.fichier), INDEX_GENERES],
|
||||
`feat : workflow ${info.fichier} généré depuis ${templateFichier}`
|
||||
);
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Régénère tous les workflows concrets dont le modèle a changé (PRD §4.6 :
|
||||
* « quand registre change → régénération des JSON concrets + commit git »).
|
||||
* Un workflow dont le modèle a disparu du registre est supprimé.
|
||||
* À appeler après chaque écriture du registre.
|
||||
*/
|
||||
export async function regenererWorkflows(): Promise<{ regeneres: number; supprimes: number }> {
|
||||
const entrees = await lireGeneres();
|
||||
if (entrees.length === 0) return { regeneres: 0, supprimes: 0 };
|
||||
|
||||
const registre = await lireRegistre();
|
||||
const cheminsModifies: string[] = [];
|
||||
let regeneres = 0;
|
||||
let supprimes = 0;
|
||||
const restantes: GenereInfo[] = [];
|
||||
|
||||
for (const entree of entrees) {
|
||||
const modele = registre.find((m) => m.id === entree.modele_id);
|
||||
if (!modele) {
|
||||
await rm(join(config.dataDir, DOSSIER_GENERES, entree.fichier), { force: true });
|
||||
cheminsModifies.push(join(DOSSIER_GENERES, entree.fichier));
|
||||
supprimes++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const variables = variablesPourModele(modele, entree.resolution);
|
||||
variables.vram_profile = entree.vram_profile;
|
||||
const templateBrut = await readFile(
|
||||
join(config.dataDir, DOSSIER_TEMPLATES, entree.template),
|
||||
'utf8'
|
||||
);
|
||||
const concret = substituerWorkflow(templateBrut, variables);
|
||||
await writeFile(join(config.dataDir, DOSSIER_GENERES, entree.fichier), concret, 'utf8');
|
||||
cheminsModifies.push(join(DOSSIER_GENERES, entree.fichier));
|
||||
regeneres++;
|
||||
restantes.push({ ...entree, genere_le: new Date().toISOString() });
|
||||
} catch {
|
||||
// Modèle incomplet (ex. checkpoint devenu null) : on conserve le fichier
|
||||
// existant et on le signalera côté UI via l'index — pas de suppression silencieuse.
|
||||
restantes.push(entree);
|
||||
}
|
||||
}
|
||||
|
||||
if (supprimes > 0 || regeneres > 0) {
|
||||
await ecrireIndexGeneres(restantes);
|
||||
cheminsModifies.push(INDEX_GENERES);
|
||||
await committer(
|
||||
cheminsModifies,
|
||||
`feat : régénération des workflows (${regeneres} régénérés, ${supprimes} supprimés)`
|
||||
);
|
||||
}
|
||||
return { regeneres, supprimes };
|
||||
}
|
||||
|
||||
// ── Smoke test ComfyUI (PRD §4.6) ──
|
||||
|
||||
/**
|
||||
* Prompt canari : graphe minimal sans modèle (EmptyImage → PreviewImage),
|
||||
* exécutable sur toute installation ComfyUI.
|
||||
*/
|
||||
export const PROMPT_CANARI = {
|
||||
'1': { class_type: 'EmptyImage', inputs: { width: 64, height: 64, batch_size: 1, color: 0 } },
|
||||
'2': { class_type: 'PreviewImage', inputs: { images: ['1', 0] } }
|
||||
} as const;
|
||||
|
||||
export interface StatutComfyUI {
|
||||
joignable: boolean;
|
||||
accepte: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/** POST du prompt canari sur /prompt — fetch injectable pour les tests. */
|
||||
export async function testerComfyUI(
|
||||
url: string,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<StatutComfyUI> {
|
||||
const endpoint = `${url.replace(/\/$/, '')}/prompt`;
|
||||
try {
|
||||
const reponse = await fetchImpl(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: PROMPT_CANARI }),
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
});
|
||||
if (reponse.ok) {
|
||||
const corps = (await reponse.json()) as { prompt_id?: string };
|
||||
return {
|
||||
joignable: true,
|
||||
accepte: true,
|
||||
detail: `Prompt canari accepté (id ${corps.prompt_id ?? 'inconnu'}).`
|
||||
};
|
||||
}
|
||||
const texte = await reponse.text();
|
||||
return {
|
||||
joignable: true,
|
||||
accepte: false,
|
||||
detail: `API joignable mais prompt refusé (${reponse.status}) : ${texte.slice(0, 300)}`
|
||||
};
|
||||
} catch (erreur) {
|
||||
return {
|
||||
joignable: false,
|
||||
accepte: false,
|
||||
detail: `ComfyUI injoignable (${endpoint}) : ${erreur instanceof Error ? erreur.message : String(erreur)} — dégradation gracieuse, l'app fonctionne sans.`
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
{ href: '/profils', libelle: fr.nav.profils, icone: 'profils', bientot: false },
|
||||
{ href: '/recommander', libelle: fr.nav.recommander, icone: 'cible', bientot: false },
|
||||
{ href: '/exports', libelle: fr.nav.exports, icone: 'registre', bientot: false },
|
||||
{ href: '/workflows', libelle: fr.nav.workflows, icone: 'workflow', bientot: false },
|
||||
{ href: '/alertes', libelle: fr.nav.alertes, icone: 'alerte', bientot: false }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
texte: fr.accueil.carte_alertes_texte,
|
||||
icone: 'alerte',
|
||||
href: '/alertes'
|
||||
},
|
||||
{
|
||||
titre: fr.accueil.carte_workflows_titre,
|
||||
texte: fr.accueil.carte_workflows_texte,
|
||||
icone: 'workflow',
|
||||
href: '/workflows'
|
||||
}
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { modeleSchema } from '$lib/schemas';
|
||||
import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires';
|
||||
import { lireAlertes } from '$lib/server/alertes';
|
||||
import { ecrireRegistre, lireRegistre } from '$lib/server/stockage';
|
||||
import { regenererWorkflows } from '$lib/server/workflows';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url }) => {
|
||||
@@ -45,6 +46,7 @@ export const actions: Actions = {
|
||||
|
||||
registre[index] = resultat.data;
|
||||
await ecrireRegistre(registre, `feat : mise à jour du modèle ${params.id}`);
|
||||
await regenererWorkflows();
|
||||
redirect(303, '/registre');
|
||||
},
|
||||
|
||||
@@ -54,6 +56,7 @@ export const actions: Actions = {
|
||||
if (restant.length === registre.length) error(404, 'Modèle introuvable');
|
||||
|
||||
await ecrireRegistre(restant, `feat : suppression du modèle ${params.id}`);
|
||||
await regenererWorkflows();
|
||||
redirect(303, '/registre');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { modeleSchema } from '$lib/schemas';
|
||||
import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires';
|
||||
import { lireAlertes } from '$lib/server/alertes';
|
||||
import { ecrireRegistre, lireRegistre } from '$lib/server/stockage';
|
||||
import { regenererWorkflows } from '$lib/server/workflows';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
/**
|
||||
@@ -48,6 +49,7 @@ export const actions: Actions = {
|
||||
[...registre, resultat.data],
|
||||
`feat : ajout du modèle ${resultat.data.id} au registre`
|
||||
);
|
||||
await regenererWorkflows();
|
||||
redirect(303, '/registre');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { chaineOuNull } from '$lib/server/formulaires';
|
||||
import { lireRegistre } from '$lib/server/stockage';
|
||||
import { config } from '$lib/server/config';
|
||||
import {
|
||||
genererWorkflow,
|
||||
lireGeneres,
|
||||
lireTemplates,
|
||||
testerComfyUI,
|
||||
type StatutComfyUI
|
||||
} from '$lib/server/workflows';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const [templates, generes, registre] = await Promise.all([
|
||||
lireTemplates(),
|
||||
lireGeneres(),
|
||||
lireRegistre()
|
||||
]);
|
||||
return { templates, generes, registre, comfyuiUrl: config.comfyuiUrl };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
generer: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const templateFichier = chaineOuNull(data.get('template'));
|
||||
const modeleId = chaineOuNull(data.get('modele_id'));
|
||||
const resolution = chaineOuNull(data.get('resolution'));
|
||||
const vramBrut = chaineOuNull(data.get('vram_profile'));
|
||||
const vramOverride =
|
||||
vramBrut === 'low' || vramBrut === 'medium' || vramBrut === 'high' ? vramBrut : null;
|
||||
|
||||
const registre = await lireRegistre();
|
||||
const modele = registre.find((m) => m.id === modeleId);
|
||||
if (!templateFichier || !modele || !resolution) {
|
||||
return fail(400, { erreurGenerer: 'Template, modèle et résolution sont requis.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await genererWorkflow(templateFichier, modele, resolution, vramOverride);
|
||||
return { genere: info.fichier };
|
||||
} catch (erreur) {
|
||||
return fail(400, {
|
||||
erreurGenerer: erreur instanceof Error ? erreur.message : String(erreur)
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
smoke: async (): Promise<{ smoke: StatutComfyUI }> => {
|
||||
return { smoke: await testerComfyUI(config.comfyuiUrl) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
const smoke = $derived(form && 'smoke' in form ? form.smoke : null);
|
||||
const profilsVram = ['low', 'medium', 'high'] as const;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fr.workflows.titre} — {fr.app.nom}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>{fr.workflows.titre}</h1>
|
||||
<p class="intro">{fr.workflows.intro}</p>
|
||||
|
||||
<section aria-labelledby="titre-generer">
|
||||
<h2 id="titre-generer">{fr.workflows.section_generer}</h2>
|
||||
|
||||
{#if form && 'erreurGenerer' in form}
|
||||
<p class="erreur" role="alert">{fr.workflows.erreur_generation} {form.erreurGenerer}</p>
|
||||
{/if}
|
||||
|
||||
<form method="post" action="?/generer" use:enhance class="filtres">
|
||||
<label class="champ">
|
||||
<span>{fr.workflows.champ_template}</span>
|
||||
<select name="template" required>
|
||||
{#each data.templates as template (template.fichier)}
|
||||
<option value={template.fichier} title={template.description}>{template.nom}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="champ">
|
||||
<span>{fr.workflows.champ_modele}</span>
|
||||
<select name="modele_id" required>
|
||||
{#each data.registre as modele (modele.id)}
|
||||
<option value={modele.id}>{modele.nom}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="champ">
|
||||
<span>{fr.workflows.champ_resolution}</span>
|
||||
<input
|
||||
name="resolution"
|
||||
value="1024x1024"
|
||||
required
|
||||
pattern="\d+x\d+"
|
||||
placeholder="1024x1024"
|
||||
/>
|
||||
</label>
|
||||
<label class="champ">
|
||||
<span>{fr.workflows.champ_vram}</span>
|
||||
<select name="vram_profile">
|
||||
<option value="">{fr.workflows.vram_auto}</option>
|
||||
{#each profilsVram as profil (profil)}
|
||||
<option value={profil}>{profil}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button class="bouton" type="submit">{fr.workflows.generer}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="titre-generes">
|
||||
<h2 id="titre-generes">{fr.workflows.section_generes}</h2>
|
||||
<p class="aide">{fr.workflows.regenere_auto}</p>
|
||||
|
||||
{#if data.generes.length === 0}
|
||||
<p>{fr.workflows.aucun_genere}</p>
|
||||
{:else}
|
||||
<div class="table-conteneur">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{fr.workflows.colonne_fichier}</th>
|
||||
<th scope="col">{fr.workflows.colonne_template}</th>
|
||||
<th scope="col">{fr.workflows.colonne_modele}</th>
|
||||
<th scope="col">{fr.workflows.colonne_genere_le}</th>
|
||||
<th scope="col"><span class="sr-only">{fr.workflows.telecharger}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.generes as genere (genere.fichier)}
|
||||
<tr>
|
||||
<th scope="row">{genere.fichier}</th>
|
||||
<td>{genere.template}</td>
|
||||
<td>{genere.modele_id}</td>
|
||||
<td>
|
||||
<time datetime={genere.genere_le}>{genere.genere_le.slice(0, 10)}</time>
|
||||
</td>
|
||||
<td>
|
||||
<a href={resolve(`/workflows/fichier/${genere.fichier}`)} download>
|
||||
{fr.workflows.telecharger}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="titre-smoke">
|
||||
<h2 id="titre-smoke">{fr.workflows.section_smoke}</h2>
|
||||
<p class="aide">
|
||||
{fr.workflows.smoke_intro}
|
||||
{fr.workflows.endpoint} : <code>{data.comfyuiUrl}</code>
|
||||
</p>
|
||||
|
||||
<form method="post" action="?/smoke" use:enhance>
|
||||
<button class="bouton" type="submit">{fr.workflows.lancer_smoke}</button>
|
||||
</form>
|
||||
|
||||
{#if smoke}
|
||||
<p class="statut" class:ok={smoke.accepte} class:ko={smoke.joignable && !smoke.accepte}>
|
||||
<Icon nom={smoke.accepte ? 'verifie' : 'avertissement'} taille={18} />
|
||||
<strong>
|
||||
{smoke.accepte
|
||||
? fr.workflows.smoke_ok
|
||||
: smoke.joignable
|
||||
? fr.workflows.smoke_ko
|
||||
: fr.workflows.smoke_injoignable}
|
||||
</strong>
|
||||
{smoke.detail}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-block: var(--space-3) var(--space-2);
|
||||
}
|
||||
|
||||
.intro,
|
||||
.aide {
|
||||
color: var(--muted);
|
||||
max-width: 75ch;
|
||||
}
|
||||
|
||||
.filtres {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
background: var(--card-bg);
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
||||
.champ > span {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
select,
|
||||
input {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg);
|
||||
padding: 0.5em 0.75em;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.table-conteneur {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: var(--space-1);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
thead th {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.erreur {
|
||||
color: var(--signal);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.statut.ok :global(svg),
|
||||
.statut.ok strong {
|
||||
color: var(--succes);
|
||||
}
|
||||
|
||||
.statut.ko :global(svg),
|
||||
.statut.ko strong {
|
||||
color: var(--signal);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { lireFichierGenere } from '$lib/server/workflows';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/** GET /workflows/fichier/[nom] — téléchargement d'un workflow JSON généré. */
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
try {
|
||||
const contenu = await lireFichierGenere(params.nom);
|
||||
return new Response(contenu, {
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'content-disposition': `attachment; filename="${params.nom}"`
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return new Response('Workflow introuvable', { status: 404 });
|
||||
}
|
||||
};
|
||||
@@ -39,4 +39,11 @@
|
||||
<symbol id="verifie" viewBox="0 0 24 24">
|
||||
<path d="M3 13l6 6L21 5" />
|
||||
</symbol>
|
||||
<symbol id="workflow" viewBox="0 0 24 24">
|
||||
<!-- Graphe de nœuds -->
|
||||
<rect x="2" y="9" width="6" height="6" />
|
||||
<rect x="16" y="2" width="6" height="6" />
|
||||
<rect x="16" y="16" width="6" height="6" />
|
||||
<path d="M8 12h4M12 12l4-7M12 12l4 7" />
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
Reference in New Issue
Block a user