feat: chat unifié, GestureCatalog, Copilot, Léa UI, extraction données, vérification replay

Refonte majeure du système Agent Chat et ajout de nombreux modules :

- Chat unifié : suppression du dual Workflows/Agent Libre, tout passe par /api/chat
  avec résolution en 3 niveaux (workflow → geste → "montre-moi")
- GestureCatalog : 38 raccourcis clavier universels Windows avec matching sémantique,
  substitution automatique dans les replays, et endpoint /api/gestures
- Mode Copilot : exécution pas-à-pas des workflows avec validation humaine via WebSocket
  (approve/skip/abort) avant chaque action
- Léa UI (agent_v0/lea_ui/) : interface PyQt5 pour Windows avec overlay transparent
  pour feedback visuel pendant le replay
- Data Extraction (core/extraction/) : moteur d'extraction visuelle de données
  (OCR + VLM → SQLite), avec schémas YAML et export CSV/Excel
- ReplayVerifier (agent_v0/server_v1/) : vérification post-action par comparaison
  de screenshots, avec logique de retry (max 3)
- IntentParser durci : meilleur fallback regex, type GREETING, patterns améliorés
- Dashboard : nouvelles pages gestures, streaming, extractions
- Tests : 63 tests GestureCatalog, 47 tests extraction, corrections tests existants
- Dépréciation : /api/agent/plan et /api/agent/execute retournent HTTP 410,
  suppression du code hardcodé _plan_to_replay_actions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-15 10:02:09 +01:00
parent 74a1cb4e03
commit cf495dd82f
93 changed files with 12463 additions and 1080 deletions

View File

