La dictée a été corrigée sur l’ordre du contrôle rétrograde, puis complétée par un deuxième temps distal. Les données de fermeture non dictées sont volontairement laissées non renseignées.
✓ Ordre rétrograde corrigé
✓ Deux temps opératoires distingués
✓ Valeurs inconnues non inventées
À arbitrer : formulation de l’indication et niveau de détail attendu
);
}
const CH_PREFERENCE_PREVIEW_MESSAGE = {
id: 'preference-proposal-preview',
role: 'assistant',
widget: 'preference_proposal',
data: {
label: 'Espacement des calibres',
content: 'Écrire les calibres avec une espace entre le nombre et F, par exemple « 6 F » et non « 6F ».',
scope: 'CRO',
saved_at: null,
personal_prompt_id: null,
discarded_at: null,
},
};
// Le mode de démonstration est annoncé une fois par le composeur. Répéter la
// même mise en garde dans chaque bulle masque le contenu utile ; seule la phrase
// exacte du seed, et uniquement avec une provenance simulée, est retirée.
function chDisplayedAssistantText(msg) {
const text = msg?.text || '';
if (msg?.role !== 'assistant' || msg?.provenance?.providerMode !== 'simulated') return text;
if (!text.startsWith(CH_DEMO_DISCLAIMER)) return text;
return text.slice(CH_DEMO_DISCLAIMER.length).trimStart();
}
function chDisplayedConversationPreview(preview) {
const text = preview || '';
if (!text.startsWith(CH_DEMO_DISCLAIMER)) return text;
return text.slice(CH_DEMO_DISCLAIMER.length).trimStart();
}
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.',
ccam_cro_required: 'Choisissez le CRO auquel rattacher ce codage.',
ccam_review_required: 'Le CRO a changé : ce codage doit être conservé ou actualisé.',
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é.',
preference_saved: 'Cette préférence est déjà enregistrée.',
preference_discarded: 'Rétablissez la préférence avant de l’enregistrer.',
document_not_saved: 'Enregistrez l’ordonnance au dossier avant d’ouvrir son PDF.',
document_already_saved: 'Modifiez l’ordonnance pour enregistrer une nouvelle version.',
};
const CH_WIDGET_REASON_OVERRIDES = {
courrier: {
document_not_saved: 'Enregistrez le courrier au dossier avant d’ouvrir son PDF.',
document_already_saved: 'Modifiez le courrier pour enregistrer une nouvelle version.',
},
};
const CH_ACTION_META = {
open: { label: 'Ouvrir', icon: <>> },
copy: { label: 'Copier', icon: <>> },
edit: { label: 'Modifier', icon: <>> },
send: { label: 'Envoyer', icon: <>> },
print: { label: 'Afficher le PDF', icon: <>> },
confirm: { label: 'Confirmer', icon: <>> },
discard: { label: 'Rejeter', icon: <>> },
};
function chWidgetReason(capability, widget = null) {
if (!capability || capability.enabled) return null;
return CH_WIDGET_REASON_OVERRIDES[widget]?.[capability.reason_code]
|| 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 { IconButton } = window.VascoUI;
const blocked = Boolean(reason) || busy;
const shown = done ? (doneLabel || label) : label;
return (
{busy ? : (
)}
);
}
// 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 { InlineAction } = window.VascoUI;
const blocked = Boolean(reason) || busy;
const shown = done ? (doneLabel || label) : label;
return (
{CH_DONE_ICON} : null}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '2px 0', fontSize: 12,
color: done ? CH_OK : CH_SUB,
transition: `color ${CH_MOTION.responsive}ms ease`,
...(style || {}),
}}>
{shown}
);
}
// 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,
onNeedCcamDecision, onCcamKept, onSavedForCcam, 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 previewWindow = null;
if (action === 'print' && ['ordonnance', 'courrier'].includes(msg.widget)) {
previewWindow = window.open('', '_blank');
if (!previewWindow) {
showToast('Autorisez l’ouverture d’un nouvel onglet pour afficher le PDF.', 'error');
return;
}
previewWindow.opener = null;
}
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 };
}
// Une nouvelle version de CRO ne peut pas hériter silencieusement d'un codage
// produit depuis l'ancienne. La feuille ne s'ouvre que si un CCAM est réellement
// lié ; `undefined` signifie qu'il n'y en a aucun, `null` que le médecin a fermé.
if (action === 'save' && msg.widget === 'cr' && msg.data?.document_id
&& onNeedCcamDecision) {
const decision = await onNeedCcamDecision(msg);
if (decision === null) return;
if (decision) settings = { ...(settings || {}), ccamDecision: decision };
}
setBusyAction(action);
try {
const result = await runtime.widgetAction(widgetRef, action, settings);
onWidgetUpdated?.(result.widget);
if (action === 'save' && msg.widget === 'cr' && result.widget?.data?.document_id) {
if (settings?.ccamDecision === 'keep') {
// Le serveur a rattaché le même codage à la nouvelle version. La relecture
// remet immédiatement à jour sa version et ses capacités dans le fil.
await onCcamKept?.();
} else if (settings?.ccamDecision === 'regenerate'
|| result.widget?.data?.propose_ccam_after_save) {
// Premier enregistrement ou choix « Actualiser » : le document existe
// maintenant et son UUID précis devient la source de la proposition.
await onSavedForCcam?.(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. */ }
}
if (action === 'print') {
await chOpenDocumentPdf(result.widget, runtime, previewWindow);
}
// « 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) {
previewWindow?.close();
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' || msg.widget === 'ordonnance'
|| msg.widget === 'courrier') {
// 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' || msg.widget === 'ordonnance'
|| msg.widget === 'courrier')
&& capability.action === 'save') return null;
return (
void run(capability.action)}
/>
);
})}
);
}
// 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: 'PDF ouvert',
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${data.subject || ''}\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 || '';
}
async function chOpenDocumentPdf(widget, runtime, previewWindow) {
const data = widget?.data || {};
if (!previewWindow || !runtime?.documentPdf || !data.patient_id
|| !data.document_id || !Number.isInteger(data.document_version)) {
throw new Error('La version PDF de ce document est indisponible');
}
const result = await runtime.documentPdf(data.patient_id, {
id: data.document_id,
version: data.document_version,
document_type: widget.type === 'courrier' ? 'LETTRE' : 'ORD',
}, false);
previewWindow.location.replace(result.url);
window.setTimeout(() => URL.revokeObjectURL(result.url), 60000);
}
// ═══════════════════════════════════════════════════════════
// 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(
;
}
// ═══════════════════════════════════════════════════════════
// Pièces jointes d'un message – vignette, ouverture, téléchargement
// ═══════════════════════════════════════════════════════════
// Comme dans une messagerie ordinaire : l'image se voit, le PDF s'ouvre dans le
// lecteur du navigateur, et le Word se télécharge – aucun navigateur ne sait
// l'afficher. Une pièce jointe qu'on ne peut ni voir ni ouvrir n'est qu'un nom
// de fichier, et l'expéditeur ne peut pas vérifier ce qu'il a envoyé.
const CH_DOCX_MEDIA_TYPE =
'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const CH_KIND_LABELS = {
'application/pdf': 'PDF',
[CH_DOCX_MEDIA_TYPE]: 'Document Word',
'text/plain': 'Texte',
};
function chAttachmentLabel(attachment) {
if (attachment.mediaType?.startsWith('image/')) return 'Image';
return CH_KIND_LABELS[attachment.mediaType] || 'Document';
}
function ChatAttachment({ attachment, runtime, isUser, onPreview }) {
const { InteractiveRow } = window.VascoUI;
const isImage = Boolean(attachment.mediaType?.startsWith('image/'));
const [url, setUrl] = React.useState(attachment.previewUrl || null);
const [failed, setFailed] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const ownedRef = React.useRef(null);
// Une URL d'objet non libérée retient le fichier entier en mémoire tant que
// l'onglet vit.
React.useEffect(() => () => {
if (ownedRef.current) URL.revokeObjectURL(ownedRef.current);
}, []);
const ensureUrl = React.useCallback(async () => {
if (url) return url;
if (!runtime?.attachmentObjectUrl || !attachment.id) return null;
const next = await runtime.attachmentObjectUrl(attachment);
ownedRef.current = next;
setUrl(next);
return next;
}, [url, runtime, attachment]);
// La vignette se charge d'elle-même ; le reste attend un geste explicite.
React.useEffect(() => {
if (!isImage || url || failed || !runtime?.attachmentObjectUrl) return undefined;
let cancelled = false;
(async () => {
try {
const next = await ensureUrl();
if (cancelled && next) URL.revokeObjectURL(next);
} catch {
if (!cancelled) setFailed(true);
}
})();
return () => { cancelled = true; };
}, [isImage, url, failed, runtime, ensureUrl]);
const open = async () => {
if (busy) return;
setBusy(true);
try {
const target = await ensureUrl();
if (!target) return;
if (isImage) { onPreview?.({ url: target, filename: attachment.filename }); return; }
if (attachment.mediaType === 'application/pdf') {
// Le navigateur sait afficher un PDF ; il ne sait pas afficher un Word.
window.open(target, '_blank', 'noopener');
return;
}
const link = document.createElement('a');
link.href = target;
link.download = attachment.filename || 'document';
document.body.appendChild(link);
link.click();
link.remove();
} catch (error) {
showToast(error?.message || 'Pièce jointe indisponible', 'error');
} finally {
setBusy(false);
}
};
const meta = [
chAttachmentLabel(attachment),
attachment.sizeBytes ? readableFileSize(attachment.sizeBytes) : null,
].filter(Boolean).join(' · ');
if (isImage && url) {
return (
);
}
return (
{busy ? : (
)}
{attachment.filename}
{failed ? 'Contenu indisponible' : meta}
{attachment.scanMode === 'simulated' && ' · inspection simulée locale'}
);
}
// Aperçu plein cadre d'une image, refermable au clic ou à l'Échap.
function ChatImagePreview({ preview, onClose }) {
const { DetailSheet } = window.VascoUI;
return
{preview.filename}
;
}
// ═══════════════════════════════════════════════════════════
// Feuille générique – saisie, choix ou confirmation
// ═══════════════════════════════════════════════════════════
// Les boîtes natives `prompt`/`confirm` bloquent le fil du navigateur, sortent
// du cadre iOS de l'application et ne suivent aucun de ses styles. Tout passe
// donc par cette feuille, comme partout ailleurs dans Vasco.
function ChPromptSheet({ title, message, mode = 'text', value = '', options = [],
confirmLabel = 'Valider', cancelLabel = 'Annuler', danger = false, busy = false,
cancelVariant = 'secondary', showClose = true, onCancel, onSubmit }) {
const [draft, setDraft] = React.useState(value);
const inputRef = React.useRef(null);
React.useEffect(() => {
if (mode === 'text') setTimeout(() => inputRef.current?.focus(), 60);
}, [mode]);
const canSubmit = mode !== 'text' || draft.trim().length > 0;
const { FormSheet, Button } = window.VascoUI;
return
{mode !== 'choice' && }
>}>
{mode === 'text' && (
setDraft(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter' && canSubmit) onSubmit(draft.trim()); }}
maxLength={120}
style={{
marginTop: 14, width: '100%', height: 44, padding: '0 12px',
borderRadius: VT.rMd || 12, border: `1px solid ${CH_HAIR2}`,
background: CH_SURF, outline: 'none', fontFamily: 'inherit',
fontSize: 14, fontWeight: 500, color: CH_INK,
}} />
)}
{mode === 'choice' && (
{options.map((option) => (
))}
)}
;
}
// ═══════════════════════════════════════════════════════════
// Feuille de choix d'un patient – recherche serveur
// ═══════════════════════════════════════════════════════════
// Les deux feuilles patient de l'assistant – « Associer un patient » et
// « Enregistrer au dossier de » – passaient par ChPromptSheet en mode
// `choice` : une liste des cent premiers dossiers, sans champ de saisie.
// Au-delà de cent patients, le bon dossier n'existait tout simplement pas à
// l'écran. La saisie interroge maintenant la recherche patient unifiée –
// même prédicat que le répertoire, la dictée et l'agenda : nom, prénom,
// permutations, IPP, fautes tolérées côté serveur – à la cadence commune
// (VascoApi.search.debounceMs). La ChPromptSheet générique reste intacte
// pour ses autres usages.
//
// `pinned` porte les choix qui ne sont pas des dossiers : « Aucun patient »
// pour dissocier le fil, « + Nouveau patient » pour créer. `onPick` reçoit
// exactement ce que la feuille d'avant soumettait : la valeur épinglée ou
// l'identifiant serveur du patient.
function ChPatientPickSheet({ title, message, pinned = [], activePatientId = null, emptyRosterMessage, onCancel, onPick }) {
const { FormSheet, Button, InteractiveRow } = window.VascoUI;
const [query, setQuery] = React.useState('');
// `null` tant que le serveur n'a jamais répondu : « Aucun patient » ne se
// montre que devant une réponse réelle, jamais pendant l'attente.
const [roster, setRoster] = React.useState(null);
const [searching, setSearching] = React.useState(false);
const [searchError, setSearchError] = React.useState(null);
const inputRef = React.useRef(null);
React.useEffect(() => {
setTimeout(() => inputRef.current?.focus(), 60);
}, []);
React.useEffect(() => {
const patientOptions = window.VascoApi?.search?.patientOptions;
if (!patientOptions) return undefined;
let cancelled = false;
setSearching(true);
setSearchError(null);
const term = query.trim();
const debounceMs = window.VascoApi?.search?.debounceMs ?? 250;
const handle = setTimeout(() => {
patientOptions(term)
.then((items) => {
if (cancelled) return;
// Le serveur dit le statut clinique : le marqueur « décédé » vient
// de la réponse, jamais du cache du répertoire. L'assistant ne
// refuse pas un dossier décédé (associer un fil, enregistrer un
// compte rendu restent permis – seul l'archivage bloque) : on
// marque, on n'interdit pas.
setRoster(items.map((item) => ({
id: item.id,
last: item.family_name.toLocaleUpperCase('fr-FR'),
first: item.given_name,
ipp: item.medical_record_number,
age: item.age,
deceased: item.clinical_status === 'deceased',
})));
setSearching(false);
})
.catch((failure) => {
if (!cancelled) {
setSearchError(failure?.message || 'Recherche des patients impossible');
setRoster([]);
setSearching(false);
}
});
// La liste initiale n'attend pas : le délai ne concerne que la frappe.
}, term ? debounceMs : 0);
return () => { cancelled = true; clearTimeout(handle); };
}, [query]);
const term = query.trim();
// Un espace encore vide n'est ni un échec ni « aucun résultat » : sans
// terme saisi, une réponse vide veut dire qu'aucun dossier n'existe, et la
// feuille le dit elle-même, là où l'on cherchait à choisir.
const spaceIsEmpty = roster !== null && roster.length === 0 && !term && !searching && !searchError;
return (
Annuler}
>
{/* Sans aucun dossier dans l'espace, un champ de recherche ne
trouverait jamais rien : seuls restent les choix épinglés. */}
{!spaceIsEmpty && (
setQuery(event.target.value)}
placeholder="Rechercher par nom, prénom ou IPP"
aria-label="Rechercher un patient"
maxLength={100}
style={{
width: '100%', height: 44, padding: '0 12px', flexShrink: 0,
borderRadius: VT.rMd || 12, border: `1px solid ${CH_HAIR2}`,
background: CH_SURF, outline: 'none', fontFamily: 'inherit',
fontSize: 14, fontWeight: 500, color: CH_INK,
}} />
)}
)}
{/* « Aucun patient » n'apparaît que devant une réponse réelle du
serveur, jamais pendant l'attente. */}
{!searchError && !searching && roster !== null && roster.length === 0 && term && (
setSubject(e.target.value)}
maxLength={200} style={chEditInput} />
>}
{kind === 'cr' && <>
setType(e.target.value)} maxLength={80} style={chEditInput} />
{/* Le rangement au dossier, qui décide de l'onglet où le compte rendu se
retrouvera. Proposé par le modèle, corrigé ici. */}
Cette trame a été modifiée depuis la génération. La version {source.version} reste
identifiée dans le CRO, mais son ancien contenu n’est pas affiché comme s’il était actuel.
{/* 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}
)}
);
}
// ═══════════════════════════════════════════════════════════
// Message bubble
// ═══════════════════════════════════════════════════════════
// Éditeur CCAM partagé entre le fil Assistant et le dossier patient. Les deux
// surfaces manipulent le même widget serveur, avec la même règle de classement,
// le même recalcul et les mêmes commandes de confirmation. Une correction faite
// depuis le CRO est donc immédiatement celle que l'on retrouvera dans le fil.
function CCAMWidgetEditor({ msg, runtime, onWidgetUpdated, allowCroSelection = true }) {
const [ccamBusy, setCcamBusy] = React.useState(null);
// 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);
}
};
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] });
const selectCcamCro = (croDocumentId) => editCcam('cro', {
cro_document_id: croDocumentId || null,
});
// Remonter d'un rang, pas sauter en tête : l'acte échange sa place avec le
// précédent qui compte pour le rang. Le serveur recalcule les taux et retire
// la confirmation puisque la liste signée vient de changer.
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 });
};
const headerActions = (
);
return
}
onToggle={runtime && msg.widgetId ? toggleCcamCode : null}
onAdd={runtime && msg.widgetId ? addCcamCode : null}
// Depuis un CRO, le rattachement est le contexte d'ouverture : le modifier
// ici déplacerait silencieusement le codage vers un autre document.
onSelectCro={allowCroSelection && runtime && msg.widgetId ? selectCcamCro : null}
onRaise={runtime && msg.widgetId ? raiseCcamCode : null} />;
}
window.VascoCCAMWidgetEditor = CCAMWidgetEditor;
function ChatBubble({ msg, accent, runtime = null, onWidgetUpdated, onEditWidget, onRetry,
retrying = false, onPreviewImage, onNeedPatient = null, onOpenPatient = null,
onNeedCcamDecision = null, onCcamKept = null, onSavedForCcam = null }) {
const { Button } = window.VascoUI;
const isUser = msg.role === 'user';
const displayedText = isUser ? (msg.text || '') : chDisplayedAssistantText(msg);
const [rdvBusy, setRdvBusy] = React.useState(false);
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);
}
};
const widgetActions = (
onEditWidget?.(msg)}
onOpenPatient={onOpenPatient} onNeedCcamDecision={onNeedCcamDecision}
onCcamKept={onCcamKept} onSavedForCcam={onSavedForCcam} />
);
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 */}
{displayedText && (
{/* Le message de l'utilisateur est rendu littéralement : ce qu'il a
tapé ne doit pas être réinterprété. */}
{isUser ? displayedText : }
{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 && (
{ event.stopPropagation(); onRaise(c.code); }}
disabled={Boolean(busyCode)}
title={ranksBefore(i + 1) === 1
? `Faire de ${c.code} l’acte principal`
: `Remonter ${c.code} d’un rang`}
label={ranksBefore(i + 1) === 1
? `Faire de ${c.code} l’acte principal`
: `Remonter ${c.code} d’un rang`}
size={26}
style={{
// `margin-left` et le positionnement vivent dans la feuille de
// style : ils diffèrent selon que le pointeur sait survoler.
padding: 0, borderRadius: 999, border: 'none',
background: 'rgba(10,10,10,0.04)', color: CH_SUB,
cursor: busyCode ? 'default' : 'pointer',
// Le style en ligne l'emporte sur la règle de survol : une
// promotion en cours reste visible même si le curseur s'en va.
...(busyCode === c.code ? { opacity: 1 } : {}),
}}>
{busyCode === c.code ? (
) : (
)}
)}
))}
)}
{/* 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,
openHistoryOnMount = false, onHistoryOpened = null,
openConversationRequest = null, onConversationRequestHandled = null,
askRequest = null, onAskHandled = null,
// Deux reglages de presentation, et rien d'autre. Sans eux, le composeur et le
// menu d'attachement sont exactement ceux du telephone : cet ecran ne demande
// jamais dans quelle disposition il est rendu, c'est la couche bureau qui les
// fournit. `composerBounds` porte les deux bornes de hauteur d'une zone de
// saisie multiligne ; `sceneSelector` nomme la scene dans laquelle recaler le
// menu d'attachement quand elle n'est plus la fenetre.
composerBounds = null, sceneSelector = null,
}) {
const { Button, IconButton, InlineAction, InteractiveRow, StatusNotice } = window.VascoUI;
const connected = Boolean(runtime);
const [activeConvId, setActiveConvId] = React.useState(null);
const [conversations, setConversations] = React.useState([]);
const [showHistory, setShowHistory] = React.useState(false);
const [input, setInput] = React.useState('');
const [askConversation, setAskConversation] = React.useState(null);
const attemptedAskCreate = React.useRef(null);
const askRequestRef = React.useRef(askRequest);
askRequestRef.current = askRequest;
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;
// Sans scene designee, la scene est la fenetre : c'est le cas du telephone,
// et les deux bornes valent alors 0 et `innerWidth`, comme auparavant.
const scene = sceneSelector && typeof el.closest === 'function'
? el.closest(sceneSelector) : null;
const sceneBox = scene ? scene.getBoundingClientRect() : null;
const left = sceneBox ? sceneBox.left : 0;
const right = sceneBox ? sceneBox.right : window.innerWidth;
const r = el.getBoundingClientRect();
const margin = 8;
if (r.left < left + margin) setAttachShift(left + margin - r.left);
else if (r.right > right - margin) setAttachShift(right - margin - r.right);
}, [showAttach, sceneSelector]);
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);
// Zone de saisie multiligne : on remet la hauteur a zero pour lire la hauteur
// reelle du contenu, puis on borne. Au-dela du maximum, c'est elle qui defile,
// jamais le fil. Sans bornes recues, ce composeur n'existe pas et l'effet ne
// fait rien.
const composerRef = React.useRef(null);
React.useLayoutEffect(() => {
const el = composerRef.current;
if (!composerBounds || !el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(Math.max(el.scrollHeight, composerBounds.minHeight), composerBounds.maxHeight)}px`;
el.style.overflowY = el.scrollHeight > composerBounds.maxHeight ? 'auto' : 'hidden';
}, [composerBounds, input, listening]);
React.useEffect(() => {
if (!openHistoryOnMount) return;
setShowHistory(true);
// La demande est consommée une seule fois : revenir plus tard dans
// l'Assistant par sa barre d'onglets doit afficher le fil courant.
onHistoryOpened?.();
}, [openHistoryOnMount, onHistoryOpened]);
const loadServerConversations = React.useCallback(async () => {
if (!runtime) return;
// Le rappel possède sa propre cible : ne pas charger le dernier fil.
if (askRequestRef.current) {
attemptedAskCreate.current = null;
setServerError(null);
setServerBusy(false);
return;
}
setServerBusy(true);
setServerError(null);
try {
const summaries = await runtime.listConversations();
if (askRequestRef.current) return;
if (summaries.length === 0) {
setConversations([]);
setActiveConvId(null);
return;
}
const first = await runtime.getConversation(summaries[0].id);
if (askRequestRef.current) return;
setConversations([first, ...summaries.slice(1)]);
setActiveConvId(first.id);
} catch (error) {
setServerError(error?.message || 'Les conversations sont indisponibles.');
} finally {
setServerBusy(false);
}
}, [runtime]);
React.useEffect(() => {
// Une dictée désigne déjà la conversation à ouvrir. Dans ce cas, éviter le
// chargement générique du dernier fil supprime une course entre deux
// requêtes et permet d’afficher immédiatement l’état de réflexion ciblé.
if (!connected || openConversationRequest) return;
loadServerConversations();
}, [connected, openConversationRequest, loadServerConversations]);
// Toutes les conversations peuvent être supprimées : `activeConv` vaut alors
// undefined et l'écran plantait sur `.messages`. On tient le cas vide.
const awaitingAskConversation = Boolean(askRequest && askConversation?.request !== askRequest);
const conversationLoading = serverBusy || (awaitingAskConversation && !serverError);
// Le masque est synchrone : aucun rendu de l'ancien fil entre la demande
// et le premier effet, ni pendant la création asynchrone.
const activeConv = awaitingAskConversation
? null
: (conversations.find(c => c.id === activeConvId) || conversations[0]);
const activeAssistantPending = Boolean(activeConv?.messages?.some((message) => (
message.role === 'assistant'
&& (message.status === 'queued' || message.status === 'streaming')
)));
// 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);
React.useEffect(() => {
if (!runtime || !openConversationRequest) return undefined;
let cancelled = false;
const requestedConversationId = openConversationRequest.conversationId;
(async () => {
try {
let detail = await runtime.getConversation(requestedConversationId);
if (cancelled) return;
setConversations((current) => [
detail,
...current.filter((conversation) => conversation.id !== detail.id),
]);
setActiveConvId(detail.id);
setShowHistory(false);
setFocusWidgetId(openConversationRequest.widgetId);
setServerBusy(false);
const hasPendingAssistant = () => detail.messages.some((message) => (
message.role === 'assistant'
&& (message.status === 'queued' || message.status === 'streaming')
));
if (hasPendingAssistant()) {
setThinkingIn(detail.id);
const deadline = Date.now() + 65000;
while (!cancelled && Date.now() < deadline && hasPendingAssistant()) {
await new Promise((resolve) => setTimeout(resolve, 1000));
detail = await runtime.getConversation(requestedConversationId);
if (cancelled) return;
setConversations((current) => [
detail,
...current.filter((conversation) => conversation.id !== detail.id),
]);
}
if (hasPendingAssistant()) {
showToast('La réponse de l’Assistant met plus de temps que prévu', 'error');
}
setThinkingIn(null);
}
} catch (error) {
if (!cancelled) {
setServerError(error?.message || 'Cette conversation est indisponible.');
setThinkingIn(null);
setServerBusy(false);
}
} finally {
if (!cancelled) onConversationRequestHandled?.();
}
})();
return () => { cancelled = true; };
}, [runtime, openConversationRequest, onConversationRequestHandled]);
// Filet de sécurité indépendant de la navigation directe : le chargement
// initial de l’Assistant peut lui aussi tomber pendant que le job OpenAI est
// encore en cours. Dans ce cas, le fil actif se relit jusqu’à son état final
// au lieu de rester visuellement bloqué sur la seule dictée.
React.useEffect(() => {
if (!runtime || serverBusy || openConversationRequest || !activeAssistantPending || !activeConv?.id) {
return undefined;
}
let cancelled = false;
const conversationId = activeConv.id;
const deadline = Date.now() + 65000;
setThinkingIn(conversationId);
(async () => {
try {
let pending = true;
while (!cancelled && pending && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const detail = await runtime.getConversation(conversationId);
if (cancelled) return;
pending = detail.messages.some((message) => (
message.role === 'assistant'
&& (message.status === 'queued' || message.status === 'streaming')
));
setConversations((current) => [
detail,
...current.filter((conversation) => conversation.id !== detail.id),
]);
}
if (!cancelled && pending) {
showToast('La réponse de l’Assistant met plus de temps que prévu', 'error');
}
} catch (error) {
if (!cancelled) setServerError(error?.message || 'Cette conversation est indisponible.');
} finally {
if (!cancelled) setThinkingIn(null);
}
})();
return () => {
cancelled = true;
setThinkingIn((current) => current === conversationId ? null : current);
};
}, [runtime, serverBusy, openConversationRequest, activeAssistantPending, activeConv?.id]);
// 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);
const [capabilitiesFailed, setCapabilitiesFailed] = React.useState(false);
React.useEffect(() => {
if (!connected) return undefined;
let cancelled = false;
setServerCapabilities(null);
setCapabilitiesFailed(false);
(async () => {
try {
const value = await runtime.capabilities();
if (!cancelled) {
setServerCapabilities(value);
setCapabilitiesFailed(false);
}
} catch {
// Sans contrat de capacité, l'écran ne prétend plus que la génération
// fonctionne : l'historique reste lisible, le composeur reste inerte.
if (!cancelled) setCapabilitiesFailed(true);
}
})();
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;
const generationReady = !connected || serverCapabilities?.assistant_generation === true;
const generationUnavailable = connected && (
capabilitiesFailed || serverCapabilities?.assistant_generation === false
);
const generationSimulated = generationReady
&& serverCapabilities?.provider_is_simulated === true;
const generationUnavailableMessage = capabilitiesFailed
? 'La disponibilité de l’IA n’a pas pu être vérifiée sur cet environnement.'
: 'Aucun fournisseur d’IA n’est configuré sur cet environnement.';
const generationBlockingReason = !generationReady
? (generationUnavailable ? generationUnavailableMessage : 'Vérification de la disponibilité de l’IA…')
: 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 = () => {
const conversation = conversations.find((item) => item.id === activeConvId) || conversations[0];
if (!conversation) return;
// La feuille interroge elle-même la recherche patient unifiée : plus de
// répertoire préchargé, donc plus de plafond aux cent premiers dossiers.
setSheet({ kind: 'patient', conversation });
};
// 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 = (widgetRef) => new Promise((resolve) => {
reportPatientResolver.current = resolve;
setSheet({ kind: 'report_patient', widget: widgetRef });
});
// Le choix ne se pose que pour un CRO déjà relié à une proposition CCAM dans
// cette conversation. Sans codage existant, l'enregistrement suit son parcours
// normal ; fermer la feuille annule l'enregistrement, donc aucun état incohérent
// n'est créé dans le dossier.
const ccamDecisionResolver = React.useRef(null);
const settleCcamDecision = (decision) => {
const resolve = ccamDecisionResolver.current;
ccamDecisionResolver.current = null;
setSheet(null);
resolve?.(decision);
};
const requestCcamDecision = (reportMessage) => {
const documentId = reportMessage?.data?.document_id;
const conversation = activeConvRef.current;
const linkedCcam = [...(conversation?.messages || [])].reverse().find((message) => (
message.widget === 'ccam'
&& message.widgetCroDocumentId === documentId
&& !message.data?.discarded_at
));
if (!linkedCcam) return Promise.resolve(undefined);
return new Promise((resolve) => {
ccamDecisionResolver.current = resolve;
setSheet({ kind: 'ccam_decision', linkedCcam });
});
};
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;
const editedMessage = editingWidget;
setWidgetEditBusy(true);
// Journal temporaire : la forme du contrat uniquement, jamais le contenu clinique.
// À conserver jusqu'à validation du parcours par Sfar.
console.debug('[VASCO][widget-edit]', {
widgetType: editingWidget.widget,
widgetId: editingWidget.widgetId,
replacementKeys: Object.keys(replacement || {}).sort(),
hasCorrespondentId: Object.prototype.hasOwnProperty.call(
replacement || {}, 'correspondent_id',
),
});
try {
// Un CRO déjà enregistré ne publie jamais d'abord un brouillon corrigé.
// Le médecin tranche le CCAM avant le premier appel serveur ; fermer la
// feuille abandonne donc réellement la modification.
if (editedMessage.widget === 'cr' && editedMessage.data?.document_id) {
setEditingWidget(null);
const decision = await requestCcamDecision(editedMessage);
if (decision === null) return;
const saved = await runtime.widgetAction(
{
id: editedMessage.widgetId,
version: editedMessage.widgetVersion,
type: editedMessage.widget,
},
'save',
{ replacement, ccamDecision: decision },
);
applyWidgetUpdate(saved.widget);
if (decision === 'keep') {
await refreshActiveConversation();
} else if (decision === 'regenerate' || saved.widget?.data?.propose_ccam_after_save) {
await proposeCcamForSavedReport(saved.widget);
}
return;
}
const result = await runtime.widgetAction(
{ id: editedMessage.widgetId, version: editedMessage.widgetVersion, type: editedMessage.widget },
'edit',
{ replacement },
);
const editedWidget = result.widget;
applyWidgetUpdate(editedWidget);
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);
} else if (editedMessage.widget === 'cr' && editedMessage.data?.document_id) {
// Une panne réseau ne doit pas faire perdre la saisie locale. Le serveur
// atomique n'a rien écrit : l'éditeur peut être rouvert avec le brouillon.
setEditingWidget({
...editedMessage,
data: { ...editedMessage.data, ...replacement },
});
}
} 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 || [],
widgetCroDocumentId: widget.type === 'ccam'
? (widget.cro_document_id || null)
: message.widgetCroDocumentId,
}
: message
)),
})));
}, [refreshConversationPatient]);
const refreshActiveConversation = React.useCallback(async () => {
const conversation = activeConvRef.current;
if (!runtime || !conversation) return;
const refreshed = await runtime.getConversation(conversation.id);
setConversations((current) => [
refreshed,
...current.filter((item) => item.id !== refreshed.id),
]);
setActiveConvId(refreshed.id);
}, [runtime]);
const proposeCcamForSavedReport = React.useCallback(async (widget) => {
const documentId = widget?.data?.document_id;
const conversation = activeConvRef.current;
if (!runtime || !documentId || !conversation) return false;
const title = String(widget.data?.title || 'Compte rendu opératoire').trim();
const prompt = `Propose le codage CCAM du CRO « ${title} » que je viens d’enregistrer.`;
const optimisticId = `ccam-follow-up:${documentId}:${Date.now()}`;
setConversations((current) => current.map((item) => (
item.id === conversation.id
? {
...item,
messages: [
...(item.messages || []),
{ id: optimisticId, role: 'user', text: prompt, status: 'completed', attachments: [] },
],
preview: prompt.slice(0, 80),
}
: item
)));
setThinkingIn(conversation.id);
setThinkingStage('generating');
try {
const completed = await runtime.sendMessage(conversation, prompt, {
ccamCroDocumentId: documentId,
onProgress: (stage) => {
if (stage === 'compaction' || stage === 'generating') setThinkingStage(stage);
},
});
setConversations((current) => [
completed,
...current.filter((item) => item.id !== completed.id),
]);
setActiveConvId(completed.id);
return true;
} catch (error) {
// Le premier geste reste acquis : on ne présente jamais l'échec du CCAM comme
// un échec d'enregistrement du CRO. Une relecture récupère aussi le message
// durable si le réseau s'est coupé après son acceptation.
showToast(
error?.message
? `Compte rendu enregistré. Proposition CCAM impossible : ${error.message}`
: 'Compte rendu enregistré. La proposition CCAM n’a pas pu être générée.',
'error',
);
try {
const refreshed = await runtime.getConversation(conversation.id);
setConversations((current) => current.map((item) => (
item.id === conversation.id ? refreshed : item
)));
} catch {
setConversations((current) => current.map((item) => (
item.id === conversation.id
? { ...item, messages: (item.messages || []).filter((message) => message.id !== optimisticId) }
: item
)));
}
return true;
} finally {
setThinkingIn((current) => (current === conversation.id ? null : current));
setThinkingStage('generating');
}
}, [runtime]);
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 (CH_CRO_QUALITY_PREVIEW) {
if (scrollRef.current) scrollRef.current.scrollTop = 0;
return;
}
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);
const followedByCcam = result.widget?.data?.propose_ccam_after_save
? await proposeCcamForSavedReport(result.widget)
: false;
// La vue est revenue en bas du fil au remontage : on vise la carte
// enregistrée, sinon la preuve du geste reste hors champ.
if (!followedByCcam) 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,
proposeCcamForSavedReport, onResumeHandled]);
// 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 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 (!runtime) 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 (!runtime) 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 (!runtime) return;
if (pendingAttachments.length + files.length > 5) {
showToast('Maximum 5 pièces jointes par message', 'error');
return;
}
setServerError(null);
try {
const normalizeImage = window.VascoAssistantAttachmentImage?.normalizeAttachmentImageFile;
const preparedFiles = [];
for (const file of files) {
setAttachmentPhase({ stage: 'preparing', filename: file.name });
preparedFiles.push(normalizeImage ? await normalizeImage(file) : file);
}
// Refuser le lot entier après la conversion : un HEIC est désormais un
// JPEG contractuel, tandis qu'un vrai format inconnu reste rejeté avant
// le premier téléversement.
const rejected = preparedFiles.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;
}
for (const file of preparedFiles) {
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 (forcedText = null, forcedConversation = null) => {
// Un texte imposé vient d'un autre écran, un rappel qu'on confie à
// l'Assistant. Il emprunte exactement le chemin d'une saisie au clavier :
// conversation dédiée au rappel, même envoi, mêmes refus.
const typed = typeof forcedText === 'string' ? forcedText : input;
if (!typed.trim() && pendingAttachments.length === 0) return;
if (!generationReady) return;
if (connected) {
if (thinkingIn || voicePhase || attachmentPhase) return;
const q = typed.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)
: (forcedConversation || activeConv);
if (voiceForSend && target) setActiveConvId(target.id);
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;
}
};
// Une demande venue d'un autre écran.
//
// Un rappel confié à l'Assistant arrive ici comme une phrase déjà écrite.
// Elle part toute seule quand rien ne s'y oppose : le praticien a déjà dit ce
// qu'il voulait en touchant le bouton, lui redemander de valider une phrase
// qu'il n'a pas tapée n'apprendrait rien à personne.
//
// Mais rien ne se décide avant de savoir. L'écran vient d'être monté par la
// navigation, et `generationReady` est faux tant que le serveur n'a pas dit
// ce qu'il sait faire : c'est l'état que le composeur annonce lui-même par
// « Vérification de la disponibilité de l'IA ». Trancher à ce moment-là
// lirait une indisponibilité qui n'est pas établie, poserait la phrase dans
// le composeur et consommerait la demande : là où un fournisseur existe,
// l'envoi automatique n'aurait jamais lieu. On attend donc, sans consommer.
//
// Une fois les capacités connues, ou leur vérification échouée, la règle est
// celle du module partagé, la même pour les deux coques. Génération
// indisponible, ou composeur déjà occupé par un brouillon, une pièce jointe
// ou une dictée : la phrase se pose dans le composeur à la suite de ce qui
// s'y trouve, sans rien effacer, et l'écran garde le message
// d'indisponibilité qu'il affiche déjà de lui-même. Un envoi qui n'a pas eu
// lieu ne doit jamais en avoir l'air.
//
// La demande se consomme une seule fois : `onAskHandled` la rend à la coque.
React.useEffect(() => {
if (!askRequest || serverBusy || thinkingIn) return;
const asked = String(askRequest.text || '').trim();
if (!asked) { onAskHandled?.(); return; }
const capabilitiesKnown = serverCapabilities !== null || capabilitiesFailed;
if (!window.VascoAssistantAsk.decides({ connected, capabilitiesKnown })) return;
// Une demande est une nouvelle intention, même si son texte est identique.
// La création emprunte le geste existant ; le rendu suivant relit le
// composeur avant de décider, sans envoyer dans l'ancien fil.
if (connected && askConversation?.request !== askRequest) {
if (attemptedAskCreate.current === askRequest) return;
attemptedAskCreate.current = askRequest;
void newConversation().then((conversation) => {
if (conversation) setAskConversation({ request: askRequest, conversation });
});
return;
}
const composerBusy = Boolean(input.trim() || pendingAttachments.length
|| voiceDraft || voicePhase || attachmentPhase);
if (window.VascoAssistantAsk.sendsItself({ generationReady, connected, composerBusy })) {
void send(asked, askConversation?.conversation);
} else {
setInput((current) => window.VascoAssistantAsk.composeWith(current, asked));
}
onAskHandled?.();
}, [askRequest, askConversation, onAskHandled, serverBusy, serverError, thinkingIn, generationReady, connected,
serverCapabilities, capabilitiesFailed,
input, pendingAttachments.length, voiceDraft, voicePhase, attachmentPhase]);
const newConversation = async () => {
if (!runtime || serverBusy || thinkingIn) return;
setServerBusy(true);
setServerError(null);
try {
const conversation = await runtime.createConversation('Nouvelle conversation');
setConversations((current) => [conversation, ...current]);
setActiveConvId(conversation.id);
return conversation;
} catch (error) {
setServerError(error?.message || 'La conversation n’a pas pu être créée.');
showToast(error?.message || 'Création impossible', 'error');
} finally {
setServerBusy(false);
}
};
const deleteConversation = async (id) => {
const conversation = conversations.find((item) => item.id === id);
if (!runtime || !conversation) return;
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')}
)}
{/* Stop button */}
) : (
/* Normal mode – input + mic + send */
{composerBounds ? (
/* La ou l'on ecrit plus qu'on ne dicte : Entree envoie,
Maj+Entree passe a la ligne. La hauteur suit le texte entre
les deux bornes recues, calees sur la ligne de 20 px. */
)}
{/* 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' && (
setSheet(null)}
onPick={(patientId) => void applyConversationPatient(sheet.conversation, patientId)}
/>
)}
{sheet?.kind === 'ccam_decision' && (
settleCcamDecision(null)}
onSubmit={settleCcamDecision}
/>
)}
{sheet?.kind === 'report_patient' && (
settleReportPatient(null)}
onPick={(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;
// Les extensions chargées ensuite remplacent le widget via le même symbole
// explicite, sans dépendre d'une liaison lexicale propre à Babel.
window.WidgetCCAM = WidgetCCAM;
// 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;