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:
Dom
2026-01-19 08:40:54 +01:00
parent d6e2530f2a
commit 38a1a5ddd8
21 changed files with 7269 additions and 0 deletions

View 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

View 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
})

View File

@@ -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:

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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"
>
&#x21BB;
</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">&#x1F4C8;</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">&#x26A0;</span>
<span className="warning-text">
Taux d'acceptation faible. Le workflow necessite plus de corrections.
</span>
</div>
)}
</div>
);
};
export default CoachingStatsDisplay;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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">&#x1F393;</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">&#x26A0;</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;

View File

@@ -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;
}

View File

@@ -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}>
&times;
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{error && (
<div className="modal-error">
<span>&#x26A0;</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;

View File

@@ -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)}>
&#x270E; Modifier
</button>
<button className="btn-export" onClick={() => onExport(pack, 'json')}>
&#x2B07; Export JSON
</button>
<button className="btn-export" onClick={() => onExport(pack, 'yaml')}>
&#x2B07; 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;

View File

@@ -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">&#x1F4DD;</span>
{pack.corrections?.length || 0}
</span>
<span className="stat" title="Confiance moyenne">
<span className="stat-icon">&#x2713;</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')}
>
&#x2B07;
</button>
<button
className="btn-action delete"
title="Supprimer"
onClick={() => onDelete(pack)}
>
&#x1F5D1;
</button>
</div>
</div>
))}
</div>
);
};
export default PackList;

View File

@@ -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>&#x2191;</span> Importer
<input
type="file"
accept=".json,.yaml,.yml"
onChange={handleImport}
disabled={importLoading}
hidden
/>
</label>
<button
className="btn-refresh"
onClick={fetchPacks}
disabled={loading}
>
&#x21BB; Actualiser
</button>
</div>
</div>
{/* Error display */}
{error && (
<div className="dashboard-error">
<span>&#x26A0;</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">&#x1F4E6;</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;

View File

@@ -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;

View 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;