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:
cyber-mawonaj
2026-08-01 11:05:30 -04:00
parent 7a90ddd641
commit 2e0220b1c9
19 changed files with 871 additions and 1 deletions
+6 -1
View File
@@ -35,6 +35,7 @@ Pour tester finement une variable précise, copie `.env.example` en `.env` et ut
| `DATA_DIR` | `./data` (relatif à `app/`) | Dossier persistant : YAML + git interne. En prod : `/home/yunohost.app/veille-ia` |
| `OLLAMA_URL` | `http://127.0.0.1:11434` | Endpoint Ollama. Optionnel : dégradation gracieuse si injoignable |
| `ALERTS_TOKEN` | *(aucun)* | Token Bearer de `POST /api/alerts`. Généré à l'install YNH, jamais commité |
| `COMFYUI_URL` | `http://127.0.0.1:8188` | API ComfyUI pour les smoke tests. Dégradation gracieuse si injoignable |
| `SSO_HEADER` | `ynh-user` | Header HTTP injecté par SSOwat portant l'utilisateur authentifié |
| `DEV_USER` | *(aucun)* | Dev uniquement : simule un utilisateur SSO. Ignoré en production |
| `BASE_PATH` | *(aucun)* | Build uniquement : sous-chemin de montage (ex. `/veille`) pour `paths.base` |
@@ -91,5 +92,9 @@ Optionnel. Si le paquet `ollama_ynh` est indisponible : Ollama sur la machine lo
- `POST /api/alerts` : token Bearer (`ALERTS_TOKEN`, comparaison en temps constant), schéma d'alerte du PRD §4.4 validé par Zod, 503 si non configuré
- Inbox `/alertes` : filtres par profil impacté et statut, actions « Marquer traité » et « Créer/MAJ fiche » (pré-remplissage du formulaire registre depuis l'alerte, sans inventer de champ)
- `nodered-flows/` : 4 flows importables (rss-ingest, license-watch, classifier avec le prompt figé du PRD §6, notify) — nœuds core uniquement, configuration par variables d'env
- [ ] **Phase 3** — templates ComfyUI + smoke tests
- [x] **Phase 3** — templates ComfyUI + smoke tests
- Templates paramétrés (`data/workflows/templates/`) : substitution de variables (`{{modele_checkpoint}}`, `{{resolution}}`, `{{vram_profile}}`…), **jamais de réécriture de topologie**
- Génération de JSON concrets (`/workflows`) + **régénération automatique à chaque modification du registre**, commit git ; suppression si le modèle disparaît
- Champ `comfyui_checkpoint` dans le 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 si injoignable
- [ ] **Phase 4** — durcissement (i18n gcf/en, niveau 8 package_check)
+3
View File
@@ -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=
+8
View File
@@ -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>
+34
View File
@@ -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 nest 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 lAPI 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).',
+2
View File
@@ -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)
});
+3
View File
@@ -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,
+1
View File
@@ -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,
+64
View File
@@ -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.
`;
+15
View File
@@ -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,
+123
View File
@@ -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]);
});
});
+290
View File
@@ -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.`
};
}
}
+1
View File
@@ -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>
+6
View File
@@ -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');
}
};
+52
View File
@@ -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) };
}
};
+234
View File
@@ -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 });
}
};
+7
View File
@@ -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