// 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 secours maintient ce script testable seul ; la coque réelle fournit // systématiquement la palette de mouvement officielle. const CH_MOTION = window.VascoUI?.tokens?.motion || Object.freeze({ micro: 80, fast: 120, quick: 150, responsive: 160, normal: 200, deliberate: 250, emphasis: 350, spinner: 750, pulse: 1200, ambient: 2000, standard: 'cubic-bezier(.2,.7,.2,1)', sheet: 'cubic-bezier(.16,1,.3,1)', }); // 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, HEIC/HEIF ou TXT, 10 Mo maximum'; const CH_DEMO_DISCLAIMER = 'Simulation sans donnée clinique réelle. Le contenu et le codage doivent être validés à partir du geste effectivement réalisé.'; // Aperçu isolé demandé pendant la conception. Il ne crée aucune préférence et // disparaît dès que le paramètre est retiré. Le vrai branchement restera soumis // au reçu serveur, comme toutes les autres décisions persistantes du fil. const CH_PREFERENCE_PREVIEW = new URLSearchParams(window.location.search) .get('preference-proposal') === '1'; const CH_TEMPLATE_TRACE_PREVIEW = new URLSearchParams(window.location.search) .get('template-trace') === '1'; const CH_CRO_QUALITY_PREVIEW = new URLSearchParams(window.location.search) .get('cro-quality-compare') === '1'; const CH_TEMPLATE_TRACE_PREVIEW_SOURCE = Object.freeze({ ref: 'template:11111111-1111-4111-8111-111111111111:v3', resourceType: 'template', resourceId: '11111111-1111-4111-8111-111111111111', version: 3, label: 'CRO – endartériectomie carotidienne', previewContent: `COMPTE RENDU OPÉRATOIRE Patient : [NOM DU PATIENT], né(e) le [date de naissance] Date d'intervention : [date] Opérateur : [opérateur] – Aide : [aide] – Anesthésie : [type d'anesthésie] INDICATION [Sténose carotidienne interne – côté, degré, caractère symptomatique ou non, bilan d'imagerie ayant posé l'indication.] INTERVENTION : ENDARTÉRIECTOMIE CAROTIDIENNE [côté] Installation : [décubitus dorsal, tête tournée du côté opposé.] Voie d'abord : [cervicotomie pré-sterno-cléido-mastoïdienne.] Exploration : [aspect de la bifurcation, étendue de la plaque.] Héparinisation générale : [dose]. Clampage : [séquence et durée]. [Endartériectomie conventionnelle ou par éversion, gestion du shunt, qualité du plan de sortie, fermeture – patch ou suture directe.] Contrôle : [doppler peropératoire, hémostase.] Fermeture : [plans, drainage]. SUITES IMMÉDIATES [État neurologique au réveil, constantes, destination du patient.] CONCLUSION [Geste réalisé, incidents éventuels, consignes de surveillance et traitement.]`, }); const CH_TEMPLATE_TRACE_PREVIEW_REPORT = Object.freeze({ type: 'Compte rendu opératoire', document_type: 'CRO', title: 'Endartériectomie carotidienne droite', sections: Object.freeze([ Object.freeze({ heading: 'COMPTE RENDU OPÉRATOIRE', content: `Patient : patiente de démonstration, née le [à compléter] Date d'intervention : [à compléter] Opérateur : [à compléter] – Aide : [à compléter] – Anesthésie : générale`, }), Object.freeze({ heading: 'INDICATION', content: `Sténose symptomatique de la carotide interne droite. Degré de sténose : [à compléter]. Bilan d'imagerie ayant posé l'indication : [à compléter].`, }), Object.freeze({ heading: 'INTERVENTION : ENDARTÉRIECTOMIE CAROTIDIENNE DROITE', content: `Installation : [à compléter]. Voie d'abord : [à compléter]. Exploration : [à compléter]. Héparinisation générale : [à compléter]. Clampage : [à compléter]. Endartériectomie carotidienne droite. Fermeture par patch. Contrôle : [à compléter]. Fermeture : [à compléter].`, }), Object.freeze({ heading: 'SUITES IMMÉDIATES', content: `Suites immédiates simples. État neurologique au réveil : [à compléter]. Constantes : [à compléter]. Destination de la patiente : [à compléter].`, }), Object.freeze({ heading: 'CONCLUSION', content: `Endartériectomie carotidienne droite avec fermeture par patch. Incidents éventuels : [à compléter]. Consignes de surveillance et traitement : [à compléter].`, }), ]), status: 'draft', propose_ccam_after_save: true, }); const CH_TEMPLATE_TRACE_PREVIEW_MESSAGES = Object.freeze([ Object.freeze({ id: 'template-trace-preview-dictation', role: 'user', status: 'completed', text: 'Dictée synthétique : sténose carotidienne interne droite symptomatique. Endartériectomie carotidienne sous anesthésie générale avec fermeture par patch. Suites immédiates simples.', attachments: Object.freeze([]), }), Object.freeze({ id: 'template-trace-preview-report', role: 'assistant', status: 'completed', text: 'Le brouillon du compte rendu opératoire est prêt à être relu et validé.', widget: 'cr', data: CH_TEMPLATE_TRACE_PREVIEW_REPORT, widgetSources: Object.freeze([CH_TEMPLATE_TRACE_PREVIEW_SOURCE]), widgetCapabilities: Object.freeze([]), }), ]); const CH_CRO_QUALITY_VASCO_REPORT = Object.freeze([ Object.freeze({ heading: 'COMPTE RENDU OPÉRATOIRE', content: `Patient : Patient synthétique A, né(e) le [à compléter] Date d'intervention : [à compléter] Opérateur : [à compléter] / Aide : [à compléter] / Anesthésie : [à compléter]`, }), Object.freeze({ heading: 'INDICATION', content: '[à compléter]' }), Object.freeze({ heading: 'INTERVENTION', content: `Premier temps : abord proximal. Clampage de l’artère fémorale superficielle. Ligature de l’artère poplitée en amont de l’anévrisme. Greffon synthétique et pontage fémoro-poplité. Contrôle peropératoire rétrograde par l’accès rétrograde. Fermeture de l’accès rétrograde. Deuxième temps : abord distal. Calibre de l’introducteur : [à compléter]. Contrôle peropératoire antérograde. Fermeture séparée de l’accès distal. Type de fermeture : [à compléter]. Matériau du fil : [à compléter].`, }), Object.freeze({ heading: 'SUITES IMMÉDIATES', content: '[à compléter]' }), Object.freeze({ heading: 'CONCLUSION', content: 'Pontage fémoro-poplité avec exclusion de l’anévrisme poplité.', }), ]); const CH_CRO_QUALITY_REFERENCE_REPORT = Object.freeze([ Object.freeze({ heading: 'IDENTIFICATION', content: `Patient : Patient synthétique A Date, opérateur, aide et anesthésie : non renseignés`, }), Object.freeze({ heading: 'INDICATION', content: 'Anévrisme poplité. Indication précise à confirmer.', }), Object.freeze({ heading: 'INTERVENTION', content: `Premier temps : abord proximal, clampage de l’artère fémorale superficielle, ligature de l’artère poplitée en amont de l’anévrisme, puis pontage avec greffon synthétique. Contrôle rétrograde avant fermeture de l’accès rétrograde. Deuxième temps distinct : abord distal, contrôle antérograde, puis fermeture séparée de l’accès distal. Calibre de l’introducteur, type de fermeture et matériau du fil non renseignés.`, }), Object.freeze({ heading: 'CONCLUSION', content: 'Pontage fémoro-poplité avec exclusion d’un anévrisme poplité.', }), ]); function CroQualityReportColumn({ eyebrow, title, sections, tone }) { const isReference = tone === 'reference'; return (
{eyebrow}
{title}
{sections.map((section) => (
{section.heading}
{section.content}
))}
); } function CroQualityComparisonPreview() { return (
Test synthétique de rédaction
gpt-5.6-sol, effort medium, trame pontage fémoro-poplité v1
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 ? : ( {done ? CH_DONE_ICON : icon} )} ); } // 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 (
{data.saved_at ? ( Enregistré {chStamp(data.saved_at)} ) : command( 'save', data.propose_ccam_after_save ? 'Enregistrer et proposer le CCAM' : ((msg.widget === 'ordonnance' || msg.widget === 'courrier') ? 'Enregistrer au dossier' : 'Enregistrer'), )}
); } 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(
    {chInline(heading[2], `h${lineIndex}`)}
    , ); return; } const bullet = /^\s*[-*•]\s+(.*)$/.exec(line); const ordered = /^\s*(\d+)[.)]\s+(.*)$/.exec(line); if (bullet || ordered) { const isOrdered = Boolean(ordered); if (!list || list.ordered !== isOrdered) { flushList(); list = { ordered: isOrdered, items: [] }; } list.items.push(isOrdered ? ordered[2] : bullet[1]); return; } flushList(); blocks.push(
    {chInline(line, `p${lineIndex}`)}
    , ); }); flushList(); return
    {blocks}
    ; } // ═══════════════════════════════════════════════════════════ // 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 ? : ( {isImage ? <> : <>} )} {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}
    {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, }} /> )}
    {pinned.map((option) => ( onPick(option.value)} style={{ width: '100%', textAlign: 'left', padding: '11px 12px', marginBottom: 6, borderRadius: 14, background: option.active ? CH_MED_TINT : 'rgba(10,10,10,0.03)', border: 'none', cursor: 'pointer', fontSize: 13.5, fontWeight: 600, color: option.active ? CH_MED : CH_INK, }}>{option.label} ))} {searchError && (
    {searchError}
    )} {!searchError && searching && (
    Recherche…
    )} {/* « 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 && (
    Aucun patient ne correspond à « {term} »
    )} {!searchError && (roster || []).map((patient) => { const active = activePatientId != null && patient.id === activePatientId; return ( onPick(patient.id)} style={{ width: '100%', textAlign: 'left', padding: '9px 12px', marginBottom: 6, borderRadius: 14, background: active ? CH_MED_TINT : 'rgba(10,10,10,0.03)', border: 'none', cursor: 'pointer', }}>
    {patient.last} {patient.first}{patient.deceased ? ' · décédé' : ''}
    IPP {patient.ipp}{patient.age != null ? ` · ${patient.age} ans` : ''}
    ); })}
    ); } // ═══════════════════════════════════════════════════════════ // Édition d'un widget – POST /assistant/widgets/{id}/actions (action `edit`) // ═══════════════════════════════════════════════════════════ // Le serveur exige un remplacement complet et typé, pas un patch : chaque // champ ci-dessous correspond exactement au schéma `WidgetEditReplacement`. const CH_EDIT_TITLES = { email: 'Modifier l’e-mail', courrier: 'Modifier le courrier', cr: 'Modifier le compte rendu', ordonnance: 'Modifier l’ordonnance', }; const chEditInput = { width: '100%', padding: '10px 12px', borderRadius: VT.rMd || 12, border: `1px solid ${CH_HAIR2}`, background: CH_SURF, outline: 'none', fontFamily: 'inherit', fontSize: 13.5, fontWeight: 500, color: CH_INK, }; function ChEditField({ label, children }) { return ( ); } function WidgetEditSheet({ msg, busy, onClose, onSave }) { const { FormSheet, Button, ChoiceChip, InlineAction } = window.VascoUI; const data = msg.data || {}; const [to, setTo] = React.useState(data.to || ''); const [subject, setSubject] = React.useState(data.subject || ''); const [body, setBody] = React.useState(data.body || ''); const [type, setType] = React.useState(data.type || ''); const [documentType, setDocumentType] = React.useState(data.document_type || 'CRO'); const [title, setTitle] = React.useState(data.title || ''); const [sections, setSections] = React.useState(() => (data.sections || []).map((s) => ({ ...s }))); const [meds, setMeds] = React.useState(() => (data.meds || []).map((m) => ({ ...m }))); const kind = msg.widget; const valid = (() => { if (kind === 'email') return subject.trim() && body.trim(); if (kind === 'courrier') return to.trim() && subject.trim() && body.trim(); if (kind === 'cr') { return type.trim() && title.trim() && sections.length > 0 && sections.every((s) => s.heading.trim() && s.content.trim()); } if (kind === 'ordonnance') { return meds.length > 0 && meds.every((m) => m.name.trim() && m.posology.trim() && m.duration.trim()); } return false; })(); const submit = () => { if (!valid || busy) return; if (kind === 'email') { onSave({ to: to.trim() || null, subject: subject.trim(), body }); return; } if (kind === 'courrier') { // Le destinataire est une fiche d'annuaire, jamais une chaîne libre. Cette // feuille modifie le texte et conserve l'identifiant choisi sur la carte. onSave(window.VascoCorrespondents.letterReplacement( { ...data, subject: subject.trim(), body }, data.correspondentId || null, )); return; } if (kind === 'cr') { onSave({ type: type.trim(), document_type: documentType, title: title.trim(), sections, }); return; } onSave({ meds }); }; return ( } >
    {kind === 'email' && <> setTo(e.target.value)} maxLength={254} style={chEditInput} /> setSubject(e.target.value)} maxLength={200} style={chEditInput} />