seo-generator-server/public/pipeline-builder.js
StillHammer 471058f731 Add flexible pipeline system with per-module LLM configuration
- New modular pipeline architecture allowing custom workflow combinations
- Per-step LLM provider configuration (Claude, OpenAI, Gemini, Deepseek, Moonshot, Mistral)
- Visual pipeline builder and runner interfaces with drag-and-drop
- 10 predefined pipeline templates (minimal-test to originality-bypass)
- Pipeline CRUD operations via ConfigManager and REST API
- Fix variable resolution in instructions (HTML tags were breaking {{variables}})
- Fix hardcoded LLM providers in AdversarialCore
- Add TESTS_LLM_PROVIDER.md documentation with validation results
- Update dashboard to disable legacy config editor

API Endpoints:
- POST /api/pipeline/save, execute, validate, estimate
- GET /api/pipeline/list, modules, templates

Backward compatible with legacy modular workflow system.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 14:01:52 +08:00

579 lines
17 KiB
JavaScript

/**
* Pipeline Builder - Client Side Logic
* Gestion de la construction interactive de pipelines modulaires
*/
// État global du builder
const state = {
pipeline: {
name: '',
description: '',
pipeline: [],
metadata: {
author: 'user',
created: new Date().toISOString(),
version: '1.0',
tags: []
}
},
modules: [],
templates: [],
llmProviders: [],
nextStepNumber: 1
};
// ====================
// INITIALIZATION
// ====================
window.onload = async function() {
await loadModules();
await loadTemplates();
await loadLLMProviders();
updatePreview();
};
// Load available modules from API
async function loadModules() {
try {
const response = await fetch('/api/pipeline/modules');
const data = await response.json();
if (data.success) {
state.modules = data.modules;
renderModulesPalette();
}
} catch (error) {
showStatus(`Erreur chargement modules: ${error.message}`, 'error');
}
}
// Load templates from API
async function loadTemplates() {
try {
const response = await fetch('/api/pipeline/templates');
const data = await response.json();
if (data.success) {
state.templates = data.templates;
renderTemplates();
}
} catch (error) {
showStatus(`Erreur chargement templates: ${error.message}`, 'error');
}
}
// Load LLM providers from API
async function loadLLMProviders() {
try {
const response = await fetch('/api/pipeline/modules');
const data = await response.json();
if (data.success && data.llmProviders) {
state.llmProviders = data.llmProviders;
}
} catch (error) {
console.error('Erreur chargement LLM providers:', error);
// Fallback providers si l'API échoue
state.llmProviders = [
{ id: 'claude', name: 'Claude (Anthropic)', default: true },
{ id: 'openai', name: 'OpenAI GPT-4' },
{ id: 'gemini', name: 'Google Gemini' },
{ id: 'deepseek', name: 'Deepseek' },
{ id: 'moonshot', name: 'Moonshot' },
{ id: 'mistral', name: 'Mistral AI' }
];
}
}
// ====================
// RENDERING
// ====================
function renderModulesPalette() {
const container = document.getElementById('modulesContainer');
container.innerHTML = '';
const categories = {
core: ['generation'],
enhancement: ['selective'],
protection: ['adversarial', 'human', 'pattern']
};
const categoryLabels = {
core: '🎯 Génération',
enhancement: '✨ Enhancement',
protection: '🛡️ Protection'
};
Object.entries(categories).forEach(([catKey, moduleIds]) => {
const catDiv = document.createElement('div');
catDiv.className = 'module-category';
const catTitle = document.createElement('h3');
catTitle.textContent = categoryLabels[catKey];
catDiv.appendChild(catTitle);
moduleIds.forEach(moduleId => {
const module = state.modules.find(m => m.id === moduleId);
if (!module) return;
const moduleDiv = document.createElement('div');
moduleDiv.className = 'module-item';
moduleDiv.draggable = true;
moduleDiv.dataset.moduleId = module.id;
moduleDiv.innerHTML = `
<div class="module-name">${module.name}</div>
<div class="module-desc">${module.description}</div>
`;
// Drag events
moduleDiv.addEventListener('dragstart', handleDragStart);
moduleDiv.addEventListener('dragend', handleDragEnd);
// Click to add
moduleDiv.addEventListener('click', () => {
addStep(module.id, module.modes[0]);
});
catDiv.appendChild(moduleDiv);
});
container.appendChild(catDiv);
});
}
function renderTemplates() {
const container = document.getElementById('templatesContainer');
container.innerHTML = '';
state.templates.forEach(template => {
const templateDiv = document.createElement('div');
templateDiv.className = 'template-item';
templateDiv.innerHTML = `
<div class="template-name">${template.name}</div>
<div class="template-desc">${template.description.substring(0, 60)}...</div>
`;
templateDiv.addEventListener('click', () => loadTemplate(template.id));
container.appendChild(templateDiv);
});
}
function renderPipeline() {
const container = document.getElementById('stepsContainer');
const empty = document.getElementById('canvasEmpty');
if (state.pipeline.pipeline.length === 0) {
container.style.display = 'none';
empty.style.display = 'flex';
return;
}
empty.style.display = 'none';
container.style.display = 'block';
container.innerHTML = '';
state.pipeline.pipeline.forEach((step, index) => {
const stepDiv = createStepElement(step, index);
container.appendChild(stepDiv);
});
updatePreview();
}
function createStepElement(step, index) {
const module = state.modules.find(m => m.id === step.module);
if (!module) return document.createElement('div');
const stepDiv = document.createElement('div');
stepDiv.className = 'pipeline-step';
stepDiv.dataset.stepIndex = index;
stepDiv.innerHTML = `
<div class="step-header">
<div class="step-number">${step.step}</div>
<div class="step-title">${module.name} - ${step.mode}</div>
<div class="step-actions">
<button class="step-btn" onclick="moveStepUp(${index})" ${index === 0 ? 'disabled' : ''}>↑</button>
<button class="step-btn" onclick="moveStepDown(${index})" ${index === state.pipeline.pipeline.length - 1 ? 'disabled' : ''}>↓</button>
<button class="step-btn" onclick="duplicateStep(${index})">📋</button>
<button class="step-btn" onclick="deleteStep(${index})">🗑️</button>
</div>
</div>
<div class="step-config">
<div class="config-row">
<label>Mode:</label>
<select onchange="updateStepMode(${index}, this.value)">
${module.modes.map(mode =>
`<option value="${mode}" ${mode === step.mode ? 'selected' : ''}>${mode}</option>`
).join('')}
</select>
</div>
<div class="config-row">
<label>Intensité:</label>
<input type="number" step="0.1" min="0.1" max="2.0" value="${step.intensity || 1.0}"
onchange="updateStepIntensity(${index}, parseFloat(this.value))">
</div>
${renderModuleParameters(step, index, module)}
</div>
`;
return stepDiv;
}
function renderModuleParameters(step, index, module) {
let html = '';
// Toujours afficher le dropdown LLM Provider en premier
const currentProvider = step.parameters?.llmProvider || module.defaultLLM || '';
const defaultProvider = module.defaultLLM || 'claude';
html += `
<div class="config-row">
<label>LLM:</label>
<select onchange="updateStepParameter(${index}, 'llmProvider', this.value)">
<option value="">Default (${defaultProvider})</option>
${state.llmProviders.map(provider =>
`<option value="${provider.id}" ${provider.id === currentProvider ? 'selected' : ''}>${provider.name}</option>`
).join('')}
</select>
</div>
`;
// Autres paramètres du module (sauf llmProvider qui est déjà affiché)
if (module.parameters && Object.keys(module.parameters).length > 0) {
Object.entries(module.parameters).forEach(([paramName, paramConfig]) => {
// Skip llmProvider car déjà affiché ci-dessus
if (paramName === 'llmProvider') return;
const value = step.parameters?.[paramName] || paramConfig.default || '';
if (paramConfig.enum) {
html += `
<div class="config-row">
<label>${paramName}:</label>
<select onchange="updateStepParameter(${index}, '${paramName}', this.value)">
${paramConfig.enum.map(opt =>
`<option value="${opt}" ${opt === value ? 'selected' : ''}>${opt}</option>`
).join('')}
</select>
</div>
`;
} else if (paramConfig.type === 'number') {
html += `
<div class="config-row">
<label>${paramName}:</label>
<input type="number" step="${paramConfig.step || 0.1}"
min="${paramConfig.min || 0}" max="${paramConfig.max || 10}"
value="${value}"
onchange="updateStepParameter(${index}, '${paramName}', parseFloat(this.value))">
</div>
`;
}
});
}
return html;
}
// ====================
// PIPELINE OPERATIONS
// ====================
function addStep(moduleId, mode = null) {
const module = state.modules.find(m => m.id === moduleId);
if (!module) return;
const newStep = {
step: state.nextStepNumber++,
module: moduleId,
mode: mode || module.modes[0],
intensity: module.defaultIntensity || 1.0,
parameters: {},
enabled: true
};
state.pipeline.pipeline.push(newStep);
reorderSteps();
renderPipeline();
}
function deleteStep(index) {
state.pipeline.pipeline.splice(index, 1);
reorderSteps();
renderPipeline();
}
function duplicateStep(index) {
const step = state.pipeline.pipeline[index];
const duplicated = JSON.parse(JSON.stringify(step));
duplicated.step = state.nextStepNumber++;
state.pipeline.pipeline.splice(index + 1, 0, duplicated);
reorderSteps();
renderPipeline();
}
function moveStepUp(index) {
if (index === 0) return;
const temp = state.pipeline.pipeline[index];
state.pipeline.pipeline[index] = state.pipeline.pipeline[index - 1];
state.pipeline.pipeline[index - 1] = temp;
reorderSteps();
renderPipeline();
}
function moveStepDown(index) {
if (index === state.pipeline.pipeline.length - 1) return;
const temp = state.pipeline.pipeline[index];
state.pipeline.pipeline[index] = state.pipeline.pipeline[index + 1];
state.pipeline.pipeline[index + 1] = temp;
reorderSteps();
renderPipeline();
}
function updateStepMode(index, mode) {
state.pipeline.pipeline[index].mode = mode;
updatePreview();
}
function updateStepIntensity(index, intensity) {
state.pipeline.pipeline[index].intensity = intensity;
updatePreview();
}
function updateStepParameter(index, paramName, value) {
if (!state.pipeline.pipeline[index].parameters) {
state.pipeline.pipeline[index].parameters = {};
}
// Si value est vide/null/undefined, supprimer la clé pour utiliser le default
if (value === '' || value === null || value === undefined) {
delete state.pipeline.pipeline[index].parameters[paramName];
} else {
state.pipeline.pipeline[index].parameters[paramName] = value;
}
updatePreview();
}
function reorderSteps() {
state.pipeline.pipeline.forEach((step, index) => {
step.step = index + 1;
});
state.nextStepNumber = state.pipeline.pipeline.length + 1;
}
function clearPipeline() {
if (!confirm('Effacer tout le pipeline ?')) return;
state.pipeline.pipeline = [];
state.nextStepNumber = 1;
document.getElementById('pipelineName').value = '';
document.getElementById('pipelineDesc').value = '';
renderPipeline();
}
// ====================
// DRAG & DROP
// ====================
let draggedElement = null;
function handleDragStart(e) {
draggedElement = e.target;
e.target.classList.add('dragging');
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('moduleId', e.target.dataset.moduleId);
}
function handleDragEnd(e) {
e.target.classList.remove('dragging');
}
// Setup drop zone
document.addEventListener('DOMContentLoaded', () => {
const canvas = document.getElementById('pipelineCanvas');
canvas.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
canvas.addEventListener('drop', (e) => {
e.preventDefault();
const moduleId = e.dataTransfer.getData('moduleId');
if (moduleId) {
addStep(moduleId);
}
});
// Add step button
document.getElementById('addStepBtn').addEventListener('click', () => {
const firstModule = state.modules[0];
if (firstModule) {
addStep(firstModule.id);
}
});
});
// ====================
// TEMPLATES
// ====================
async function loadTemplate(templateId) {
try {
const response = await fetch(`/api/pipeline/templates/${templateId}`);
const data = await response.json();
if (data.success) {
state.pipeline = data.template;
state.nextStepNumber = data.template.pipeline.length + 1;
document.getElementById('pipelineName').value = data.template.name;
document.getElementById('pipelineDesc').value = data.template.description || '';
renderPipeline();
showStatus(`Template "${data.template.name}" chargé`, 'success');
}
} catch (error) {
showStatus(`Erreur chargement template: ${error.message}`, 'error');
}
}
// ====================
// SAVE / VALIDATE / TEST
// ====================
async function savePipeline() {
const name = document.getElementById('pipelineName').value.trim();
const description = document.getElementById('pipelineDesc').value.trim();
if (!name) {
showStatus('Nom du pipeline requis', 'error');
return;
}
if (state.pipeline.pipeline.length === 0) {
showStatus('Pipeline vide, ajoutez au moins une étape', 'error');
return;
}
state.pipeline.name = name;
state.pipeline.description = description;
state.pipeline.metadata.saved = new Date().toISOString();
try {
const response = await fetch('/api/pipeline/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pipelineDefinition: state.pipeline })
});
const data = await response.json();
if (data.success) {
showStatus(`✅ Pipeline "${name}" sauvegardé`, 'success');
} else {
showStatus(`Erreur: ${data.error}`, 'error');
}
} catch (error) {
showStatus(`Erreur sauvegarde: ${error.message}`, 'error');
}
}
async function validatePipeline() {
const name = document.getElementById('pipelineName').value.trim();
if (!name) {
state.pipeline.name = 'Unnamed Pipeline';
} else {
state.pipeline.name = name;
}
try {
const response = await fetch('/api/pipeline/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pipelineDefinition: state.pipeline })
});
const data = await response.json();
if (data.valid) {
showStatus('✅ Pipeline valide', 'success');
} else {
showStatus(`❌ Erreurs: ${data.errors.join(', ')}`, 'error');
}
} catch (error) {
showStatus(`Erreur validation: ${error.message}`, 'error');
}
}
async function testPipeline() {
const name = document.getElementById('pipelineName').value.trim();
if (!name) {
showStatus('Nom du pipeline requis pour le test', 'error');
return;
}
state.pipeline.name = name;
state.pipeline.description = document.getElementById('pipelineDesc').value.trim();
const rowNumber = prompt('Numéro de ligne Google Sheets à tester ?', '2');
if (!rowNumber) return;
showStatus('🚀 Test en cours...', 'info');
try {
const response = await fetch('/api/pipeline/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pipelineConfig: state.pipeline,
rowNumber: parseInt(rowNumber)
})
});
const data = await response.json();
if (data.success) {
showStatus(`✅ Test réussi! Durée: ${data.result.stats.totalDuration}ms`, 'success');
console.log('Test result:', data.result);
} else {
showStatus(`❌ Test échoué: ${data.error}`, 'error');
}
} catch (error) {
showStatus(`Erreur test: ${error.message}`, 'error');
}
}
// ====================
// PREVIEW & HELPERS
// ====================
function updatePreview() {
const preview = document.getElementById('previewJson');
preview.textContent = JSON.stringify(state.pipeline, null, 2);
}
function showStatus(message, type) {
const status = document.getElementById('status');
status.textContent = message;
status.className = `status ${type}`;
status.style.display = 'block';
setTimeout(() => {
status.style.display = 'none';
}, 5000);
}