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:
@@ -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">
|
||||
|
||||
@@ -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">🚀</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">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user