feat: DAG executor async + intégration IA/LLM dans le VWB

- DAGExecutor : exécution workflow par graphe de dépendances,
  étapes LLM parallèles, UI séquentielles, injection ${step.result}
- LLMActionHandler : analyze_text, translate, extract_data, generate_text
  via Ollama /api/chat (qwen3-vl:8b, temperature 0.1)
- VWB palette : catégorie "IA / LLM" avec 4 actions draggables
- VWB propriétés : éditeurs pour chaque action LLM (modèle, prompt, langue)
- VWB endpoint : POST /api/v3/workflow/<id>/execute-dag
- 37 tests unitaires DAG executor (tous passent)
- Fix log spam cache workflows (info → debug)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-16 22:58:44 +01:00
parent ad15237fe0
commit 5e3865d328
11 changed files with 2911 additions and 2 deletions

View File

@@ -788,6 +788,194 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
</>
);
// === DAG LLM ===
case 'llm_analyze':
return (
<>
<div className="prop-section-title">
<span className="icon">🔬</span> Analyser texte (LLM)
</div>
<div className="prop-field">
<label>Texte à analyser</label>
<textarea
value={String(params.text || '')}
onChange={(e) => updateParam('text', e.target.value)}
rows={4}
placeholder={"Texte direct ou référence : ${etape_prec.result}"}
/>
<small className="field-hint">Utilisez {'${step_id.result}'} pour injecter le résultat d'une étape précédente</small>
</div>
<div className="prop-field">
<label>Instruction</label>
<textarea
value={String(params.instruction || 'Analyse et résume ce texte.')}
onChange={(e) => updateParam('instruction', e.target.value)}
rows={2}
placeholder="Analyse et résume ce texte."
/>
</div>
<div className="prop-field">
<label>Modèle Ollama (optionnel)</label>
<input
type="text"
value={String(params.model || '')}
onChange={(e) => updateParam('model', e.target.value)}
placeholder="Par défaut : qwen3-vl:8b"
/>
</div>
<div className="prop-field">
<label>Température ({Number(params.temperature || 0.1).toFixed(1)})</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={Number(params.temperature || 0.1)}
onChange={(e) => updateParam('temperature', Number(e.target.value))}
/>
</div>
</>
);
case 'llm_translate':
return (
<>
<div className="prop-section-title">
<span className="icon">🌐</span> Traduire (LLM)
</div>
<div className="prop-field">
<label>Texte à traduire</label>
<textarea
value={String(params.text || '')}
onChange={(e) => updateParam('text', e.target.value)}
rows={4}
placeholder={"Texte direct ou référence : ${etape_prec.result}"}
/>
<small className="field-hint">Utilisez {'${step_id.result}'} pour injecter le résultat d'une étape précédente</small>
</div>
<div className="prop-field">
<label>Langue cible</label>
<select
value={String(params.target_lang || 'français')}
onChange={(e) => updateParam('target_lang', e.target.value)}
>
<option value="français">Français</option>
<option value="anglais">Anglais</option>
<option value="espagnol">Espagnol</option>
<option value="allemand">Allemand</option>
<option value="chinois">Chinois</option>
<option value="arabe">Arabe</option>
<option value="japonais">Japonais</option>
</select>
</div>
<div className="prop-field">
<label>Langue source (optionnel)</label>
<input
type="text"
value={String(params.source_lang || '')}
onChange={(e) => updateParam('source_lang', e.target.value)}
placeholder="Auto-détection"
/>
</div>
<div className="prop-field">
<label>Modèle Ollama (optionnel)</label>
<input
type="text"
value={String(params.model || '')}
onChange={(e) => updateParam('model', e.target.value)}
placeholder="Par défaut : qwen3-vl:8b"
/>
</div>
</>
);
case 'llm_extract_data':
return (
<>
<div className="prop-section-title">
<span className="icon">🗂</span> Extraire données (LLM)
</div>
<div className="prop-field">
<label>Texte source</label>
<textarea
value={String(params.text || '')}
onChange={(e) => updateParam('text', e.target.value)}
rows={4}
placeholder={"Texte direct ou référence : ${etape_prec.result}"}
/>
<small className="field-hint">Utilisez {'${step_id.result}'} pour injecter le résultat d'une étape précédente</small>
</div>
<div className="prop-field">
<label>Schéma d'extraction (JSON)</label>
<textarea
value={String(params.schema || '{\n "nom": "Nom complet",\n "date": "Date au format JJ/MM/AAAA"\n}')}
onChange={(e) => updateParam('schema', e.target.value)}
rows={5}
placeholder={'{\n "nom": "Nom complet",\n "date": "Date"\n}'}
/>
<small className="field-hint">Clés = champs à extraire, valeurs = descriptions</small>
</div>
<div className="prop-field">
<label>Modèle Ollama (optionnel)</label>
<input
type="text"
value={String(params.model || '')}
onChange={(e) => updateParam('model', e.target.value)}
placeholder="Par défaut : qwen3-vl:8b"
/>
</div>
</>
);
case 'llm_generate':
return (
<>
<div className="prop-section-title">
<span className="icon"></span> Générer texte (LLM)
</div>
<div className="prop-field">
<label>Prompt de génération</label>
<textarea
value={String(params.prompt || '')}
onChange={(e) => updateParam('prompt', e.target.value)}
rows={4}
placeholder="Rédige un email de relance à partir des données suivantes..."
/>
</div>
<div className="prop-field">
<label>Contexte (optionnel)</label>
<textarea
value={String(params.context || '')}
onChange={(e) => updateParam('context', e.target.value)}
rows={3}
placeholder={"Contexte additionnel ou référence : ${etape_prec.result}"}
/>
<small className="field-hint">Utilisez {'${step_id.result}'} pour injecter le résultat d'une étape précédente</small>
</div>
<div className="prop-field">
<label>Modèle Ollama (optionnel)</label>
<input
type="text"
value={String(params.model || '')}
onChange={(e) => updateParam('model', e.target.value)}
placeholder="Par défaut : qwen3-vl:8b"
/>
</div>
<div className="prop-field">
<label>Température ({Number(params.temperature || 0.1).toFixed(1)})</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={Number(params.temperature || 0.1)}
onChange={(e) => updateParam('temperature', Number(e.target.value))}
/>
<small className="field-hint">0 = précis/déterministe, 1 = créatif/varié</small>
</div>
</>
);
// === BDD ===
case 'db_save_data':
return (

View File

@@ -45,6 +45,23 @@ function StepNode({ data, selected }: StepNodeProps) {
</div>
)}
{/* Aperçu des paramètres LLM DAG */}
{step.action_type === 'llm_translate' && step.parameters?.target_lang && (
<div className="step-node-params">
{`${step.parameters.target_lang}`}
</div>
)}
{step.action_type === 'llm_generate' && typeof step.parameters?.prompt === 'string' && step.parameters.prompt.length > 0 && (
<div className="step-node-params">
{`"${step.parameters.prompt.slice(0, 25)}${step.parameters.prompt.length > 25 ? '...' : ''}"`}
</div>
)}
{step.action_type === 'llm_analyze' && typeof step.parameters?.instruction === 'string' && step.parameters.instruction.length > 0 && (
<div className="step-node-params">
{`"${step.parameters.instruction.slice(0, 25)}${step.parameters.instruction.length > 25 ? '...' : ''}"`}
</div>
)}
{!step.anchor_id && action?.needsAnchor && (
<div className="step-node-warning">
Ancre requise

View File

@@ -260,3 +260,49 @@ export async function submitReview(
}> {
return request('POST', `/workflow/${workflowId}/review`, { status, feedback });
}
// DAG Execution — Exécution parallèle avec étapes LLM
export interface DAGEdge {
source: string;
target: string;
}
export interface DAGExecutionResult {
success: boolean;
steps: Record<string, {
step_id: string;
step_type: string;
status: string;
result: unknown;
error: string | null;
duration: number | null;
}>;
results: Record<string, unknown>;
errors: string[];
duration_seconds: number;
}
export async function executeDag(
workflowId: string,
edges: DAGEdge[],
options?: {
timeout?: number;
model?: string;
ollama_endpoint?: string;
}
): Promise<{ execution: DAGExecutionResult }> {
return request('POST', `/workflow/${workflowId}/execute-dag`, {
edges,
...options,
});
}
export async function getDagStatus(
workflowId: string
): Promise<{
completed: boolean;
status: DAGExecutionResult | null;
message?: string;
}> {
return request('GET', `/workflow/${workflowId}/dag-status`);
}

View File

@@ -48,13 +48,18 @@ export type ActionType =
| 'db_save_data'
| 'db_read_data'
| 'verify_element_exists'
| 'verify_text_content';
| 'verify_text_content'
// === DAG LLM — étapes IA exécutées via le DAGExecutor ===
| 'llm_analyze'
| 'llm_translate'
| 'llm_extract_data'
| 'llm_generate';
export interface ActionDefinition {
type: ActionType;
label: string;
icon: string;
category: 'mouse' | 'keyboard' | 'wait' | 'data' | 'logic' | 'ai' | 'validation';
category: 'mouse' | 'keyboard' | 'wait' | 'data' | 'logic' | 'ai' | 'llm' | 'validation';
needsAnchor: boolean;
params: string[];
}
@@ -99,6 +104,12 @@ export const ACTIONS: ActionDefinition[] = [
{ type: 'db_save_data', label: 'Sauvegarder en BDD', icon: '💿', category: 'data', needsAnchor: false, params: ['table', 'data'] },
{ type: 'db_read_data', label: 'Lire depuis BDD', icon: '📖', category: 'data', needsAnchor: false, params: ['query', 'variable_name'] },
// === DAG LLM — Actions IA via DAGExecutor (parallèle, Ollama) ===
{ type: 'llm_analyze', label: 'Analyser texte', icon: '🔬', category: 'llm', needsAnchor: false, params: ['text', 'instruction', 'model'] },
{ type: 'llm_translate', label: 'Traduire', icon: '🌐', category: 'llm', needsAnchor: false, params: ['text', 'target_lang', 'model'] },
{ type: 'llm_extract_data', label: 'Extraire données', icon: '🗂️', category: 'llm', needsAnchor: false, params: ['text', 'schema', 'model'] },
{ type: 'llm_generate', label: 'Générer texte', icon: '✍️', category: 'llm', needsAnchor: false, params: ['prompt', 'context', 'model'] },
// === VALIDATION ===
{ type: 'verify_element_exists', label: 'Vérifier présence', icon: '✅', category: 'validation', needsAnchor: true, params: ['timeout_ms'] },
{ type: 'verify_text_content', label: 'Vérifier texte', icon: '🔍', category: 'validation', needsAnchor: true, params: ['expected_text'] },
@@ -111,6 +122,7 @@ export const ACTION_CATEGORIES = {
data: { label: 'Données', icon: '📊' },
logic: { label: 'Logique', icon: '🔀' },
ai: { label: 'IA', icon: '🤖' },
llm: { label: 'IA / LLM', icon: '🧪' },
validation: { label: 'Validation', icon: '✅' },
};