// AI Chat — assistant médical intelligent avec widgets riches.
// Style ChatGPT adapté au design Vasco.
const CH_VT = window.VT || {};
const CH_INK = CH_VT.ink || '#0a0a0a';
const CH_SUB = CH_VT.sub || '#6b7280';
const CH_BG = CH_VT.paper || '#f6f5f1';
const CH_SURF = CH_VT.surface || '#ffffff';
const CH_HAIR = CH_VT.hair || 'rgba(10,10,10,0.06)';
const CH_HAIR2 = CH_VT.hair2 || 'rgba(10,10,10,0.10)';
const CH_OK = CH_VT.success || '#059669';
const CH_MED = CH_VT.medBlue || '#3d6b8f';
const CH_MED_TINT = CH_VT.medBlueTint || 'rgba(61,107,143,0.10)';
const CH_OK_TINT = CH_VT.successTint || 'rgba(5,150,105,0.10)';
const CH_OK_TINT_SOFT = CH_VT.successTintSoft || 'rgba(5,150,105,0.06)';
// Le serveur n'accepte que quatre types et vérifie en plus le contenu réel du
// fichier : Word, Excel et les autres formats bureautiques sont refusés, y
// compris renommés, un `.docx` étant une archive ZIP détectée comme telle.
// La règle vit dans `api-client.js` ; on ne la duplique pas ici.
const CH_ACCEPTED_LABEL = 'PDF, Word (.docx), JPEG, PNG ou TXT, 10 Mo maximum';
const CH_WAVE_BARS = 20;
// Un RMS de parole ordinaire tourne autour de 0,02–0,15 : sans gain, les
// barres resteraient collées au plancher.
const CH_WAVE_GAIN = 12;
// En dessous, on considère qu'aucune voix n'est captée.
const CH_SILENCE_RMS = 0.004;
// Libellés repris de `mobile/src/features/assistant/assistant-widgets.tsx` :
// deux formulations pour un même refus serveur finiraient par diverger.
const CH_WIDGET_REASONS = {
role_not_allowed: 'Votre rôle ne permet pas cette action.',
not_owner: 'Seul le propriétaire de la conversation peut agir.',
patient_access_revoked: 'L’accès au patient a été révoqué.',
resource_stale: 'Cette proposition doit être rechargée.',
generation_in_progress: 'La réponse est encore en cours.',
provider_unavailable: 'Le fournisseur requis est indisponible.',
scanner_unavailable: 'Le service d’inspection est indisponible.',
extractor_unavailable: 'L’extracteur de contenu n’est pas configuré.',
mail_channel_not_configured: 'Aucun canal sécurisé (MSSanté ou Apicrypt) n’est configuré.',
print_provider_unavailable: 'La préparation du document imprimable est indisponible.',
ccam_source_unavailable: 'Le référentiel CCAM autorisé est indisponible.',
source_unavailable: 'Une source nécessaire n’est plus accessible.',
conversation_deleted: 'La conversation a été supprimée.',
recipient_missing: 'Ajoutez un destinataire avant l’envoi.',
already_confirmed: 'Ce rendez-vous est déjà confirmé.',
};
const CH_ACTION_META = {
open: { label: 'Ouvrir', icon: <>> },
copy: { label: 'Copier', icon: <>> },
edit: { label: 'Modifier', icon: <>> },
send: { label: 'Envoyer', icon: <>> },
print: { label: 'Imprimer', icon: <>> },
confirm: { label: 'Confirmer', icon: <>> },
discard: { label: 'Rejeter', icon: <>> },
};
function chWidgetReason(capability) {
if (!capability || capability.enabled) return null;
return CH_WIDGET_REASONS[capability.reason_code] || 'Action indisponible.';
}
// ── Widget action buttons ──
// Un bouton refusé par le serveur affiche sa raison au survol et reste inerte :
// inventer un succès sur une action clinique serait un mensonge d'interface.
// `done` remplace l'icône par une coche, le temps de faire savoir que l'action a
// abouti. C'est le bouton qui porte sa confirmation, jamais une notification.
const CH_DONE_ICON = ;
function WAction({ icon, onClick, label, reason = null, busy = false, done = false, doneLabel = null }) {
const blocked = Boolean(reason) || busy;
const shown = done ? (doneLabel || label) : label;
return (
);
}
// Même bouton, écrit en toutes lettres. Les commandes d'un codage se lisent au pied
// de la carte, où une icône seule demanderait de deviner ce qu'on engage.
function WTextAction({
label, onClick, reason = null, busy = false, done = false, doneLabel = null, style = null,
}) {
const blocked = Boolean(reason) || busy;
const shown = done ? (doneLabel || label) : label;
return (
);
}
// Barre d'actions d'un widget, entièrement dérivée de `capabilities` renvoyé
// par le serveur : l'ordre, la présence et l'état de chaque bouton viennent de
// l'allowlist canonique, jamais d'une supposition du client.
// `variant="text"` rend la même barre en libellés discrets, pour un pied de carte.
function WidgetActions({
msg, runtime, onWidgetUpdated, onEdit, onNeedPatient, onOpenPatient, variant = 'icons',
}) {
const [busyAction, setBusyAction] = React.useState(null);
const [doneAction, markDone] = useActionDone();
const capabilities = msg.widgetCapabilities || [];
if (!runtime || !msg.widgetId || capabilities.length === 0) return null;
const widgetRef = { id: msg.widgetId, version: msg.widgetVersion, type: msg.widget };
const run = async (action, options) => {
if (busyAction) return;
if (action === 'edit') { onEdit?.(); return; }
let settings = options;
// Un compte rendu ne s'écrit que dans le dossier d'un patient. Le fil décide : le
// résolveur n'est fourni que s'il n'en désigne aucun. On demande alors avant
// d'appeler le serveur — partir pour se faire refuser, puis rouvrir une feuille,
// ferait deux allers pour un seul geste.
if (action === 'save' && onNeedPatient) {
const patientId = await onNeedPatient(widgetRef);
if (!patientId) return;
settings = { ...(options || {}), patientId };
}
setBusyAction(action);
try {
const result = await runtime.widgetAction(widgetRef, action, settings);
onWidgetUpdated?.(result.widget);
// `copy` est la seule action dont l'effet utile est local : le reçu
// serveur prouve la traçabilité, le presse-papiers rend le service.
if (action === 'copy' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(chWidgetPlainText(msg));
} catch { /* Le refus du presse-papiers n'annule pas le reçu serveur. */ }
}
// `print` est de la même famille : le serveur enregistre la passe-main, et
// c'est l'appareil qui imprime. Un refus du navigateur remonte en revanche
// jusqu'au toast et laisse le bouton muet — le reçu resterait acquis, mais
// rien n'aurait été transmis, et la coche le dirait à tort.
if (action === 'print') await chPrintOrdonnance(msg);
// « Ouvrir » d'une fiche patient ne se termine pas au reçu : ce que le geste
// promet, c'est le dossier à l'écran. Le serveur a vérifié que ce patient
// appartient bien à l'organisation et aux sources de la fiche — il renvoie son
// id dans le reçu, et c'est celui-là qu'on ouvre, jamais un id relu de la carte.
// Un refus (fiche sans patient, dossier d'une autre organisation, dossier archivé)
// sort en 409 ou 404 avant ce point : il tombe dans le `catch`, dit sa raison au
// toast et laisse la conversation à l'écran — la carte reste sous les yeux, alors
// qu'un écran « Dossier introuvable » aurait fait quitter le fil pour l'apprendre.
if (action === 'open' && onOpenPatient) {
const patientId = msg.widget === 'patient' ? result.receipt?.resource_id : null;
if (!patientId) {
throw new Error('La commande d’ouverture ne désigne aucun patient autorisé');
}
// Le dossier est chargé avant la navigation : si ce chargement échoue,
// l'ouverture a dit pourquoi et le bouton ne se coche pas — le reçu, lui,
// reste acquis au serveur, mais aucun écran ne s'est ouvert.
if (await onOpenPatient(patientId) === false) return;
}
// Aucune notification de réussite : le bouton porte lui-même le résultat,
// et les actions qui changent l'état du widget — confirmer, écarter — le
// montrent déjà dans la carte.
markDone(action);
} catch (error) {
showToast(error?.message || 'Action impossible', 'error');
} finally {
setBusyAction(null);
}
};
if (variant === 'text') {
const data = msg.data || {};
const capability = (action) => capabilities.find((entry) => entry.action === action) || null;
const discarded = Boolean(data.discarded_at);
const confirmed = Boolean(data.confirmed_at);
// Le codage s'édite en cochant les actes dans la carte elle-même : un bouton
// « modifier » ouvrirait un éditeur de texte sur une liste de codes.
const command = (action, label, options) => {
const entry = capability(action);
if (!entry) return null;
return (
void run(action, options)}
/>
);
};
// Le compte rendu n'a qu'une commande au pied : enregistrer. Une fois signé, le
// bouton cède la place à la signature elle-même ; corriger le texte l'efface côté
// serveur et le bouton revient, pour réécrire une version de plus au dossier.
// Un relevé de constantes entre au dossier de la même façon, et se lit de la même
// façon : même commande, même signature, même place.
if (msg.widget === 'cr' || msg.widget === 'constantes') {
// Tant que le serveur n'autorise pas « enregistrer » et que rien n'est signé, la
// carte se termine où elle se terminait : un pied vide y ajouterait un blanc.
if (!data.saved_at && !capability('save')) return null;
return (
{discarded ? (
<>
Codage écarté
{command('discard', 'Rétablir', { discarded: false })}
>
) : (
<>
{/* Une signature déjà posée ne se repose pas : elle s'affiche. Toute
modification l'efface côté serveur, et le bouton revient. */}
{confirmed ? (
Confirmé {chStamp(data.confirmed_at)}
) : command('confirm', 'Confirmer')}
{command('discard', 'Rejeter', { discarded: true })}
>
)}
);
}
return (
{capabilities.map((capability) => {
const meta = CH_ACTION_META[capability.action];
if (!meta) return null;
// Le codage s'édite en cochant les actes dans la carte elle-même, et se
// confirme ou s'écarte au pied de celle-ci : l'en-tête ne garde que
// « copier », dont l'effet ne dépend pas de ce qu'on vient de décider.
if (msg.widget === 'ccam' && capability.action !== 'copy') return null;
// Enregistrer se lit au pied de la carte, sous le compte rendu — ou sous le
// relevé — qu'il fait entrer au dossier : une icône de plus dans l'en-tête ne
// dirait pas ce qu'elle engage.
if ((msg.widget === 'cr' || msg.widget === 'constantes') && capability.action === 'save') return null;
return (
void run(capability.action)}
/>
);
})}
);
}
// Ce que le bouton peut affirmer s'arrête à ce que l'application a constaté. La
// feuille est remise au dialogue de l'appareil, qui ne dit jamais si elle est
// sortie : « transmis à l'impression » est tout ce qui est vrai — le serveur, lui,
// n'enregistre que la passe-main (`widget_print_authorized`).
// Aucun libellé pour `send` : tant qu'aucun canal sécurisé n'est configuré, le serveur
// refuse l'envoi, et un mot d'aboutissement gardé au chaud ici finirait par s'afficher.
// Il reviendra avec l'adaptateur MSSanté ou Apicrypt, et dira ce que le reçu prouve.
const CH_ACTION_RESULTS = {
open: 'Ouverture enregistrée',
copy: 'Copié',
print: 'Transmis à l’impression',
confirm: 'Rendez-vous confirmé',
};
// Confirmer un codage n'est pas confirmer un rendez-vous, et écarter n'est pas
// rétablir : un message unique par action mentirait une fois sur deux.
function chActionResult(widget, action, options) {
if (widget === 'ccam' && action === 'confirm') return 'Codage confirmé';
if (action === 'save') return 'Enregistré au dossier';
if (action === 'discard') {
return options?.discarded === false ? 'Codage rétabli' : 'Codage écarté';
}
return CH_ACTION_RESULTS[action] || 'Action enregistrée';
}
// Horodatage d'une signature : jour et heure, sans les secondes que personne ne lit.
function chStamp(value) {
const at = new Date(value);
if (Number.isNaN(at.getTime())) return '';
return `le ${at.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })} à ${
at.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
}
// Le presse-papiers doit recevoir le contenu réellement affiché, pas un JSON.
function chWidgetPlainText(msg) {
const data = msg.data || {};
if (msg.widget === 'email') return `${data.subject || ''}\n\n${data.body || ''}`.trim();
if (msg.widget === 'courrier') return `${data.to || ''}\n\n${data.body || ''}`.trim();
if (msg.widget === 'cr') {
return [data.title, ...(data.sections || []).map((s) => `${s.heading}\n${s.content}`)]
.filter(Boolean).join('\n\n');
}
if (msg.widget === 'constantes') {
return (data.vitals || []).map((v) => `${v.label} ${v.value} ${v.unit || ''}`.trim()).join('\n');
}
if (msg.widget === 'ccam') {
const principal = data.principal ? `${data.principal.code} ${data.principal.label}` : '';
const extras = (data.complementaires || []).map((c) => `${c.code} ${c.label}`).join('\n');
return [principal, extras].filter(Boolean).join('\n');
}
return msg.text || '';
}
// ═══════════════════════════════════════════════════════════
// Impression de la fiche ordonnance
// ═══════════════════════════════════════════════════════════
// L'application vit dans un cadre de téléphone : la feuille est composée dans un
// document à part et remise au dialogue d'impression de l'appareil, par la même
// mécanique que le dossier patient (`vascoPrintFrame`).
//
// Le titre et la mention sont ceux de
// `mobile/src/features/assistant/assistant-conversation-screen.tsx`, qui imprime
// déjà cette fiche : deux formulations pour un même papier finiraient par diverger,
// et c'est sur du papier que la divergence se lirait.
//
// Ce qui part sur la feuille est exactement ce que la carte affiche : le patient
// nommé par la fiche et ses lignes de traitement, rien d'autre. Aucun prescripteur
// n'y est imprimé — la fiche n'en porte aucun, et un nom sous des médicaments se
// lit comme une signature. Le document dit donc ce qu'il est : un brouillon non
// signé. `verification_required` est vrai pour toute ordonnance servie par l'API,
// et le papier, lui, circule sans écran autour pour le rappeler.
const CH_ORDONNANCE_PRINT_STYLES = `
@page { size: A4; margin: 18mm 16mm; }
body {
margin: 0;
font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 10.5pt; line-height: 1.45; color: #111;
}
.head { border-bottom: 1.5pt solid #111; padding-bottom: 8pt; margin-bottom: 16pt; }
.kicker { font-size: 8pt; letter-spacing: 1.2pt; text-transform: uppercase; color: #555; }
.name { font-size: 20pt; font-weight: 700; margin-top: 4pt; }
.head-meta {
display: flex; justify-content: space-between; gap: 12pt;
font-size: 9pt; color: #555; margin-top: 6pt;
}
.med { padding: 8pt 0; border-bottom: 0.5pt solid #bbb; page-break-inside: avoid; }
.med-name { font-size: 12pt; font-weight: 700; }
.med-posology { margin-top: 2pt; }
.med-duration { margin-top: 2pt; color: #555; }
.draft {
margin-top: 20pt; border: 1pt solid #111; padding: 10pt 12pt;
font-size: 10pt; font-weight: 700;
}
`;
function chOrdonnancePrintHtml(data, printedAt) {
const esc = window.vascoPrintEscape || ((value) => String(value));
// Un champ vide ne s'imprime pas : sur du papier qui suivra le patient, une
// ligne remplie pour faire joli se lit comme une posologie.
const value = (raw) => {
const text = typeof raw === 'string' ? raw.trim() : raw;
return text && text !== '—' ? String(text) : null;
};
const meds = (Array.isArray(data.meds) ? data.meds : []).map((med) => {
const name = value(med.name);
if (!name) return '';
const posology = value(med.posology);
const duration = value(med.duration);
return `
`;
}
// Remettre la feuille au dialogue d'impression de l'appareil. Ce que l'appareil en
// fait ensuite — une feuille, un PDF, une annulation — ne remonte pas, ni ici ni au
// serveur : personne dans l'application ne peut donc dire qu'une ordonnance est
// imprimée, seulement qu'elle a été transmise.
function chPrintOrdonnance(msg) {
// Seule l'ordonnance s'imprime. Si le serveur venait à offrir « imprimer »
// ailleurs, mieux vaut un refus visible qu'une coche sur une feuille que
// personne n'a su composer.
if (msg.widget !== 'ordonnance') {
return Promise.reject(new Error('Cette fiche ne peut pas être imprimée'));
}
const frame = window.vascoPrintFrame;
if (!frame) {
return Promise.reject(
new Error('Le dialogue d’impression est indisponible. Le brouillon est conservé.'),
);
}
const printedAt = new Intl.DateTimeFormat('fr-FR', {
dateStyle: 'long', timeStyle: 'short'
}).format(new Date());
return frame(chOrdonnancePrintHtml(msg.data || {}, printedAt));
}
// ═══════════════════════════════════════════════════════════
// Rendu Markdown minimal des réponses
// ═══════════════════════════════════════════════════════════
// Le modèle répond en Markdown : sans interprétation, l'utilisateur lit des
// astérisques au milieu d'un texte clinique. Les éléments sont construits en
// React et jamais injectés en HTML : la réponse reste une donnée non fiable,
// et `dangerouslySetInnerHTML` ouvrirait une injection depuis une pièce jointe
// ou un message d'un tiers.
function chInline(text, keyPrefix) {
const nodes = [];
// Gras, italique et code littéral, dans cet ordre : `**` doit être reconnu
// avant `*`, sinon un gras se lit comme deux italiques.
const pattern = /(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*\n]+\*|_[^_\n]+_)/g;
let last = 0;
let match = pattern.exec(text);
let index = 0;
while (match) {
if (match.index > last) nodes.push(text.slice(last, match.index));
const token = match[0];
const key = `${keyPrefix}-i${index}`;
index += 1;
if (token.startsWith('**') || token.startsWith('__')) {
nodes.push({token.slice(2, -2)});
} else if (token.startsWith('`')) {
nodes.push(
{token.slice(1, -1)},
);
} else {
nodes.push({token.slice(1, -1)});
}
last = match.index + token.length;
match = pattern.exec(text);
}
if (last < text.length) nodes.push(text.slice(last));
return nodes.length ? nodes : [text];
}
function ChatMarkdown({ text }) {
if (!text) return null;
const lines = String(text).replace(/\r/g, '').split('\n');
const blocks = [];
let list = null;
const flushList = () => {
if (!list) return;
const Tag = list.ordered ? 'ol' : 'ul';
blocks.push(
{list.items.map((item, index) => (
{chInline(item, `l${blocks.length}-${index}`)}
))}
,
);
list = null;
};
lines.forEach((raw, lineIndex) => {
const line = raw.trimEnd();
if (!line.trim()) { flushList(); return; }
const heading = /^(#{1,4})\s+(.*)$/.exec(line);
if (heading) {
flushList();
blocks.push(
);
}
// ═══════════════════════════════════════════════════════════
// WIDGET: Compte Rendu
// ═══════════════════════════════════════════════════════════
// Valeur réservée de la feuille de choix : elle ne peut pas se confondre avec un
// identifiant de patient, qui est un UUID.
const CH_NEW_PATIENT = 'nouveau-patient';
const CH_CR_CATEGORIES = [
{ value: 'CRO', label: 'Opératoire' },
{ value: 'CRC', label: 'Consultation' },
{ value: 'CRH', label: 'Hospitalisation' },
];
function chCrCategory(value) {
const found = CH_CR_CATEGORIES.find((entry) => entry.value === value);
return found ? found.label : '';
}
function WidgetCR({ data, actions, footer }) {
return (
{/* Confirmer crée un vrai rendez-vous côté serveur. Tant que l'appel n'a
pas abouti, rien n'est annoncé : un « Confirmé » affiché sans reçu
ferait croire à un rendez-vous inexistant. */}
{reason && (
{reason}
)}
);
}
// ═══════════════════════════════════════════════════════════
// Chat messages — user + assistant with widgets
// ═══════════════════════════════════════════════════════════
const DEMO_MESSAGES = [
// 1. Fiche patient
{ role: 'user', text: 'Donne-moi la fiche de M. Martin Jean' },
{ role: 'assistant', text: 'Voici la fiche du patient :', widget: 'patient', data: {
name: 'Jean Martin', initials: 'JM', age: 72, sex: 'M', ipp: '2210·44871',
status: 'hosp', bed: 'CV2 · 412',
allergies: 'Pénicilline, Iode (modéré)',
treatments: 'Warfarine 5mg, Atorvastatine 40mg, Aspirine 75mg',
mt: 'Dr. Vidal, Hervé',
}},
// 2. Constantes
{ role: 'user', text: 'Quelles sont ses constantes du jour ?' },
{ role: 'assistant', text: 'Constantes relevées ce matin :', widget: 'constantes', data: {
patient: 'M. Martin',
date: '30/04/26', time: '07:30',
vitals: [
{ label: 'TA Syst.', value: '14.8', unit: 'cmHg', status: 'warning' },
{ label: 'TA Diast.', value: '8.2', unit: 'cmHg', status: 'normal' },
{ label: 'FC', value: '78', unit: 'bpm', status: 'normal' },
{ label: 'SpO2', value: '96', unit: '%', status: 'normal' },
{ label: 'Temp.', value: '37.2', unit: '°C', status: 'normal' },
{ label: 'INR', value: '3.8', unit: '', status: 'critical' },
],
}},
// 3. Email
{ role: 'user', text: 'Rédige un mail au Dr. Legrand pour lui demander un avis cardio pour ce patient' },
{ role: 'assistant', text: '', widget: 'email', data: {
subject: 'Demande d\'avis cardiologique — M. Martin Jean',
body: `Cher Confrère,
Je me permets de vous adresser M. Martin Jean, 72 ans, actuellement hospitalisé dans notre service pour un pontage fémoro-poplité sus-géniculé gauche (J0).
Le patient présente un antécédent de pontage coronarien (2015) et un INR supra-thérapeutique à 3.8 ce matin sous Warfarine 5mg.
Pourriez-vous nous donner votre avis sur la prise en charge cardiologique périopératoire, et notamment sur l'ajustement de l'anticoagulation ?
Je vous remercie par avance et reste à votre disposition.
Confraternellement,
Dr. Farmehdi`,
}},
// 4. Ordonnance
{ role: 'user', text: 'Fais-moi l\'ordonnance de sortie pour Mme Durand' },
{ role: 'assistant', text: 'Ordonnance générée :', widget: 'ordonnance', data: {
patient: 'Mme Durand Hélène',
meds: [
{ name: 'Clopidogrel 75mg', posology: '1 comprimé par jour au petit-déjeuner', duration: '6 mois' },
{ name: 'Atorvastatine 20mg', posology: '1 comprimé le soir', duration: '12 mois' },
{ name: 'Metformine 1000mg', posology: '1 comprimé matin et soir', duration: '12 mois' },
{ name: 'Paracétamol 1g', posology: '1 comprimé si douleur, max 4/jour', duration: '10 jours' },
{ name: 'Enoxaparine 4000 UI', posology: '1 injection sous-cutanée par jour', duration: '21 jours' },
],
}},
// 5. Courrier
{ role: 'user', text: 'Rédige un courrier pour le médecin traitant de M. Bernard Paul' },
{ role: 'assistant', text: '', widget: 'courrier', data: {
to: 'Dr. Ferrand Luc — Médecin traitant',
body: `Mon cher Confrère,
Je vous adresse ce courrier concernant votre patient M. Bernard Paul, 79 ans, que nous suivons pour un anévrysme de l'aorte abdominale infra-rénal de 54mm.
Après discussion en staff de chirurgie vasculaire, l'indication opératoire est retenue. Nous proposons un traitement endovasculaire (EVAR) compte tenu du terrain (BPCO stade III, AOD en cours).
L'intervention est programmée pour le 15 mai 2026. Un bilan pré-opératoire complet est nécessaire comprenant : ECG, échocardiographie, EFR, bilan biologique standard et consultation d'anesthésie.
Merci de bien vouloir organiser ce bilan et de nous recontacter pour la suite.
Confraternellement,
Dr. Farmehdi`,
}},
// 6. Compte rendu
{ role: 'user', text: 'Génère le CR opératoire du pontage de M. Martin' },
{ role: 'assistant', text: '', widget: 'cr', data: {
type: 'CR Opératoire',
title: 'Pontage fémoro-poplité sus-géniculé gauche',
sections: [
{ heading: 'Indication', content: 'AOMI stade IIb gauche. Claudication invalidante à 60 mètres. Lésions TASC C de l\'artère fémorale superficielle gauche.' },
{ heading: 'Anesthésie', content: 'Anesthésie générale. Installation en décubitus dorsal.' },
{ heading: 'Intervention', content: 'Abord de l\'artère fémorale commune gauche au scarpa. Abord de l\'artère poplitée sus-articulaire par voie médiale. Tunnellisation sous-sartoriale. Pontage par prothèse PTFE 6mm armé. Anastomose proximale termino-latérale sur l\'AFC. Anastomose distale termino-latérale sur l\'AP1.' },
{ heading: 'Suites', content: 'Bon flux dans le pontage en fin d\'intervention. Pouls pédieux et tibial postérieur retrouvés. Hémostase soigneuse. Fermeture plan par plan. Pansement compressif.' },
],
}},
// 7. Codage CCAM — placé juste après le CR : c'est l'enchaînement réel
// (on code l'intervention qu'on vient de décrire) et ça rend la feature
// visible dans la conversation d'ouverture, pas seulement dans l'historique.
{ role: 'user', text: 'Code-moi cette intervention en CCAM' },
{ role: 'assistant', text: 'Voici les codes CCAM correspondants :', widget: 'ccam', data: {
intervention: 'Pontage fémoro-poplité G',
principal: {
code: 'EDCA003',
label: 'Pontage fémoro-poplité ou fémoro-jambier par abord direct, avec veine ou prothèse',
tarif: '522,41 €',
activite: '1',
},
complementaires: [
{ code: 'ZZQP004', label: 'Écho-doppler artériel peropératoire', tarif: '75,60 €', modificateur: '50%' },
{ code: 'YYYY030', label: 'Supplément pour anesthésie > 2h', tarif: '63,00 €', modificateur: '100%' },
],
total: '660,71 €',
}},
// 8. RDV
{ role: 'user', text: 'Programme un RDV de contrôle pour Mme Petit dans 1 mois' },
{ role: 'assistant', text: 'RDV programmé :', widget: 'rdv', data: {
patient: 'Mme Petit Aline',
day: '30', month: 'Mai',
time: '10:30', lieu: 'Salle 2',
motif: 'Contrôle FAV radio-céphalique à 2 mois',
}},
];
// ═══════════════════════════════════════════════════════════
// Message bubble
// ═══════════════════════════════════════════════════════════
function ChatBubble({ msg, accent, runtime = null, onWidgetUpdated, onEditWidget, onRetry, retrying = false, onPreviewImage, onNeedPatient = null, onOpenPatient = null }) {
const isUser = msg.role === 'user';
const [rdvBusy, setRdvBusy] = React.useState(false);
const [ccamBusy, setCcamBusy] = React.useState(null);
const [conflict, setConflict] = React.useState(null);
const confirmCapability = (msg.widgetCapabilities || [])
.find((capability) => capability.action === 'confirm');
// Un rendez-vous qui chevauche un créneau existant est refusé en 409 : le
// serveur renvoie alors un jeton et la liste des chevauchements. On les
// montre puis on rejoue l'action avec ce jeton, jamais sans.
const confirmRdv = async () => {
if (!runtime || !msg.widgetId || rdvBusy) return;
const widgetRef = { id: msg.widgetId, version: msg.widgetVersion, type: 'rdv' };
setRdvBusy(true);
try {
const result = await runtime.widgetAction(widgetRef, 'confirm');
// Le widget passe en « confirmé » et porte le rendez-vous créé : rien à
// annoncer en bas de l'écran.
onWidgetUpdated?.(result.widget);
} catch (error) {
const detail = error?.detail;
if (error?.status === 409 && detail?.confirmation_token) {
// On ne force rien tant que l'utilisateur n'a pas vu les créneaux
// qui se chevauchent : le jeton atteste précisément de cette vue.
setConflict(detail);
} else {
showToast(error?.message || 'Confirmation impossible', 'error');
}
} finally {
setRdvBusy(false);
}
};
const confirmRdvDespiteConflict = async () => {
if (!conflict) return;
const widgetRef = { id: msg.widgetId, version: msg.widgetVersion, type: 'rdv' };
setRdvBusy(true);
try {
const forced = await runtime.widgetAction(widgetRef, 'confirm', {
confirmation: 'appointment_conflict',
conflictToken: conflict.confirmation_token,
});
onWidgetUpdated?.(forced.widget);
setConflict(null);
} catch (error) {
showToast(error?.message || 'Confirmation impossible', 'error');
} finally {
setRdvBusy(false);
}
};
// Cocher un acte, c'est éditer le brouillon : le serveur revalide la liste retenue,
// recalcule le total et rend une nouvelle version. Rien n'est décidé côté écran.
const editCcam = async (code, changes) => {
if (!runtime || !msg.widgetId || ccamBusy) return;
const data = msg.data || {};
const all = [data.principal, ...(data.complementaires || [])].filter(Boolean);
const retained = all.filter((act) => act.selected !== false).map((act) => act.code);
setCcamBusy(code);
try {
const result = await runtime.widgetAction(
{ id: msg.widgetId, version: msg.widgetVersion, type: 'ccam' },
'edit',
{ replacement: { selected_codes: retained, ...changes } },
);
onWidgetUpdated?.(result.widget);
} catch (error) {
showToast(error?.message || 'Modification impossible', 'error');
} finally {
setCcamBusy(null);
}
};
// Cocher : la liste retenue bascule pour ce seul code. Le serveur revalide, applique la
// règle d'association et recalcule le total. Rien n'est décidé côté écran.
const toggleCcamCode = (code) => {
const data = msg.data || {};
const all = [data.principal, ...(data.complementaires || [])].filter(Boolean);
const retained = all
.filter((act) => (act.code === code ? act.selected === false : act.selected !== false))
.map((act) => act.code);
return editCcam(code, { selected_codes: retained });
};
const addCcamCode = (code) => editCcam(code, { added_codes: [code] });
// Remonter d'un rang, pas sauter en tete : l'acte echange sa place avec le precedent
// qui compte pour le rang, c'est-a-dire coche et tarife. Depuis le troisieme rang il
// faut donc deux gestes : 0 %, puis 50 %, puis principal. On envoie l'ordre voulu en
// entier ; le serveur recalcule les taux et efface la confirmation, la liste signee
// ayant change.
const raiseCcamCode = (code) => {
const data = msg.data || {};
const all = [data.principal, ...(data.complementaires || [])].filter(Boolean);
const index = all.findIndex((act) => act.code === code);
if (index <= 0) return undefined;
const counts = (act) => act.selected !== false
&& act.amount_minor !== null && act.amount_minor !== undefined;
let target = -1;
for (let position = index - 1; position >= 0; position -= 1) {
if (counts(all[position])) { target = position; break; }
}
if (target < 0) return undefined;
const order = all.map((act) => act.code);
order.splice(index, 1);
order.splice(target, 0, code);
return editCcam(code, { order_codes: order });
};
// Confirmer et rejeter ne passent pas par l'édition : ce sont des actions propres,
// tracées séparément au journal d'audit. Elles sont rendues par `WidgetActions`,
// qui tient déjà l'allowlist du serveur, la version attendue et l'état occupé.
const widgetActions = (
onEditWidget?.(msg)}
onOpenPatient={onOpenPatient} />
);
if (msg.status === 'failed') {
return (
!
Réponse non générée
Votre message a bien été envoyé, mais le traitement a échoué.
{msg.errorMessage && (
{msg.errorMessage}
)}
{/* Le serveur indique lui-même si une reprise est possible : le
bouton n'apparaît jamais sur un échec définitif. */}
{runtime && msg.retryable && (
)}
);
}
return (
{/* Text bubble */}
{msg.text && (
{/* Le message de l'utilisateur est rendu littéralement : ce qu'il a
tapé ne doit pas être réinterprété. */}
{isUser ? msg.text : }
{onToggle ? `${data.principal.selected === false ? '○' : '●'} ` : ''}Acte principal
{ccamRate(data.principal) ? ` — ${ccamRate(data.principal)}` : ''}
{/* Ici s'affichaient successivement « 100% » écrit en dur, puis une pastille
verte « vérifié ». Les deux mentaient de la même façon : posées contre le
code, elles se lisent « ce codage est bon », alors que la seule chose
établie est que le code existe dans le référentiel. Le choix de l'acte,
lui, n'est vérifié par personne. Ce qui a réellement été relu est écrit en
toutes lettres au pied du widget, où il ne peut pas être pris pour un
acquittement. */}
{/* Remonter n'est offert que sur un acte coché et tarifé, et seulement
s'il reste un rang à lui prendre : le rang d'association se compte sur
ceux-là. Un clic vaut une place, pas un saut en tête — depuis le
troisième rang il en faut deux. Le clic ne doit pas remonter à la
ligne, qui coche et décoche. */}
{onRaise && ranks(c) && ranksBefore(i + 1) > 0 && (
)}
))}
)}
{/* Ajouter un acte que le modèle n'a pas retenu. Il ne voit que ce que sa
recherche a ramené ; le praticien n'a pas à s'y limiter. */}
{onAdd && a.code)} />}
{/* Total */}
);
}
// ═══════════════════════════════════════════════════════════
// AIChatScreen — main component
// ═══════════════════════════════════════════════════════════
function AIChatScreen({
accent, onTabChange, runtime = null, onCreatePatient = null,
resumeSave = null, onResumeHandled = null, onOpenPatient = null,
}) {
const connected = Boolean(runtime);
const [activeConvId, setActiveConvId] = React.useState(connected ? null : 'current');
const [conversations, setConversations] = React.useState(connected ? [] : CHAT_HISTORY);
const [showHistory, setShowHistory] = React.useState(false);
const [input, setInput] = React.useState('');
const [showAttach, setShowAttach] = React.useState(false);
const [serverBusy, setServerBusy] = React.useState(connected);
const [serverError, setServerError] = React.useState(null);
// Id de la conversation où l'assistant rédige (null = aucune). On stocke
// l'id plutôt qu'un booléen : changer de fil pendant l'attente ne doit pas
// faire apparaître les points dans la conversation qu'on vient d'ouvrir.
const [thinkingIn, setThinkingIn] = React.useState(null);
const [thinkingStage, setThinkingStage] = React.useState('generating');
// Le menu d'attachement était positionné en dur : sur écran étroit il
// débordait. On mesure après ouverture et on le recale dans la fenêtre.
const attachMenuRef = React.useRef(null);
const [attachShift, setAttachShift] = React.useState(0);
React.useLayoutEffect(() => {
if (!showAttach) { setAttachShift(0); return; }
const el = attachMenuRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const margin = 8;
if (r.left < margin) setAttachShift(margin - r.left);
else if (r.right > window.innerWidth - margin) setAttachShift(window.innerWidth - margin - r.right);
}, [showAttach]);
const [listening, setListening] = React.useState(false);
const [listenElapsed, setListenElapsed] = React.useState(0);
// Niveaux audio réels des dernières trames, alimentés par `onaudioprocess`
// (~12 mesures par seconde avec une trame de 4096 échantillons).
const [levels, setLevels] = React.useState([]);
const levelsRef = React.useRef([]);
// Assez de mesures pour ne pas crier au silence sur une simple respiration.
const silentInput = levels.length >= CH_WAVE_BARS
&& levels.every((level) => level < CH_SILENCE_RMS);
const [voicePhase, setVoicePhase] = React.useState(null);
const [voiceDraft, setVoiceDraft] = React.useState(null);
const [pendingAttachments, setPendingAttachments] = React.useState([]);
const [attachmentPhase, setAttachmentPhase] = React.useState(null);
const voiceRecorderRef = React.useRef(null);
const attachFileRef = React.useRef(null);
const attachCameraRef = React.useRef(null);
const attachGalleryRef = React.useRef(null);
const scrollRef = React.useRef(null);
const loadServerConversations = React.useCallback(async () => {
if (!runtime) return;
setServerBusy(true);
setServerError(null);
try {
const summaries = await runtime.listConversations();
if (summaries.length === 0) {
setConversations([]);
setActiveConvId(null);
return;
}
const first = await runtime.getConversation(summaries[0].id);
setConversations([first, ...summaries.slice(1)]);
setActiveConvId(first.id);
} catch (error) {
setServerError(error?.message || 'Les conversations sont indisponibles.');
} finally {
setServerBusy(false);
}
}, [runtime]);
React.useEffect(() => {
if (!connected) return;
loadServerConversations();
}, [connected, loadServerConversations]);
// Toutes les conversations peuvent être supprimées : `activeConv` vaut alors
// undefined et l'écran plantait sur `.messages`. On tient le cas vide.
const activeConv = conversations.find(c => c.id === activeConvId) || conversations[0];
// Le fil courant, lisible depuis un rappel sans le prendre en dépendance : les
// actions de widget sont créées une fois et ne doivent pas se recréer à chaque
// changement de conversation.
const activeConvRef = React.useRef(null);
activeConvRef.current = activeConv;
// Carte à ramener sous les yeux après un enregistrement repris depuis un autre écran.
const [focusWidgetId, setFocusWidgetId] = React.useState(null);
const [editingWidget, setEditingWidget] = React.useState(null);
const [widgetEditBusy, setWidgetEditBusy] = React.useState(false);
const [retryingId, setRetryingId] = React.useState(null);
const [imagePreview, setImagePreview] = React.useState(null);
const attachRowRef = React.useRef(null);
// Une molette ou un trackpad produisent un delta vertical. Sans conversion,
// la rangée ne bouge pas et le défilement, bien que présent, reste hors de
// portée : c'est exactement ce qui la rendait inutilisable.
React.useEffect(() => {
const row = attachRowRef.current;
if (!row) return undefined;
const onWheel = (event) => {
if (row.scrollWidth <= row.clientWidth) return;
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
event.preventDefault();
row.scrollLeft += event.deltaY;
};
row.addEventListener('wheel', onWheel, { passive: false });
return () => row.removeEventListener('wheel', onWheel);
}, [pendingAttachments.length]);
// Le serveur déclare lui-même ce qu'il sait faire : deviner côté client
// conduirait à offrir un micro ou une pièce jointe qu'aucun fournisseur
// n'assure, donc à un échec au moment du geste.
const [serverCapabilities, setServerCapabilities] = React.useState(null);
React.useEffect(() => {
if (!connected) return undefined;
let cancelled = false;
(async () => {
try {
const value = await runtime.capabilities();
if (!cancelled) setServerCapabilities(value);
} catch {
// L'absence de capacités n'empêche pas de discuter : les commandes
// restent refusées une par une par le serveur.
}
})();
return () => { cancelled = true; };
}, [connected, runtime]);
const voiceReason = connected && serverCapabilities && !serverCapabilities.voice
? (serverCapabilities.voice_reason || 'La dictée n’est pas disponible sur ce serveur.')
: null;
const attachmentsReason = connected && serverCapabilities && !serverCapabilities.attachments
? (serverCapabilities.attachments_reason || 'Les pièces jointes ne sont pas disponibles sur ce serveur.')
: null;
// L'association patient est une commande serveur versionnée, pas un simple
// affichage : elle conditionne les widgets que le fournisseur peut proposer.
// Feuille active : { kind, ... }. Une seule à la fois, comme les autres
// surfaces modales de l'écran.
const [sheet, setSheet] = React.useState(null);
const pickConversationPatient = async () => {
const conversation = conversations.find((item) => item.id === activeConvId) || conversations[0];
if (!conversation) return;
// `PATIENTS_FULL` contient les fixtures de la maquette tant que l'onglet
// Patients n'a pas été ouvert : leurs identifiants (`p-2210`) ne sont pas
// des UUID serveur. Se fier à la longueur du tableau enverrait donc un
// identifiant inexistant. Seul le drapeau de chargement fait foi.
let roster;
try {
roster = await window.VascoApi.loadPatients();
} catch (error) {
showToast(error?.message || 'Liste des patients indisponible', 'error');
return;
}
// Un espace encore vide n'est pas un échec : la feuille le dit elle-même,
// là où l'on cherchait à choisir.
setSheet({ kind: 'patient', conversation, roster: roster || [] });
};
// Enregistrer un compte rendu suppose un dossier où l'écrire. Quand le fil n'en désigne
// aucun, le patient se choisit ici, avant l'appel : la feuille rend l'identifiant, et
// c'est l'enregistrement lui-même qui rattache le fil, en une seule commande serveur.
const reportPatientResolver = React.useRef(null);
const settleReportPatient = (patientId) => {
const resolve = reportPatientResolver.current;
reportPatientResolver.current = null;
setSheet(null);
resolve?.(patientId || null);
};
const requestReportPatient = async (widgetRef) => {
let roster;
try {
roster = await window.VascoApi.loadPatients();
} catch (error) {
showToast(error?.message || 'Liste des patients indisponible', 'error');
return null;
}
return new Promise((resolve) => {
reportPatientResolver.current = resolve;
setSheet({ kind: 'report_patient', roster: roster || [], widget: widgetRef });
});
};
const applyConversationPatient = async (conversation, patientId) => {
setSheet(null);
try {
const updated = await runtime.setConversationPatient(conversation, patientId);
setConversations((current) => current.map((item) => (
item.id === updated.id
? { ...item, patient: updated.patient, version: updated.version }
: item
)));
// L'en-tête de la conversation porte désormais le patient associé.
} catch (error) {
showToast(error?.message || 'Association impossible', 'error');
}
};
const renameConversation = (conversation) => {
setSheet({ kind: 'rename', conversation });
};
const applyConversationRename = async (conversation, title) => {
setSheet(null);
if (!title || title === conversation.title) return;
try {
const updated = await runtime.renameConversation(conversation, title);
setConversations((current) => current.map((item) => (
item.id === updated.id ? { ...item, title: updated.title, version: updated.version } : item
)));
// Le nouveau titre s'affiche dans la liste et dans l'en-tête.
} catch (error) {
showToast(error?.message || 'Renommage impossible', 'error');
}
};
// La reprise relance une génération durable : il faut attendre son issue
// comme pour un envoi, sinon la bulle resterait figée sur l'échec précédent.
const retryMessage = async (message) => {
if (!connected || retryingId) return;
const conversation = conversations.find((item) => item.id === activeConvId) || conversations[0];
if (!conversation) return;
setRetryingId(message.id);
try {
await runtime.retryMessage(conversation, message);
const deadline = Date.now() + 65000;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 1200));
const detail = await runtime.getConversation(conversation.id);
const updated = detail.messages.find((item) => item.id === message.id);
if (!updated || (updated.status !== 'pending' && updated.status !== 'processing')) {
setConversations((current) => current.map((item) => (
item.id === detail.id ? detail : item
)));
if (updated?.status === 'failed') {
showToast(updated.errorMessage || 'La nouvelle tentative a échoué', 'error');
}
return;
}
}
showToast('La nouvelle tentative met trop de temps', 'error');
} catch (error) {
showToast(error?.message || 'Nouvelle tentative impossible', 'error');
} finally {
setRetryingId(null);
}
};
const saveWidgetEdit = async (replacement) => {
if (!editingWidget || widgetEditBusy) return;
setWidgetEditBusy(true);
try {
const result = await runtime.widgetAction(
{ id: editingWidget.widgetId, version: editingWidget.widgetVersion, type: editingWidget.widget },
'edit',
{ replacement },
);
// La feuille d'édition se ferme sur le widget mis à jour : c'est la
// preuve de l'enregistrement.
applyWidgetUpdate(result.widget);
setEditingWidget(null);
} catch (error) {
showToast(error?.message || 'Enregistrement impossible', 'error');
// Une version périmée rend le brouillon inutilisable : le fil rechargé
// porte la version courante.
if (error?.status === 409) setEditingWidget(null);
} finally {
setWidgetEditBusy(false);
}
};
// Enregistrer un compte rendu peut rattacher le fil à un patient côté serveur.
// L'écran ne l'apprend pas de la réponse, qui ne porte que le widget : sans cette
// relecture, l'en-tête continuerait d'afficher « Associer un patient » au-dessus
// d'un compte rendu qui vient d'entrer dans le dossier de quelqu'un.
const refreshConversationPatient = React.useCallback(async (conversationId) => {
if (!runtime || !conversationId) return;
try {
const detail = await runtime.getConversation(conversationId);
setConversations((current) => current.map((item) => (
item.id === detail.id
? { ...item, patient: detail.patient, version: detail.version }
: item
)));
} catch { /* L'en-tête reste tel quel : rien d'écrit ne dépend de lui. */ }
}, [runtime]);
// Une action de widget renvoie la version à jour : la réinjecter évite qu'un
// second geste reparte sur une version périmée et soit refusé en 409.
const applyWidgetUpdate = React.useCallback((widget) => {
if (!widget?.id) return;
const conversation = activeConvRef.current;
if (widget.data?.patient_id && conversation && !conversation.patient) {
void refreshConversationPatient(conversation.id);
}
setConversations((current) => current.map((conversation) => ({
...conversation,
messages: (conversation.messages || []).map((message) => (
message.widgetId === widget.id
? {
...message,
data: widget.data,
widgetVersion: widget.version,
widgetStatus: widget.status,
widgetCapabilities: widget.capabilities || [],
}
: message
)),
})));
}, [refreshConversationPatient]);
const messages = activeConv ? activeConv.messages : [];
// Le fil se recolle en bas quand il s'allonge, quand la réponse en cours grandit ou
// quand on change de conversation — pas quand une carte se met à jour. Réinjecter la
// version d'un widget reconstruit la liste des messages : s'y fier jetterait la vue à
// la fin du fil à chaque geste sur une carte, y compris une carte lue au milieu.
const last = messages[messages.length - 1];
const scrollSignature = [
activeConvId, messages.length, last?.id ?? '', last?.text?.length ?? 0, last?.status ?? '',
].join('|');
// Une carte à ramener sous les yeux prime sur le bas du fil, et elle le fait à
// chaque recalcul tant qu'elle est visée : le fil se charge en deux temps — le
// résumé puis les messages — et un unique défilement serait défait par l'arrivée
// du second.
const scrollToCard = React.useCallback((widgetId) => {
const view = scrollRef.current;
const card = view?.querySelector(`[data-widget-id="${widgetId}"]`);
if (!view || !card) return false;
// La maquette est mise à l'échelle par une transformation CSS : les rectangles
// écran sont en pixels mis à l'échelle, `scrollTop` en pixels de mise en page.
const viewBox = view.getBoundingClientRect();
const scale = viewBox.height / (view.clientHeight || 1);
const offset = (card.getBoundingClientRect().top - viewBox.top) / (scale || 1);
view.scrollTop = Math.max(0, view.scrollTop + offset - 24);
return true;
}, []);
// La visée est lue par une référence, jamais prise en dépendance : la fin de la
// visée ne doit pas relancer le défilement, sans quoi le fil sauterait en bas
// une seconde et demie après avoir montré la carte.
const focusRef = React.useRef(null);
focusRef.current = focusWidgetId;
React.useEffect(() => {
if (focusRef.current) return;
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [scrollSignature, thinkingIn]);
React.useEffect(() => {
if (!focusWidgetId) return undefined;
scrollToCard(focusWidgetId);
// La visée ne dure que le temps que le fil se stabilise : passé ce délai, le fil
// redevient un fil ordinaire qui suit le bas quand un message arrive.
const timer = window.setTimeout(() => setFocusWidgetId(null), 1600);
return () => window.clearTimeout(timer);
}, [focusWidgetId, scrollSignature, scrollToCard]);
// Reprise après création d'un patient : l'écran a été quitté au milieu d'un
// enregistrement, on le termine au retour plutôt que de laisser le praticien
// retrouver la carte et réappuyer sur un bouton qu'il a déjà pressé.
React.useEffect(() => {
if (!resumeSave?.widgetId || !resumeSave?.patientId || !runtime) return;
if (serverBusy || messages.length === 0) return;
const target = messages.find((message) => message.widgetId === resumeSave.widgetId);
// La carte peut avoir disparu — fil supprimé, session changée. On ne rejoue rien
// alors : mieux vaut ne rien enregistrer que de deviner où.
if (!target) { onResumeHandled?.(); return; }
let cancelled = false;
(async () => {
try {
const result = await runtime.widgetAction(
{ id: target.widgetId, version: target.widgetVersion, type: target.widget },
'save',
{ patientId: resumeSave.patientId },
);
if (cancelled) return;
applyWidgetUpdate(result.widget);
// La vue est revenue en bas du fil au remontage : on vise la carte
// enregistrée, sinon la preuve du geste reste hors champ.
setFocusWidgetId(resumeSave.widgetId);
} catch (error) {
if (!cancelled) showToast(error?.message || 'Enregistrement impossible', 'error');
} finally {
if (!cancelled) onResumeHandled?.();
}
})();
return () => { cancelled = true; };
}, [resumeSave, runtime, serverBusy, messages, applyWidgetUpdate, onResumeHandled]);
// Comptes rendus dictés arrivant du dashboard. L'écran étant démonté quand on
// ne le regarde pas, AgentInbox garde l'envoi en file et le livre au montage.
React.useEffect(() => {
if (connected) return;
if (typeof AgentInbox === 'undefined') return;
return AgentInbox.subscribe((cr) => {
const head = `${cr.typeLabel.charAt(0).toUpperCase()}${cr.typeLabel.slice(1)}`;
const intro = cr.patient
? `${head} dicté pour ${cr.patient} (${cr.duration}) :`
: `${head} dicté sans patient associé (${cr.duration}) :`;
const userMsg = { role: 'user', text: `${intro}\n\n${cr.text}` };
const reply = cr.patient
? `J'ai analysé le ${cr.typeLabel} de ${cr.patient}.\n\nJe peux enchaîner sur : la génération du courrier au médecin traitant, le codage CCAM de l'intervention, la planification du prochain contrôle, ou l'ajout au dossier patient.\n\nQue voulez-vous que je fasse ?`
: `J'ai bien reçu cette dictée, mais elle n'est rattachée à aucun dossier.\n\nVous pouvez : l'associer à un patient existant, créer un nouveau dossier à partir de son contenu, ou la laisser ici et la traiter plus tard.\n\nJe la garde de côté en attendant.`;
// Sans patient, la dictée ouvre sa propre conversation plutôt que de
// s'ajouter au fil d'un autre patient.
const targetId = cr.newConversation ? 'cr-' + Date.now() : 'current';
if (cr.newConversation) {
setConversations((convs) => [{
id: targetId,
title: 'Dictée sans patient',
date: "Aujourd'hui",
preview: cr.text.slice(0, 60),
messages: [userMsg],
}, ...convs]);
} else {
setConversations((convs) => convs.map((c) => c.id === targetId
? { ...c, messages: [...c.messages, userMsg] } : c));
}
setActiveConvId(targetId);
setThinkingIn(targetId);
setTimeout(() => {
setConversations((convs) => convs.map((c) => c.id === targetId
? { ...c, messages: [...c.messages, { role: 'assistant', text: reply }] } : c));
setThinkingIn((cur) => (cur === targetId ? null : cur));
}, 1400);
});
}, [connected]);
// Listening timer
React.useEffect(() => {
if (!listening) { setListenElapsed(0); return; }
const start = Date.now();
const id = setInterval(() => setListenElapsed(Math.floor((Date.now() - start) / 1000)), 250);
return () => clearInterval(id);
}, [listening]);
React.useEffect(() => () => {
const recorder = voiceRecorderRef.current;
voiceRecorderRef.current = null;
if (!recorder) return;
recorder.processor.onaudioprocess = null;
recorder.source.disconnect();
recorder.processor.disconnect();
recorder.silentOutput.disconnect();
recorder.stream.getTracks().forEach((track) => track.stop());
recorder.context.close().catch(() => {});
}, []);
const FAKE_DICTATIONS = [
'Quelles sont les constantes de M. Martin ?',
'Rédige un courrier pour le Dr. Legrand',
'Ordonnance de sortie pour Mme Durand',
'Programme un RDV de contrôle pour Mme Petit',
'Donne-moi le code CCAM pour un pontage fémoro-poplité',
];
const ensureConversation = async (title = 'Nouvelle conversation') => {
if (activeConv) return activeConv;
if (!runtime) return null;
const conversation = await runtime.createConversation(title);
setConversations((current) => [conversation, ...current]);
setActiveConvId(conversation.id);
return conversation;
};
const startListening = async () => {
if (!connected) {
setListening(true);
return;
}
if (listening || voicePhase || thinkingIn) return;
setServerError(null);
try {
const conversation = await ensureConversation('Dictée vocale');
if (!conversation) throw new Error('La conversation est indisponible.');
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('Ce navigateur ne permet pas l’enregistrement vocal.');
}
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) throw new Error('L’enregistrement vocal n’est pas disponible.');
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const context = new AudioContextClass();
const source = context.createMediaStreamSource(stream);
const processor = context.createScriptProcessor(4096, 1, 1);
const silentOutput = context.createGain();
const chunks = [];
silentOutput.gain.value = 0;
processor.onaudioprocess = (event) => {
const frame = event.inputBuffer.getChannelData(0);
chunks.push(new Float32Array(frame));
// Niveau réellement capté, pour que l'onde affichée dise la vérité :
// un micro muet doit se voir pendant l'enregistrement, pas seulement
// à la fin par « Aucun son n'a été enregistré ».
let sum = 0;
for (let i = 0; i < frame.length; i += 1) sum += frame[i] * frame[i];
const rms = Math.sqrt(sum / frame.length);
const next = levelsRef.current.concat(rms).slice(-CH_WAVE_BARS);
levelsRef.current = next;
setLevels(next);
};
source.connect(processor);
processor.connect(silentOutput);
silentOutput.connect(context.destination);
voiceRecorderRef.current = {
conversation,
context,
source,
processor,
silentOutput,
stream,
chunks,
sampleRate: context.sampleRate,
startedAt: performance.now(),
};
levelsRef.current = [];
setLevels([]);
setVoiceDraft(null);
setListening(true);
} catch (error) {
showToast(error?.message || 'Le micro n’a pas pu démarrer', 'error');
}
};
const stopListening = async () => {
setListening(false);
levelsRef.current = [];
setLevels([]);
if (!connected) {
const fake = FAKE_DICTATIONS[Math.floor(Math.random() * FAKE_DICTATIONS.length)];
setInput(fake);
return;
}
const recorder = voiceRecorderRef.current;
voiceRecorderRef.current = null;
if (!recorder) return;
recorder.processor.onaudioprocess = null;
recorder.source.disconnect();
recorder.processor.disconnect();
recorder.silentOutput.disconnect();
recorder.stream.getTracks().forEach((track) => track.stop());
await recorder.context.close().catch(() => {});
const durationMs = Math.max(1, performance.now() - recorder.startedAt);
const audio = encodePcmWav(recorder.chunks, recorder.sampleRate);
if (audio.size <= 44) {
showToast('Aucun son n’a été enregistré', 'error');
return;
}
setActiveConvId(recorder.conversation.id);
try {
setVoicePhase('uploading');
const transcript = await runtime.transcribeVoice(
recorder.conversation,
audio,
durationMs,
(phase) => setVoicePhase(phase),
);
setInput(transcript.text);
setVoiceDraft({
captureId: transcript.captureId,
transcriptVersion: transcript.transcriptVersion,
conversationId: recorder.conversation.id,
});
// Le bandeau « Transcription prête » de la zone de saisie dit déjà la même
// chose, à l'endroit où le texte vient d'arriver.
} catch (error) {
showToast(error?.message || 'La transcription a échoué', 'error');
} finally {
setVoicePhase(null);
}
};
const handleAttachmentFiles = async (fileList, kind) => {
const files = Array.from(fileList || []);
if (!files.length) return;
if (!connected) {
setInput((current) => `${current}${current ? ' ' : ''}[pièce jointe]`);
return;
}
if (pendingAttachments.length + files.length > 5) {
showToast('Maximum 5 pièces jointes par message', 'error');
return;
}
// Refuser d'abord le lot entier : sans cela, sélectionner cinq fichiers
// dont un Word téléverserait les quatre premiers avant d'échouer.
const rejected = files.filter((file) => !runtime.attachmentMediaType(file));
if (rejected.length) {
showToast(
`${rejected.map((file) => file.name).join(', ')} : format non pris en charge. `
+ `Formats acceptés : ${CH_ACCEPTED_LABEL}.`,
'error',
);
return;
}
setServerError(null);
try {
for (const file of files) {
setAttachmentPhase({ stage: 'preparing', filename: file.name });
const attachment = await runtime.uploadAttachment(file, kind, (progress) => {
setAttachmentPhase({ stage: progress.stage, filename: file.name });
});
// L'aperçu vient du fichier local : inutile de le retélécharger depuis
// le serveur pour montrer ce que l'on vient tout juste de choisir.
setPendingAttachments((current) => [...current, {
...attachment,
previewUrl: file.type?.startsWith('image/') ? URL.createObjectURL(file) : null,
}]);
}
// Aucune notification de succès : la pièce s'affiche désormais dans le
// brouillon, vignette comprise. L'annoncer en plus était redondant.
} catch (error) {
showToast(error?.message || 'La pièce jointe n’a pas pu être ajoutée', 'error');
} finally {
setAttachmentPhase(null);
}
};
const removePendingAttachment = (attachment) => {
// L'aperçu local retient le fichier entier tant qu'il n'est pas libéré.
if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
setPendingAttachments((current) => current.filter((item) => item.id !== attachment.id));
runtime?.cancelAttachment(attachment).catch(() => {});
};
const openConversation = async (id) => {
setActiveConvId(id);
if (!runtime) return;
setServerBusy(true);
setServerError(null);
try {
const detail = await runtime.getConversation(id);
setConversations((current) => current.map((conversation) => (
conversation.id === id ? detail : conversation
)));
} catch (error) {
setServerError(error?.message || 'Cette conversation est indisponible.');
} finally {
setServerBusy(false);
}
};
const send = async () => {
if (!input.trim() && pendingAttachments.length === 0) return;
if (connected) {
if (thinkingIn || voicePhase || attachmentPhase) return;
const q = input.trim();
const attachmentsForSend = [...pendingAttachments];
const voiceForSend = voiceDraft;
// Le composeur se vide entièrement dès l'envoi, comme le champ texte.
// Attendre la fin de la génération laissait la pièce jointe affichée
// pendant toute la réponse, en donnant l'impression qu'elle n'était pas
// partie. En cas d'échec, le brouillon complet est restitué plus bas.
setInput('');
setPendingAttachments([]);
setVoiceDraft(null);
setServerError(null);
let target = voiceForSend
? conversations.find((conversation) => conversation.id === voiceForSend.conversationId)
: activeConv;
if (voiceForSend && target) setActiveConvId(target.id);
if (
q.toLocaleLowerCase('fr-FR') === '/demo compactage'
&& attachmentsForSend.length === 0
&& !voiceForSend
) {
if (!target) {
setInput(q);
showToast('Créez d’abord une conversation pour lancer l’aperçu', 'error');
return;
}
const previewUserMessage = { role: 'user', text: q, status: 'completed' };
setConversations((current) => current.map((conversation) => (
conversation.id === target.id
? {
...conversation,
messages: [...conversation.messages, previewUserMessage],
preview: 'Aperçu du compactage',
}
: conversation
)));
setThinkingIn(target.id);
setThinkingStage('compaction');
window.setTimeout(() => {
setConversations((current) => current.map((conversation) => (
conversation.id === target.id
? {
...conversation,
messages: [...conversation.messages, {
role: 'assistant',
status: 'completed',
text: 'Aperçu terminé. En situation réelle, cette indication disparaît dès que la réponse reprend.',
}],
}
: conversation
)));
setThinkingIn((current) => (current === target.id ? null : current));
setThinkingStage('generating');
}, 5000);
return;
}
try {
if (!target) {
const title = q || attachmentsForSend[0]?.filename || 'Nouvelle conversation';
target = await runtime.createConversation(title.slice(0, 80));
setConversations((current) => [target, ...current]);
setActiveConvId(target.id);
}
const optimistic = {
role: 'user', text: q, status: 'completed', attachments: attachmentsForSend,
};
setConversations((current) => current.map((conversation) => (
conversation.id === target.id
? {
...conversation,
messages: [...conversation.messages, optimistic],
preview: (q || attachmentsForSend[0]?.filename || '').slice(0, 80),
}
: conversation
)));
setThinkingIn(target.id);
setThinkingStage('generating');
const completed = await runtime.sendMessage(target, q, {
attachmentIds: attachmentsForSend.map((attachment) => attachment.id),
voiceTranscription: voiceForSend,
onProgress: (stage) => {
if (stage === 'compaction' || stage === 'generating') setThinkingStage(stage);
},
});
setConversations((current) => [
completed,
...current.filter((conversation) => conversation.id !== completed.id),
]);
setActiveConvId(completed.id);
// Le fil rechargé porte désormais ses propres aperçus : les URL locales
// du brouillon n'ont plus d'usage et doivent être libérées.
attachmentsForSend.forEach((attachment) => {
if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
});
} catch (error) {
// Le brouillon est restitué tel quel : rien ne doit être perdu parce
// que la génération a échoué.
if (q) setInput(q);
if (attachmentsForSend.length) setPendingAttachments(attachmentsForSend);
if (voiceForSend) setVoiceDraft(voiceForSend);
const isGenerationFailure = typeof error?.code === 'string'
&& error.code.startsWith('assistant_');
setServerError(isGenerationFailure
? null
: (error?.message || 'La réponse de l’Assistant est indisponible.'));
showToast(
isGenerationFailure
? 'La réponse n’a pas été générée'
: (error?.message || 'La réponse de l’Assistant est indisponible'),
'error',
);
if (target) {
try {
const refreshed = await runtime.getConversation(target.id);
setConversations((current) => current.map((conversation) => (
conversation.id === target.id ? refreshed : conversation
)));
} catch { /* Le message reste récupérable au prochain chargement. */ }
}
} finally {
setThinkingIn((current) => (current === target?.id ? null : current));
setThinkingStage('generating');
}
return;
}
const newMsg = { role: 'user', text: input.trim() };
const q = input.trim();
setInput('');
// Plus aucune conversation (tout supprimé) : écrire en ouvre une, plutôt
// que d'envoyer le message dans le vide.
if (!activeConv) {
const id = 'new-' + Date.now();
setConversations([{
id, title: q.slice(0, 32), date: "Aujourd'hui", preview: q.slice(0, 60),
messages: [newMsg],
}]);
setActiveConvId(id);
setThinkingIn(id);
setTimeout(() => {
setConversations((convs) => convs.map((c) => c.id === id
? { ...c, messages: [...c.messages, { role: 'assistant', text: `Je traite votre demande : "${q}". Cette fonctionnalité sera connectée à l'agent VASCO.` }] } : c));
setThinkingIn((cur) => (cur === id ? null : cur));
}, 1200);
return;
}
setConversations(convs => convs.map(c =>
c.id === activeConvId ? { ...c, messages: [...c.messages, newMsg] } : c
));
const target = activeConvId;
setThinkingIn(target);
setTimeout(() => {
setConversations(convs => convs.map(c =>
c.id === target ? { ...c, messages: [...c.messages, { role: 'assistant', text: `Je traite votre demande : "${q}". Cette fonctionnalité sera connectée à l'agent VASCO.` }] } : c
));
setThinkingIn((cur) => (cur === target ? null : cur));
}, 1200);
};
const newConversation = async () => {
if (connected) {
if (serverBusy || thinkingIn) return;
setServerBusy(true);
setServerError(null);
try {
const conversation = await runtime.createConversation('Nouvelle conversation');
setConversations((current) => [conversation, ...current]);
setActiveConvId(conversation.id);
} catch (error) {
setServerError(error?.message || 'La conversation n’a pas pu être créée.');
showToast(error?.message || 'Création impossible', 'error');
} finally {
setServerBusy(false);
}
return;
}
const id = 'new-' + Date.now();
const conv = {
id, title: 'Nouvelle conversation', date: "Aujourd'hui", preview: '',
messages: [{ role: 'assistant', text: 'Bonjour Docteur, comment puis-je vous aider ?' }],
};
setConversations(c => [conv, ...c]);
setActiveConvId(id);
};
const deleteConversation = async (id) => {
const conversation = conversations.find((item) => item.id === id);
if (runtime && conversation) {
setServerError(null);
try {
await runtime.deleteConversation(conversation);
} catch (error) {
setServerError(error?.message || 'La conversation n’a pas pu être supprimée.');
showToast(error?.message || 'Suppression impossible', 'error');
return;
}
}
setConversations((current) => {
const rest = current.filter((item) => item.id !== id);
if (activeConvId === id) setActiveConvId(rest.length ? rest[0].id : null);
return rest;
});
};
const canSend = Boolean(input.trim() || pendingAttachments.length > 0);
const inputBusy = Boolean(voicePhase || attachmentPhase || thinkingIn);
return (
)}
{pendingAttachments.length > 0 && (
// Rangée défilante. `minWidth: 0` est indispensable — sans lui, un
// enfant de conteneur flex prend la largeur de son contenu et
// déborde au lieu de défiler. La barre est rendue visible et un
// geste vertical de molette est converti en défilement horizontal,
// faute de quoi le défilement existe sans être utilisable.
{pendingAttachments.map((attachment) => (
{attachment.previewUrl && (
)}
{attachment.filename}
{/* La mention d'inspection est portée une seule fois sous
la rangée : la répéter par carte ne laissait plus de
place au nom du fichier. */}
{listening ? (
/* Écoute — onde + chrono. Bleu médical, pas rouge : le rouge est
réservé au danger, et le micro du dashboard est déjà passé au
bleu. Un rouge ici faisait croire à une alerte. (07/08) */
{/* Pulsing dot */}
{/* Onde audio — hauteur des barres = niveau réellement capté */}
{Array.from({ length: CH_WAVE_BARS }).map((_, i) => {
// Sans serveur, la maquette n'enregistre rien : l'onde y
// reste décorative et assumée comme telle.
if (!connected) {
return (
);
}
const level = levels[i];
// Racine carrée : sans elle, une voix normale reste
// écrasée en bas de l'échelle linéaire.
const height = level === undefined
? 6
: Math.max(6, Math.min(100, Math.sqrt(level * CH_WAVE_GAIN) * 100));
return (
);
})}
{/* Chrono, ou avertissement si le micro ne capte rien */}
{silentInput ? (
Aucun son détecté
) : (
{String(Math.floor(listenElapsed / 60)).padStart(2, '0')}:{String(listenElapsed % 60).padStart(2, '0')}
)}
{/* Bottom nav */}
{typeof BottomNav !== 'undefined' && (
{ if (onTabChange) onTabChange(id); }} accent={accent} />
)}
{/* History panel */}
{showHistory && (
setShowHistory(false)}
onNew={newConversation}
onDeleteConv={deleteConversation}
onRename={connected ? renameConversation : null}
/>
)}
{editingWidget && (
setEditingWidget(null)}
onSave={saveWidgetEdit}
/>
)}
{imagePreview && (
setImagePreview(null)} />
)}
{sheet?.kind === 'rename' && (
setSheet(null)}
onSubmit={(title) => void applyConversationRename(sheet.conversation, title)}
/>
)}
{sheet?.kind === 'patient' && (
({
value: patient.id,
label: `${patient.last} ${patient.first}`,
active: sheet.conversation.patient?.id === patient.id,
})),
]}
onCancel={() => setSheet(null)}
onSubmit={(patientId) => void applyConversationPatient(sheet.conversation, patientId)}
/>
)}
{sheet?.kind === 'report_patient' && (
({
value: patient.id,
label: `${patient.last} ${patient.first}`,
})),
]}
onCancel={() => settleReportPatient(null)}
onSubmit={(value) => {
// Créer un patient est un formulaire en trois étapes, sur son propre écran :
// l'enregistrement s'interrompt ici, et se reprend d'un geste au retour — le
// compte rendu est intact dans le fil, et l'écran de création y ramène.
if (value === CH_NEW_PATIENT) {
const pending = sheet.widget;
settleReportPatient(null);
onCreatePatient?.(pending);
return;
}
settleReportPatient(value);
}}
/>
)}
);
}
window.AIChatScreen = AIChatScreen;
// La dictée clinique capte le micro de la même façon que l'assistant : un seul
// encodeur WAV, pour que les deux envoient exactement le même format.
window.encodePcmWav = encodePcmWav;