feat: unification VWB ↔ Léa — import/export bidirectionnel

- Workflows appris par Léa visibles dans le VWB ("Appris par Léa")
- Bouton "Importer" pour éditer un workflow appris
- Bouton "Exporter pour Léa" pour rendre un workflow VWB exécutable
- Conversion bidirectionnelle core ↔ VWB via learned_workflow_bridge
- Liste unifiée dans le chat Léa (merged + dédupliquée)
- reload_workflows() sur le streaming server (pas de redémarrage)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-18 22:41:34 +01:00
parent aa39af327f
commit 5973058f08
10 changed files with 1407 additions and 6 deletions

View File

@@ -1,5 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import type { WorkflowSummary } from '../types';
import * as api from '../services/api';
import type { LearnedWorkflow } from '../services/api';
interface Props {
workflows: WorkflowSummary[];
@@ -22,6 +24,9 @@ export default function WorkflowSelector({
const [search, setSearch] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [learnedWorkflows, setLearnedWorkflows] = useState<LearnedWorkflow[]>([]);
const [learnedLoading, setLearnedLoading] = useState(false);
const [importingId, setImportingId] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -45,12 +50,39 @@ export default function WorkflowSelector({
}
}, [editingId]);
// Filtrer les workflows
// Charger les workflows appris quand le dropdown s'ouvre
const loadLearnedWorkflows = useCallback(async () => {
setLearnedLoading(true);
try {
const data = await api.getLearnedWorkflows();
// Ne garder que ceux qui ne sont pas encore importés
setLearnedWorkflows(data.workflows.filter(w => !w.already_imported));
} catch {
// Silencieux : le streaming server n'est peut-être pas lancé
setLearnedWorkflows([]);
} finally {
setLearnedLoading(false);
}
}, []);
useEffect(() => {
if (isOpen) {
loadLearnedWorkflows();
}
}, [isOpen, loadLearnedWorkflows]);
// Filtrer les workflows VWB
const filteredWorkflows = workflows.filter(wf =>
wf.name.toLowerCase().includes(search.toLowerCase()) ||
(wf.tags || []).some(tag => tag.toLowerCase().includes(search.toLowerCase()))
);
// Filtrer les workflows appris
const filteredLearned = learnedWorkflows.filter(wf =>
wf.name.toLowerCase().includes(search.toLowerCase()) ||
wf.workflow_id.toLowerCase().includes(search.toLowerCase())
);
// Workflows récents (les 8 premiers)
const recentWorkflows = filteredWorkflows.slice(0, 8);
const hasMore = filteredWorkflows.length > 8;
@@ -81,6 +113,27 @@ export default function WorkflowSelector({
}
};
const handleImportLearned = async (wf: LearnedWorkflow, e: React.MouseEvent) => {
e.stopPropagation();
setImportingId(wf.workflow_id);
try {
const result = await api.importLearnedWorkflow(wf.workflow_id, {
machine_id: wf.machine_id,
});
// Sélectionner le workflow importé
if (result.workflow?.id) {
onSelect(result.workflow.id);
setIsOpen(false);
}
// Retirer de la liste des appris
setLearnedWorkflows(prev => prev.filter(w => w.workflow_id !== wf.workflow_id));
} catch (err) {
console.error('Erreur import workflow appris:', err);
} finally {
setImportingId(null);
}
};
return (
<div className="workflow-selector" ref={dropdownRef}>
{/* Bouton principal */}
@@ -120,7 +173,7 @@ export default function WorkflowSelector({
</button>
</div>
{/* Liste des workflows */}
{/* Liste des workflows VWB */}
<div className="dropdown-list">
{recentWorkflows.length === 0 ? (
<p className="no-results">
@@ -177,6 +230,42 @@ export default function WorkflowSelector({
)}
</div>
{/* Section workflows appris par Léa */}
{(filteredLearned.length > 0 || learnedLoading) && (
<>
<div className="dropdown-section-header">
Appris par Léa
{learnedLoading && <span className="loading-dot">...</span>}
</div>
<div className="dropdown-list learned-list">
{filteredLearned.map(wf => (
<div
key={wf.workflow_id}
className="dropdown-item learned-item"
>
<span className="item-name">
{wf.name}
<span className="learned-badge" title={`Machine: ${wf.machine_id}`}>
appris
</span>
</span>
<span className="item-meta">
{wf.nodes} noeuds, {wf.edges} transitions
</span>
<button
className="import-btn"
disabled={importingId === wf.workflow_id}
onClick={(e) => handleImportLearned(wf, e)}
title="Importer dans l'éditeur pour review"
>
{importingId === wf.workflow_id ? '...' : 'Importer'}
</button>
</div>
))}
</div>
</>
)}
{/* Lien vers le gestionnaire */}
{(hasMore || workflows.length > 0) && (
<div className="dropdown-footer">

View File

@@ -18,6 +18,8 @@ export default function WorkflowValidation({ workflowId }: WorkflowValidationPro
const [result, setResult] = useState<ValidationResult | null>(null);
const [exportPath, setExportPath] = useState<string | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const [leaExportMessage, setLeaExportMessage] = useState<string | null>(null);
const [leaExportError, setLeaExportError] = useState<string | null>(null);
const handleValidate = async () => {
if (!workflowId) return;
@@ -54,11 +56,26 @@ export default function WorkflowValidation({ workflowId }: WorkflowValidationPro
}
};
const handleExportForLea = async () => {
if (!workflowId) return;
setLeaExportError(null);
setLeaExportMessage(null);
try {
const data = await api.exportForLea(workflowId);
setLeaExportMessage(data.message);
} catch (err) {
setLeaExportError((err as Error).message);
}
};
const handleClose = () => {
setShowModal(false);
setResult(null);
setExportPath(null);
setExportError(null);
setLeaExportMessage(null);
setLeaExportError(null);
};
return (
@@ -154,6 +171,34 @@ export default function WorkflowValidation({ workflowId }: WorkflowValidationPro
</div>
)}
{/* Export pour Léa */}
{result.is_valid && !leaExportMessage && (
<div className="validation-export" style={{ marginTop: '0.5rem' }}>
<button className="btn-secondary" onClick={handleExportForLea}>
Exporter pour Léa (replay)
</button>
</div>
)}
{leaExportError && (
<div className="validation-errors">
<h5>Erreur export Léa</h5>
<ul>
<li>{leaExportError}</li>
</ul>
</div>
)}
{leaExportMessage && (
<div className="validation-success">
<span className="validation-success-icon">&#128640;</span>
<div>
<strong>Export Léa réussi</strong>
<p style={{ margin: '0.3rem 0 0', fontSize: '0.85rem' }}>{leaExportMessage}</p>
</div>
</div>
)}
{/* Message si invalide */}
{!result.is_valid && (
<div className="validation-hint">

View File

@@ -306,3 +306,52 @@ export async function getDagStatus(
}> {
return request('GET', `/workflow/${workflowId}/dag-status`);
}
// Workflows appris par Léa — pont avec le streaming server
export interface LearnedWorkflow {
workflow_id: string;
name: string;
description: string;
machine_id: string;
nodes: number;
edges: number;
learning_state: string;
source: string;
already_imported: boolean;
vwb_workflow_id: string | null;
created_at?: string;
}
export async function getLearnedWorkflows(machineId?: string): Promise<{
workflows: LearnedWorkflow[];
streaming_server_available: boolean;
}> {
const params = machineId ? `?machine_id=${encodeURIComponent(machineId)}` : '';
return request('GET', `/learned-workflows${params}`);
}
export async function importLearnedWorkflow(
workflowId: string,
options?: { name?: string; machine_id?: string }
): Promise<{
workflow: Workflow;
warnings: string[];
message: string;
}> {
return request('POST', `/learned-workflows/${workflowId}/import`, options);
}
export async function exportForLea(
workflowId: string,
machineId?: string
): Promise<{
core_workflow_id: string;
export_path: string;
nodes_count: number;
edges_count: number;
message: string;
}> {
return request('POST', `/workflow/${workflowId}/export-for-lea`, {
machine_id: machineId,
});
}

View File

@@ -2513,6 +2513,70 @@ body {
text-decoration: underline;
}
/* Section workflows appris par Léa */
.dropdown-section-header {
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-muted);
background: var(--bg-sidebar);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 0.3rem;
}
.dropdown-section-header .loading-dot {
color: var(--primary);
animation: pulse-dots 1s infinite;
}
@keyframes pulse-dots {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.learned-list .learned-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
}
.learned-badge {
display: inline-block;
font-size: 0.65rem;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: #dbeafe;
color: #1d4ed8;
margin-left: 0.3rem;
font-weight: 500;
}
.import-btn {
margin-left: auto;
padding: 0.2rem 0.6rem;
font-size: 0.75rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
flex-shrink: 0;
}
.import-btn:hover:not(:disabled) {
background: var(--primary-hover, #2563eb);
}
.import-btn:disabled {
opacity: 0.5;
cursor: wait;
}
/* ===========================================
WorkflowManagerModal
=========================================== */
@@ -3610,6 +3674,11 @@ body {
font-size: 0.9rem;
}
.validation-export .btn-secondary {
padding: 0.6rem 1.5rem;
font-size: 0.9rem;
}
.validation-success {
display: flex;
align-items: center;