feat(vwb): PauseDialog + ChecklistPanel + extension PropertiesPanel pour safety_checks
PauseDialog (composant nouveau) : - 2 modes selon payload : bulle simple legacy si safety_checks vide, ChecklistPanel sinon - Continuer désactivé tant que required non cochés - Badge [obligatoire] et [Léa] (avec evidence en tooltip) - POST /api/v3/replay/resume avec acknowledged_check_ids quand replay_id présent, fallback api.resumeExecution() pour la voie locale types.ts : SafetyCheck, SafetyLevel, extension Execution (pause_reason, pause_message, safety_checks, replay_id, status 'paused_need_help'). Action pause_for_human enrichie de safety_level et safety_checks dans le catalogue ACTIONS. PropertiesPanel : éditeur safety_level (dropdown standard/medical_critical) + liste éditable de safety_checks (id/label/required + ajout/suppression). App.tsx : rendu conditionnel du PauseDialog en overlay quand status == paused_need_help, ou paused avec safety_checks. Backward 100% : workflows existants sans safety_checks affichent la bulle legacy. CSS : .pause-dialog-overlay/.pause-dialog-checks/.checklist-panel/ .check-item/.badge-required/.badge-lea/.check-editor-row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import ExecutionOverlay from './components/ExecutionOverlay';
|
||||
import type { Variable } from './components/VariableManager';
|
||||
import RightPanel from './components/RightPanel';
|
||||
import SelfHealingDialog from './components/SelfHealingDialog';
|
||||
import PauseDialog from './components/PauseDialog';
|
||||
import ConfidenceDashboard from './components/ConfidenceDashboard';
|
||||
import WorkflowValidation from './components/WorkflowValidation';
|
||||
import ReviewModal from './components/ReviewModal';
|
||||
@@ -569,6 +570,47 @@ function App() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* QW4 — Pause supervisée (safety_checks).
|
||||
Affiché si le serveur renvoie status == paused_need_help, ou
|
||||
status == paused avec un payload de checks. Backward 100% : si
|
||||
safety_checks vide, PauseDialog rend la bulle simple legacy. */}
|
||||
{(appState?.execution?.status === 'paused_need_help' ||
|
||||
(appState?.execution?.status === 'paused' &&
|
||||
(appState?.execution?.safety_checks?.length ?? 0) > 0)) && (
|
||||
<div className="pause-dialog-overlay">
|
||||
<PauseDialog
|
||||
pauseMessage={appState.execution.pause_message || 'Validation requise'}
|
||||
pauseReason={appState.execution.pause_reason}
|
||||
safetyChecks={appState.execution.safety_checks || []}
|
||||
onResume={async (ackIds) => {
|
||||
const replayId = appState.execution?.replay_id || appState.execution?.id;
|
||||
if (replayId) {
|
||||
// Voie streaming server (Agent V1 / replay distant)
|
||||
const resp = await fetch('/api/v3/replay/resume', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
replay_id: replayId,
|
||||
acknowledged_check_ids: ackIds,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err?.detail?.error || resp.statusText);
|
||||
}
|
||||
} else {
|
||||
// Voie locale (execute/resume)
|
||||
await api.resumeExecution();
|
||||
}
|
||||
await loadState();
|
||||
}}
|
||||
onCancel={() => {
|
||||
handleStopExecution();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ConfidenceDashboard déplacé dans le header */}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// QW4 — PauseDialog : bulle de pause supervisée avec ChecklistPanel intégré.
|
||||
//
|
||||
// 2 modes de rendu :
|
||||
// - safety_checks vide -> bulle simple legacy (Continuer / Annuler)
|
||||
// - safety_checks fournis -> ChecklistPanel ; bouton Continuer désactivé
|
||||
// tant qu'un check `required` n'est pas coché.
|
||||
//
|
||||
// Les checks `llm_contextual` portent un badge [Léa] avec evidence en tooltip.
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { SafetyCheck } from '../types';
|
||||
|
||||
interface Props {
|
||||
pauseMessage: string;
|
||||
pauseReason?: string;
|
||||
safetyChecks: SafetyCheck[];
|
||||
onResume: (acknowledgedIds: string[]) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function PauseDialog({
|
||||
pauseMessage,
|
||||
pauseReason,
|
||||
safetyChecks,
|
||||
onResume,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const [checked, setChecked] = useState<Record<string, boolean>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const allRequiredOK = useMemo(() => {
|
||||
return safetyChecks
|
||||
.filter((c) => c.required)
|
||||
.every((c) => checked[c.id] === true);
|
||||
}, [safetyChecks, checked]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setChecked((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const handleResume = async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const acknowledgedIds = Object.entries(checked)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k);
|
||||
await onResume(acknowledgedIds);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Erreur lors de la reprise');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Backward compat : pas de checks -> bulle simple legacy
|
||||
if (safetyChecks.length === 0) {
|
||||
return (
|
||||
<div className="pause-dialog-simple">
|
||||
<p>{pauseMessage}</p>
|
||||
{pauseReason && <small className="pause-reason">Raison : {pauseReason}</small>}
|
||||
<div className="pause-actions">
|
||||
<button onClick={() => onResume([])} disabled={submitting}>
|
||||
Continuer
|
||||
</button>
|
||||
<button onClick={onCancel} disabled={submitting}>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pause-dialog-checks">
|
||||
<h3>Pause supervisée</h3>
|
||||
<p className="pause-message">{pauseMessage}</p>
|
||||
{pauseReason && (
|
||||
<div className="pause-reason-banner">
|
||||
<strong>Raison :</strong> {pauseReason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="checklist-panel">
|
||||
{safetyChecks.map((c) => (
|
||||
<li key={c.id} className={`check-item ${c.required ? 'required' : 'optional'}`}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!checked[c.id]}
|
||||
onChange={() => toggle(c.id)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<span className="check-label">{c.label}</span>
|
||||
{c.required && <span className="badge badge-required">obligatoire</span>}
|
||||
{c.source === 'llm_contextual' && (
|
||||
<span className="badge badge-lea" title={c.evidence || ''}>
|
||||
Léa
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{c.source === 'llm_contextual' && c.evidence && (
|
||||
<small className="check-evidence">-> {c.evidence}</small>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{error && <div className="pause-error">{error}</div>}
|
||||
|
||||
<div className="pause-actions">
|
||||
<button
|
||||
onClick={handleResume}
|
||||
disabled={!allRequiredOK || submitting}
|
||||
title={!allRequiredOK ? 'Coche tous les checks obligatoires' : 'Reprendre le replay'}
|
||||
>
|
||||
{submitting ? 'Reprise...' : 'Continuer'}
|
||||
</button>
|
||||
<button onClick={onCancel} disabled={submitting}>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1353,6 +1353,136 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
|
||||
</>
|
||||
);
|
||||
|
||||
case 'pause_for_human': {
|
||||
// QW4 — éditeur safety_level + safety_checks (déclaratifs)
|
||||
const safetyChecks = Array.isArray(params.safety_checks)
|
||||
? (params.safety_checks as Array<{ id?: string; label?: string; required?: boolean }>)
|
||||
: [];
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Message affiché à l'opérateur</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={String(params.message || '')}
|
||||
onChange={(e) => updateParam('message', e.target.value)}
|
||||
placeholder="Ex: Décision : {{dec.decision}} {{dec.justification}}"
|
||||
style={{ width: '100%', fontFamily: 'monospace', fontSize: '12px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* QW4 — Niveau de sécurité */}
|
||||
<div className="prop-field">
|
||||
<label>Niveau de sécurité</label>
|
||||
<select
|
||||
value={String(params.safety_level || 'standard')}
|
||||
onChange={(e) => updateParam('safety_level', e.target.value)}
|
||||
>
|
||||
<option value="standard">Standard (pas de LLM)</option>
|
||||
<option value="medical_critical">Médical critique (LLM contextuel)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* QW4 — Liste éditable de checks déclaratifs */}
|
||||
<div className="prop-field">
|
||||
<label>Checks à valider (déclaratifs)</label>
|
||||
{safetyChecks.map((check, i) => (
|
||||
<div key={i} className="check-editor-row">
|
||||
<input
|
||||
placeholder="ID (ex: check_ipp)"
|
||||
value={check.id || ''}
|
||||
style={{ width: '30%' }}
|
||||
onChange={(e) => {
|
||||
const next = [...safetyChecks];
|
||||
next[i] = { ...check, id: e.target.value };
|
||||
updateParam('safety_checks', next);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder="Libellé"
|
||||
value={check.label || ''}
|
||||
style={{ flex: 1 }}
|
||||
onChange={(e) => {
|
||||
const next = [...safetyChecks];
|
||||
next[i] = { ...check, label: e.target.value };
|
||||
updateParam('safety_checks', next);
|
||||
}}
|
||||
/>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!check.required}
|
||||
onChange={(e) => {
|
||||
const next = [...safetyChecks];
|
||||
next[i] = { ...check, required: e.target.checked };
|
||||
updateParam('safety_checks', next);
|
||||
}}
|
||||
/>
|
||||
Obligatoire
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = safetyChecks.filter((_, j) => j !== i);
|
||||
updateParam('safety_checks', next);
|
||||
}}
|
||||
title="Supprimer ce check"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = [
|
||||
...safetyChecks,
|
||||
{ id: '', label: '', required: true },
|
||||
];
|
||||
updateParam('safety_checks', next);
|
||||
}}
|
||||
>
|
||||
+ Ajouter un check
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
case 't2a_decision':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Template d'entrée (supporte {'{{var}}'})</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={String(params.input_template || '')}
|
||||
onChange={(e) => updateParam('input_template', e.target.value)}
|
||||
placeholder={'{{t0}}\n---\n{{t1}}\n{{t2}}\n{{t3}}\n{{t4}}'}
|
||||
style={{ width: '100%', fontFamily: 'monospace', fontSize: '12px' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Variable de sortie (ex: dec)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.output_var || '')}
|
||||
onChange={(e) => updateParam('output_var', e.target.value)}
|
||||
placeholder="dec"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Modèle Ollama</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.model || 'qwen2.5:7b')}
|
||||
onChange={(e) => updateParam('model', e.target.value)}
|
||||
placeholder="qwen2.5:7b"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div className="prop-info">Pas de paramètres supplémentaires</div>;
|
||||
}
|
||||
|
||||
@@ -4491,3 +4491,86 @@ body {
|
||||
.right-panel-tabbed .capture-library {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* === QW4 — PauseDialog & ChecklistPanel === */
|
||||
.pause-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
.pause-dialog-simple,
|
||||
.pause-dialog-checks {
|
||||
padding: 16px;
|
||||
max-width: 480px;
|
||||
background: #fff;
|
||||
border: 2px solid #f59e0b;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.pause-dialog-checks h3 { margin: 0 0 8px; color: #92400e; }
|
||||
.pause-message { margin: 0 0 12px; }
|
||||
.pause-reason-banner {
|
||||
background: #fef3c7;
|
||||
padding: 8px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pause-reason { color: #6b7280; display: block; margin-top: 4px; }
|
||||
.checklist-panel {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.check-item {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.check-item.required { background: #fef9c3; }
|
||||
.check-item label {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.badge-required { background: #dc2626; color: #fff; }
|
||||
.badge-lea { background: #2563eb; color: #fff; cursor: help; }
|
||||
.check-evidence {
|
||||
display: block;
|
||||
font-style: italic;
|
||||
color: #6b7280;
|
||||
margin-left: 24px;
|
||||
}
|
||||
.pause-error {
|
||||
color: #dc2626;
|
||||
padding: 8px;
|
||||
background: #fef2f2;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pause-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.pause-actions button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* QW4 — éditeur de safety_checks dans PropertiesPanel */
|
||||
.check-editor-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
// Types pour l'API v3
|
||||
|
||||
// === QW4 — Safety checks (pause supervisée) ===
|
||||
export type SafetyLevel = 'standard' | 'medical_critical';
|
||||
|
||||
export interface SafetyCheck {
|
||||
id: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
source: 'declarative' | 'llm_contextual';
|
||||
evidence?: string | null;
|
||||
}
|
||||
|
||||
// Mode d'exécution
|
||||
export type ExecutionMode = 'basic' | 'intelligent' | 'debug' | 'verified';
|
||||
|
||||
@@ -133,7 +144,9 @@ export const ACTIONS: ActionDefinition[] = [
|
||||
{ name: 'max_iterations', type: 'number', description: 'Nombre maximum d\'itérations' }
|
||||
] },
|
||||
{ type: 'pause_for_human', label: 'Pause supervisée', icon: '⏸', description: 'Léa s\'arrête et demande validation humaine via une bulle interactive (boutons Continuer / Annuler).', category: 'logic', needsAnchor: false, params: [
|
||||
{ name: 'message', type: 'string', description: 'Message affiché dans la bulle (ex: "Je ne suis pas sûre du critère 3, validez-vous UHCD ?")' }
|
||||
{ name: 'message', type: 'string', description: 'Message affiché dans la bulle (ex: "Je ne suis pas sûre du critère 3, validez-vous UHCD ?")' },
|
||||
{ name: 'safety_level', type: 'select', description: 'Niveau de sécurité : standard (pas de LLM) ou medical_critical (LLM contextuel)' },
|
||||
{ name: 'safety_checks', type: 'safety_checks_editor', description: 'Liste de checks à valider avant reprise (id, libellé, obligatoire ?). Édité dans le panneau Propriétés.' }
|
||||
] },
|
||||
{ type: 't2a_decision', label: 'Décision T2A (LLM)', icon: '🧠', description: 'Analyse un DPI urgences via LLM local (qwen2.5:7b par défaut) et propose FORFAIT_URGENCE ou REQUALIFICATION_HOSPITALISATION. Retourne JSON {decision, justification, elements_pour/contre, confiance}. Bench validé 100% accuracy.', category: 'logic', needsAnchor: false, params: [
|
||||
{ name: 'input_template', type: 'string', description: 'DPI à analyser. Supporte le templating {{var}} pour concaténer plusieurs extractions (ex: "{{texte_motif}}\\n{{texte_examens}}\\n{{texte_notes}}")' },
|
||||
@@ -312,13 +325,19 @@ export interface WorkflowSummary {
|
||||
export interface Execution {
|
||||
id: string;
|
||||
workflow_id: string;
|
||||
status: 'pending' | 'running' | 'paused' | 'completed' | 'error' | 'cancelled';
|
||||
status: 'pending' | 'running' | 'paused' | 'paused_need_help' | 'completed' | 'error' | 'cancelled';
|
||||
progress: number;
|
||||
current_step_index: number;
|
||||
completed_steps: number;
|
||||
failed_steps: number;
|
||||
total_steps: number;
|
||||
error_message?: string;
|
||||
// === QW4 — Pause supervisée (renvoyés par /replay/state quand status = paused_need_help) ===
|
||||
pause_reason?: string;
|
||||
pause_message?: string;
|
||||
safety_checks?: SafetyCheck[];
|
||||
// ID du replay (utile pour appeler /replay/resume avec acknowledged_check_ids)
|
||||
replay_id?: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
|
||||
Reference in New Issue
Block a user