diff --git a/README.md b/README.md index 93f15cb..0e42537 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/app/.env.example b/app/.env.example index f0dca25..b36c1f4 100644 --- a/app/.env.example +++ b/app/.env.example @@ -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= diff --git a/app/src/lib/components/ModeleForm.svelte b/app/src/lib/components/ModeleForm.svelte index 823ec88..44898e0 100644 --- a/app/src/lib/components/ModeleForm.svelte +++ b/app/src/lib/components/ModeleForm.svelte @@ -171,6 +171,14 @@ + diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index ca5d284..14c47dc 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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).', diff --git a/app/src/lib/schemas.ts b/app/src/lib/schemas.ts index e9a434a..783151b 100644 --- a/app/src/lib/schemas.ts +++ b/app/src/lib/schemas.ts @@ -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) }); diff --git a/app/src/lib/server/config.ts b/app/src/lib/server/config.ts index 5ccf4e8..f516238 100644 --- a/app/src/lib/server/config.ts +++ b/app/src/lib/server/config.ts @@ -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, diff --git a/app/src/lib/server/formulaires.ts b/app/src/lib/server/formulaires.ts index da6943b..90c4276 100644 --- a/app/src/lib/server/formulaires.ts +++ b/app/src/lib/server/formulaires.ts @@ -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, diff --git a/app/src/lib/server/seed/workflows.ts b/app/src/lib/server/seed/workflows.ts new file mode 100644 index 0000000..0ffc865 --- /dev/null +++ b/app/src/lib/server/seed/workflows.ts @@ -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 = { + '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. +`; diff --git a/app/src/lib/server/stockage.ts b/app/src/lib/server/stockage.ts index adac994..78bd85d 100644 --- a/app/src/lib/server/stockage.ts +++ b/app/src/lib/server/stockage.ts @@ -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 { 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, diff --git a/app/src/lib/server/workflows.test.ts b/app/src/lib/server/workflows.test.ts new file mode 100644 index 0000000..c7f091a --- /dev/null +++ b/app/src/lib/server/workflows.test.ts @@ -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]); + }); +}); diff --git a/app/src/lib/server/workflows.ts b/app/src/lib/server/workflows.ts new file mode 100644 index 0000000..1221e20 --- /dev/null +++ b/app/src/lib/server/workflows.ts @@ -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; + +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; + +// ── 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 { + 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 { + 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 { + await initialiserDonnees(); + const brut = await readFile(join(config.dataDir, INDEX_TEMPLATES), 'utf8'); + return z.array(templateInfoSchema).parse(loadYaml(brut)); +} + +export async function lireGeneres(): Promise { + 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 { + 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 { + 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 { + 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 { + 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.` + }; + } +} diff --git a/app/src/routes/+layout.svelte b/app/src/routes/+layout.svelte index 20f2dcb..884903d 100644 --- a/app/src/routes/+layout.svelte +++ b/app/src/routes/+layout.svelte @@ -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; diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte index e47e75a..d09b21f 100644 --- a/app/src/routes/+page.svelte +++ b/app/src/routes/+page.svelte @@ -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; diff --git a/app/src/routes/registre/[id]/+page.server.ts b/app/src/routes/registre/[id]/+page.server.ts index 1052bef..916f180 100644 --- a/app/src/routes/registre/[id]/+page.server.ts +++ b/app/src/routes/registre/[id]/+page.server.ts @@ -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'); } }; diff --git a/app/src/routes/registre/nouveau/+page.server.ts b/app/src/routes/registre/nouveau/+page.server.ts index 4a93f7e..e402bf8 100644 --- a/app/src/routes/registre/nouveau/+page.server.ts +++ b/app/src/routes/registre/nouveau/+page.server.ts @@ -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'); } }; diff --git a/app/src/routes/workflows/+page.server.ts b/app/src/routes/workflows/+page.server.ts new file mode 100644 index 0000000..aa02abe --- /dev/null +++ b/app/src/routes/workflows/+page.server.ts @@ -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) }; + } +}; diff --git a/app/src/routes/workflows/+page.svelte b/app/src/routes/workflows/+page.svelte new file mode 100644 index 0000000..31add8a --- /dev/null +++ b/app/src/routes/workflows/+page.svelte @@ -0,0 +1,234 @@ + + + + {fr.workflows.titre} — {fr.app.nom} + + +

{fr.workflows.titre}

+

{fr.workflows.intro}

+ +
+

{fr.workflows.section_generer}

+ + {#if form && 'erreurGenerer' in form} + + {/if} + +
+ + + + + +
+
+ +
+

{fr.workflows.section_generes}

+

{fr.workflows.regenere_auto}

+ + {#if data.generes.length === 0} +

{fr.workflows.aucun_genere}

+ {:else} +
+ + + + + + + + + + + + {#each data.generes as genere (genere.fichier)} + + + + + + + + {/each} + +
{fr.workflows.colonne_fichier}{fr.workflows.colonne_template}{fr.workflows.colonne_modele}{fr.workflows.colonne_genere_le}{fr.workflows.telecharger}
{genere.fichier}{genere.template}{genere.modele_id} + + + + {fr.workflows.telecharger} + +
+
+ {/if} +
+ +
+

{fr.workflows.section_smoke}

+

+ {fr.workflows.smoke_intro} + {fr.workflows.endpoint} : {data.comfyuiUrl} +

+ +
+ +
+ + {#if smoke} +

+ + + {smoke.accepte + ? fr.workflows.smoke_ok + : smoke.joignable + ? fr.workflows.smoke_ko + : fr.workflows.smoke_injoignable} + + {smoke.detail} +

+ {/if} +
+ + diff --git a/app/src/routes/workflows/fichier/[nom]/+server.ts b/app/src/routes/workflows/fichier/[nom]/+server.ts new file mode 100644 index 0000000..21c4535 --- /dev/null +++ b/app/src/routes/workflows/fichier/[nom]/+server.ts @@ -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 }); + } +}; diff --git a/app/static/icons.svg b/app/static/icons.svg index cf85afe..ed92484 100644 --- a/app/static/icons.svg +++ b/app/static/icons.svg @@ -39,4 +39,11 @@ + + + + + + +