feat(coaching): Implement complete COACHING mode infrastructure
Add comprehensive COACHING mode system with: Backend: - core/coaching module with session persistence and metrics - CoachingSessionPersistence for pause/resume sessions - CoachingMetricsCollector with learning progress tracking - REST API blueprint for coaching sessions management - Execution integration with COACHING mode support Frontend: - CoachingPanel component with keyboard shortcuts - Decision buttons (accept/reject/correct/manual/skip) - Real-time stats display and correction editor - CorrectionPacksDashboard for pack visualization - WebSocket hooks for real-time COACHING events Metrics & Monitoring: - WorkflowLearningMetrics with confidence scoring - GlobalCoachingMetrics for system-wide analytics - AUTO mode readiness detection (85% acceptance threshold) - Learning progress levels (OBSERVATION → COACHING → AUTO) Tests: - E2E tests for complete OBSERVATION → AUTO journey - Session persistence and recovery tests - Metrics threshold validation tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
531
visual_workflow_builder/backend/api/coaching_sessions.py
Normal file
531
visual_workflow_builder/backend/api/coaching_sessions.py
Normal file
@@ -0,0 +1,531 @@
|
||||
"""
|
||||
COACHING Sessions API Blueprint
|
||||
|
||||
Provides REST endpoints for managing COACHING session persistence:
|
||||
- List/create/load sessions
|
||||
- Pause/resume sessions
|
||||
- Session statistics
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
# Add core to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
|
||||
from core.coaching import (
|
||||
CoachingSessionPersistence,
|
||||
CoachingSessionState,
|
||||
get_coaching_persistence,
|
||||
)
|
||||
from core.coaching.session_persistence import SessionStatus
|
||||
|
||||
coaching_sessions_bp = Blueprint('coaching_sessions', __name__)
|
||||
|
||||
|
||||
def get_persistence() -> CoachingSessionPersistence:
|
||||
"""Get the coaching session persistence instance."""
|
||||
return get_coaching_persistence()
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/', methods=['GET'])
|
||||
def list_sessions():
|
||||
"""
|
||||
List COACHING sessions.
|
||||
|
||||
Query params:
|
||||
workflow_id: Filter by workflow ID
|
||||
status: Filter by status (active, paused, completed, failed, abandoned)
|
||||
limit: Maximum number of sessions (default: 100)
|
||||
|
||||
Returns:
|
||||
sessions: List of session summaries
|
||||
"""
|
||||
workflow_id = request.args.get('workflow_id')
|
||||
status_str = request.args.get('status')
|
||||
limit = int(request.args.get('limit', 100))
|
||||
|
||||
status = None
|
||||
if status_str:
|
||||
try:
|
||||
status = SessionStatus(status_str)
|
||||
except ValueError:
|
||||
return jsonify({
|
||||
'error': f'Invalid status. Valid values: {[s.value for s in SessionStatus]}'
|
||||
}), 400
|
||||
|
||||
persistence = get_persistence()
|
||||
sessions = persistence.list_sessions(
|
||||
workflow_id=workflow_id,
|
||||
status=status,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return jsonify({'sessions': sessions})
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/', methods=['POST'])
|
||||
def create_session():
|
||||
"""
|
||||
Create a new COACHING session.
|
||||
|
||||
Body JSON:
|
||||
workflow_id: str - ID of the workflow
|
||||
execution_id: str - ID of the execution
|
||||
total_steps: int (optional) - Total number of steps
|
||||
variables: dict (optional) - Initial variables
|
||||
metadata: dict (optional) - Additional metadata
|
||||
|
||||
Returns:
|
||||
session: Created session state
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
workflow_id = data.get('workflow_id')
|
||||
execution_id = data.get('execution_id')
|
||||
|
||||
if not workflow_id or not execution_id:
|
||||
return jsonify({'error': 'workflow_id and execution_id are required'}), 400
|
||||
|
||||
persistence = get_persistence()
|
||||
session = persistence.create_session(
|
||||
workflow_id=workflow_id,
|
||||
execution_id=execution_id,
|
||||
total_steps=data.get('total_steps', 0),
|
||||
variables=data.get('variables', {}),
|
||||
metadata=data.get('metadata', {})
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'message': 'Session created',
|
||||
'session': session.to_dict()
|
||||
}), 201
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/<session_id>', methods=['GET'])
|
||||
def get_session(session_id: str):
|
||||
"""
|
||||
Get a COACHING session by ID.
|
||||
|
||||
Returns:
|
||||
session: Full session state
|
||||
"""
|
||||
persistence = get_persistence()
|
||||
session = persistence.load_session(session_id)
|
||||
|
||||
if session is None:
|
||||
return jsonify({'error': f'Session {session_id} not found'}), 404
|
||||
|
||||
return jsonify({'session': session.to_dict()})
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/<session_id>', methods=['PUT'])
|
||||
def update_session(session_id: str):
|
||||
"""
|
||||
Update a COACHING session.
|
||||
|
||||
Body JSON:
|
||||
current_step_index: int (optional)
|
||||
variables: dict (optional)
|
||||
metadata: dict (optional)
|
||||
|
||||
Returns:
|
||||
session: Updated session state
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
|
||||
persistence = get_persistence()
|
||||
session = persistence.load_session(session_id)
|
||||
|
||||
if session is None:
|
||||
return jsonify({'error': f'Session {session_id} not found'}), 404
|
||||
|
||||
# Update allowed fields
|
||||
if 'current_step_index' in data:
|
||||
session.current_step_index = data['current_step_index']
|
||||
if 'variables' in data:
|
||||
session.variables.update(data['variables'])
|
||||
if 'metadata' in data:
|
||||
session.metadata.update(data['metadata'])
|
||||
|
||||
persistence.save_session(session)
|
||||
|
||||
return jsonify({
|
||||
'message': 'Session updated',
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/<session_id>', methods=['DELETE'])
|
||||
def delete_session(session_id: str):
|
||||
"""
|
||||
Delete a COACHING session.
|
||||
|
||||
Returns:
|
||||
success: bool
|
||||
"""
|
||||
persistence = get_persistence()
|
||||
|
||||
if persistence.delete_session(session_id):
|
||||
return jsonify({
|
||||
'message': 'Session deleted',
|
||||
'session_id': session_id
|
||||
})
|
||||
else:
|
||||
return jsonify({'error': f'Session {session_id} not found'}), 404
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/<session_id>/decisions', methods=['POST'])
|
||||
def add_decision(session_id: str):
|
||||
"""
|
||||
Add a decision to a COACHING session.
|
||||
|
||||
Body JSON:
|
||||
step_index: int
|
||||
node_id: str
|
||||
action_type: str
|
||||
decision: str (accept, reject, correct, manual, skip)
|
||||
correction: dict (optional)
|
||||
feedback: str (optional)
|
||||
execution_success: bool (optional)
|
||||
|
||||
Returns:
|
||||
session: Updated session state
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
|
||||
required = ['step_index', 'node_id', 'action_type', 'decision']
|
||||
for field in required:
|
||||
if field not in data:
|
||||
return jsonify({'error': f'{field} is required'}), 400
|
||||
|
||||
valid_decisions = ['accept', 'reject', 'correct', 'manual', 'skip']
|
||||
if data['decision'] not in valid_decisions:
|
||||
return jsonify({
|
||||
'error': f'Invalid decision. Valid values: {valid_decisions}'
|
||||
}), 400
|
||||
|
||||
persistence = get_persistence()
|
||||
session = persistence.load_session(session_id)
|
||||
|
||||
if session is None:
|
||||
return jsonify({'error': f'Session {session_id} not found'}), 404
|
||||
|
||||
from core.coaching.session_persistence import CoachingDecisionRecord
|
||||
|
||||
decision = CoachingDecisionRecord(
|
||||
step_index=data['step_index'],
|
||||
node_id=data['node_id'],
|
||||
action_type=data['action_type'],
|
||||
decision=data['decision'],
|
||||
correction=data.get('correction'),
|
||||
feedback=data.get('feedback'),
|
||||
execution_success=data.get('execution_success')
|
||||
)
|
||||
|
||||
session.add_decision(decision)
|
||||
persistence.save_session(session)
|
||||
|
||||
return jsonify({
|
||||
'message': 'Decision added',
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/<session_id>/pause', methods=['POST'])
|
||||
def pause_session(session_id: str):
|
||||
"""
|
||||
Pause an active COACHING session.
|
||||
|
||||
Returns:
|
||||
success: bool
|
||||
"""
|
||||
persistence = get_persistence()
|
||||
|
||||
if persistence.pause_session(session_id):
|
||||
session = persistence.load_session(session_id)
|
||||
return jsonify({
|
||||
'message': 'Session paused',
|
||||
'session': session.to_dict() if session else None
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'error': 'Cannot pause session (not active or not found)'
|
||||
}), 400
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/<session_id>/resume', methods=['POST'])
|
||||
def resume_session(session_id: str):
|
||||
"""
|
||||
Resume a paused COACHING session.
|
||||
|
||||
Returns:
|
||||
session: Resumed session state
|
||||
"""
|
||||
persistence = get_persistence()
|
||||
session = persistence.resume_session(session_id)
|
||||
|
||||
if session:
|
||||
return jsonify({
|
||||
'message': 'Session resumed',
|
||||
'session': session.to_dict()
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'error': 'Cannot resume session (not paused or not found)'
|
||||
}), 400
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/<session_id>/complete', methods=['POST'])
|
||||
def complete_session(session_id: str):
|
||||
"""
|
||||
Mark a COACHING session as completed.
|
||||
|
||||
Body JSON:
|
||||
success: bool (default: True)
|
||||
error_message: str (optional)
|
||||
|
||||
Returns:
|
||||
session: Completed session state
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
success = data.get('success', True)
|
||||
error_message = data.get('error_message')
|
||||
|
||||
persistence = get_persistence()
|
||||
session = persistence.complete_session(
|
||||
session_id,
|
||||
success=success,
|
||||
error_message=error_message
|
||||
)
|
||||
|
||||
if session:
|
||||
return jsonify({
|
||||
'message': 'Session completed',
|
||||
'session': session.to_dict()
|
||||
})
|
||||
else:
|
||||
return jsonify({'error': f'Session {session_id} not found'}), 404
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/<session_id>/abandon', methods=['POST'])
|
||||
def abandon_session(session_id: str):
|
||||
"""
|
||||
Mark a COACHING session as abandoned.
|
||||
|
||||
Returns:
|
||||
success: bool
|
||||
"""
|
||||
persistence = get_persistence()
|
||||
|
||||
if persistence.abandon_session(session_id):
|
||||
return jsonify({
|
||||
'message': 'Session abandoned',
|
||||
'session_id': session_id
|
||||
})
|
||||
else:
|
||||
return jsonify({'error': f'Session {session_id} not found'}), 404
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/resumable', methods=['GET'])
|
||||
def get_resumable_sessions():
|
||||
"""
|
||||
Get all resumable sessions for a workflow.
|
||||
|
||||
Query params:
|
||||
workflow_id: str (required) - Workflow ID
|
||||
|
||||
Returns:
|
||||
sessions: List of resumable session states
|
||||
"""
|
||||
workflow_id = request.args.get('workflow_id')
|
||||
|
||||
if not workflow_id:
|
||||
return jsonify({'error': 'workflow_id is required'}), 400
|
||||
|
||||
persistence = get_persistence()
|
||||
sessions = persistence.get_resumable_sessions(workflow_id)
|
||||
|
||||
return jsonify({
|
||||
'sessions': [s.to_dict() for s in sessions]
|
||||
})
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/statistics', methods=['GET'])
|
||||
def get_statistics():
|
||||
"""
|
||||
Get overall COACHING session statistics.
|
||||
|
||||
Returns:
|
||||
statistics: Overall statistics
|
||||
"""
|
||||
persistence = get_persistence()
|
||||
stats = persistence.get_statistics()
|
||||
|
||||
return jsonify({'statistics': stats})
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/cleanup', methods=['POST'])
|
||||
def cleanup_sessions():
|
||||
"""
|
||||
Clean up old COACHING sessions.
|
||||
|
||||
Body JSON:
|
||||
max_age_days: int (default: 30)
|
||||
|
||||
Returns:
|
||||
removed_count: int - Number of sessions removed
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
max_age_days = data.get('max_age_days', 30)
|
||||
|
||||
persistence = get_persistence()
|
||||
removed = persistence.cleanup_old_sessions(max_age_days)
|
||||
|
||||
return jsonify({
|
||||
'message': f'Removed {removed} old sessions',
|
||||
'removed_count': removed
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Metrics & Monitoring Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@coaching_sessions_bp.route('/metrics/global', methods=['GET'])
|
||||
def get_global_metrics():
|
||||
"""
|
||||
Get global COACHING metrics across all workflows.
|
||||
|
||||
Returns:
|
||||
metrics: GlobalCoachingMetrics with aggregated data
|
||||
"""
|
||||
try:
|
||||
from core.coaching import get_metrics_collector
|
||||
collector = get_metrics_collector()
|
||||
metrics = collector.get_global_metrics()
|
||||
return jsonify({'metrics': metrics.to_dict()})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/metrics/workflow/<workflow_id>', methods=['GET'])
|
||||
def get_workflow_metrics(workflow_id: str):
|
||||
"""
|
||||
Get detailed learning metrics for a specific workflow.
|
||||
|
||||
Returns:
|
||||
metrics: WorkflowLearningMetrics with learning progress and recommendations
|
||||
"""
|
||||
try:
|
||||
from core.coaching import get_metrics_collector
|
||||
collector = get_metrics_collector()
|
||||
metrics = collector.get_workflow_metrics(workflow_id)
|
||||
return jsonify({'metrics': metrics.to_dict()})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/metrics/ready-for-auto', methods=['GET'])
|
||||
def get_workflows_ready_for_auto():
|
||||
"""
|
||||
Get list of workflows ready for autonomous mode.
|
||||
|
||||
Returns:
|
||||
workflows: List of workflow IDs ready for AUTO mode
|
||||
"""
|
||||
try:
|
||||
from core.coaching import get_metrics_collector
|
||||
collector = get_metrics_collector()
|
||||
|
||||
# Get global metrics to find workflows
|
||||
global_metrics = collector.get_global_metrics()
|
||||
|
||||
# Check each workflow
|
||||
ready_workflows = []
|
||||
persistence = get_persistence()
|
||||
all_sessions = persistence.list_sessions(limit=10000)
|
||||
|
||||
workflow_ids = set(s.get('workflow_id') for s in all_sessions if s.get('workflow_id'))
|
||||
|
||||
for workflow_id in workflow_ids:
|
||||
metrics = collector.get_workflow_metrics(workflow_id)
|
||||
if metrics.ready_for_auto:
|
||||
ready_workflows.append({
|
||||
'workflow_id': workflow_id,
|
||||
'confidence_score': metrics.confidence_score,
|
||||
'acceptance_rate': metrics.acceptance_rate,
|
||||
'total_sessions': metrics.total_sessions
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'workflows_ready': ready_workflows,
|
||||
'total_ready': len(ready_workflows)
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@coaching_sessions_bp.route('/metrics/dashboard', methods=['GET'])
|
||||
def get_metrics_dashboard():
|
||||
"""
|
||||
Get comprehensive dashboard data for monitoring.
|
||||
|
||||
Returns all metrics needed for a monitoring dashboard:
|
||||
- Global statistics
|
||||
- Recent activity
|
||||
- Top workflows
|
||||
- Recommendations
|
||||
"""
|
||||
try:
|
||||
from core.coaching import get_metrics_collector
|
||||
collector = get_metrics_collector()
|
||||
|
||||
# Get global metrics
|
||||
global_metrics = collector.get_global_metrics()
|
||||
|
||||
# Get recent activity (last 7 days sessions)
|
||||
persistence = get_persistence()
|
||||
recent_sessions = persistence.list_sessions(limit=50)
|
||||
|
||||
# Build dashboard response
|
||||
dashboard = {
|
||||
'overview': {
|
||||
'total_workflows': global_metrics.total_workflows,
|
||||
'total_sessions': global_metrics.total_sessions,
|
||||
'active_sessions': global_metrics.active_sessions,
|
||||
'workflows_ready_for_auto': global_metrics.workflows_ready_for_auto,
|
||||
'workflows_in_learning': global_metrics.workflows_in_learning,
|
||||
},
|
||||
'rates': {
|
||||
'acceptance_rate': round(global_metrics.overall_acceptance_rate * 100, 1),
|
||||
'correction_rate': round(global_metrics.overall_correction_rate * 100, 1),
|
||||
},
|
||||
'activity': {
|
||||
'sessions_last_24h': global_metrics.sessions_last_24h,
|
||||
'decisions_last_24h': global_metrics.decisions_last_24h,
|
||||
},
|
||||
'decisions': {
|
||||
'total': global_metrics.total_decisions,
|
||||
'accepted': global_metrics.total_accepted,
|
||||
'rejected': global_metrics.total_rejected,
|
||||
'corrected': global_metrics.total_corrected,
|
||||
},
|
||||
'top_workflows': {
|
||||
'by_sessions': global_metrics.top_workflows_by_sessions,
|
||||
'by_corrections': global_metrics.top_workflows_by_corrections,
|
||||
},
|
||||
'recent_sessions': [
|
||||
{
|
||||
'session_id': s.get('session_id'),
|
||||
'workflow_id': s.get('workflow_id'),
|
||||
'status': s.get('status'),
|
||||
'updated_at': s.get('updated_at'),
|
||||
}
|
||||
for s in recent_sessions[:10]
|
||||
]
|
||||
}
|
||||
|
||||
return jsonify({'dashboard': dashboard})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
267
visual_workflow_builder/backend/api/executions.py
Normal file
267
visual_workflow_builder/backend/api/executions.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
Executions API Blueprint
|
||||
|
||||
Provides REST endpoints for workflow execution management.
|
||||
|
||||
Exigences: 6.1, 6.2, 6.3, 6.4
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from services.execution_integration import get_executor
|
||||
|
||||
executions_bp = Blueprint('executions', __name__)
|
||||
|
||||
|
||||
@executions_bp.route('/', methods=['POST'])
|
||||
def start_execution():
|
||||
"""
|
||||
Lance l'exécution d'un workflow.
|
||||
|
||||
Body JSON:
|
||||
workflow_id: str - ID du workflow à exécuter
|
||||
variables: dict (optionnel) - Variables d'entrée
|
||||
mode: str (optionnel) - 'normal' ou 'coaching' (défaut: 'normal')
|
||||
|
||||
Returns:
|
||||
execution_id: str - ID de l'exécution lancée
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
workflow_id = data.get('workflow_id')
|
||||
variables = data.get('variables', {})
|
||||
mode = data.get('mode', 'normal')
|
||||
|
||||
if not workflow_id:
|
||||
return jsonify({'error': 'workflow_id requis'}), 400
|
||||
|
||||
try:
|
||||
executor = get_executor()
|
||||
|
||||
if mode == 'coaching':
|
||||
execution_id = executor.execute_workflow_coaching(
|
||||
workflow_id=workflow_id,
|
||||
variables=variables
|
||||
)
|
||||
else:
|
||||
execution_id = executor.execute_workflow(
|
||||
workflow_id=workflow_id,
|
||||
variables=variables
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'execution_id': execution_id,
|
||||
'workflow_id': workflow_id,
|
||||
'mode': mode,
|
||||
'status': 'started'
|
||||
}), 201
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@executions_bp.route('/coaching', methods=['POST'])
|
||||
def start_coaching_execution():
|
||||
"""
|
||||
Lance l'exécution d'un workflow en mode COACHING.
|
||||
|
||||
En mode COACHING, chaque étape est soumise à l'utilisateur pour
|
||||
validation/correction avant exécution.
|
||||
|
||||
Body JSON:
|
||||
workflow_id: str - ID du workflow à exécuter
|
||||
variables: dict (optionnel) - Variables d'entrée
|
||||
|
||||
Returns:
|
||||
execution_id: str - ID de l'exécution COACHING lancée
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
workflow_id = data.get('workflow_id')
|
||||
variables = data.get('variables', {})
|
||||
|
||||
if not workflow_id:
|
||||
return jsonify({'error': 'workflow_id requis'}), 400
|
||||
|
||||
try:
|
||||
executor = get_executor()
|
||||
|
||||
execution_id = executor.execute_workflow_coaching(
|
||||
workflow_id=workflow_id,
|
||||
variables=variables
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'execution_id': execution_id,
|
||||
'workflow_id': workflow_id,
|
||||
'mode': 'coaching',
|
||||
'status': 'started',
|
||||
'message': 'Connectez-vous via WebSocket pour recevoir les suggestions'
|
||||
}), 201
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@executions_bp.route('/', methods=['GET'])
|
||||
def list_executions():
|
||||
"""
|
||||
Liste les exécutions.
|
||||
|
||||
Query params:
|
||||
workflow_id: str (optionnel) - Filtrer par workflow
|
||||
mode: str (optionnel) - Filtrer par mode ('normal', 'coaching')
|
||||
|
||||
Returns:
|
||||
executions: list - Liste des exécutions
|
||||
"""
|
||||
workflow_id = request.args.get('workflow_id')
|
||||
mode = request.args.get('mode')
|
||||
|
||||
executor = get_executor()
|
||||
executions = executor.list_executions(workflow_id)
|
||||
|
||||
# Filtrer par mode si demandé
|
||||
if mode:
|
||||
if mode == 'coaching':
|
||||
executions = [
|
||||
e for e in executions
|
||||
if executor.is_coaching_execution(e['execution_id'])
|
||||
]
|
||||
elif mode == 'normal':
|
||||
executions = [
|
||||
e for e in executions
|
||||
if not executor.is_coaching_execution(e['execution_id'])
|
||||
]
|
||||
|
||||
return jsonify({'executions': executions})
|
||||
|
||||
|
||||
@executions_bp.route('/<execution_id>', methods=['GET'])
|
||||
def get_execution(execution_id):
|
||||
"""
|
||||
Récupère le statut et les détails d'une exécution.
|
||||
|
||||
Returns:
|
||||
Détails de l'exécution incluant statut, progression, logs
|
||||
"""
|
||||
executor = get_executor()
|
||||
result = executor.get_execution_status(execution_id)
|
||||
|
||||
if result is None:
|
||||
return jsonify({'error': f'Exécution {execution_id} introuvable'}), 404
|
||||
|
||||
response = result.to_dict()
|
||||
response['is_coaching'] = executor.is_coaching_execution(execution_id)
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
|
||||
@executions_bp.route('/<execution_id>/cancel', methods=['POST'])
|
||||
def cancel_execution(execution_id):
|
||||
"""
|
||||
Annule une exécution en cours.
|
||||
|
||||
Returns:
|
||||
success: bool - Si l'annulation a réussi
|
||||
"""
|
||||
executor = get_executor()
|
||||
|
||||
if executor.cancel_execution(execution_id):
|
||||
return jsonify({
|
||||
'execution_id': execution_id,
|
||||
'status': 'cancelled',
|
||||
'message': 'Exécution annulée avec succès'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'error': 'Impossible d\'annuler l\'exécution (déjà terminée ou inexistante)'
|
||||
}), 400
|
||||
|
||||
|
||||
@executions_bp.route('/<execution_id>/coaching/decision', methods=['POST'])
|
||||
def submit_coaching_decision(execution_id):
|
||||
"""
|
||||
Soumet une décision COACHING pour une exécution.
|
||||
|
||||
Alternative REST à WebSocket pour soumettre une décision.
|
||||
|
||||
Body JSON:
|
||||
decision: str - 'accept' | 'reject' | 'correct' | 'manual' | 'skip'
|
||||
correction: dict (optionnel) - Correction si decision == 'correct'
|
||||
feedback: str (optionnel) - Commentaire utilisateur
|
||||
|
||||
Returns:
|
||||
success: bool
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
decision = data.get('decision')
|
||||
|
||||
if not decision:
|
||||
return jsonify({'error': 'decision requis'}), 400
|
||||
|
||||
valid_decisions = ['accept', 'reject', 'correct', 'manual', 'skip']
|
||||
if decision not in valid_decisions:
|
||||
return jsonify({
|
||||
'error': f'decision invalide. Valeurs acceptées: {valid_decisions}'
|
||||
}), 400
|
||||
|
||||
executor = get_executor()
|
||||
|
||||
if not executor.is_coaching_execution(execution_id):
|
||||
return jsonify({
|
||||
'error': f'{execution_id} n\'est pas une exécution COACHING'
|
||||
}), 400
|
||||
|
||||
decision_response = {
|
||||
'decision': decision,
|
||||
'correction': data.get('correction'),
|
||||
'feedback': data.get('feedback'),
|
||||
'executed_manually': decision == 'manual'
|
||||
}
|
||||
|
||||
success = executor.submit_coaching_decision(execution_id, decision_response)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'execution_id': execution_id,
|
||||
'decision': decision,
|
||||
'status': 'accepted'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'error': 'Impossible de soumettre la décision'
|
||||
}), 400
|
||||
|
||||
|
||||
@executions_bp.route('/<execution_id>/coaching/stats', methods=['GET'])
|
||||
def get_coaching_stats(execution_id):
|
||||
"""
|
||||
Récupère les statistiques COACHING d'une exécution.
|
||||
|
||||
Returns:
|
||||
stats: dict - Statistiques (suggestions, accepted, rejected, etc.)
|
||||
"""
|
||||
executor = get_executor()
|
||||
|
||||
if not executor.is_coaching_execution(execution_id):
|
||||
return jsonify({
|
||||
'error': f'{execution_id} n\'est pas une exécution COACHING'
|
||||
}), 400
|
||||
|
||||
stats = executor.get_coaching_stats(execution_id)
|
||||
|
||||
if stats is None:
|
||||
stats = {
|
||||
'suggestions_made': 0,
|
||||
'accepted': 0,
|
||||
'rejected': 0,
|
||||
'corrected': 0,
|
||||
'manual_executions': 0
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'execution_id': execution_id,
|
||||
'stats': stats
|
||||
})
|
||||
@@ -119,6 +119,13 @@ try:
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Blueprint correction_packs désactivé: {e}")
|
||||
|
||||
try:
|
||||
from api.coaching_sessions import coaching_sessions_bp
|
||||
app.register_blueprint(coaching_sessions_bp, url_prefix='/api/coaching-sessions')
|
||||
print("✅ Blueprint coaching_sessions enregistré")
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Blueprint coaching_sessions désactivé: {e}")
|
||||
|
||||
|
||||
# Import WebSocket handlers (optional)
|
||||
try:
|
||||
|
||||
1073
visual_workflow_builder/backend/services/execution_integration.py
Normal file
1073
visual_workflow_builder/backend/services/execution_integration.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* CoachingDecisionButtons Component
|
||||
*
|
||||
* Decision buttons for COACHING mode.
|
||||
* Provides accept, reject, correct, manual, and skip options.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { CoachingDecision } from '../../hooks/useCoachingWebSocket';
|
||||
|
||||
interface CoachingDecisionButtonsProps {
|
||||
onDecision: (decision: CoachingDecision) => void;
|
||||
onShowCorrection: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface DecisionButtonConfig {
|
||||
decision: CoachingDecision | 'correction';
|
||||
label: string;
|
||||
shortcut: string;
|
||||
icon: string;
|
||||
className: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const DECISION_BUTTONS: DecisionButtonConfig[] = [
|
||||
{
|
||||
decision: 'accept',
|
||||
label: 'Accepter',
|
||||
shortcut: 'A',
|
||||
icon: '\u2713',
|
||||
className: 'btn-accept',
|
||||
title: 'Accepter et executer cette action (A)',
|
||||
},
|
||||
{
|
||||
decision: 'reject',
|
||||
label: 'Rejeter',
|
||||
shortcut: 'R',
|
||||
icon: '\u2717',
|
||||
className: 'btn-reject',
|
||||
title: 'Rejeter cette action et passer (R)',
|
||||
},
|
||||
{
|
||||
decision: 'correction',
|
||||
label: 'Corriger',
|
||||
shortcut: 'C',
|
||||
icon: '\u270E',
|
||||
className: 'btn-correct',
|
||||
title: 'Modifier cette action avant execution (C)',
|
||||
},
|
||||
{
|
||||
decision: 'manual',
|
||||
label: 'Manuel',
|
||||
shortcut: 'M',
|
||||
icon: '\u{1F590}',
|
||||
className: 'btn-manual',
|
||||
title: 'Executer manuellement puis continuer (M)',
|
||||
},
|
||||
{
|
||||
decision: 'skip',
|
||||
label: 'Passer',
|
||||
shortcut: 'S',
|
||||
icon: '\u23E9',
|
||||
className: 'btn-skip',
|
||||
title: 'Passer cette etape (S)',
|
||||
},
|
||||
];
|
||||
|
||||
const CoachingDecisionButtons: React.FC<CoachingDecisionButtonsProps> = ({
|
||||
onDecision,
|
||||
onShowCorrection,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const handleClick = (button: DecisionButtonConfig) => {
|
||||
if (button.decision === 'correction') {
|
||||
onShowCorrection();
|
||||
} else {
|
||||
onDecision(button.decision as CoachingDecision);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="coaching-decision-buttons">
|
||||
{DECISION_BUTTONS.map((button) => (
|
||||
<button
|
||||
key={button.decision}
|
||||
className={`coaching-decision-btn ${button.className}`}
|
||||
onClick={() => handleClick(button)}
|
||||
disabled={disabled}
|
||||
title={button.title}
|
||||
aria-label={button.title}
|
||||
>
|
||||
<span className="btn-icon">{button.icon}</span>
|
||||
<span className="btn-label">{button.label}</span>
|
||||
<kbd className="btn-shortcut">{button.shortcut}</kbd>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoachingDecisionButtons;
|
||||
@@ -0,0 +1,695 @@
|
||||
/**
|
||||
* CoachingPanel Styles
|
||||
*
|
||||
* Styles for the COACHING mode UI components.
|
||||
*/
|
||||
|
||||
/* Main Panel */
|
||||
.coaching-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1e1e2e;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #313244;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.coaching-panel.active {
|
||||
border-color: #89b4fa;
|
||||
box-shadow: 0 0 10px rgba(137, 180, 250, 0.2);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.coaching-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #313244;
|
||||
border-bottom: 1px solid #45475a;
|
||||
}
|
||||
|
||||
.coaching-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #cdd6f4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.coaching-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.coaching-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #f38ba8;
|
||||
}
|
||||
|
||||
.status-indicator.connected {
|
||||
background: #a6e3a1;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Error display */
|
||||
.coaching-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(243, 139, 168, 0.1);
|
||||
color: #f38ba8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.coaching-panel-content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Start prompt */
|
||||
.coaching-start-prompt {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.coaching-start-prompt p {
|
||||
color: #a6adc8;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.btn-start-coaching {
|
||||
padding: 12px 24px;
|
||||
background: #89b4fa;
|
||||
color: #1e1e2e;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-start-coaching:hover {
|
||||
background: #b4befe;
|
||||
}
|
||||
|
||||
.btn-start-coaching:disabled {
|
||||
background: #45475a;
|
||||
color: #6c7086;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Waiting state */
|
||||
.coaching-waiting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 32px;
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #45475a;
|
||||
border-top-color: #89b4fa;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Suggestion Card */
|
||||
.coaching-suggestion-card {
|
||||
background: #313244;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.suggestion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.action-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.confidence-badge {
|
||||
margin-left: auto;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.suggestion-target,
|
||||
.suggestion-params {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.suggestion-target label,
|
||||
.suggestion-params label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #6c7086;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.target-value {
|
||||
color: #89b4fa;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.suggestion-params ul {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
color: #cdd6f4;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.suggestion-params li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.suggestion-screenshot {
|
||||
margin-top: 12px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.suggestion-screenshot img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.suggestion-alternatives {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #45475a;
|
||||
}
|
||||
|
||||
.suggestion-alternatives label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #6c7086;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.suggestion-alternatives ul {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
font-size: 12px;
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.suggestion-context {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.suggestion-context summary {
|
||||
cursor: pointer;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
.suggestion-context pre {
|
||||
background: #1e1e2e;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
color: #a6adc8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Decision Buttons */
|
||||
.coaching-decision-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.coaching-decision-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px 8px;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 8px;
|
||||
background: #313244;
|
||||
color: #cdd6f4;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.coaching-decision-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.coaching-decision-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.coaching-decision-btn .btn-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.coaching-decision-btn .btn-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.coaching-decision-btn .btn-shortcut {
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
background: #45475a;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.btn-accept:hover {
|
||||
background: rgba(166, 227, 161, 0.2);
|
||||
border-color: #a6e3a1;
|
||||
}
|
||||
|
||||
.btn-reject:hover {
|
||||
background: rgba(243, 139, 168, 0.2);
|
||||
border-color: #f38ba8;
|
||||
}
|
||||
|
||||
.btn-correct:hover {
|
||||
background: rgba(249, 226, 175, 0.2);
|
||||
border-color: #f9e2af;
|
||||
}
|
||||
|
||||
.btn-manual:hover {
|
||||
background: rgba(137, 180, 250, 0.2);
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
|
||||
.btn-skip:hover {
|
||||
background: rgba(166, 173, 200, 0.2);
|
||||
border-color: #a6adc8;
|
||||
}
|
||||
|
||||
/* Feedback input */
|
||||
.coaching-feedback {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.coaching-feedback label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #a6adc8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.coaching-feedback input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: #313244;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
color: #cdd6f4;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.coaching-feedback input:focus {
|
||||
outline: none;
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
|
||||
/* Result display */
|
||||
.coaching-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.coaching-result.success {
|
||||
background: rgba(166, 227, 161, 0.1);
|
||||
color: #a6e3a1;
|
||||
}
|
||||
|
||||
.coaching-result.error {
|
||||
background: rgba(243, 139, 168, 0.1);
|
||||
color: #f38ba8;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Stats Display */
|
||||
.coaching-stats {
|
||||
padding: 16px;
|
||||
background: #313244;
|
||||
border-top: 1px solid #45475a;
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stats-header h4 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.btn-refresh-stats {
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 4px;
|
||||
color: #a6adc8;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-refresh-stats:hover {
|
||||
background: #45475a;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
background: #1e1e2e;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: #6c7086;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat-item.accepted .stat-value { color: #a6e3a1; }
|
||||
.stat-item.rejected .stat-value { color: #f38ba8; }
|
||||
.stat-item.corrected .stat-value { color: #f9e2af; }
|
||||
.stat-item.manual .stat-value { color: #89b4fa; }
|
||||
|
||||
.stats-rates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rate-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rate-label {
|
||||
font-size: 11px;
|
||||
color: #a6adc8;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.rate-bar-container {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: #1e1e2e;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rate-bar {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.rate-value {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.learning-progress,
|
||||
.learning-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.learning-progress {
|
||||
background: rgba(166, 227, 161, 0.1);
|
||||
color: #a6e3a1;
|
||||
}
|
||||
|
||||
.learning-warning {
|
||||
background: rgba(249, 226, 175, 0.1);
|
||||
color: #f9e2af;
|
||||
}
|
||||
|
||||
/* Correction Editor */
|
||||
.correction-editor {
|
||||
background: #313244;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.correction-editor h4 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #a6adc8;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
color: #cdd6f4;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
|
||||
.params-editor {
|
||||
background: #1e1e2e;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.param-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.param-key {
|
||||
font-size: 12px;
|
||||
color: #89b4fa;
|
||||
font-family: monospace;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.param-row input {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-add-param {
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px dashed #45475a;
|
||||
border-radius: 4px;
|
||||
color: #6c7086;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-add-param:hover {
|
||||
border-color: #89b4fa;
|
||||
color: #89b4fa;
|
||||
}
|
||||
|
||||
.correction-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-apply-correction {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: #45475a;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: #585b70;
|
||||
}
|
||||
|
||||
.btn-apply-correction {
|
||||
background: #89b4fa;
|
||||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
.btn-apply-correction:hover {
|
||||
background: #b4befe;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.coaching-panel-footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #45475a;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-end-session {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid #f38ba8;
|
||||
border-radius: 6px;
|
||||
color: #f38ba8;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-end-session:hover {
|
||||
background: rgba(243, 139, 168, 0.1);
|
||||
}
|
||||
|
||||
/* Shortcuts help */
|
||||
.coaching-shortcuts-help {
|
||||
padding: 8px 16px;
|
||||
background: #1e1e2e;
|
||||
text-align: center;
|
||||
border-top: 1px solid #313244;
|
||||
}
|
||||
|
||||
.coaching-shortcuts-help small {
|
||||
color: #6c7086;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.coaching-shortcuts-help kbd {
|
||||
padding: 2px 6px;
|
||||
background: #313244;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.coaching-decision-buttons {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* CoachingStatsDisplay Component
|
||||
*
|
||||
* Displays COACHING session statistics including:
|
||||
* - Total suggestions made
|
||||
* - Acceptance/rejection/correction counts
|
||||
* - Acceptance and correction rates
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { CoachingStats } from '../../hooks/useCoachingWebSocket';
|
||||
|
||||
interface CoachingStatsDisplayProps {
|
||||
stats: CoachingStats;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
const CoachingStatsDisplay: React.FC<CoachingStatsDisplayProps> = ({
|
||||
stats,
|
||||
onRefresh,
|
||||
}) => {
|
||||
// Calculate rates
|
||||
const total = stats.accepted + stats.rejected + stats.corrected + stats.manualExecutions;
|
||||
const acceptanceRate = total > 0 ? (stats.accepted / total) * 100 : 0;
|
||||
const correctionRate = total > 0 ? (stats.corrected / total) * 100 : 0;
|
||||
|
||||
// Get color for rate
|
||||
const getRateColor = (rate: number, isAcceptance: boolean): string => {
|
||||
if (isAcceptance) {
|
||||
if (rate >= 80) return '#4caf50';
|
||||
if (rate >= 50) return '#ff9800';
|
||||
return '#f44336';
|
||||
}
|
||||
// For correction rate, lower is better
|
||||
if (rate <= 10) return '#4caf50';
|
||||
if (rate <= 30) return '#ff9800';
|
||||
return '#f44336';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="coaching-stats">
|
||||
<div className="stats-header">
|
||||
<h4>Statistiques COACHING</h4>
|
||||
{onRefresh && (
|
||||
<button
|
||||
className="btn-refresh-stats"
|
||||
onClick={onRefresh}
|
||||
title="Actualiser les statistiques"
|
||||
aria-label="Actualiser"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
{/* Suggestions count */}
|
||||
<div className="stat-item">
|
||||
<span className="stat-value">{stats.suggestionsMade}</span>
|
||||
<span className="stat-label">Suggestions</span>
|
||||
</div>
|
||||
|
||||
{/* Accepted count */}
|
||||
<div className="stat-item accepted">
|
||||
<span className="stat-value">{stats.accepted}</span>
|
||||
<span className="stat-label">Acceptees</span>
|
||||
</div>
|
||||
|
||||
{/* Rejected count */}
|
||||
<div className="stat-item rejected">
|
||||
<span className="stat-value">{stats.rejected}</span>
|
||||
<span className="stat-label">Rejetees</span>
|
||||
</div>
|
||||
|
||||
{/* Corrected count */}
|
||||
<div className="stat-item corrected">
|
||||
<span className="stat-value">{stats.corrected}</span>
|
||||
<span className="stat-label">Corrigees</span>
|
||||
</div>
|
||||
|
||||
{/* Manual executions */}
|
||||
<div className="stat-item manual">
|
||||
<span className="stat-value">{stats.manualExecutions}</span>
|
||||
<span className="stat-label">Manuelles</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rate indicators */}
|
||||
<div className="stats-rates">
|
||||
{/* Acceptance rate */}
|
||||
<div className="rate-item">
|
||||
<div className="rate-label">Taux d'acceptation</div>
|
||||
<div className="rate-bar-container">
|
||||
<div
|
||||
className="rate-bar"
|
||||
style={{
|
||||
width: `${acceptanceRate}%`,
|
||||
backgroundColor: getRateColor(acceptanceRate, true),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="rate-value"
|
||||
style={{ color: getRateColor(acceptanceRate, true) }}
|
||||
>
|
||||
{acceptanceRate.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Correction rate */}
|
||||
<div className="rate-item">
|
||||
<div className="rate-label">Taux de correction</div>
|
||||
<div className="rate-bar-container">
|
||||
<div
|
||||
className="rate-bar"
|
||||
style={{
|
||||
width: `${correctionRate}%`,
|
||||
backgroundColor: getRateColor(correctionRate, false),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="rate-value"
|
||||
style={{ color: getRateColor(correctionRate, false) }}
|
||||
>
|
||||
{correctionRate.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Learning progress indicator */}
|
||||
{total >= 10 && acceptanceRate >= 80 && (
|
||||
<div className="learning-progress">
|
||||
<span className="progress-icon">📈</span>
|
||||
<span className="progress-text">
|
||||
Excellent ! Le workflow peut passer en mode AUTO.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{total >= 10 && acceptanceRate < 50 && (
|
||||
<div className="learning-warning">
|
||||
<span className="warning-icon">⚠</span>
|
||||
<span className="warning-text">
|
||||
Taux d'acceptation faible. Le workflow necessite plus de corrections.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoachingStatsDisplay;
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* CoachingSuggestionCard Component
|
||||
*
|
||||
* Displays the current action suggestion in COACHING mode.
|
||||
* Shows action type, target, parameters, and confidence level.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { CoachingSuggestion } from '../../hooks/useCoachingWebSocket';
|
||||
|
||||
interface CoachingSuggestionCardProps {
|
||||
suggestion: CoachingSuggestion;
|
||||
onViewDetails?: () => void;
|
||||
}
|
||||
|
||||
const CoachingSuggestionCard: React.FC<CoachingSuggestionCardProps> = ({
|
||||
suggestion,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
// Get confidence color based on level
|
||||
const getConfidenceColor = (confidence: number): string => {
|
||||
if (confidence >= 0.8) return '#4caf50'; // Green
|
||||
if (confidence >= 0.5) return '#ff9800'; // Orange
|
||||
return '#f44336'; // Red
|
||||
};
|
||||
|
||||
// Get action icon
|
||||
const getActionIcon = (action: string): string => {
|
||||
const icons: Record<string, string> = {
|
||||
click: '\u{1F5B1}',
|
||||
double_click: '\u{1F5B1}',
|
||||
right_click: '\u{1F5B1}',
|
||||
type: '\u2328',
|
||||
fill: '\u2328',
|
||||
scroll: '\u2195',
|
||||
hover: '\u{1F4CD}',
|
||||
wait: '\u23F1',
|
||||
screenshot: '\u{1F4F8}',
|
||||
navigate: '\u{1F517}',
|
||||
default: '\u2699',
|
||||
};
|
||||
return icons[action.toLowerCase()] || icons.default;
|
||||
};
|
||||
|
||||
// Format target for display
|
||||
const formatTarget = (target: Record<string, any>): string => {
|
||||
if (target.text) return `Text: "${target.text}"`;
|
||||
if (target.id) return `ID: ${target.id}`;
|
||||
if (target.xpath) return `XPath: ${target.xpath.substring(0, 50)}...`;
|
||||
if (target.css) return `CSS: ${target.css}`;
|
||||
if (target.image) return 'Image match';
|
||||
if (target.x !== undefined && target.y !== undefined) {
|
||||
return `Coordinates: (${target.x}, ${target.y})`;
|
||||
}
|
||||
return JSON.stringify(target).substring(0, 50);
|
||||
};
|
||||
|
||||
// Format params for display
|
||||
const formatParams = (params: Record<string, any>): string[] => {
|
||||
return Object.entries(params)
|
||||
.filter(([key]) => !['target', 'action'].includes(key))
|
||||
.map(([key, value]) => {
|
||||
if (typeof value === 'string' && value.length > 30) {
|
||||
return `${key}: "${value.substring(0, 30)}..."`;
|
||||
}
|
||||
return `${key}: ${JSON.stringify(value)}`;
|
||||
});
|
||||
};
|
||||
|
||||
const confidencePercent = Math.round(suggestion.confidence * 100);
|
||||
|
||||
return (
|
||||
<div className="coaching-suggestion-card">
|
||||
{/* Action header */}
|
||||
<div className="suggestion-header">
|
||||
<span className="action-icon">{getActionIcon(suggestion.action)}</span>
|
||||
<span className="action-name">{suggestion.action.toUpperCase()}</span>
|
||||
<div
|
||||
className="confidence-badge"
|
||||
style={{ backgroundColor: getConfidenceColor(suggestion.confidence) }}
|
||||
title={`Confiance: ${confidencePercent}%`}
|
||||
>
|
||||
{confidencePercent}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target */}
|
||||
<div className="suggestion-target">
|
||||
<label>Cible:</label>
|
||||
<span className="target-value">{formatTarget(suggestion.target)}</span>
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
{Object.keys(suggestion.params).length > 0 && (
|
||||
<div className="suggestion-params">
|
||||
<label>Parametres:</label>
|
||||
<ul>
|
||||
{formatParams(suggestion.params).map((param, index) => (
|
||||
<li key={index}>{param}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Screenshot preview */}
|
||||
{suggestion.screenshotPath && (
|
||||
<div className="suggestion-screenshot">
|
||||
<img
|
||||
src={`http://localhost:5000${suggestion.screenshotPath}`}
|
||||
alt="Target element"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alternatives */}
|
||||
{suggestion.alternatives && suggestion.alternatives.length > 0 && (
|
||||
<div className="suggestion-alternatives">
|
||||
<label>Alternatives ({suggestion.alternatives.length}):</label>
|
||||
<ul>
|
||||
{suggestion.alternatives.slice(0, 3).map((alt, index) => (
|
||||
<li key={index}>
|
||||
{alt.action}: {formatTarget(alt.target)} ({Math.round(alt.confidence * 100)}%)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context info */}
|
||||
{suggestion.context && Object.keys(suggestion.context).length > 0 && (
|
||||
<details className="suggestion-context">
|
||||
<summary>Contexte</summary>
|
||||
<pre>{JSON.stringify(suggestion.context, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* View details button */}
|
||||
{onViewDetails && (
|
||||
<button className="btn-view-details" onClick={onViewDetails}>
|
||||
Voir details
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoachingSuggestionCard;
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* CorrectionEditor Component
|
||||
*
|
||||
* Allows users to modify a suggested action before execution.
|
||||
* Supports editing:
|
||||
* - Target (element selector)
|
||||
* - Parameters (timeout, value, etc.)
|
||||
* - Action type (in some cases)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CoachingSuggestion } from '../../hooks/useCoachingWebSocket';
|
||||
|
||||
interface CorrectionEditorProps {
|
||||
suggestion: CoachingSuggestion;
|
||||
onSubmit: (correction: Record<string, any>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const CorrectionEditor: React.FC<CorrectionEditorProps> = ({
|
||||
suggestion,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}) => {
|
||||
// State for editable fields
|
||||
const [targetType, setTargetType] = useState<string>('text');
|
||||
const [targetValue, setTargetValue] = useState<string>('');
|
||||
const [params, setParams] = useState<Record<string, any>>({});
|
||||
const [feedback, setFeedback] = useState('');
|
||||
|
||||
// Initialize from suggestion
|
||||
useEffect(() => {
|
||||
// Detect target type
|
||||
if (suggestion.target.text) {
|
||||
setTargetType('text');
|
||||
setTargetValue(suggestion.target.text);
|
||||
} else if (suggestion.target.id) {
|
||||
setTargetType('id');
|
||||
setTargetValue(suggestion.target.id);
|
||||
} else if (suggestion.target.xpath) {
|
||||
setTargetType('xpath');
|
||||
setTargetValue(suggestion.target.xpath);
|
||||
} else if (suggestion.target.css) {
|
||||
setTargetType('css');
|
||||
setTargetValue(suggestion.target.css);
|
||||
} else if (suggestion.target.x !== undefined && suggestion.target.y !== undefined) {
|
||||
setTargetType('coordinates');
|
||||
setTargetValue(`${suggestion.target.x},${suggestion.target.y}`);
|
||||
}
|
||||
|
||||
// Copy params
|
||||
setParams({ ...suggestion.params });
|
||||
}, [suggestion]);
|
||||
|
||||
// Build correction object
|
||||
const buildCorrection = (): Record<string, any> => {
|
||||
const correction: Record<string, any> = {};
|
||||
|
||||
// Build corrected target
|
||||
const correctedTarget: Record<string, any> = {};
|
||||
switch (targetType) {
|
||||
case 'text':
|
||||
correctedTarget.text = targetValue;
|
||||
break;
|
||||
case 'id':
|
||||
correctedTarget.id = targetValue;
|
||||
break;
|
||||
case 'xpath':
|
||||
correctedTarget.xpath = targetValue;
|
||||
break;
|
||||
case 'css':
|
||||
correctedTarget.css = targetValue;
|
||||
break;
|
||||
case 'coordinates':
|
||||
const [x, y] = targetValue.split(',').map((v) => parseInt(v.trim(), 10));
|
||||
correctedTarget.x = x;
|
||||
correctedTarget.y = y;
|
||||
break;
|
||||
}
|
||||
|
||||
// Only include target if changed
|
||||
if (JSON.stringify(correctedTarget) !== JSON.stringify(suggestion.target)) {
|
||||
correction.target = correctedTarget;
|
||||
}
|
||||
|
||||
// Only include params if changed
|
||||
if (JSON.stringify(params) !== JSON.stringify(suggestion.params)) {
|
||||
correction.params = params;
|
||||
}
|
||||
|
||||
// Include feedback
|
||||
if (feedback.trim()) {
|
||||
correction.feedback = feedback;
|
||||
}
|
||||
|
||||
return correction;
|
||||
};
|
||||
|
||||
// Handle param change
|
||||
const handleParamChange = (key: string, value: any) => {
|
||||
setParams((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const correction = buildCorrection();
|
||||
onSubmit(correction);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="correction-editor">
|
||||
<h4>Corriger l'action</h4>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Target type selector */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="target-type">Type de cible:</label>
|
||||
<select
|
||||
id="target-type"
|
||||
value={targetType}
|
||||
onChange={(e) => setTargetType(e.target.value)}
|
||||
>
|
||||
<option value="text">Texte visible</option>
|
||||
<option value="id">ID element</option>
|
||||
<option value="xpath">XPath</option>
|
||||
<option value="css">Selecteur CSS</option>
|
||||
<option value="coordinates">Coordonnees (x,y)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Target value */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="target-value">
|
||||
{targetType === 'coordinates' ? 'Coordonnees (x,y):' : 'Valeur cible:'}
|
||||
</label>
|
||||
{targetType === 'xpath' ? (
|
||||
<textarea
|
||||
id="target-value"
|
||||
value={targetValue}
|
||||
onChange={(e) => setTargetValue(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="//button[@id='submit']"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id="target-value"
|
||||
type="text"
|
||||
value={targetValue}
|
||||
onChange={(e) => setTargetValue(e.target.value)}
|
||||
placeholder={
|
||||
targetType === 'coordinates'
|
||||
? '100, 200'
|
||||
: targetType === 'text'
|
||||
? 'Texte du bouton'
|
||||
: targetType === 'id'
|
||||
? 'element-id'
|
||||
: '.css-selector'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
<div className="form-group">
|
||||
<label>Parametres:</label>
|
||||
<div className="params-editor">
|
||||
{Object.entries(params).map(([key, value]) => (
|
||||
<div key={key} className="param-row">
|
||||
<span className="param-key">{key}:</span>
|
||||
{typeof value === 'boolean' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(e) => handleParamChange(key, e.target.checked)}
|
||||
/>
|
||||
) : typeof value === 'number' ? (
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => handleParamChange(key, parseFloat(e.target.value))}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={String(value)}
|
||||
onChange={(e) => handleParamChange(key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add new param */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-add-param"
|
||||
onClick={() => {
|
||||
const newKey = prompt('Nom du parametre:');
|
||||
if (newKey && !params[newKey]) {
|
||||
handleParamChange(newKey, '');
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ Ajouter parametre
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="correction-feedback">Raison de la correction:</label>
|
||||
<textarea
|
||||
id="correction-feedback"
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Expliquez pourquoi cette correction est necessaire..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="correction-actions">
|
||||
<button type="button" className="btn-cancel" onClick={onCancel}>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" className="btn-apply-correction">
|
||||
Appliquer la correction
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CorrectionEditor;
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* CoachingPanel Component
|
||||
*
|
||||
* Main UI component for COACHING mode execution.
|
||||
* Displays action suggestions and allows user decisions (accept, reject, correct, manual, skip).
|
||||
*
|
||||
* Features:
|
||||
* - Real-time suggestion display via WebSocket
|
||||
* - Decision buttons with keyboard shortcuts
|
||||
* - Correction editor for adjusting suggested actions
|
||||
* - Statistics dashboard showing acceptance/rejection rates
|
||||
* - Screenshot preview of the target element
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
useCoachingWebSocket,
|
||||
CoachingDecision,
|
||||
CoachingSuggestion,
|
||||
CoachingStats,
|
||||
} from '../../hooks/useCoachingWebSocket';
|
||||
import CoachingSuggestionCard from './CoachingSuggestionCard';
|
||||
import CoachingDecisionButtons from './CoachingDecisionButtons';
|
||||
import CoachingStatsDisplay from './CoachingStatsDisplay';
|
||||
import CorrectionEditor from './CorrectionEditor';
|
||||
import './CoachingPanel.css';
|
||||
|
||||
interface CoachingPanelProps {
|
||||
executionId?: string;
|
||||
workflowId?: string;
|
||||
onSessionStart?: (executionId: string) => void;
|
||||
onSessionEnd?: (stats: CoachingStats) => void;
|
||||
onDecisionMade?: (decision: CoachingDecision, suggestion: CoachingSuggestion) => void;
|
||||
serverUrl?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CoachingPanel: React.FC<CoachingPanelProps> = ({
|
||||
executionId: initialExecutionId,
|
||||
workflowId,
|
||||
onSessionStart,
|
||||
onSessionEnd,
|
||||
onDecisionMade,
|
||||
serverUrl,
|
||||
className = '',
|
||||
}) => {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [showCorrectionEditor, setShowCorrectionEditor] = useState(false);
|
||||
const [pendingCorrection, setPendingCorrection] = useState<Record<string, any> | null>(null);
|
||||
const [feedback, setFeedback] = useState('');
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
isSubscribed,
|
||||
currentSuggestion,
|
||||
stats,
|
||||
lastActionResult,
|
||||
error,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
submitDecision,
|
||||
refreshStats,
|
||||
} = useCoachingWebSocket({ serverUrl });
|
||||
|
||||
// Subscribe when executionId is provided
|
||||
useEffect(() => {
|
||||
if (initialExecutionId && isConnected && !isSubscribed) {
|
||||
subscribe(initialExecutionId);
|
||||
setIsActive(true);
|
||||
onSessionStart?.(initialExecutionId);
|
||||
}
|
||||
}, [initialExecutionId, isConnected, isSubscribed, subscribe, onSessionStart]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!currentSuggestion || showCorrectionEditor) return;
|
||||
|
||||
// Don't capture if user is typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'a':
|
||||
handleDecision('accept');
|
||||
break;
|
||||
case 'r':
|
||||
handleDecision('reject');
|
||||
break;
|
||||
case 'c':
|
||||
setShowCorrectionEditor(true);
|
||||
break;
|
||||
case 'm':
|
||||
handleDecision('manual');
|
||||
break;
|
||||
case 's':
|
||||
handleDecision('skip');
|
||||
break;
|
||||
case 'escape':
|
||||
if (showCorrectionEditor) {
|
||||
setShowCorrectionEditor(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [currentSuggestion, showCorrectionEditor]);
|
||||
|
||||
// Handle decision submission
|
||||
const handleDecision = useCallback(
|
||||
(decision: CoachingDecision) => {
|
||||
if (!currentSuggestion) return;
|
||||
|
||||
submitDecision(
|
||||
decision,
|
||||
decision === 'correct' ? pendingCorrection || undefined : undefined,
|
||||
feedback || undefined
|
||||
);
|
||||
|
||||
onDecisionMade?.(decision, currentSuggestion);
|
||||
|
||||
// Reset state
|
||||
setShowCorrectionEditor(false);
|
||||
setPendingCorrection(null);
|
||||
setFeedback('');
|
||||
},
|
||||
[currentSuggestion, submitDecision, pendingCorrection, feedback, onDecisionMade]
|
||||
);
|
||||
|
||||
// Handle correction submission
|
||||
const handleCorrectionSubmit = useCallback(
|
||||
(correction: Record<string, any>) => {
|
||||
setPendingCorrection(correction);
|
||||
handleDecision('correct');
|
||||
},
|
||||
[handleDecision]
|
||||
);
|
||||
|
||||
// Start a new COACHING session
|
||||
const startSession = useCallback(
|
||||
async (wfId: string) => {
|
||||
try {
|
||||
const response = await fetch(`${serverUrl || 'http://localhost:5000'}/api/executions/coaching`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workflow_id: wfId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to start COACHING session');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
subscribe(data.execution_id);
|
||||
setIsActive(true);
|
||||
onSessionStart?.(data.execution_id);
|
||||
} catch (err) {
|
||||
console.error('Error starting COACHING session:', err);
|
||||
}
|
||||
},
|
||||
[serverUrl, subscribe, onSessionStart]
|
||||
);
|
||||
|
||||
// End the current session
|
||||
const endSession = useCallback(() => {
|
||||
unsubscribe();
|
||||
setIsActive(false);
|
||||
onSessionEnd?.(stats);
|
||||
}, [unsubscribe, onSessionEnd, stats]);
|
||||
|
||||
return (
|
||||
<div className={`coaching-panel ${className} ${isActive ? 'active' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="coaching-panel-header">
|
||||
<h3>
|
||||
<span className="coaching-icon">🎓</span>
|
||||
Mode COACHING
|
||||
</h3>
|
||||
<div className="coaching-status">
|
||||
<span className={`status-indicator ${isConnected ? 'connected' : 'disconnected'}`} />
|
||||
{isConnected ? (isSubscribed ? 'En session' : 'Connecte') : 'Deconnecte'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="coaching-error">
|
||||
<span className="error-icon">⚠</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="coaching-panel-content">
|
||||
{/* Not started state */}
|
||||
{!isActive && workflowId && (
|
||||
<div className="coaching-start-prompt">
|
||||
<p>Demarrer une session COACHING pour valider chaque etape avant execution.</p>
|
||||
<button
|
||||
className="btn-start-coaching"
|
||||
onClick={() => startSession(workflowId)}
|
||||
disabled={!isConnected}
|
||||
>
|
||||
Demarrer COACHING
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Waiting for suggestion */}
|
||||
{isActive && isSubscribed && !currentSuggestion && (
|
||||
<div className="coaching-waiting">
|
||||
<div className="spinner" />
|
||||
<p>En attente de la prochaine action...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current suggestion */}
|
||||
{currentSuggestion && !showCorrectionEditor && (
|
||||
<>
|
||||
<CoachingSuggestionCard
|
||||
suggestion={currentSuggestion}
|
||||
onViewDetails={() => {}}
|
||||
/>
|
||||
|
||||
<CoachingDecisionButtons
|
||||
onDecision={handleDecision}
|
||||
onShowCorrection={() => setShowCorrectionEditor(true)}
|
||||
disabled={false}
|
||||
/>
|
||||
|
||||
{/* Feedback input */}
|
||||
<div className="coaching-feedback">
|
||||
<label htmlFor="coaching-feedback-input">Commentaire (optionnel):</label>
|
||||
<input
|
||||
id="coaching-feedback-input"
|
||||
type="text"
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="Raison de la decision..."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Correction editor */}
|
||||
{showCorrectionEditor && currentSuggestion && (
|
||||
<CorrectionEditor
|
||||
suggestion={currentSuggestion}
|
||||
onSubmit={handleCorrectionSubmit}
|
||||
onCancel={() => setShowCorrectionEditor(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Last action result */}
|
||||
{lastActionResult && (
|
||||
<div className={`coaching-result ${lastActionResult.success ? 'success' : 'error'}`}>
|
||||
<span className="result-icon">
|
||||
{lastActionResult.success ? '\u2713' : '\u2717'}
|
||||
</span>
|
||||
<span className="result-text">
|
||||
{lastActionResult.success
|
||||
? `Action "${lastActionResult.action}" executee avec succes`
|
||||
: `Echec: ${lastActionResult.error}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
{isSubscribed && (
|
||||
<CoachingStatsDisplay stats={stats} onRefresh={refreshStats} />
|
||||
)}
|
||||
|
||||
{/* Session controls */}
|
||||
{isActive && (
|
||||
<div className="coaching-panel-footer">
|
||||
<button className="btn-end-session" onClick={endSession}>
|
||||
Terminer la session
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard shortcuts help */}
|
||||
<div className="coaching-shortcuts-help">
|
||||
<small>
|
||||
Raccourcis: <kbd>A</kbd> Accepter | <kbd>R</kbd> Rejeter |{' '}
|
||||
<kbd>C</kbd> Corriger | <kbd>M</kbd> Manuel | <kbd>S</kbd> Passer
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoachingPanel;
|
||||
@@ -0,0 +1,838 @@
|
||||
/**
|
||||
* CorrectionPacksDashboard Styles
|
||||
*/
|
||||
|
||||
/* Dashboard Container */
|
||||
.correction-packs-dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.dashboard-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #313244;
|
||||
}
|
||||
|
||||
.dashboard-header h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 24px;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.dashboard-header .subtitle {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-actions button,
|
||||
.header-actions label {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-create {
|
||||
background: #89b4fa;
|
||||
color: #1e1e2e;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-create:hover {
|
||||
background: #b4befe;
|
||||
}
|
||||
|
||||
.btn-import {
|
||||
background: #313244;
|
||||
color: #cdd6f4;
|
||||
border: 1px solid #45475a;
|
||||
}
|
||||
|
||||
.btn-import:hover {
|
||||
background: #45475a;
|
||||
}
|
||||
|
||||
.btn-refresh {
|
||||
background: transparent;
|
||||
color: #a6adc8;
|
||||
border: 1px solid #45475a;
|
||||
}
|
||||
|
||||
.btn-refresh:hover {
|
||||
background: #313244;
|
||||
}
|
||||
|
||||
/* Error display */
|
||||
.dashboard-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: rgba(243, 139, 168, 0.1);
|
||||
color: #f38ba8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.dashboard-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.dashboard-loading .spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #45475a;
|
||||
border-top-color: #89b4fa;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Content layout */
|
||||
.dashboard-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-sidebar {
|
||||
width: 320px;
|
||||
border-right: 1px solid #313244;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dashboard-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Pack List */
|
||||
.pack-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.pack-list-empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
.pack-list-empty small {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.pack-item {
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
background: #313244;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pack-item:hover {
|
||||
background: #45475a;
|
||||
}
|
||||
|
||||
.pack-item.selected {
|
||||
border-color: #89b4fa;
|
||||
background: rgba(137, 180, 250, 0.1);
|
||||
}
|
||||
|
||||
.pack-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pack-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.pack-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pack-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pack-version {
|
||||
font-size: 11px;
|
||||
color: #6c7086;
|
||||
background: #1e1e2e;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pack-description {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 12px;
|
||||
color: #a6adc8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pack-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
.pack-stats .stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pack-stats .stat-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pack-tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 2px 8px;
|
||||
background: #45475a;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.tag.more {
|
||||
background: #585b70;
|
||||
}
|
||||
|
||||
.pack-actions {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.pack-item:hover .pack-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: #45475a;
|
||||
color: #cdd6f4;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
background: #585b70;
|
||||
}
|
||||
|
||||
.btn-action.delete:hover {
|
||||
background: #f38ba8;
|
||||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
/* Packs Summary */
|
||||
.packs-summary {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #313244;
|
||||
background: #181825;
|
||||
}
|
||||
|
||||
.packs-summary h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 12px;
|
||||
color: #6c7086;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-stats .stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-stats .value {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #89b4fa;
|
||||
}
|
||||
|
||||
.summary-stats .label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
/* No Selection */
|
||||
.no-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
.no-selection .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.no-selection h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.no-selection p {
|
||||
margin: 0;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* Pack Details */
|
||||
.pack-details {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.details-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.details-header .header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.details-header h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.details-header .version {
|
||||
padding: 4px 8px;
|
||||
background: #313244;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.details-header .description {
|
||||
margin: 0 0 12px 0;
|
||||
color: #a6adc8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.details-header .tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-edit,
|
||||
.btn-export {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #45475a;
|
||||
background: transparent;
|
||||
color: #a6adc8;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-edit:hover,
|
||||
.btn-export:hover {
|
||||
background: #313244;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
/* Edit form */
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.edit-form input,
|
||||
.edit-form textarea {
|
||||
padding: 8px 12px;
|
||||
background: #313244;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
color: #cdd6f4;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.edit-form input:focus,
|
||||
.edit-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
|
||||
.edit-form .edit-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-save {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: #45475a;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: #89b4fa;
|
||||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
/* Statistics */
|
||||
.details-stats {
|
||||
background: #313244;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.details-stats h4 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: #1e1e2e;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #89b4fa;
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #6c7086;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.distribution h5 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 12px;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
.distribution-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
width: 100px;
|
||||
font-size: 11px;
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.bar-container {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: #1e1e2e;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 100%;
|
||||
background: #89b4fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
/* Corrections list */
|
||||
.details-corrections {
|
||||
background: #313244;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.corrections-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.corrections-header h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.corrections-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-input,
|
||||
.sort-select {
|
||||
padding: 6px 10px;
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 4px;
|
||||
color: #cdd6f4;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.no-corrections {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
.corrections-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.correction-item {
|
||||
background: #1e1e2e;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.correction-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.correction-summary:hover {
|
||||
background: #252536;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.correction-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.correction-info .action {
|
||||
font-weight: 600;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.correction-info .element {
|
||||
color: #89b4fa;
|
||||
}
|
||||
|
||||
.correction-info .correction-type {
|
||||
color: #6c7086;
|
||||
padding: 2px 6px;
|
||||
background: #313244;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.correction-metrics {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.correction-metrics .confidence {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.correction-metrics .success-rate {
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 10px;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
.correction-details {
|
||||
padding: 12px;
|
||||
border-top: 1px solid #313244;
|
||||
background: #181825;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-row label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: #6c7086;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail-row span {
|
||||
font-size: 12px;
|
||||
color: #a6adc8;
|
||||
}
|
||||
|
||||
.detail-row pre {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background: #313244;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: #cdd6f4;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.detail-row.source {
|
||||
font-size: 11px;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: #313244;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #45475a;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #a6adc8;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: rgba(243, 139, 168, 0.1);
|
||||
color: #f38ba8;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-body .form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-body label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #a6adc8;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.modal-body input,
|
||||
.modal-body select,
|
||||
.modal-body textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #45475a;
|
||||
border-radius: 6px;
|
||||
color: #cdd6f4;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modal-body input:focus,
|
||||
.modal-body select:focus,
|
||||
.modal-body textarea:focus {
|
||||
outline: none;
|
||||
border-color: #89b4fa;
|
||||
}
|
||||
|
||||
.modal-body small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #45475a;
|
||||
}
|
||||
|
||||
.modal-footer button {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-footer .btn-cancel {
|
||||
background: #45475a;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.modal-footer .btn-create {
|
||||
background: #89b4fa;
|
||||
color: #1e1e2e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-footer .btn-create:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* CreatePackModal Component
|
||||
*
|
||||
* Modal for creating a new Correction Pack.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface CreatePackModalProps {
|
||||
onClose: () => void;
|
||||
onCreate: (
|
||||
name: string,
|
||||
description: string,
|
||||
tags: string[],
|
||||
category: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'default', label: 'General' },
|
||||
{ value: 'ui', label: 'Interface utilisateur' },
|
||||
{ value: 'form', label: 'Formulaires' },
|
||||
{ value: 'navigation', label: 'Navigation' },
|
||||
{ value: 'data', label: 'Donnees' },
|
||||
{ value: 'error', label: 'Gestion d\'erreurs' },
|
||||
];
|
||||
|
||||
const CreatePackModal: React.FC<CreatePackModalProps> = ({
|
||||
onClose,
|
||||
onCreate,
|
||||
}) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [category, setCategory] = useState('default');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('Le nom est requis');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await onCreate(
|
||||
name.trim(),
|
||||
description.trim(),
|
||||
tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
category
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur lors de la creation');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Nouveau Correction Pack</h3>
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
{error && (
|
||||
<div className="modal-error">
|
||||
<span>⚠</span> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="pack-name">Nom *</label>
|
||||
<input
|
||||
id="pack-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Mon pack de corrections"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="pack-description">Description</label>
|
||||
<textarea
|
||||
id="pack-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description du pack..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="pack-category">Categorie</label>
|
||||
<select
|
||||
id="pack-category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<option key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="pack-tags">Tags</label>
|
||||
<input
|
||||
id="pack-tags"
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="tag1, tag2, tag3"
|
||||
/>
|
||||
<small>Separez les tags par des virgules</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-cancel"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-create"
|
||||
disabled={loading || !name.trim()}
|
||||
>
|
||||
{loading ? 'Creation...' : 'Creer le pack'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreatePackModal;
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* PackDetails Component
|
||||
*
|
||||
* Shows detailed view of a Correction Pack with all its corrections.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CorrectionPack, Correction, useCorrectionPacks } from '../../hooks/useCorrectionPacks';
|
||||
|
||||
interface PackDetailsProps {
|
||||
pack: CorrectionPack;
|
||||
onUpdate: (packId: string, updates: Partial<CorrectionPack>) => Promise<boolean>;
|
||||
onExport: (pack: CorrectionPack, format: 'json' | 'yaml') => void;
|
||||
}
|
||||
|
||||
const PackDetails: React.FC<PackDetailsProps> = ({
|
||||
pack,
|
||||
onUpdate,
|
||||
onExport,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(pack.name);
|
||||
const [editDescription, setEditDescription] = useState(pack.description);
|
||||
const [editTags, setEditTags] = useState(pack.tags.join(', '));
|
||||
const [filter, setFilter] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'confidence' | 'type' | 'date'>('confidence');
|
||||
|
||||
const { getStatistics } = useCorrectionPacks();
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
|
||||
// Load statistics
|
||||
useEffect(() => {
|
||||
getStatistics(pack.id).then(setStats);
|
||||
}, [pack.id, getStatistics]);
|
||||
|
||||
// Reset form when pack changes
|
||||
useEffect(() => {
|
||||
setEditName(pack.name);
|
||||
setEditDescription(pack.description);
|
||||
setEditTags(pack.tags.join(', '));
|
||||
setIsEditing(false);
|
||||
}, [pack]);
|
||||
|
||||
// Handle save
|
||||
const handleSave = async () => {
|
||||
const success = await onUpdate(pack.id, {
|
||||
name: editName,
|
||||
description: editDescription,
|
||||
tags: editTags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
});
|
||||
if (success) {
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter and sort corrections
|
||||
const filteredCorrections = pack.corrections
|
||||
?.filter((c) => {
|
||||
if (!filter) return true;
|
||||
const searchLower = filter.toLowerCase();
|
||||
return (
|
||||
c.actionType?.toLowerCase().includes(searchLower) ||
|
||||
c.elementType?.toLowerCase().includes(searchLower) ||
|
||||
c.correctionType?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'confidence':
|
||||
return (b.confidenceScore || 0) - (a.confidenceScore || 0);
|
||||
case 'type':
|
||||
return (a.correctionType || '').localeCompare(b.correctionType || '');
|
||||
case 'date':
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}) || [];
|
||||
|
||||
// Get correction type icon
|
||||
const getCorrectionTypeIcon = (type: string): string => {
|
||||
const icons: Record<string, string> = {
|
||||
target_change: '\u{1F3AF}',
|
||||
parameter_change: '\u{2699}',
|
||||
timing_adjust: '\u{23F1}',
|
||||
coordinates_adjust: '\u{1F4CD}',
|
||||
other: '\u{2753}',
|
||||
};
|
||||
return icons[type?.toLowerCase()] || icons.other;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pack-details">
|
||||
{/* Header */}
|
||||
<div className="details-header">
|
||||
{isEditing ? (
|
||||
<div className="edit-form">
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="Nom du pack"
|
||||
className="edit-name"
|
||||
/>
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Description"
|
||||
className="edit-description"
|
||||
rows={2}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editTags}
|
||||
onChange={(e) => setEditTags(e.target.value)}
|
||||
placeholder="Tags (separes par des virgules)"
|
||||
className="edit-tags"
|
||||
/>
|
||||
<div className="edit-actions">
|
||||
<button className="btn-cancel" onClick={() => setIsEditing(false)}>
|
||||
Annuler
|
||||
</button>
|
||||
<button className="btn-save" onClick={handleSave}>
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="header-info">
|
||||
<h3>{pack.name}</h3>
|
||||
<span className="version">v{pack.version}</span>
|
||||
</div>
|
||||
{pack.description && <p className="description">{pack.description}</p>}
|
||||
{pack.tags && pack.tags.length > 0 && (
|
||||
<div className="tags">
|
||||
{pack.tags.map((tag, i) => (
|
||||
<span key={i} className="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="header-actions">
|
||||
<button className="btn-edit" onClick={() => setIsEditing(true)}>
|
||||
✎ Modifier
|
||||
</button>
|
||||
<button className="btn-export" onClick={() => onExport(pack, 'json')}>
|
||||
⬇ Export JSON
|
||||
</button>
|
||||
<button className="btn-export" onClick={() => onExport(pack, 'yaml')}>
|
||||
⬇ Export YAML
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
{stats && (
|
||||
<div className="details-stats">
|
||||
<h4>Statistiques</h4>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<span className="value">{stats.totalCorrections || 0}</span>
|
||||
<span className="label">Corrections</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="value">
|
||||
{stats.avgSuccessRate ? `${Math.round(stats.avgSuccessRate * 100)}%` : '-'}
|
||||
</span>
|
||||
<span className="label">Taux succes</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="value">
|
||||
{stats.avgConfidence ? `${Math.round(stats.avgConfidence * 100)}%` : '-'}
|
||||
</span>
|
||||
<span className="label">Confiance moy.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Distribution by type */}
|
||||
{stats.byType && Object.keys(stats.byType).length > 0 && (
|
||||
<div className="distribution">
|
||||
<h5>Par type de correction</h5>
|
||||
<div className="distribution-bars">
|
||||
{Object.entries(stats.byType).map(([type, count]: [string, any]) => (
|
||||
<div key={type} className="bar-item">
|
||||
<span className="bar-label">{type}</span>
|
||||
<div className="bar-container">
|
||||
<div
|
||||
className="bar"
|
||||
style={{
|
||||
width: `${(count / stats.totalCorrections) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-value">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Corrections list */}
|
||||
<div className="details-corrections">
|
||||
<div className="corrections-header">
|
||||
<h4>Corrections ({filteredCorrections.length})</h4>
|
||||
<div className="corrections-controls">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filtrer..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="filter-input"
|
||||
/>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="sort-select"
|
||||
>
|
||||
<option value="confidence">Confiance</option>
|
||||
<option value="type">Type</option>
|
||||
<option value="date">Date</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredCorrections.length === 0 ? (
|
||||
<div className="no-corrections">
|
||||
<p>Aucune correction dans ce pack</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="corrections-list">
|
||||
{filteredCorrections.map((correction, index) => (
|
||||
<CorrectionItem key={correction.id || index} correction={correction} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Sub-component for individual correction
|
||||
interface CorrectionItemProps {
|
||||
correction: Correction;
|
||||
}
|
||||
|
||||
const CorrectionItem: React.FC<CorrectionItemProps> = ({ correction }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const getCorrectionTypeIcon = (type: string): string => {
|
||||
const icons: Record<string, string> = {
|
||||
target_change: '\u{1F3AF}',
|
||||
parameter_change: '\u{2699}',
|
||||
timing_adjust: '\u{23F1}',
|
||||
coordinates_adjust: '\u{1F4CD}',
|
||||
other: '\u{2753}',
|
||||
};
|
||||
return icons[type?.toLowerCase()] || icons.other;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`correction-item ${expanded ? 'expanded' : ''}`}>
|
||||
<div className="correction-summary" onClick={() => setExpanded(!expanded)}>
|
||||
<span className="type-icon">{getCorrectionTypeIcon(correction.correctionType)}</span>
|
||||
<div className="correction-info">
|
||||
<span className="action">{correction.actionType}</span>
|
||||
<span className="element">{correction.elementType}</span>
|
||||
<span className="correction-type">{correction.correctionType}</span>
|
||||
</div>
|
||||
<div className="correction-metrics">
|
||||
<span
|
||||
className="confidence"
|
||||
style={{
|
||||
color: correction.confidenceScore >= 0.8 ? '#a6e3a1' :
|
||||
correction.confidenceScore >= 0.5 ? '#f9e2af' : '#f38ba8'
|
||||
}}
|
||||
>
|
||||
{Math.round((correction.confidenceScore || 0) * 100)}%
|
||||
</span>
|
||||
<span className="success-rate">
|
||||
{correction.successCount}/{correction.successCount + correction.failureCount}
|
||||
</span>
|
||||
</div>
|
||||
<span className="expand-icon">{expanded ? '\u25BC' : '\u25B6'}</span>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="correction-details">
|
||||
{correction.failureReason && (
|
||||
<div className="detail-row">
|
||||
<label>Raison de l'echec:</label>
|
||||
<span>{correction.failureReason}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-row">
|
||||
<label>Cible originale:</label>
|
||||
<pre>{JSON.stringify(correction.originalTarget, null, 2)}</pre>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<label>Cible corrigee:</label>
|
||||
<pre>{JSON.stringify(correction.correctedTarget, null, 2)}</pre>
|
||||
</div>
|
||||
{correction.source && (
|
||||
<div className="detail-row source">
|
||||
<label>Source:</label>
|
||||
<span>
|
||||
{correction.source.workflowId && `Workflow: ${correction.source.workflowId}`}
|
||||
{correction.source.sessionId && ` | Session: ${correction.source.sessionId}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PackDetails;
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* PackList Component
|
||||
*
|
||||
* Displays list of Correction Packs with summary information.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { CorrectionPack } from '../../hooks/useCorrectionPacks';
|
||||
|
||||
interface PackListProps {
|
||||
packs: CorrectionPack[];
|
||||
selectedPack: CorrectionPack | null;
|
||||
onSelect: (pack: CorrectionPack) => void;
|
||||
onDelete: (pack: CorrectionPack) => void;
|
||||
onExport: (pack: CorrectionPack, format: 'json' | 'yaml') => void;
|
||||
}
|
||||
|
||||
const PackList: React.FC<PackListProps> = ({
|
||||
packs,
|
||||
selectedPack,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onExport,
|
||||
}) => {
|
||||
// Format date
|
||||
const formatDate = (dateStr: string): string => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// Get category icon
|
||||
const getCategoryIcon = (category: string): string => {
|
||||
const icons: Record<string, string> = {
|
||||
'default': '\u{1F4E6}',
|
||||
'ui': '\u{1F5A5}',
|
||||
'form': '\u{1F4DD}',
|
||||
'navigation': '\u{1F517}',
|
||||
'data': '\u{1F4CA}',
|
||||
'error': '\u{26A0}',
|
||||
};
|
||||
return icons[category?.toLowerCase()] || icons.default;
|
||||
};
|
||||
|
||||
if (packs.length === 0) {
|
||||
return (
|
||||
<div className="pack-list-empty">
|
||||
<p>Aucun pack de corrections</p>
|
||||
<small>Creez un nouveau pack pour commencer</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pack-list">
|
||||
{packs.map((pack) => (
|
||||
<div
|
||||
key={pack.id}
|
||||
className={`pack-item ${selectedPack?.id === pack.id ? 'selected' : ''}`}
|
||||
onClick={() => onSelect(pack)}
|
||||
>
|
||||
{/* Pack icon and name */}
|
||||
<div className="pack-header">
|
||||
<span className="pack-icon">{getCategoryIcon(pack.category)}</span>
|
||||
<div className="pack-title">
|
||||
<span className="pack-name">{pack.name}</span>
|
||||
<span className="pack-version">v{pack.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pack description */}
|
||||
{pack.description && (
|
||||
<p className="pack-description">{pack.description}</p>
|
||||
)}
|
||||
|
||||
{/* Pack stats */}
|
||||
<div className="pack-stats">
|
||||
<span className="stat" title="Nombre de corrections">
|
||||
<span className="stat-icon">📝</span>
|
||||
{pack.corrections?.length || 0}
|
||||
</span>
|
||||
<span className="stat" title="Confiance moyenne">
|
||||
<span className="stat-icon">✓</span>
|
||||
{pack.statistics?.avgConfidence
|
||||
? `${Math.round(pack.statistics.avgConfidence * 100)}%`
|
||||
: '-'}
|
||||
</span>
|
||||
<span className="stat date" title="Derniere modification">
|
||||
{formatDate(pack.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{pack.tags && pack.tags.length > 0 && (
|
||||
<div className="pack-tags">
|
||||
{pack.tags.slice(0, 3).map((tag, index) => (
|
||||
<span key={index} className="tag">{tag}</span>
|
||||
))}
|
||||
{pack.tags.length > 3 && (
|
||||
<span className="tag more">+{pack.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pack-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="btn-action"
|
||||
title="Exporter JSON"
|
||||
onClick={() => onExport(pack, 'json')}
|
||||
>
|
||||
⬇
|
||||
</button>
|
||||
<button
|
||||
className="btn-action delete"
|
||||
title="Supprimer"
|
||||
onClick={() => onDelete(pack)}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PackList;
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* CorrectionPacksDashboard Component
|
||||
*
|
||||
* Dashboard for managing Correction Packs:
|
||||
* - List all packs with statistics
|
||||
* - Create/edit/delete packs
|
||||
* - Import/export functionality
|
||||
* - View corrections in a pack
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useCorrectionPacks, CorrectionPack, Correction } from '../../hooks/useCorrectionPacks';
|
||||
import PackList from './PackList';
|
||||
import PackDetails from './PackDetails';
|
||||
import CreatePackModal from './CreatePackModal';
|
||||
import './CorrectionPacksDashboard.css';
|
||||
|
||||
interface CorrectionPacksDashboardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CorrectionPacksDashboard: React.FC<CorrectionPacksDashboardProps> = ({
|
||||
className = '',
|
||||
}) => {
|
||||
const {
|
||||
packs,
|
||||
selectedPack,
|
||||
loading,
|
||||
error,
|
||||
fetchPacks,
|
||||
createPack,
|
||||
updatePack,
|
||||
deletePack,
|
||||
exportPack,
|
||||
importPack,
|
||||
selectPack,
|
||||
} = useCorrectionPacks();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
|
||||
// Handle pack creation
|
||||
const handleCreatePack = useCallback(async (
|
||||
name: string,
|
||||
description: string,
|
||||
tags: string[],
|
||||
category: string
|
||||
) => {
|
||||
const newPack = await createPack(name, description, tags, category);
|
||||
if (newPack) {
|
||||
setShowCreateModal(false);
|
||||
selectPack(newPack);
|
||||
}
|
||||
}, [createPack, selectPack]);
|
||||
|
||||
// Handle pack deletion
|
||||
const handleDeletePack = useCallback(async (pack: CorrectionPack) => {
|
||||
if (window.confirm(`Supprimer le pack "${pack.name}" ?`)) {
|
||||
await deletePack(pack.id);
|
||||
}
|
||||
}, [deletePack]);
|
||||
|
||||
// Handle import
|
||||
const handleImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const imported = await importPack(file);
|
||||
if (imported) {
|
||||
selectPack(imported);
|
||||
}
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
e.target.value = ''; // Reset input
|
||||
}
|
||||
}, [importPack, selectPack]);
|
||||
|
||||
// Handle export
|
||||
const handleExport = useCallback(async (pack: CorrectionPack, format: 'json' | 'yaml') => {
|
||||
await exportPack(pack.id, format);
|
||||
}, [exportPack]);
|
||||
|
||||
return (
|
||||
<div className={`correction-packs-dashboard ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="dashboard-header">
|
||||
<h2>Correction Packs</h2>
|
||||
<p className="subtitle">
|
||||
Gerez les corrections apprises pour ameliorer l'execution automatique
|
||||
</p>
|
||||
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="btn-create"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
<span>+</span> Nouveau Pack
|
||||
</button>
|
||||
|
||||
<label className="btn-import">
|
||||
<span>↑</span> Importer
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,.yaml,.yml"
|
||||
onChange={handleImport}
|
||||
disabled={importLoading}
|
||||
hidden
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="btn-refresh"
|
||||
onClick={fetchPacks}
|
||||
disabled={loading}
|
||||
>
|
||||
↻ Actualiser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="dashboard-error">
|
||||
<span>⚠</span> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && !packs.length && (
|
||||
<div className="dashboard-loading">
|
||||
<div className="spinner" />
|
||||
<p>Chargement des packs...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="dashboard-content">
|
||||
{/* Pack list */}
|
||||
<div className="dashboard-sidebar">
|
||||
<PackList
|
||||
packs={packs}
|
||||
selectedPack={selectedPack}
|
||||
onSelect={selectPack}
|
||||
onDelete={handleDeletePack}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
|
||||
{/* Summary stats */}
|
||||
{packs.length > 0 && (
|
||||
<div className="packs-summary">
|
||||
<h4>Resume</h4>
|
||||
<div className="summary-stats">
|
||||
<div className="stat">
|
||||
<span className="value">{packs.length}</span>
|
||||
<span className="label">Packs</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="value">
|
||||
{packs.reduce((sum, p) => sum + (p.corrections?.length || 0), 0)}
|
||||
</span>
|
||||
<span className="label">Corrections</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pack details */}
|
||||
<div className="dashboard-main">
|
||||
{selectedPack ? (
|
||||
<PackDetails
|
||||
pack={selectedPack}
|
||||
onUpdate={updatePack}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
) : (
|
||||
<div className="no-selection">
|
||||
<div className="icon">📦</div>
|
||||
<h3>Selectionnez un pack</h3>
|
||||
<p>
|
||||
Choisissez un pack dans la liste pour voir ses corrections
|
||||
ou creez-en un nouveau.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create pack modal */}
|
||||
{showCreateModal && (
|
||||
<CreatePackModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreate={handleCreatePack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CorrectionPacksDashboard;
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Hook for COACHING mode WebSocket communication.
|
||||
*
|
||||
* Manages real-time communication for COACHING sessions including:
|
||||
* - Subscribing to COACHING events
|
||||
* - Receiving action suggestions
|
||||
* - Submitting user decisions
|
||||
* - Tracking COACHING statistics
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
// Types for COACHING mode
|
||||
export type CoachingDecision = 'accept' | 'reject' | 'correct' | 'manual' | 'skip';
|
||||
|
||||
export interface CoachingSuggestion {
|
||||
executionId: string;
|
||||
action: string;
|
||||
target: Record<string, any>;
|
||||
params: Record<string, any>;
|
||||
confidence: number;
|
||||
alternatives?: Array<{
|
||||
action: string;
|
||||
target: Record<string, any>;
|
||||
confidence: number;
|
||||
}>;
|
||||
screenshotPath?: string;
|
||||
context?: Record<string, any>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface CoachingStats {
|
||||
suggestionsMade: number;
|
||||
accepted: number;
|
||||
rejected: number;
|
||||
corrected: number;
|
||||
manualExecutions: number;
|
||||
acceptanceRate: number;
|
||||
correctionRate: number;
|
||||
}
|
||||
|
||||
export interface CoachingActionResult {
|
||||
executionId: string;
|
||||
action: string;
|
||||
success: boolean;
|
||||
result?: Record<string, any>;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface UseCoachingWebSocketOptions {
|
||||
serverUrl?: string;
|
||||
autoConnect?: boolean;
|
||||
}
|
||||
|
||||
interface UseCoachingWebSocketReturn {
|
||||
isConnected: boolean;
|
||||
isSubscribed: boolean;
|
||||
currentSuggestion: CoachingSuggestion | null;
|
||||
stats: CoachingStats;
|
||||
lastActionResult: CoachingActionResult | null;
|
||||
error: string | null;
|
||||
subscribe: (executionId: string) => void;
|
||||
unsubscribe: () => void;
|
||||
submitDecision: (decision: CoachingDecision, correction?: Record<string, any>, feedback?: string) => void;
|
||||
refreshStats: () => void;
|
||||
}
|
||||
|
||||
const initialStats: CoachingStats = {
|
||||
suggestionsMade: 0,
|
||||
accepted: 0,
|
||||
rejected: 0,
|
||||
corrected: 0,
|
||||
manualExecutions: 0,
|
||||
acceptanceRate: 0,
|
||||
correctionRate: 0,
|
||||
};
|
||||
|
||||
export function useCoachingWebSocket(
|
||||
options: UseCoachingWebSocketOptions = {}
|
||||
): UseCoachingWebSocketReturn {
|
||||
const { serverUrl = 'http://localhost:5000', autoConnect = true } = options;
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const [currentSuggestion, setCurrentSuggestion] = useState<CoachingSuggestion | null>(null);
|
||||
const [stats, setStats] = useState<CoachingStats>(initialStats);
|
||||
const [lastActionResult, setLastActionResult] = useState<CoachingActionResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const executionIdRef = useRef<string | null>(null);
|
||||
|
||||
// Initialize socket connection
|
||||
useEffect(() => {
|
||||
if (!autoConnect) return;
|
||||
|
||||
const socket = io(serverUrl, {
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[COACHING WS] Connected');
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('[COACHING WS] Disconnected');
|
||||
setIsConnected(false);
|
||||
setIsSubscribed(false);
|
||||
});
|
||||
|
||||
socket.on('connect_error', (err) => {
|
||||
console.error('[COACHING WS] Connection error:', err);
|
||||
setError(`Connection error: ${err.message}`);
|
||||
});
|
||||
|
||||
// COACHING specific events
|
||||
socket.on('coaching_subscribed', (data) => {
|
||||
console.log('[COACHING WS] Subscribed:', data);
|
||||
setIsSubscribed(true);
|
||||
if (data.stats) {
|
||||
setStats(convertStats(data.stats));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('coaching_unsubscribed', () => {
|
||||
console.log('[COACHING WS] Unsubscribed');
|
||||
setIsSubscribed(false);
|
||||
setCurrentSuggestion(null);
|
||||
});
|
||||
|
||||
socket.on('coaching_suggestion', (data: any) => {
|
||||
console.log('[COACHING WS] Suggestion received:', data);
|
||||
setCurrentSuggestion({
|
||||
executionId: data.execution_id,
|
||||
action: data.action,
|
||||
target: data.target || {},
|
||||
params: data.params || {},
|
||||
confidence: data.confidence || 0,
|
||||
alternatives: data.alternatives,
|
||||
screenshotPath: data.screenshot_path,
|
||||
context: data.context,
|
||||
timestamp: data.timestamp,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('coaching_action_result', (data: any) => {
|
||||
console.log('[COACHING WS] Action result:', data);
|
||||
setLastActionResult({
|
||||
executionId: data.execution_id,
|
||||
action: data.action,
|
||||
success: data.success,
|
||||
result: data.result,
|
||||
error: data.error,
|
||||
timestamp: data.timestamp,
|
||||
});
|
||||
// Clear current suggestion after result
|
||||
setCurrentSuggestion(null);
|
||||
});
|
||||
|
||||
socket.on('coaching_stats_update', (data: any) => {
|
||||
console.log('[COACHING WS] Stats update:', data);
|
||||
if (data.stats) {
|
||||
setStats(convertStats(data.stats));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('coaching_decision_accepted', (data: any) => {
|
||||
console.log('[COACHING WS] Decision accepted:', data);
|
||||
});
|
||||
|
||||
socket.on('coaching_decision_broadcast', (data: any) => {
|
||||
console.log('[COACHING WS] Decision broadcast:', data);
|
||||
});
|
||||
|
||||
socket.on('coaching_session_end', (data: any) => {
|
||||
console.log('[COACHING WS] Session ended:', data);
|
||||
setIsSubscribed(false);
|
||||
setCurrentSuggestion(null);
|
||||
if (data.stats) {
|
||||
setStats(convertStats(data.stats));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (data) => {
|
||||
console.error('[COACHING WS] Error:', data);
|
||||
setError(data.message || 'Unknown error');
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [serverUrl, autoConnect]);
|
||||
|
||||
// Convert backend stats format to frontend format
|
||||
const convertStats = (backendStats: Record<string, any>): CoachingStats => {
|
||||
return {
|
||||
suggestionsMade: backendStats.suggestions_made || 0,
|
||||
accepted: backendStats.accepted || 0,
|
||||
rejected: backendStats.rejected || 0,
|
||||
corrected: backendStats.corrected || 0,
|
||||
manualExecutions: backendStats.manual_executions || 0,
|
||||
acceptanceRate: backendStats.acceptance_rate || 0,
|
||||
correctionRate: backendStats.correction_rate || 0,
|
||||
};
|
||||
};
|
||||
|
||||
// Subscribe to COACHING events for an execution
|
||||
const subscribe = useCallback((executionId: string) => {
|
||||
if (!socketRef.current || !isConnected) {
|
||||
setError('Not connected to server');
|
||||
return;
|
||||
}
|
||||
|
||||
executionIdRef.current = executionId;
|
||||
socketRef.current.emit('subscribe_coaching', { execution_id: executionId });
|
||||
}, [isConnected]);
|
||||
|
||||
// Unsubscribe from COACHING events
|
||||
const unsubscribe = useCallback(() => {
|
||||
if (!socketRef.current || !executionIdRef.current) return;
|
||||
|
||||
socketRef.current.emit('unsubscribe_coaching', {
|
||||
execution_id: executionIdRef.current,
|
||||
});
|
||||
executionIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Submit a COACHING decision
|
||||
const submitDecision = useCallback(
|
||||
(decision: CoachingDecision, correction?: Record<string, any>, feedback?: string) => {
|
||||
if (!socketRef.current || !executionIdRef.current) {
|
||||
setError('Not subscribed to any execution');
|
||||
return;
|
||||
}
|
||||
|
||||
socketRef.current.emit('coaching_decision', {
|
||||
execution_id: executionIdRef.current,
|
||||
decision,
|
||||
correction,
|
||||
feedback,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Refresh stats
|
||||
const refreshStats = useCallback(() => {
|
||||
if (!socketRef.current || !executionIdRef.current) return;
|
||||
|
||||
socketRef.current.emit('get_coaching_stats', {
|
||||
execution_id: executionIdRef.current,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
isSubscribed,
|
||||
currentSuggestion,
|
||||
stats,
|
||||
lastActionResult,
|
||||
error,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
submitDecision,
|
||||
refreshStats,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCoachingWebSocket;
|
||||
287
visual_workflow_builder/frontend/src/hooks/useCorrectionPacks.ts
Normal file
287
visual_workflow_builder/frontend/src/hooks/useCorrectionPacks.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Hook for Correction Packs API
|
||||
*
|
||||
* Provides methods to interact with the Correction Packs backend API.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
// Types
|
||||
export interface Correction {
|
||||
id: string;
|
||||
actionType: string;
|
||||
elementType: string;
|
||||
failureReason?: string;
|
||||
correctionType: string;
|
||||
originalTarget: Record<string, any>;
|
||||
correctedTarget: Record<string, any>;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
confidenceScore: number;
|
||||
createdAt: string;
|
||||
source: {
|
||||
sessionId?: string;
|
||||
workflowId?: string;
|
||||
nodeId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CorrectionPack {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
corrections: Correction[];
|
||||
tags: string[];
|
||||
category: string;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
statistics: {
|
||||
totalCorrections: number;
|
||||
avgConfidence: number;
|
||||
mostCommonType: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PackStatistics {
|
||||
totalCorrections: number;
|
||||
avgSuccessRate: number;
|
||||
avgConfidence: number;
|
||||
byType: Record<string, number>;
|
||||
byElement: Record<string, number>;
|
||||
}
|
||||
|
||||
interface UseCorrectionPacksReturn {
|
||||
packs: CorrectionPack[];
|
||||
selectedPack: CorrectionPack | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
fetchPacks: () => Promise<void>;
|
||||
fetchPack: (packId: string) => Promise<CorrectionPack | null>;
|
||||
createPack: (name: string, description?: string, tags?: string[], category?: string) => Promise<CorrectionPack | null>;
|
||||
updatePack: (packId: string, updates: Partial<CorrectionPack>) => Promise<boolean>;
|
||||
deletePack: (packId: string) => Promise<boolean>;
|
||||
exportPack: (packId: string, format?: 'json' | 'yaml') => Promise<void>;
|
||||
importPack: (file: File) => Promise<CorrectionPack | null>;
|
||||
getStatistics: (packId: string) => Promise<PackStatistics | null>;
|
||||
findApplicable: (context: Record<string, any>) => Promise<Correction[]>;
|
||||
selectPack: (pack: CorrectionPack | null) => void;
|
||||
}
|
||||
|
||||
const API_BASE = 'http://localhost:5000/api';
|
||||
|
||||
export function useCorrectionPacks(): UseCorrectionPacksReturn {
|
||||
const [packs, setPacks] = useState<CorrectionPack[]>([]);
|
||||
const [selectedPack, setSelectedPack] = useState<CorrectionPack | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch all packs
|
||||
const fetchPacks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/correction-packs`);
|
||||
if (!response.ok) throw new Error('Failed to fetch packs');
|
||||
|
||||
const data = await response.json();
|
||||
setPacks(data.packs || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch single pack
|
||||
const fetchPack = useCallback(async (packId: string): Promise<CorrectionPack | null> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/correction-packs/${packId}`);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json();
|
||||
return data.pack || data;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Create new pack
|
||||
const createPack = useCallback(async (
|
||||
name: string,
|
||||
description?: string,
|
||||
tags?: string[],
|
||||
category?: string
|
||||
): Promise<CorrectionPack | null> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/correction-packs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description, tags, category }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create pack');
|
||||
|
||||
const data = await response.json();
|
||||
await fetchPacks(); // Refresh list
|
||||
return data.pack || data;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
return null;
|
||||
}
|
||||
}, [fetchPacks]);
|
||||
|
||||
// Update pack
|
||||
const updatePack = useCallback(async (
|
||||
packId: string,
|
||||
updates: Partial<CorrectionPack>
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/correction-packs/${packId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (!response.ok) return false;
|
||||
|
||||
await fetchPacks(); // Refresh list
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
return false;
|
||||
}
|
||||
}, [fetchPacks]);
|
||||
|
||||
// Delete pack
|
||||
const deletePack = useCallback(async (packId: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/correction-packs/${packId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) return false;
|
||||
|
||||
await fetchPacks(); // Refresh list
|
||||
if (selectedPack?.id === packId) {
|
||||
setSelectedPack(null);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
return false;
|
||||
}
|
||||
}, [fetchPacks, selectedPack]);
|
||||
|
||||
// Export pack
|
||||
const exportPack = useCallback(async (
|
||||
packId: string,
|
||||
format: 'json' | 'yaml' = 'json'
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/correction-packs/${packId}/export?format=${format}`
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error('Failed to export pack');
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `correction_pack_${packId}.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Import pack
|
||||
const importPack = useCallback(async (file: File): Promise<CorrectionPack | null> => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`${API_BASE}/correction-packs/import`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to import pack');
|
||||
|
||||
const data = await response.json();
|
||||
await fetchPacks(); // Refresh list
|
||||
return data.pack || data;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
return null;
|
||||
}
|
||||
}, [fetchPacks]);
|
||||
|
||||
// Get statistics
|
||||
const getStatistics = useCallback(async (packId: string): Promise<PackStatistics | null> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/correction-packs/${packId}/statistics`);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json();
|
||||
return data.statistics || data;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Find applicable corrections
|
||||
const findApplicable = useCallback(async (
|
||||
context: Record<string, any>
|
||||
): Promise<Correction[]> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/correction-packs/find`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(context),
|
||||
});
|
||||
|
||||
if (!response.ok) return [];
|
||||
|
||||
const data = await response.json();
|
||||
return data.corrections || [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Select pack
|
||||
const selectPack = useCallback((pack: CorrectionPack | null) => {
|
||||
setSelectedPack(pack);
|
||||
}, []);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchPacks();
|
||||
}, [fetchPacks]);
|
||||
|
||||
return {
|
||||
packs,
|
||||
selectedPack,
|
||||
loading,
|
||||
error,
|
||||
fetchPacks,
|
||||
fetchPack,
|
||||
createPack,
|
||||
updatePack,
|
||||
deletePack,
|
||||
exportPack,
|
||||
importPack,
|
||||
getStatistics,
|
||||
findApplicable,
|
||||
selectPack,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCorrectionPacks;
|
||||
Reference in New Issue
Block a user