@@ -618,6 +618,50 @@ def import_workflow():
return error_response(500, f"Internal server error: {str(e)}")
@workflows_bp.route('/import-core', methods=['POST'])
def import_core_workflow():
"""
Import a core Workflow (from streaming/GraphBuilder) and convert to VWB format.
Accepts a core Workflow JSON (as produced by Workflow.to_dict() or save_to_file).
Converts it to VisualWorkflow via GraphToVisualConverter, saves to DB,
and returns the VWB-formatted workflow.
Body: core Workflow JSON dict
"""
try:
data = request.get_json()
if not data:
return error_response(400, "Request body (core Workflow JSON) is required")
# Charger le core Workflow
from core.models.workflow_graph import Workflow as CoreWorkflow
core_wf = CoreWorkflow.from_dict(data)
# Convertir vers VisualWorkflow (modèle riche)
from services.graph_to_visual_converter import GraphToVisualConverter
converter = GraphToVisualConverter()
visual_wf_rich = converter.convert(core_wf)
# Convertir vers le modèle simple (utilisé par le backend VWB)
visual_dict = visual_wf_rich.to_dict()
visual_wf = VisualWorkflow.from_dict(visual_dict)
# Sauvegarder
db.save(visual_wf)
workflows_store[visual_wf.id] = visual_wf
return jsonify({
'message': 'Core workflow imported and converted to VWB format',
'workflow': visual_wf.to_dict(),
'warnings': converter.warnings,
}), 201
except Exception as e:
traceback.print_exc()
return error_response(500, f"Import error: {str(e)}")
@workflows_bp.route('/<workflow_id>/feedback', methods=['POST'])
def submit_workflow_feedback(workflow_id: str):
"""

View File

@@ -15,5 +15,6 @@ from . import workflow
from . import capture
from . import execute
from . import match # Matching sémantique des workflows
from . import review # Review/Validation de workflows importés
__all__ = ['api_v3_bp']

View File

@@ -0,0 +1,384 @@
"""
API v3 - Review/Validation de workflows importes depuis le streaming
Endpoints:
GET /api/v3/workflows/pending-review -> liste les workflows en attente de review
GET /api/v3/workflow/<id>/review -> donnees de review (workflow + screenshots)
POST /api/v3/workflow/<id>/review -> soumettre une decision de review
POST /api/v3/workflow/import-core -> importer un core Workflow avec review
"""
from flask import jsonify, request
from datetime import datetime
import logging
import sys
import traceback
from pathlib import Path
from . import api_v3_bp
from .workflow import generate_id
from db.models import db, Workflow, Step
logger = logging.getLogger(__name__)
@api_v3_bp.route('/workflows/pending-review', methods=['GET'])
def list_pending_review():
"""
Liste les workflows en attente de validation.
Filtre par source='graph_to_visual_converter' et review_status='pending_review'.
Retourne aussi les workflows avec review_status='needs_edit'.
Response:
{
"success": true,
"workflows": [
{
"id": "...",
"name": "...",
"description": "...",
"step_count": 5,
"source": "graph_to_visual_converter",
"review_status": "pending_review",
"created_at": "...",
"updated_at": "..."
}
],
"total": 2
}
"""
try:
workflows = Workflow.query.filter(
Workflow.is_active == True,
Workflow.review_status.in_(['pending_review', 'needs_edit'])
).order_by(Workflow.created_at.desc()).all()
result = []
for wf in workflows:
result.append({
'id': wf.id,
'name': wf.name,
'description': wf.description or '',
'tags': wf.tags or [],
'step_count': wf.steps.count(),
'source': wf.source or 'manual',
'review_status': wf.review_status,
'review_feedback': wf.review_feedback,
'created_at': wf.created_at.isoformat() if wf.created_at else None,
'updated_at': wf.updated_at.isoformat() if wf.updated_at else None,
})
return jsonify({
'success': True,
'workflows': result,
'total': len(result)
})
except Exception as e:
logger.error(f"Erreur listing pending review: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@api_v3_bp.route('/workflow/<workflow_id>/review', methods=['GET'])
def get_review_data(workflow_id: str):
"""
Retourne les donnees de review pour un workflow.
Inclut le workflow complet avec ses etapes, les screenshots
associes (si disponibles via les ancres visuelles), et les
metadonnees de la source.
Response:
{
"success": true,
"workflow": { ... },
"review_info": {
"source": "graph_to_visual_converter",
"review_status": "pending_review",
"review_feedback": null,
"reviewed_at": null,
"step_count": 5,
"steps_with_anchors": 3,
"steps_without_anchors": 2
}
}
"""
try:
workflow = Workflow.query.get(workflow_id)
if not workflow:
return jsonify({
'success': False,
'error': f"Workflow '{workflow_id}' non trouve"
}), 404
# Compter les etapes avec/sans ancres visuelles
steps = Step.query.filter_by(workflow_id=workflow_id).order_by(Step.order).all()
steps_with_anchors = sum(1 for s in steps if s.anchor_id)
steps_without_anchors = len(steps) - steps_with_anchors
review_info = {
'source': workflow.source or 'manual',
'review_status': workflow.review_status,
'review_feedback': workflow.review_feedback,
'reviewed_at': workflow.reviewed_at.isoformat() if workflow.reviewed_at else None,
'step_count': len(steps),
'steps_with_anchors': steps_with_anchors,
'steps_without_anchors': steps_without_anchors,
}
return jsonify({
'success': True,
'workflow': workflow.to_dict(),
'review_info': review_info,
})
except Exception as e:
logger.error(f"Erreur get review data: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@api_v3_bp.route('/workflow/<workflow_id>/review', methods=['POST'])
def submit_review(workflow_id: str):
"""
Soumet une decision de review pour un workflow.
Request:
{
"status": "approved" | "rejected" | "needs_edit",
"feedback": "Commentaire optionnel..."
}
Comportement selon le status:
- "approved" : le workflow est valide, passe en learning_state COACHING
- "rejected" : le workflow est marque inactif (is_active=False)
- "needs_edit": le workflow reste actif, l'utilisateur peut le modifier dans le VWB
Response:
{
"success": true,
"workflow_id": "...",
"review_status": "approved",
"message": "..."
}
"""
try:
workflow = Workflow.query.get(workflow_id)
if not workflow:
return jsonify({
'success': False,
'error': f"Workflow '{workflow_id}' non trouve"
}), 404
data = request.get_json() or {}
status = data.get('status')
if status not in ('approved', 'rejected', 'needs_edit'):
return jsonify({
'success': False,
'error': "Le champ 'status' doit etre 'approved', 'rejected' ou 'needs_edit'"
}), 400
feedback = data.get('feedback', '')
# Mettre a jour le workflow
workflow.review_status = status
workflow.review_feedback = feedback
workflow.reviewed_at = datetime.utcnow()
workflow.updated_at = datetime.utcnow()
message = ''
if status == 'approved':
# Passer le learning_state du workflow core vers COACHING
_promote_to_coaching(workflow_id)
message = f"Workflow '{workflow.name}' approuve. Le systeme peut maintenant suggerer ce workflow."
elif status == 'rejected':
# Marquer comme inactif
workflow.is_active = False
message = f"Workflow '{workflow.name}' rejete et desactive."
elif status == 'needs_edit':
# Laisser actif, l'utilisateur peut modifier
message = f"Workflow '{workflow.name}' marque pour modification."
db.session.commit()
logger.info(f"[Review] Workflow {workflow_id} -> {status} (feedback: {feedback[:50]}...)")
return jsonify({
'success': True,
'workflow_id': workflow_id,
'review_status': status,
'message': message,
})
except Exception as e:
db.session.rollback()
logger.error(f"Erreur submit review: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
def _promote_to_coaching(workflow_id: str):
"""
Passe le learning_state du workflow core vers COACHING.
Tente de mettre a jour via le LearningManager si disponible.
Fonctionnement gracieux : si le LearningManager n'est pas disponible,
on log un warning et on continue.
"""
try:
from services.learning_integration import _get_learning_manager
manager = _get_learning_manager()
if manager is None:
logger.warning(
f"[Review] LearningManager non disponible, impossible de promouvoir "
f"le workflow {workflow_id} vers COACHING"
)
return
# Tenter de changer l'etat
try:
from core.models.workflow_graph import LearningState
manager.set_workflow_state(workflow_id, LearningState.COACHING)
logger.info(f"[Review] Workflow {workflow_id} promu vers COACHING")
except AttributeError:
# set_workflow_state n'existe pas, essayer promote
try:
manager.promote_workflow(workflow_id)
logger.info(f"[Review] Workflow {workflow_id} promu via promote_workflow()")
except Exception as e2:
logger.warning(f"[Review] Impossible de promouvoir le workflow: {e2}")
except ImportError as e:
logger.warning(f"[Review] Import learning_integration impossible: {e}")
except Exception as e:
logger.warning(f"[Review] Erreur promotion workflow {workflow_id}: {e}")
@api_v3_bp.route('/workflow/import-core', methods=['POST'])
def import_core_workflow_v3():
"""
Importe un core Workflow (issu du streaming/GraphBuilder) dans la base v3.
Convertit via GraphToVisualConverter puis cree un Workflow SQLAlchemy
avec source='graph_to_visual_converter' et review_status='pending_review'.
Body: core Workflow JSON dict (tel que produit par Workflow.to_dict())
Response:
{
"success": true,
"workflow": { ... },
"warnings": [...],
"message": "..."
}
"""
try:
data = request.get_json()
if not data:
return jsonify({
'success': False,
'error': "Request body (core Workflow JSON) est requis"
}), 400
# Ajouter le chemin racine pour les imports core
core_path = str(Path(__file__).parent.parent.parent.parent)
if core_path not in sys.path:
sys.path.insert(0, core_path)
# Charger le core Workflow
from core.models.workflow_graph import Workflow as CoreWorkflow
core_wf = CoreWorkflow.from_dict(data)
# Convertir vers VisualWorkflow (modele riche)
from services.graph_to_visual_converter import GraphToVisualConverter
converter = GraphToVisualConverter()
visual_wf_rich = converter.convert(core_wf)
# Creer le workflow SQLAlchemy (v3)
wf_id = generate_id('wf')
workflow = Workflow(
id=wf_id,
name=visual_wf_rich.name,
description=visual_wf_rich.description or 'Workflow importe depuis le streaming',
source='graph_to_visual_converter',
review_status='pending_review',
)
if visual_wf_rich.tags:
workflow.tags = visual_wf_rich.tags
db.session.add(workflow)
# Creer les etapes
for idx, vnode in enumerate(visual_wf_rich.nodes):
# Ignorer les nodes start/end purement structurels
if vnode.type in ('start', 'end'):
continue
step = Step(
id=generate_id('step'),
workflow_id=wf_id,
action_type=_visual_type_to_action_type(vnode.type),
order=idx,
position_x=vnode.position.x,
position_y=vnode.position.y,
label=vnode.label or vnode.type,
)
step.parameters = vnode.parameters or {}
db.session.add(step)
db.session.commit()
logger.info(
f"[Review] Core workflow importe -> {wf_id} "
f"({workflow.name}, {len(visual_wf_rich.nodes)} nodes)"
)
return jsonify({
'success': True,
'workflow': workflow.to_dict(),
'warnings': converter.warnings,
'message': f"Workflow '{workflow.name}' importe et en attente de validation",
}), 201
except Exception as e:
db.session.rollback()
traceback.print_exc()
logger.error(f"[Review] Erreur import core workflow: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
def _visual_type_to_action_type(visual_type: str) -> str:
"""Convertit un type visuel VWB vers un action_type v3."""
mapping = {
'click': 'click_anchor',
'type': 'type_text',
'wait': 'wait_for_anchor',
'navigate': 'click_anchor',
'extract': 'extract_text',
'variable': 'type_text',
'condition': 'visual_condition',
'loop': 'loop_visual',
'validate': 'keyboard_shortcut',
'scroll': 'scroll_to_anchor',
'screenshot': 'screenshot_evidence',
'transform': 'type_text',
'api': 'click_anchor',
'database': 'db_save_data',
}
return mapping.get(visual_type, 'click_anchor')

View File

@@ -72,7 +72,9 @@ def get_state():
'tags': wf.tags or [],
'trigger_examples': wf.trigger_examples or [],
'step_count': wf.steps.count(),
'updated_at': wf.updated_at.isoformat() if wf.updated_at else None
'updated_at': wf.updated_at.isoformat() if wf.updated_at else None,
'source': wf.source or 'manual',
'review_status': wf.review_status,
})
return jsonify({

View File

@@ -310,6 +310,27 @@ with app.app_context():
db.create_all()
print("✅ [DB] Tables créées, utiliser 'flask db stamp head' pour initialiser les migrations")
# Migration manuelle : ajouter les colonnes review si elles n'existent pas
from sqlalchemy import inspect as sa_inspect, text
insp = sa_inspect(db.engine)
if 'workflows' in insp.get_table_names():
existing_cols = {col['name'] for col in insp.get_columns('workflows')}
new_cols = {
'source': "ALTER TABLE workflows ADD COLUMN source VARCHAR(64) DEFAULT 'manual'",
'review_status': "ALTER TABLE workflows ADD COLUMN review_status VARCHAR(32)",
'review_feedback': "ALTER TABLE workflows ADD COLUMN review_feedback TEXT",
'reviewed_at': "ALTER TABLE workflows ADD COLUMN reviewed_at DATETIME",
}
for col_name, sql in new_cols.items():
if col_name not in existing_cols:
try:
db.session.execute(text(sql))
db.session.commit()
print(f" [DB] Colonne '{col_name}' ajoutée à workflows")
except Exception as e:
db.session.rollback()
print(f" [DB] Colonne '{col_name}' déjà existante ou erreur: {e}")
# Initialize VisualTargetManager with RPA Vision V3 components (optional)
try:
from core.capture.screen_capturer import ScreenCapturer
@@ -339,14 +360,15 @@ except Exception as e:
print(f"❌ Erreur lors de l'initialisation des services visuels: {e}")
if __name__ == '__main__':
port = int(os.getenv('PORT', 5000))
debug = os.getenv('FLASK_ENV') == 'development'
port = int(os.getenv('PORT', 5002))
# Désactivation du mode debug pour stabiliser le laboratoire
debug = False
socketio.run(
app,
host='0.0.0.0',
port=port,
debug=debug,
use_reloader=debug,
allow_unsafe_werkzeug=True # For development only
debug=False,
use_reloader=False,
allow_unsafe_werkzeug=True
)

View File

@@ -27,6 +27,16 @@ class Workflow(db.Model):
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
is_active = db.Column(db.Boolean, default=True)
# Review/Validation — workflows importés depuis le streaming
# source: origine du workflow ('manual', 'graph_to_visual_converter', etc.)
source = db.Column(db.String(64), default='manual')
# review_status: 'pending_review', 'approved', 'rejected', 'needs_edit', None (pas de review)
review_status = db.Column(db.String(32), nullable=True, default=None)
# review_feedback: commentaire de l'utilisateur lors de la review
review_feedback = db.Column(db.Text, nullable=True)
# reviewed_at: date de la review
reviewed_at = db.Column(db.DateTime, nullable=True)
# Relations
steps = db.relationship('Step', backref='workflow', lazy='dynamic',
order_by='Step.order', cascade='all, delete-orphan')
@@ -65,7 +75,7 @@ class Workflow(db.Model):
def to_dict(self) -> Dict[str, Any]:
"""Sérialise le workflow complet"""
return {
result = {
'id': self.id,
'name': self.name,
'description': self.description,
@@ -74,8 +84,13 @@ class Workflow(db.Model):
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'steps': [step.to_dict() for step in self.steps.order_by(Step.order).all()],
'step_count': self.steps.count()
'step_count': self.steps.count(),
'source': self.source or 'manual',
'review_status': self.review_status,
'review_feedback': self.review_feedback,
'reviewed_at': self.reviewed_at.isoformat() if self.reviewed_at else None,
}
return result
def __repr__(self):
return f'<Workflow {self.id}: {self.name}>'

View File

@@ -0,0 +1,382 @@
"""
GraphToVisual Converter — Convertit un core Workflow en VisualWorkflow VWB.
Inverse du VisualToGraphConverter : prend un Workflow (issu du GraphBuilder
ou de l'exécution streaming) et produit un VisualWorkflow affichable
dans le Visual Workflow Builder.
Cas d'usage :
- Workflow appris par streaming → affichage/review dans le VWB
- Import d'un workflow core pour édition manuelle
- Mode validation humaine : voir et corriger un workflow auto-généré
"""
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
from datetime import datetime
# Ajouter le chemin racine pour les imports core
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from core.models.workflow_graph import (
Workflow,
WorkflowNode,
WorkflowEdge,
)
from models.visual_workflow import (
VisualWorkflow,
VisualNode,
VisualEdge,
Position,
Size,
Port,
EdgeStyle,
EdgeCondition,
Variable,
WorkflowSettings,
)
class GraphToVisualConverter:
"""
Convertit un core Workflow en VisualWorkflow VWB.
Le layout automatique place les nodes en grille verticale
(de haut en bas), avec les branches condition sur les côtés.
"""
# Mapping inverse : action_type (core) → visual_type (VWB)
ACTION_TO_NODE_TYPE = {
'mouse_click': 'click',
'text_input': 'type',
'wait': 'wait',
'navigate': 'navigate',
'extract_data': 'extract',
'set_variable': 'variable',
'evaluate_condition': 'condition',
'execute_loop': 'loop',
'key_press': 'validate',
'scroll': 'scroll',
'screenshot': 'screenshot',
'transform_data': 'transform',
'api_call': 'api',
'database_query': 'database',
'workflow_start': 'start',
'workflow_end': 'end',
}
# Couleurs par type de node
NODE_COLORS = {
'click': '#3B82F6',
'type': '#8B5CF6',
'wait': '#F59E0B',
'navigate': '#10B981',
'extract': '#06B6D4',
'variable': '#6366F1',
'condition': '#EF4444',
'loop': '#F97316',
'validate': '#14B8A6',
'scroll': '#64748B',
'screenshot': '#EC4899',
'start': '#22C55E',
'end': '#EF4444',
}
# Dimensions par défaut
DEFAULT_NODE_WIDTH = 200
DEFAULT_NODE_HEIGHT = 80
VERTICAL_SPACING = 120
HORIZONTAL_SPACING = 280
START_X = 400
START_Y = 80
def __init__(self):
self.warnings: List[str] = []
def convert(self, workflow: Workflow) -> VisualWorkflow:
"""
Convertit un core Workflow en VisualWorkflow.
Args:
workflow: Le Workflow core (issu de GraphBuilder ou load_from_file)
Returns:
VisualWorkflow prêt à être affiché dans le VWB
"""
self.warnings = []
# Convertir les nodes avec layout automatique
visual_nodes = self._convert_nodes(workflow)
# Convertir les edges
visual_edges = self._convert_edges(workflow)
# Construire le VisualWorkflow
now = datetime.now()
vw = VisualWorkflow(
id=workflow.workflow_id,
name=workflow.name or f"Workflow {workflow.workflow_id}",
description=workflow.description or "Workflow importé depuis le core pipeline",
version="1.0.0",
created_at=now,
updated_at=now,
created_by="graph_to_visual_converter",
nodes=visual_nodes,
edges=visual_edges,
variables=[],
settings=WorkflowSettings(),
tags=workflow.metadata.get('tags', []) if workflow.metadata else [],
category=workflow.metadata.get('category', 'imported') if workflow.metadata else 'imported',
is_template=False,
)
return vw
def _convert_nodes(self, workflow: Workflow) -> List[VisualNode]:
"""Convertit les WorkflowNodes en VisualNodes avec layout automatique."""
visual_nodes = []
# Déterminer l'ordre topologique pour le layout
ordered_ids = self._topological_order(workflow)
for idx, node_id in enumerate(ordered_ids):
node = self._find_node(workflow, node_id)
if node is None:
continue
vnode = self._convert_node(node, idx, workflow)
visual_nodes.append(vnode)
return visual_nodes
def _convert_node(self, node: WorkflowNode, index: int, workflow: Workflow) -> VisualNode:
"""Convertit un seul WorkflowNode en VisualNode."""
# Déterminer le type visuel
visual_type = self._infer_visual_type(node)
# Position (layout vertical simple)
pos = self._compute_position(index, visual_type)
# Extraire les paramètres depuis le node core
parameters = self._extract_parameters(node)
# Déterminer les ports
input_ports, output_ports = self._create_ports(visual_type)
# Label
label = node.name or node.node_id
# Couleur
color = self.NODE_COLORS.get(visual_type, '#64748B')
return VisualNode(
id=node.node_id,
type=visual_type,
position=pos,
size=Size(width=self.DEFAULT_NODE_WIDTH, height=self.DEFAULT_NODE_HEIGHT),
parameters=parameters,
input_ports=input_ports,
output_ports=output_ports,
label=label,
description=node.description or "",
color=color,
)
def _convert_edges(self, workflow: Workflow) -> List[VisualEdge]:
"""Convertit les WorkflowEdges en VisualEdges."""
visual_edges = []
for edge in workflow.edges:
vedge = self._convert_edge(edge)
visual_edges.append(vedge)
return visual_edges
def _convert_edge(self, edge: WorkflowEdge) -> VisualEdge:
"""Convertit un seul WorkflowEdge en VisualEdge."""
# Déterminer les ports source/target
source_port = "out"
target_port = "in"
# Si l'edge a des métadonnées visuelles (aller-retour via le converter)
if edge.metadata:
source_port = edge.metadata.get('source_port', 'out')
target_port = edge.metadata.get('target_port', 'in')
# Condition sur l'edge
condition = None
if edge.constraints and edge.constraints.pre_conditions:
pre = edge.constraints.pre_conditions
if 'condition_result' in pre:
branch = 'true' if pre['condition_result'] else 'false'
source_port = f"out_{branch}"
condition = EdgeCondition(
type='expression',
expression=f"result == {branch}"
)
elif 'expression' in pre:
condition = EdgeCondition(
type='expression',
expression=pre['expression']
)
# Style
style = EdgeStyle(color=None, width=2, dashed=bool(condition))
return VisualEdge(
id=edge.edge_id,
source=edge.from_node,
target=edge.to_node,
source_port=source_port,
target_port=target_port,
condition=condition,
style=style,
)
# =========================================================================
# Helpers
# =========================================================================
def _infer_visual_type(self, node: WorkflowNode) -> str:
"""Déterminer le type visuel VWB depuis un WorkflowNode."""
# 1. Vérifier les métadonnées (si le node a déjà un visual_type)
if node.metadata and 'visual_type' in node.metadata:
return node.metadata['visual_type']
# 2. Chercher dans les edges sortants le type d'action
# (le type d'action est sur l'edge dans le modèle core)
# On ne peut pas le faire ici sans le workflow complet,
# donc on utilise le node_type ou le label
# 3. Déduire depuis le node_type
if hasattr(node, 'node_type') and node.node_type:
reverse = self.ACTION_TO_NODE_TYPE.get(node.node_type)
if reverse:
return reverse
# 4. Heuristiques sur le nom/label
name_lower = (node.name or "").lower()
if any(k in name_lower for k in ['clic', 'click', 'bouton']):
return 'click'
if any(k in name_lower for k in ['saisie', 'type', 'input', 'texte']):
return 'type'
if any(k in name_lower for k in ['attente', 'wait', 'pause']):
return 'wait'
if 'start' in name_lower or 'début' in name_lower:
return 'start'
if 'end' in name_lower or 'fin' in name_lower:
return 'end'
# 5. Défaut
return 'click'
def _extract_parameters(self, node: WorkflowNode) -> Dict[str, Any]:
"""Extraire les paramètres depuis un WorkflowNode."""
params: Dict[str, Any] = {}
# Métadonnées visuelles (aller-retour)
if node.metadata and 'parameters' in node.metadata:
params.update(node.metadata['parameters'])
# Informations du template
if node.template:
if node.template.window and node.template.window.title_pattern:
params['window_title'] = node.template.window.title_pattern
if node.template.text and node.template.text.required_texts:
params['text_patterns'] = node.template.text.required_texts
return params
def _create_ports(self, visual_type: str) -> tuple:
"""Créer les ports par défaut pour un type de node."""
input_ports = [Port(id="in", name="Entrée", type="input")]
if visual_type == 'condition':
output_ports = [
Port(id="out_true", name="Vrai", type="output"),
Port(id="out_false", name="Faux", type="output"),
]
elif visual_type == 'loop':
output_ports = [
Port(id="out_body", name="Corps", type="output"),
Port(id="out_exit", name="Sortie", type="output"),
]
elif visual_type == 'start':
input_ports = []
output_ports = [Port(id="out", name="Sortie", type="output")]
elif visual_type == 'end':
output_ports = []
else:
output_ports = [Port(id="out", name="Sortie", type="output")]
return input_ports, output_ports
def _compute_position(self, index: int, visual_type: str) -> Position:
"""Calculer la position d'un node dans le layout vertical."""
x = self.START_X
y = self.START_Y + index * self.VERTICAL_SPACING
# Décaler les conditions légèrement à droite
if visual_type == 'condition':
x += 20
return Position(x=x, y=y)
def _topological_order(self, workflow: Workflow) -> List[str]:
"""Ordre topologique des nodes (entry → end)."""
# Construire le graphe d'adjacence
adj: Dict[str, List[str]] = {}
in_degree: Dict[str, int] = {}
all_ids = {n.node_id for n in workflow.nodes}
for nid in all_ids:
adj[nid] = []
in_degree[nid] = 0
for edge in workflow.edges:
if edge.from_node in adj and edge.to_node in in_degree:
adj[edge.from_node].append(edge.to_node)
in_degree[edge.to_node] += 1
# BFS Kahn
queue = [nid for nid in all_ids if in_degree[nid] == 0]
# Prioriser les entry_nodes
if workflow.entry_nodes:
entries = [e for e in workflow.entry_nodes if e in all_ids]
others = [q for q in queue if q not in entries]
queue = entries + others
result = []
while queue:
node = queue.pop(0)
result.append(node)
for neighbor in adj.get(node, []):
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
# Ajouter les nodes orphelins (pas atteints)
for nid in all_ids:
if nid not in result:
result.append(nid)
return result
def _find_node(self, workflow: Workflow, node_id: str) -> Optional[WorkflowNode]:
"""Trouver un node par ID."""
for n in workflow.nodes:
if n.node_id == node_id:
return n
return None
def convert_graph_to_visual(workflow: Workflow) -> VisualWorkflow:
"""Fonction utilitaire pour convertir un Workflow en VisualWorkflow."""
converter = GraphToVisualConverter()
return converter.convert(workflow)

View File

@@ -30,6 +30,7 @@ import CaptureLibrary from './components/CaptureLibrary';
import SelfHealingDialog from './components/SelfHealingDialog';
import ConfidenceDashboard from './components/ConfidenceDashboard';
import WorkflowValidation from './components/WorkflowValidation';
import ReviewModal from './components/ReviewModal';
const nodeTypes: NodeTypes = {
step: StepNode,
@@ -48,6 +49,8 @@ function App() {
const [variables, setVariables] = useState<Variable[]>([]);
const [runtimeVariables, setRuntimeVariables] = useState<Record<string, unknown>>({});
const [showWorkflowManager, setShowWorkflowManager] = useState(false);
const [showReviewModal, setShowReviewModal] = useState(false);
const [pendingReviewCount, setPendingReviewCount] = useState(0);
const [currentCapture, setCurrentCapture] = useState<Capture | null>(null);
// React Flow instance pour screenToFlowPosition
@@ -70,6 +73,11 @@ function App() {
state.workflow?.steps || [],
state.workflow?.id
);
// Compter les workflows en attente de review
const pending = (state.workflows_list || []).filter(
(wf) => wf.review_status === 'pending_review' || wf.review_status === 'needs_edit'
).length;
setPendingReviewCount(pending);
} catch (err) {
setError((err as Error).message);
}
@@ -409,6 +417,17 @@ function App() {
onOpenManager={() => setShowWorkflowManager(true)}
onRename={handleRenameWorkflow}
/>
<button
className="review-header-btn"
onClick={() => setShowReviewModal(true)}
title="Workflows en attente de validation"
>
<span className="review-header-icon">&#128203;</span>
<span className="review-header-text">Review</span>
{pendingReviewCount > 0 && (
<span className="review-header-count">{pendingReviewCount}</span>
)}
</button>
<WorkflowValidation
workflowId={appState?.session.active_workflow_id}
/>
@@ -526,6 +545,18 @@ function App() {
/>
)}
{/* Review Modal */}
{showReviewModal && (
<ReviewModal
onClose={() => setShowReviewModal(false)}
onOpenWorkflow={(id) => {
handleSelectWorkflow(id);
setShowReviewModal(false);
}}
onRefresh={loadState}
/>
)}
{/* Self-Healing Dialog */}
<SelfHealingDialog
isOpen={showSelfHealing}

View File

@@ -0,0 +1,339 @@
import { useState, useEffect } from 'react';
import * as api from '../services/api';
import type { Step, ReviewStatus } from '../types';
interface ReviewWorkflow {
id: string;
name: string;
description: string;
step_count: number;
source: string;
review_status: ReviewStatus;
review_feedback: string | null;
created_at: string;
updated_at: string;
}
interface ReviewInfo {
source: string;
review_status: ReviewStatus;
review_feedback: string | null;
reviewed_at: string | null;
step_count: number;
steps_with_anchors: number;
steps_without_anchors: number;
}
interface Props {
onClose: () => void;
onOpenWorkflow: (id: string) => void;
onRefresh: () => void;
}
type ViewMode = 'list' | 'detail';
export default function ReviewModal({ onClose, onOpenWorkflow, onRefresh }: Props) {
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [pendingWorkflows, setPendingWorkflows] = useState<ReviewWorkflow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Detail view state
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const [reviewInfo, setReviewInfo] = useState<ReviewInfo | null>(null);
const [workflowSteps, setWorkflowSteps] = useState<Step[]>([]);
const [feedback, setFeedback] = useState('');
const [submitting, setSubmitting] = useState(false);
const [submitResult, setSubmitResult] = useState<{ status: string; message: string } | null>(null);
// Charger les workflows en attente
useEffect(() => {
loadPendingWorkflows();
}, []);
const loadPendingWorkflows = async () => {
setLoading(true);
setError(null);
try {
const data = await api.getPendingReview();
setPendingWorkflows(data.workflows);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
const loadReviewDetail = async (workflowId: string) => {
setLoading(true);
setError(null);
setSubmitResult(null);
setFeedback('');
try {
const data = await api.getReviewData(workflowId);
setSelectedWorkflowId(workflowId);
setReviewInfo(data.review_info);
setWorkflowSteps(data.workflow.steps || []);
setViewMode('detail');
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
const handleSubmitReview = async (status: 'approved' | 'rejected' | 'needs_edit') => {
if (!selectedWorkflowId) return;
setSubmitting(true);
setError(null);
try {
const result = await api.submitReview(selectedWorkflowId, status, feedback);
setSubmitResult({ status: result.review_status, message: result.message });
// Si needs_edit, proposer d'ouvrir dans le VWB
if (status === 'needs_edit') {
// Laisser l'utilisateur voir le message puis ouvrir
}
// Rafraichir la liste
onRefresh();
} catch (err) {
setError((err as Error).message);
} finally {
setSubmitting(false);
}
};
const handleBackToList = () => {
setViewMode('list');
setSelectedWorkflowId(null);
setReviewInfo(null);
setWorkflowSteps([]);
setSubmitResult(null);
setFeedback('');
loadPendingWorkflows();
};
const handleOpenInEditor = () => {
if (selectedWorkflowId) {
onOpenWorkflow(selectedWorkflowId);
onClose();
}
};
const selectedWf = pendingWorkflows.find(w => w.id === selectedWorkflowId);
return (
<div className="modal-overlay" onClick={onClose}>
<div className="review-modal" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="modal-header">
<div className="review-modal-title">
{viewMode === 'list' ? (
<h2>Workflows en attente de validation</h2>
) : (
<>
<button className="back-btn" onClick={handleBackToList}>
&#8592;
</button>
<h2>Review : {selectedWf?.name || '...'}</h2>
</>
)}
</div>
<button className="close-btn" onClick={onClose}>&times;</button>
</div>
<div className="modal-body">
{error && (
<div className="review-error">
{error}
<button onClick={() => setError(null)}>&times;</button>
</div>
)}
{loading && (
<div className="review-loading">Chargement...</div>
)}
{/* === MODE LISTE === */}
{viewMode === 'list' && !loading && (
<>
{pendingWorkflows.length === 0 ? (
<div className="review-empty">
<div className="review-empty-icon">&#10003;</div>
<p>Aucun workflow en attente de validation</p>
<p className="review-empty-sub">
Les workflows importes depuis le streaming apparaitront ici.
</p>
</div>
) : (
<div className="review-list">
{pendingWorkflows.map(wf => (
<div key={wf.id} className="review-list-item" onClick={() => loadReviewDetail(wf.id)}>
<div className="review-item-header">
<span className="review-item-name">{wf.name}</span>
<span className={`review-badge ${wf.review_status}`}>
{wf.review_status === 'pending_review' ? 'En attente' : 'A modifier'}
</span>
</div>
<div className="review-item-meta">
<span>{wf.step_count} etape{wf.step_count > 1 ? 's' : ''}</span>
<span className="review-item-sep">&bull;</span>
<span>Importe le {new Date(wf.created_at).toLocaleDateString('fr-FR')}</span>
</div>
{wf.description && (
<div className="review-item-desc">{wf.description}</div>
)}
{wf.review_feedback && (
<div className="review-item-feedback">
Feedback: {wf.review_feedback}
</div>
)}
</div>
))}
</div>
)}
</>
)}
{/* === MODE DETAIL === */}
{viewMode === 'detail' && !loading && reviewInfo && (
<div className="review-detail">
{/* Info du workflow */}
<div className="review-detail-info">
<div className="review-info-grid">
<div className="review-info-item">
<span className="review-info-label">Source</span>
<span className="review-info-value">
{reviewInfo.source === 'graph_to_visual_converter' ? 'Streaming / Apprentissage auto' : reviewInfo.source}
</span>
</div>
<div className="review-info-item">
<span className="review-info-label">Etapes</span>
<span className="review-info-value">{reviewInfo.step_count}</span>
</div>
<div className="review-info-item">
<span className="review-info-label">Avec ancre visuelle</span>
<span className="review-info-value">{reviewInfo.steps_with_anchors}</span>
</div>
<div className="review-info-item">
<span className="review-info-label">Sans ancre visuelle</span>
<span className="review-info-value review-info-warning">
{reviewInfo.steps_without_anchors}
</span>
</div>
</div>
</div>
{/* Liste des etapes step-by-step */}
<div className="review-steps">
<h3>Etapes du workflow</h3>
{workflowSteps.length === 0 ? (
<p className="review-steps-empty">Aucune etape</p>
) : (
<div className="review-steps-list">
{workflowSteps.map((step, idx) => (
<div key={step.id} className="review-step-item">
<div className="review-step-number">{idx + 1}</div>
<div className="review-step-content">
<div className="review-step-header">
<span className="review-step-type">{step.action_type}</span>
<span className="review-step-label">{step.label}</span>
</div>
{step.parameters && Object.keys(step.parameters).length > 0 && (
<div className="review-step-params">
{Object.entries(step.parameters).map(([key, value]) => (
<span key={key} className="review-step-param">
{key}: {String(value)}
</span>
))}
</div>
)}
{step.anchor ? (
<div className="review-step-anchor">
{step.anchor.thumbnail_url && (
<img
src={step.anchor.thumbnail_url}
alt="Ancre visuelle"
className="review-step-thumbnail"
/>
)}
<span className="review-step-anchor-ok">Ancre visuelle configuree</span>
</div>
) : (
<div className="review-step-no-anchor">
Pas d'ancre visuelle
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Zone de decision */}
{!submitResult && (
<div className="review-decision">
<h3>Decision</h3>
<div className="review-feedback-field">
<label>Commentaire (optionnel)</label>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="Raison de la decision, suggestions..."
rows={3}
/>
</div>
<div className="review-actions">
<button
className="review-btn approve"
onClick={() => handleSubmitReview('approved')}
disabled={submitting}
>
Approuver
</button>
<button
className="review-btn edit"
onClick={() => handleSubmitReview('needs_edit')}
disabled={submitting}
>
Modifier
</button>
<button
className="review-btn reject"
onClick={() => handleSubmitReview('rejected')}
disabled={submitting}
>
Rejeter
</button>
</div>
</div>
)}
{/* Resultat de la review */}
{submitResult && (
<div className={`review-result ${submitResult.status}`}>
<div className="review-result-icon">
{submitResult.status === 'approved' && '\u2705'}
{submitResult.status === 'rejected' && '\u274C'}
{submitResult.status === 'needs_edit' && '\u270F\uFE0F'}
</div>
<div className="review-result-message">{submitResult.message}</div>
{submitResult.status === 'needs_edit' && (
<button className="review-btn-open-editor" onClick={handleOpenInEditor}>
Ouvrir dans l'editeur
</button>
)}
<button className="review-btn-back" onClick={handleBackToList}>
Retour a la liste
</button>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -128,6 +128,15 @@ export default function WorkflowManagerModal({
<div className="item-main">
<span className="item-name">{wf.name}</span>
{wf.id === activeWorkflowId && <span className="active-badge">actif</span>}
{wf.review_status === 'pending_review' && (
<span className="review-badge-small pending_review">A valider</span>
)}
{wf.review_status === 'needs_edit' && (
<span className="review-badge-small needs_edit">A modifier</span>
)}
{wf.review_status === 'approved' && (
<span className="review-badge-small approved">Approuve</span>
)}
</div>
<div className="item-info">
<span>{wf.step_count} étapes</span>

View File

@@ -147,7 +147,19 @@ export default function WorkflowSelector({
/>
) : (
<>
<span className="item-name">{wf.name}</span>
<span className="item-name">
{wf.name}
{wf.review_status === 'pending_review' && (
<span className="review-badge-inline pending_review" title="En attente de validation">
&#9679; A valider
</span>
)}
{wf.review_status === 'needs_edit' && (
<span className="review-badge-inline needs_edit" title="Modification requise">
&#9679; A modifier
</span>
)}
</span>
<span className="item-meta">
{wf.step_count} étapes
{wf.tags && wf.tags.length > 0 && (

View File

@@ -2,7 +2,7 @@
* API Client - Toutes les interactions avec le backend
*/
import type { AppState, Workflow, Step, Execution, Capture, ActionType, ExecutionMode } from '../types';
import type { AppState, Workflow, Step, Execution, Capture, ActionType, ExecutionMode, ReviewStatus } from '../types';
const API_BASE = '/api/v3';
@@ -210,3 +210,53 @@ export async function exportWorkflowForTraining(workflowId: string): Promise<{
}> {
return request('POST', `/workflow/${workflowId}/export-training`);
}
// Review/Validation
export async function getPendingReview(): Promise<{
workflows: Array<{
id: string;
name: string;
description: string;
step_count: number;
source: string;
review_status: ReviewStatus;
review_feedback: string | null;
created_at: string;
updated_at: string;
}>;
total: number;
}> {
return request('GET', '/workflows/pending-review');
}
export async function getReviewData(workflowId: string): Promise<{
workflow: Workflow & {
source: string;
review_status: ReviewStatus;
review_feedback: string | null;
reviewed_at: string | null;
};
review_info: {
source: string;
review_status: ReviewStatus;
review_feedback: string | null;
reviewed_at: string | null;
step_count: number;
steps_with_anchors: number;
steps_without_anchors: number;
};
}> {
return request('GET', `/workflow/${workflowId}/review`);
}
export async function submitReview(
workflowId: string,
status: 'approved' | 'rejected' | 'needs_edit',
feedback?: string
): Promise<{
workflow_id: string;
review_status: string;
message: string;
}> {
return request('POST', `/workflow/${workflowId}/review`, { status, feedback });
}

View File

@@ -3294,10 +3294,19 @@ body {
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
.validation-modal .modal-body {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.validation-loading {
text-align: center;
padding: 2rem;
@@ -3433,3 +3442,592 @@ body {
color: var(--text-secondary);
text-align: center;
}
/* ===========================================
Review / Validation Mode
=========================================== */
/* Header button */
.review-header-btn {
display: flex;
align-items: center;
gap: 0.35rem;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 0.35rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.2s;
}
.review-header-btn:hover {
background: rgba(255, 255, 255, 0.25);
}
.review-header-icon {
font-size: 1rem;
}
.review-header-text {
font-weight: 500;
}
.review-header-count {
background: var(--warning);
color: white;
font-size: 0.7rem;
font-weight: 700;
padding: 0.1rem 0.4rem;
border-radius: 10px;
min-width: 18px;
text-align: center;
line-height: 1.3;
}
/* Inline badge in workflow selector */
.review-badge-inline {
display: inline-block;
font-size: 0.65rem;
font-weight: 600;
padding: 0.1rem 0.4rem;
border-radius: 3px;
margin-left: 0.5rem;
vertical-align: middle;
}
.review-badge-inline.pending_review {
background: #fff3e0;
color: #e65100;
}
.review-badge-inline.needs_edit {
background: #e3f2fd;
color: #1565c0;
}
/* Small badge in WorkflowManagerModal */
.review-badge-small {
display: inline-block;
font-size: 0.6rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: 3px;
margin-left: 0.4rem;
}
.review-badge-small.pending_review {
background: #fff3e0;
color: #e65100;
}
.review-badge-small.needs_edit {
background: #e3f2fd;
color: #1565c0;
}
.review-badge-small.approved {
background: #e8f5e9;
color: #2e7d32;
}
/* Review Modal */
.review-modal {
background: var(--bg-paper);
border-radius: 12px;
width: 90%;
max-width: 800px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
.review-modal .modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
background: var(--bg-sidebar);
}
.review-modal-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.review-modal-title h2 {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.back-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.25rem 0.5rem;
cursor: pointer;
font-size: 1.1rem;
color: var(--text-secondary);
transition: all 0.2s;
}
.back-btn:hover {
background: var(--bg-sidebar);
color: var(--text-primary);
}
.review-modal .modal-body {
flex: 1;
overflow-y: auto;
padding: 1.25rem 1.5rem;
}
/* Loading & Error */
.review-loading {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
font-size: 0.95rem;
}
.review-error {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #ffebee;
color: var(--error);
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.review-error button {
background: none;
border: none;
color: var(--error);
cursor: pointer;
font-size: 1.1rem;
}
/* Empty state */
.review-empty {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary);
}
.review-empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
color: var(--success);
}
.review-empty p {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.review-empty-sub {
font-size: 0.85rem;
color: var(--text-disabled);
}
/* List view */
.review-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.review-list-item {
padding: 1rem 1.25rem;
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.review-list-item:hover {
border-color: var(--primary-light);
background: #f5f9ff;
box-shadow: 0 2px 8px rgba(25, 118, 210, 0.1);
}
.review-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
}
.review-item-name {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-primary);
}
.review-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.2rem 0.6rem;
border-radius: 12px;
}
.review-badge.pending_review {
background: #fff3e0;
color: #e65100;
}
.review-badge.needs_edit {
background: #e3f2fd;
color: #1565c0;
}
.review-item-meta {
font-size: 0.8rem;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 0.4rem;
}
.review-item-sep {
font-size: 0.6rem;
}
.review-item-desc {
margin-top: 0.4rem;
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.4;
}
.review-item-feedback {
margin-top: 0.4rem;
font-size: 0.75rem;
color: var(--primary);
font-style: italic;
}
/* Detail view */
.review-detail {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.review-detail-info {
background: var(--bg-sidebar);
border-radius: 8px;
padding: 1rem 1.25rem;
}
.review-info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.review-info-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.review-info-label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.review-info-value {
font-size: 0.95rem;
font-weight: 500;
color: var(--text-primary);
}
.review-info-warning {
color: var(--warning);
}
/* Steps list in review */
.review-steps h3 {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.75rem;
}
.review-steps-empty {
font-size: 0.85rem;
color: var(--text-secondary);
text-align: center;
padding: 1rem;
}
.review-steps-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.review-step-item {
display: flex;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-paper);
}
.review-step-number {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
}
.review-step-content {
flex: 1;
min-width: 0;
}
.review-step-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.3rem;
}
.review-step-type {
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.4rem;
background: #e8eaf6;
color: #3f51b5;
border-radius: 3px;
text-transform: uppercase;
}
.review-step-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-primary);
}
.review-step-params {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.3rem;
}
.review-step-param {
font-size: 0.7rem;
background: var(--bg-sidebar);
padding: 0.15rem 0.4rem;
border-radius: 3px;
color: var(--text-secondary);
}
.review-step-anchor {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.4rem;
}
.review-step-thumbnail {
width: 40px;
height: 30px;
object-fit: cover;
border: 1px solid var(--border);
border-radius: 3px;
}
.review-step-anchor-ok {
font-size: 0.75rem;
color: var(--success);
}
.review-step-no-anchor {
margin-top: 0.3rem;
font-size: 0.75rem;
color: var(--warning);
font-style: italic;
}
/* Decision zone */
.review-decision {
border-top: 1px solid var(--border);
padding-top: 1.25rem;
}
.review-decision h3 {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.75rem;
}
.review-feedback-field {
margin-bottom: 1rem;
}
.review-feedback-field label {
display: block;
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: 0.35rem;
}
.review-feedback-field textarea {
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.85rem;
font-family: inherit;
resize: vertical;
min-height: 60px;
}
.review-feedback-field textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.15);
}
.review-actions {
display: flex;
gap: 0.75rem;
}
.review-btn {
flex: 1;
padding: 0.65rem 1rem;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.review-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.review-btn.approve {
background: var(--success);
color: white;
}
.review-btn.approve:hover:not(:disabled) {
background: #388e3c;
}
.review-btn.edit {
background: var(--primary);
color: white;
}
.review-btn.edit:hover:not(:disabled) {
background: var(--primary-dark);
}
.review-btn.reject {
background: var(--error);
color: white;
}
.review-btn.reject:hover:not(:disabled) {
background: #c62828;
}
/* Review result */
.review-result {
text-align: center;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid var(--border);
}
.review-result.approved {
background: #e8f5e9;
border-color: #a5d6a7;
}
.review-result.rejected {
background: #ffebee;
border-color: #ef9a9a;
}
.review-result.needs_edit {
background: #e3f2fd;
border-color: #90caf9;
}
.review-result-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.review-result-message {
font-size: 0.9rem;
color: var(--text-primary);
margin-bottom: 1rem;
line-height: 1.5;
}
.review-btn-open-editor {
display: inline-block;
padding: 0.5rem 1rem;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.5rem;
transition: background 0.2s;
}
.review-btn-open-editor:hover {
background: var(--primary-dark);
}
.review-btn-back {
display: inline-block;
padding: 0.5rem 1rem;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
margin-left: 0.5rem;
transition: all 0.2s;
}
.review-btn-back:hover {
background: var(--bg-sidebar);
color: var(--text-primary);
}

View File

@@ -143,6 +143,8 @@ export interface Workflow {
updated_at: string;
}
export type ReviewStatus = 'pending_review' | 'approved' | 'rejected' | 'needs_edit' | null;
export interface WorkflowSummary {
id: string;
name: string;
@@ -150,6 +152,8 @@ export interface WorkflowSummary {
tags?: string[];
description?: string;
trigger_examples?: string[];
source?: string;
review_status?: ReviewStatus;
}
export interface Execution {

View File

@@ -4,7 +4,7 @@
# Visual Workflow Builder v4 - Script de Lancement
#
# Ce script lance :
# - Le backend Flask (port 5001)
# - Le backend Flask (port 5002)
# - Le frontend React v4 avec Vite (port 3002)
################################################################################
@@ -84,7 +84,7 @@ fi
################################################################################
echo -e "${BLUE}📦 1. Démarrage du Backend Flask${NC}"
echo " Port: 5001"
echo " Port: 5002"
echo ""
cd "$BACKEND_DIR"
@@ -103,9 +103,9 @@ echo "$BACKEND_PID" > "$BACKEND_PID_FILE"
# Attendre que le backend soit prêt
echo -n " Attente du backend"
for i in {1..30}; do
if curl -s http://localhost:5001/health > /dev/null 2>&1; then
if curl -s http://localhost:5002/health > /dev/null 2>&1; then
echo ""
echo -e "${GREEN} ✓ Backend prêt sur http://localhost:5001${NC}"
echo -e "${GREEN} ✓ Backend prêt sur http://localhost:5002${NC}"
break
fi
echo -n "."
@@ -176,8 +176,8 @@ echo -e "${CYAN}╚════════════════════
echo ""
echo -e "${GREEN}🌐 URLs:${NC}"
echo " • Frontend: http://localhost:3002"
echo " • Backend: http://localhost:5001"
echo " • API v3: http://localhost:5001/api/v3"
echo " • Backend: http://localhost:5002"
echo " • API v3: http://localhost:5002/api/v3"
echo ""
echo -e "${GREEN}📝 Logs:${NC}"
echo " • Backend: tail -f $LOG_DIR/backend_v4.log"