@ews-admin/global-design-system 1.35.0 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assistant/index.d.ts +5 -0
- package/dist/assistant/index.d.ts.map +1 -0
- package/dist/assistant/store.d.ts +17 -0
- package/dist/assistant/store.d.ts.map +1 -0
- package/dist/assistant/transport.d.ts +20 -0
- package/dist/assistant/transport.d.ts.map +1 -0
- package/dist/components/AssistantChat/AssistantChat.d.ts +15 -0
- package/dist/components/AssistantChat/AssistantChat.d.ts.map +1 -0
- package/dist/components/AssistantChat/AssistantChat.types.d.ts +173 -0
- package/dist/components/AssistantChat/AssistantChat.types.d.ts.map +1 -0
- package/dist/components/AssistantChat/AssistantComposer.d.ts +24 -0
- package/dist/components/AssistantChat/AssistantComposer.d.ts.map +1 -0
- package/dist/components/AssistantChat/AssistantConfirmationCard.d.ts +20 -0
- package/dist/components/AssistantChat/AssistantConfirmationCard.d.ts.map +1 -0
- package/dist/components/AssistantChat/AssistantConversationList.d.ts +19 -0
- package/dist/components/AssistantChat/AssistantConversationList.d.ts.map +1 -0
- package/dist/components/AssistantChat/AssistantMessages.d.ts +21 -0
- package/dist/components/AssistantChat/AssistantMessages.d.ts.map +1 -0
- package/dist/components/AssistantChat/AssistantSuggestionCard.d.ts +12 -0
- package/dist/components/AssistantChat/AssistantSuggestionCard.d.ts.map +1 -0
- package/dist/components/AssistantChat/assistantMarkdown.d.ts +14 -0
- package/dist/components/AssistantChat/assistantMarkdown.d.ts.map +1 -0
- package/dist/components/AssistantChat/index.d.ts +15 -0
- package/dist/components/AssistantChat/index.d.ts.map +1 -0
- package/dist/components/AssistantLauncher/AssistantLauncher.d.ts +58 -0
- package/dist/components/AssistantLauncher/AssistantLauncher.d.ts.map +1 -0
- package/dist/components/AssistantLauncher/index.d.ts +3 -0
- package/dist/components/AssistantLauncher/index.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useSpeechRecognition.d.ts +40 -0
- package/dist/hooks/useSpeechRecognition.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +418 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.esm.css +1 -1
- package/dist/index.esm.js +732 -2
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +751 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/env-config.d.ts +3 -0
- package/dist/utils/env-config.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/assistant/index.ts +11 -0
- package/src/assistant/store.ts +77 -0
- package/src/assistant/transport.ts +159 -0
- package/src/components/AssistantChat/AssistantChat.tsx +390 -0
- package/src/components/AssistantChat/AssistantChat.types.ts +171 -0
- package/src/components/AssistantChat/AssistantComposer.tsx +134 -0
- package/src/components/AssistantChat/AssistantConfirmationCard.tsx +58 -0
- package/src/components/AssistantChat/AssistantConversationList.tsx +85 -0
- package/src/components/AssistantChat/AssistantMessages.tsx +123 -0
- package/src/components/AssistantChat/AssistantSuggestionCard.tsx +48 -0
- package/src/components/AssistantChat/assistantMarkdown.tsx +105 -0
- package/src/components/AssistantChat/index.ts +25 -0
- package/src/components/AssistantLauncher/AssistantLauncher.tsx +408 -0
- package/src/components/AssistantLauncher/index.ts +2 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useSpeechRecognition.ts +95 -0
- package/src/index.ts +50 -0
- package/src/utils/env-config.ts +6 -0
package/dist/index.esm.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
|
|
2
2
|
import * as React from 'react';
|
|
3
3
|
import React__default, { useId, useState, useRef, useEffect, forwardRef, useCallback, createContext, useContext } from 'react';
|
|
4
|
-
import { Stethoscope, Heart, User, ChevronDown, EyeOff, Eye, X, Search, Check, AlertCircle, CheckCircle, AlertTriangle, Pencil, Trash, Lock, Delete } from 'lucide-react';
|
|
4
|
+
import { Stethoscope, Heart, User, ChevronDown, EyeOff, Eye, X, Search, Check, AlertCircle, CheckCircle, AlertTriangle, Pencil, Trash, Lock, Delete, Mic, SendHorizontal, ShieldCheck, SquarePen, MessageSquare, UserRound, Loader2, RotateCcw, Sparkles, ArrowRight, History, AudioLines, ArrowUp, Minimize2, Maximize2 } from 'lucide-react';
|
|
5
5
|
export * from 'lucide-react';
|
|
6
6
|
import { useController, Controller } from 'react-hook-form';
|
|
7
7
|
|
|
@@ -10,6 +10,7 @@ function r(e){var t,f,n="";if("string"==typeof e||"number"==typeof e)n+=e;else i
|
|
|
10
10
|
const LOCAL_PORTS = {
|
|
11
11
|
bff: 8082,
|
|
12
12
|
loginBff: 8080,
|
|
13
|
+
assistantBff: 8083,
|
|
13
14
|
app: 3000,
|
|
14
15
|
login: 3001,
|
|
15
16
|
dashboard: 3002,
|
|
@@ -23,6 +24,7 @@ function buildUrls(environment) {
|
|
|
23
24
|
return {
|
|
24
25
|
bffUrl: `http://api.${domain}:${LOCAL_PORTS.bff}/api/v1`,
|
|
25
26
|
loginBffUrl: `http://api.${domain}:${LOCAL_PORTS.loginBff}/api/v1`,
|
|
27
|
+
assistantBffUrl: `http://api.${domain}:${LOCAL_PORTS.assistantBff}/api/v1`,
|
|
26
28
|
appUrl: `http://local.app.${domain}:${LOCAL_PORTS.app}`,
|
|
27
29
|
loginUrl: `http://local.login.${domain}:${LOCAL_PORTS.login}`,
|
|
28
30
|
dashboardUrl: `http://local.dashboard.${domain}:${LOCAL_PORTS.dashboard}`,
|
|
@@ -34,6 +36,7 @@ function buildUrls(environment) {
|
|
|
34
36
|
return {
|
|
35
37
|
bffUrl: `https://${prefix}api.${domain}/bff/api/v1`,
|
|
36
38
|
loginBffUrl: `https://${prefix}api.${domain}/login-bff/api/v1`,
|
|
39
|
+
assistantBffUrl: `https://${prefix}api.${domain}/assistant/api/v1`,
|
|
37
40
|
appUrl: environment === "prod" ? `https://${prefix}${domain}` : `https://${prefix}app.${domain}`,
|
|
38
41
|
loginUrl: `https://${prefix}login.${domain}`,
|
|
39
42
|
dashboardUrl: `https://${prefix}dashboard.${domain}`,
|
|
@@ -1008,6 +1011,65 @@ function useSelectField({ name, control, options: _options, rules, defaultValue,
|
|
|
1008
1011
|
};
|
|
1009
1012
|
}
|
|
1010
1013
|
|
|
1014
|
+
const BCP47 = { fr: "fr-FR", en: "en-US", ar: "ar-SA" };
|
|
1015
|
+
function getSpeechRecognition() {
|
|
1016
|
+
if (typeof window === "undefined")
|
|
1017
|
+
return undefined;
|
|
1018
|
+
const w = window;
|
|
1019
|
+
return w.SpeechRecognition ?? w.webkitSpeechRecognition;
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Push-to-talk on the browser speech API (MED-1783). `supported` is false on
|
|
1023
|
+
* the server and in browsers without the API, so the app can hide the mic.
|
|
1024
|
+
*/
|
|
1025
|
+
function useSpeechRecognition({ language, onResult, onInterim, }) {
|
|
1026
|
+
const [supported, setSupported] = useState(false);
|
|
1027
|
+
const [listening, setListening] = useState(false);
|
|
1028
|
+
const recognitionRef = useRef(null);
|
|
1029
|
+
const transcriptRef = useRef("");
|
|
1030
|
+
const callbacks = useRef({ onResult, onInterim });
|
|
1031
|
+
callbacks.current = { onResult, onInterim };
|
|
1032
|
+
// Detected after mount: the server never has the API, and the markup must match.
|
|
1033
|
+
useEffect(() => {
|
|
1034
|
+
setSupported(getSpeechRecognition() !== undefined);
|
|
1035
|
+
}, []);
|
|
1036
|
+
const stop = useCallback(() => {
|
|
1037
|
+
recognitionRef.current?.stop();
|
|
1038
|
+
}, []);
|
|
1039
|
+
const start = useCallback(() => {
|
|
1040
|
+
const Ctor = getSpeechRecognition();
|
|
1041
|
+
if (!Ctor || recognitionRef.current)
|
|
1042
|
+
return;
|
|
1043
|
+
const recognition = new Ctor();
|
|
1044
|
+
recognition.lang = BCP47[language] ?? language;
|
|
1045
|
+
recognition.interimResults = true;
|
|
1046
|
+
recognition.continuous = true;
|
|
1047
|
+
transcriptRef.current = "";
|
|
1048
|
+
recognition.onresult = (event) => {
|
|
1049
|
+
let text = "";
|
|
1050
|
+
for (let i = 0; i < event.results.length; i++) {
|
|
1051
|
+
text += event.results[i][0]?.transcript ?? "";
|
|
1052
|
+
}
|
|
1053
|
+
transcriptRef.current = text.trim();
|
|
1054
|
+
callbacks.current.onInterim?.(transcriptRef.current);
|
|
1055
|
+
};
|
|
1056
|
+
recognition.onerror = () => {
|
|
1057
|
+
recognition.stop();
|
|
1058
|
+
};
|
|
1059
|
+
recognition.onend = () => {
|
|
1060
|
+
recognitionRef.current = null;
|
|
1061
|
+
setListening(false);
|
|
1062
|
+
if (transcriptRef.current)
|
|
1063
|
+
callbacks.current.onResult(transcriptRef.current);
|
|
1064
|
+
};
|
|
1065
|
+
recognitionRef.current = recognition;
|
|
1066
|
+
setListening(true);
|
|
1067
|
+
recognition.start();
|
|
1068
|
+
}, [language]);
|
|
1069
|
+
useEffect(() => () => recognitionRef.current?.abort(), []);
|
|
1070
|
+
return { supported, listening, start, stop };
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1011
1073
|
function SearchAutocomplete({ onSelect, selectedId, searchFunction, getEntityById, getDisplayValue, getSecondaryText, placeholder, icon = Search, disabled = false, minSearchLength = 2, debounceTime = 300, error, getMinCharactersHint = (min) => `(min. ${min} characters)`, noResultsText = "No results found. Try a different search term.", }) {
|
|
1012
1074
|
const [searchTerm, setSearchTerm] = useState("");
|
|
1013
1075
|
const [entities, setEntities] = useState([]);
|
|
@@ -3395,6 +3457,674 @@ const PinCodeInput = ({ length = 4, onComplete, onChange, masked = true, autoCom
|
|
|
3395
3457
|
}, type: "text", inputMode: "numeric", pattern: "[0-9]*", autoComplete: index === 0 ? autoComplete : "off", autoCorrect: "off", spellCheck: false, value: digit && masked ? MASK : digit, disabled: disabled, "aria-label": digitLabel(index + 1, length), "aria-invalid": invalid || undefined, "data-filled": !!digit, onChange: (event) => handleChange(index, event), onKeyDown: (event) => handleKeyDown(index, event), onPaste: (event) => handlePaste(index, event), onFocus: (event) => event.target.select(), className: cn("h-14 w-12 rounded-lg border bg-white text-center text-2xl font-medium text-ews-gray-900 outline-none", "transition-colors focus:border-ews-primary focus:ring-2 focus:ring-ews-primary", "disabled:cursor-not-allowed disabled:opacity-60", invalid ? "border-ews-error" : digit ? "border-ews-primary" : "border-ews-gray-300", disabled && digit && "animate-pulse") }, index))) }));
|
|
3396
3458
|
};
|
|
3397
3459
|
|
|
3460
|
+
const MAX_MESSAGE_LENGTH = 2000;
|
|
3461
|
+
/**
|
|
3462
|
+
* The composer of the mockup: textarea, hint, push-to-talk mic (hold to
|
|
3463
|
+
* record, release to send) and a send button. Enter sends, Shift+Enter breaks.
|
|
3464
|
+
*/
|
|
3465
|
+
const AssistantComposer = ({ language, onSend, disabled = false, voice = true, texts, className, }) => {
|
|
3466
|
+
const [value, setValue] = useState("");
|
|
3467
|
+
const textareaRef = useRef(null);
|
|
3468
|
+
const submit = useCallback((content) => {
|
|
3469
|
+
const trimmed = content.trim();
|
|
3470
|
+
if (!trimmed || disabled)
|
|
3471
|
+
return;
|
|
3472
|
+
onSend(trimmed.slice(0, MAX_MESSAGE_LENGTH));
|
|
3473
|
+
setValue("");
|
|
3474
|
+
}, [disabled, onSend]);
|
|
3475
|
+
const speech = useSpeechRecognition({
|
|
3476
|
+
language,
|
|
3477
|
+
onInterim: (text) => setValue(text),
|
|
3478
|
+
onResult: (text) => submit(text),
|
|
3479
|
+
});
|
|
3480
|
+
const onKeyDown = (event) => {
|
|
3481
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
3482
|
+
event.preventDefault();
|
|
3483
|
+
submit(value);
|
|
3484
|
+
}
|
|
3485
|
+
};
|
|
3486
|
+
const showMic = voice && speech.supported;
|
|
3487
|
+
// A plain div rather than a <form>: Enter and the send button submit, and a
|
|
3488
|
+
// form followed by a sibling trips React hydration under happy-dom (the apps'
|
|
3489
|
+
// server-rendering tests) although browsers are fine.
|
|
3490
|
+
return (jsxs("div", { className: cn("w-full", className), children: [jsxs("div", { className: cn("flex items-end gap-2 rounded-2xl border border-ews-gray-200 bg-white px-3 py-2 shadow-sm", "focus-within:border-ews-primary focus-within:ring-2 focus-within:ring-ews-primary/20"), children: [jsx("textarea", { ref: textareaRef, value: value, onChange: (event) => setValue(event.target.value), onKeyDown: onKeyDown, rows: 1, maxLength: MAX_MESSAGE_LENGTH, placeholder: speech.listening ? texts.listening : texts.placeholder, "aria-label": texts.placeholder, disabled: disabled, className: "max-h-40 min-h-[2.5rem] flex-1 resize-none bg-transparent py-2 text-base text-ews-gray-900 placeholder:text-ews-gray-400 focus:outline-none disabled:opacity-60" }), showMic && (jsx("button", { type: "button", "aria-label": texts.holdToTalk, "aria-pressed": speech.listening, title: texts.holdToTalk, disabled: disabled, onMouseDown: speech.start, onMouseUp: speech.stop, onMouseLeave: speech.listening ? speech.stop : undefined, onTouchStart: speech.start, onTouchEnd: speech.stop, onKeyDown: (event) => {
|
|
3491
|
+
if (event.key === " " || event.key === "Enter") {
|
|
3492
|
+
event.preventDefault();
|
|
3493
|
+
if (speech.listening)
|
|
3494
|
+
speech.stop();
|
|
3495
|
+
else
|
|
3496
|
+
speech.start();
|
|
3497
|
+
}
|
|
3498
|
+
}, className: cn("rounded-full p-2 transition-colors focus:outline-none focus:ring-2 focus:ring-ews-primary", speech.listening
|
|
3499
|
+
? "bg-ews-error text-white animate-pulse"
|
|
3500
|
+
: "text-ews-gray-500 hover:bg-ews-gray-100 hover:text-ews-primary", "disabled:opacity-50"), "data-testid": "assistant-mic", children: jsx(Mic, { className: "h-5 w-5", "aria-hidden": "true" }) })), jsx("button", { type: "button", onClick: () => submit(value), "aria-label": texts.send, title: texts.send, disabled: disabled || value.trim().length === 0, className: "rounded-full bg-ews-primary p-2 text-white transition-colors hover:bg-ews-primary-hover focus:outline-none focus:ring-2 focus:ring-ews-primary focus:ring-offset-1 disabled:opacity-40", "data-testid": "assistant-send", children: jsx(SendHorizontal, { className: "h-5 w-5 rtl:-scale-x-100", "aria-hidden": "true" }) })] }), jsx("p", { className: "mt-1.5 px-2 text-xs text-ews-gray-400", children: texts.hint })] }));
|
|
3501
|
+
};
|
|
3502
|
+
|
|
3503
|
+
/**
|
|
3504
|
+
* Reserved for phase 2 (MED-1779): the card the assistant shows before a
|
|
3505
|
+
* write (booking, cancellation). Title, details, confirm and cancel wired to
|
|
3506
|
+
* the app, so phase 2 needs no component change.
|
|
3507
|
+
*/
|
|
3508
|
+
const AssistantConfirmationCard = ({ confirmation, onConfirm, onCancel, texts, busy = false, className, }) => (jsxs("section", { "aria-label": confirmation.title, className: cn("rounded-xl border border-ews-primary/30 bg-ews-primary-light/30 p-4", className), "data-testid": "assistant-confirmation", children: [jsxs("h3", { className: "flex items-center gap-2 font-semibold text-ews-gray-900", children: [jsx(ShieldCheck, { className: "h-5 w-5 text-ews-primary", "aria-hidden": "true" }), confirmation.title] }), jsx("dl", { className: "mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm", children: confirmation.details.map((detail) => (jsxs(React__default.Fragment, { children: [jsx("dt", { className: "text-ews-gray-500", children: detail.label }), jsx("dd", { className: "font-medium text-ews-gray-900", children: detail.value })] }, detail.label))) }), jsxs("div", { className: "mt-4 flex gap-2", children: [jsx(Button, { size: "sm", loading: busy, onClick: () => onConfirm(confirmation), children: texts.confirm }), jsx(Button, { size: "sm", variant: "ghost", disabled: busy, onClick: () => onCancel(confirmation), children: texts.cancel })] })] }));
|
|
3509
|
+
|
|
3510
|
+
/** Left column of the mockup: new discussion, recent ones, sign-in prompt at the bottom. */
|
|
3511
|
+
const AssistantConversationList = ({ conversations, activeConversationId, onSelect, onNew, texts, signInHref, className, }) => (jsxs("nav", { "aria-label": texts.recentConversations, className: cn("flex h-full flex-col gap-4 bg-ews-gray-50 p-3", className), children: [jsxs("button", { type: "button", onClick: onNew, className: "flex items-center gap-2 rounded-lg border border-ews-gray-200 bg-white px-3 py-2 text-sm font-medium text-ews-gray-800 hover:border-ews-primary hover:text-ews-primary", children: [jsx(SquarePen, { className: "h-4 w-4", "aria-hidden": "true" }), texts.newConversation] }), conversations.length > 0 && (jsx("ul", { className: "flex-1 space-y-1 overflow-y-auto", "data-testid": "assistant-conversation-list", children: conversations.map((conversation) => {
|
|
3512
|
+
const active = conversation.id === activeConversationId;
|
|
3513
|
+
return (jsx("li", { children: jsxs("button", { type: "button", onClick: () => onSelect?.(conversation.id), "aria-current": active ? "true" : undefined, className: cn("flex w-full items-center gap-2 truncate rounded-md px-2 py-1.5 text-start text-sm", active
|
|
3514
|
+
? "bg-white font-medium text-ews-primary shadow-sm"
|
|
3515
|
+
: "text-ews-gray-700 hover:bg-white"), children: [jsx(MessageSquare, { className: "h-3.5 w-3.5 shrink-0 text-ews-gray-400", "aria-hidden": "true" }), jsx("span", { className: "truncate", children: conversation.title })] }) }, conversation.id));
|
|
3516
|
+
}) })), signInHref && (jsxs("a", { href: signInHref, className: "mt-auto flex items-start gap-2 rounded-lg px-2 py-2 text-sm hover:bg-white", children: [jsx("span", { className: "rounded-full bg-ews-primary-light p-1.5 text-ews-primary", "aria-hidden": "true", children: jsx(UserRound, { className: "h-4 w-4" }) }), jsxs("span", { children: [jsx("span", { className: "block font-medium text-ews-gray-900", children: texts.signIn }), jsx("span", { className: "block text-xs text-ews-gray-500", children: texts.signInHint })] })] }))] }));
|
|
3517
|
+
|
|
3518
|
+
/**
|
|
3519
|
+
* The tiny markdown subset the assistant contract allows: paragraphs, bullet
|
|
3520
|
+
* lists, **bold** and [links](url). Everything else is plain text, so a
|
|
3521
|
+
* payload can never inject markup. Links to the current origin open in the
|
|
3522
|
+
* same tab (the host app may route them itself through `onLinkClick`);
|
|
3523
|
+
* links to other sites open in a new tab.
|
|
3524
|
+
*/
|
|
3525
|
+
const INLINE = /(\*\*[^*]+\*\*|\[[^\]]+\]\((?:https?:\/\/|\/)[^)\s]+\))/g;
|
|
3526
|
+
/** Absolute URL on another origin. Without a window (server rendering) every absolute URL counts as external. */
|
|
3527
|
+
function isExternalLink(href) {
|
|
3528
|
+
if (!/^https?:\/\//i.test(href))
|
|
3529
|
+
return false;
|
|
3530
|
+
if (typeof window === "undefined" || !window.location?.origin)
|
|
3531
|
+
return true;
|
|
3532
|
+
try {
|
|
3533
|
+
return new URL(href).origin !== window.location.origin;
|
|
3534
|
+
}
|
|
3535
|
+
catch {
|
|
3536
|
+
return true;
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
function renderInline(text, keyPrefix, options) {
|
|
3540
|
+
const nodes = [];
|
|
3541
|
+
const isExternal = options.isExternal ?? isExternalLink;
|
|
3542
|
+
let last = 0;
|
|
3543
|
+
let index = 0;
|
|
3544
|
+
for (const match of text.matchAll(INLINE)) {
|
|
3545
|
+
const start = match.index ?? 0;
|
|
3546
|
+
if (start > last)
|
|
3547
|
+
nodes.push(text.slice(last, start));
|
|
3548
|
+
const token = match[0];
|
|
3549
|
+
if (token.startsWith("**")) {
|
|
3550
|
+
nodes.push(jsx("strong", { children: token.slice(2, -2) }, `${keyPrefix}-b${index}`));
|
|
3551
|
+
}
|
|
3552
|
+
else {
|
|
3553
|
+
const close = token.indexOf("](");
|
|
3554
|
+
const label = token.slice(1, close);
|
|
3555
|
+
const href = token.slice(close + 2, -1);
|
|
3556
|
+
const external = isExternal(href);
|
|
3557
|
+
nodes.push(jsx("a", { href: href, className: "font-medium text-ews-primary underline underline-offset-2 hover:text-ews-primary-hover", target: external ? "_blank" : undefined, rel: external ? "noopener noreferrer" : undefined, onClick: options.onLinkClick ? (event) => options.onLinkClick?.(href, event) : undefined, children: label }, `${keyPrefix}-a${index}`));
|
|
3558
|
+
}
|
|
3559
|
+
last = start + token.length;
|
|
3560
|
+
index++;
|
|
3561
|
+
}
|
|
3562
|
+
if (last < text.length)
|
|
3563
|
+
nodes.push(text.slice(last));
|
|
3564
|
+
return nodes;
|
|
3565
|
+
}
|
|
3566
|
+
function renderAssistantMarkdown(content, options = {}) {
|
|
3567
|
+
const blocks = content.replace(/\r\n/g, "\n").split(/\n{2,}/);
|
|
3568
|
+
const out = [];
|
|
3569
|
+
blocks.forEach((block, blockIndex) => {
|
|
3570
|
+
const lines = block.split("\n").filter((line) => line.trim().length > 0);
|
|
3571
|
+
if (lines.length === 0)
|
|
3572
|
+
return;
|
|
3573
|
+
const isList = lines.every((line) => /^\s*[-*•]\s+/.test(line));
|
|
3574
|
+
if (isList) {
|
|
3575
|
+
out.push(jsx("ul", { className: "my-2 list-disc space-y-1 ps-5", children: lines.map((line, lineIndex) => (jsx("li", { children: renderInline(line.replace(/^\s*[-*•]\s+/, ""), `l${blockIndex}-${lineIndex}`, options) }, lineIndex))) }, `l${blockIndex}`));
|
|
3576
|
+
}
|
|
3577
|
+
else {
|
|
3578
|
+
out.push(jsx("p", { className: "my-2 first:mt-0 last:mb-0", children: lines.map((line, lineIndex) => (jsxs(React__default.Fragment, { children: [lineIndex > 0 && jsx("br", {}), renderInline(line, `p${blockIndex}-${lineIndex}`, options)] }, lineIndex))) }, `p${blockIndex}`));
|
|
3579
|
+
}
|
|
3580
|
+
});
|
|
3581
|
+
return out;
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
/** The thread: user bubbles on the end side, assistant answers on the start side, auto-scrolled. */
|
|
3585
|
+
const AssistantMessages = ({ messages, activity, onRetry, texts, linkOptions, className, }) => {
|
|
3586
|
+
const endRef = useRef(null);
|
|
3587
|
+
const last = messages[messages.length - 1];
|
|
3588
|
+
useEffect(() => {
|
|
3589
|
+
endRef.current?.scrollIntoView?.({ block: "end" });
|
|
3590
|
+
}, [messages.length, last?.content, activity]);
|
|
3591
|
+
return (jsxs("div", { className: cn("flex flex-col gap-4", className), role: "log", "aria-live": "polite", "aria-relevant": "additions text", "data-testid": "assistant-messages", children: [messages.map((message) => {
|
|
3592
|
+
const isUser = message.role === "user";
|
|
3593
|
+
return (jsx("div", { className: cn("flex", isUser ? "justify-end" : "justify-start"), "data-testid": `assistant-message-${message.role}`, children: jsx("div", { className: cn("max-w-[85%] rounded-2xl px-4 py-3 text-base leading-relaxed", isUser
|
|
3594
|
+
? "rounded-ee-md bg-ews-primary text-white"
|
|
3595
|
+
: "rounded-es-md bg-ews-gray-100 text-ews-gray-900"), children: isUser ? (jsx("p", { className: "whitespace-pre-wrap", children: message.content })) : (jsxs(Fragment, { children: [message.content.length > 0 && renderAssistantMarkdown(message.content, linkOptions), message.status === "streaming" && message.content.length === 0 && (jsxs("span", { className: "flex items-center gap-2 text-ews-gray-500", children: [jsx(Loader2, { className: "h-4 w-4 animate-spin", "aria-hidden": "true" }), texts.thinking] })), message.status === "streaming" && activity && (jsxs("p", { className: "mt-2 flex items-center gap-2 text-sm text-ews-gray-500", "data-testid": "assistant-activity", children: [jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin", "aria-hidden": "true" }), activity] })), message.citations && message.citations.length > 0 && (jsxs("p", { className: "mt-3 border-t border-ews-gray-200 pt-2 text-xs text-ews-gray-500", children: [jsxs("span", { className: "font-medium", children: [texts.sources, ": "] }), message.citations.map((citation, index) => (jsxs(React__default.Fragment, { children: [index > 0 && ", ", jsx("a", { href: citation.url, className: "underline underline-offset-2 hover:text-ews-primary", target: "_blank", rel: "noopener noreferrer", children: citation.title })] }, `${citation.url}-${index}`)))] })), message.status === "error" && message.error && (jsxs("div", { role: "alert", className: "mt-2 flex flex-wrap items-center gap-2 text-sm text-ews-error", "data-testid": "assistant-error", children: [jsx(AlertCircle, { className: "h-4 w-4 shrink-0", "aria-hidden": "true" }), jsx("span", { children: message.error.message }), onRetry && (jsxs("button", { type: "button", onClick: () => onRetry(message), className: "inline-flex items-center gap-1 rounded-md border border-ews-error/40 px-2 py-0.5 font-medium hover:bg-ews-error/10", children: [jsx(RotateCcw, { className: "h-3.5 w-3.5", "aria-hidden": "true" }), texts.retry] }))] }))] })) }) }, message.id));
|
|
3596
|
+
}), jsx("div", { ref: endRef })] }));
|
|
3597
|
+
};
|
|
3598
|
+
|
|
3599
|
+
/** One of the cards under the hero (mockup med1): title, description, sent as a prompt on click. */
|
|
3600
|
+
const AssistantSuggestionCard = ({ suggestion, onSelect, exampleLabel, disabled = false, className, }) => {
|
|
3601
|
+
const isExample = suggestion.kind === "example";
|
|
3602
|
+
return (jsxs("button", { type: "button", disabled: disabled, onClick: () => onSelect(suggestion), className: cn("flex h-full w-full flex-col items-start gap-2 rounded-xl border border-ews-gray-200 bg-white p-4 text-start shadow-sm transition-colors", "hover:border-ews-primary hover:bg-ews-gray-50 focus:outline-none focus:ring-2 focus:ring-ews-primary focus:ring-offset-1", "disabled:cursor-not-allowed disabled:opacity-60", className), "data-testid": `assistant-suggestion-${suggestion.id}`, children: [jsx("span", { className: "text-ews-gray-400", "aria-hidden": "true", children: suggestion.icon ?? (isExample ? jsx(Sparkles, { className: "h-4 w-4" }) : jsx(ArrowRight, { className: "h-4 w-4" })) }), jsx("span", { className: "font-semibold text-ews-gray-900", children: suggestion.title }), suggestion.description && (jsx("span", { className: "text-sm text-ews-gray-600", children: suggestion.description })), isExample && (jsx("span", { className: "mt-auto text-xs uppercase tracking-wide text-ews-gray-400", children: exampleLabel }))] }));
|
|
3603
|
+
};
|
|
3604
|
+
|
|
3605
|
+
const DIRECTION$1 = {
|
|
3606
|
+
fr: "ltr",
|
|
3607
|
+
en: "ltr",
|
|
3608
|
+
ar: "rtl",
|
|
3609
|
+
};
|
|
3610
|
+
/** A conversation title is its first message, cut like the mockup's sidebar. */
|
|
3611
|
+
function conversationTitle(firstMessage, max = 60) {
|
|
3612
|
+
const oneLine = firstMessage.replace(/\s+/g, " ").trim();
|
|
3613
|
+
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
|
|
3614
|
+
}
|
|
3615
|
+
/**
|
|
3616
|
+
* The chat home of the Medecine360 assistant (MED-1783, mockup med1):
|
|
3617
|
+
* conversation list, header with brand badge, hero with suggestion cards,
|
|
3618
|
+
* streamed thread, composer with push-to-talk. UI only: the app provides the
|
|
3619
|
+
* transport, the texts, the conversation memory and the links.
|
|
3620
|
+
*
|
|
3621
|
+
* Server-rendering safe: nothing touches `window` at render time, the
|
|
3622
|
+
* speech API is detected after mount.
|
|
3623
|
+
*/
|
|
3624
|
+
const AssistantChat = ({ transport, language, texts, suggestions, conversations = [], activeConversationId = null, initialMessages, onConversationCreated, onSelectConversation, onNewConversation, signInHref, headerAction, disclaimer, heroBackground, voice = true, pendingConfirmation = null, onConfirm, onCancelConfirmation, layout = "page", queuedPrompt = null, onNavigate, historyOpen = false, onHistoryOpenChange, className, }) => {
|
|
3625
|
+
const [messages, setMessages] = useState(initialMessages ?? []);
|
|
3626
|
+
const [activity, setActivity] = useState(null);
|
|
3627
|
+
const [busy, setBusy] = useState(false);
|
|
3628
|
+
const conversationIdRef = useRef(activeConversationId);
|
|
3629
|
+
const abortRef = useRef(null);
|
|
3630
|
+
// The app switched conversation: show its messages and talk to it from now on.
|
|
3631
|
+
// Not when the app merely echoes the id this component just created (send()
|
|
3632
|
+
// already set the ref): the thread in progress must stay on screen.
|
|
3633
|
+
useEffect(() => {
|
|
3634
|
+
if (conversationIdRef.current === activeConversationId)
|
|
3635
|
+
return;
|
|
3636
|
+
conversationIdRef.current = activeConversationId;
|
|
3637
|
+
setMessages(initialMessages ?? []);
|
|
3638
|
+
setActivity(null);
|
|
3639
|
+
}, [activeConversationId, initialMessages]);
|
|
3640
|
+
useEffect(() => () => abortRef.current?.abort(), []);
|
|
3641
|
+
const patchMessage = useCallback((id, patch) => {
|
|
3642
|
+
setMessages((current) => current.map((message) => (message.id === id ? patch(message) : message)));
|
|
3643
|
+
}, []);
|
|
3644
|
+
const applyEvent = useCallback((assistantId, event) => {
|
|
3645
|
+
switch (event.type) {
|
|
3646
|
+
case "message_start":
|
|
3647
|
+
patchMessage(assistantId, (message) => ({ ...message, id: event.messageId || message.id }));
|
|
3648
|
+
return event.messageId || assistantId;
|
|
3649
|
+
case "text_delta":
|
|
3650
|
+
patchMessage(assistantId, (message) => ({ ...message, content: message.content + event.text }));
|
|
3651
|
+
return assistantId;
|
|
3652
|
+
case "tool_call":
|
|
3653
|
+
setActivity(event.status === "started" ? event.label : null);
|
|
3654
|
+
return assistantId;
|
|
3655
|
+
case "citation":
|
|
3656
|
+
patchMessage(assistantId, (message) => ({
|
|
3657
|
+
...message,
|
|
3658
|
+
citations: [...(message.citations ?? []), { title: event.title, url: event.url }],
|
|
3659
|
+
}));
|
|
3660
|
+
return assistantId;
|
|
3661
|
+
case "message_end":
|
|
3662
|
+
setActivity(null);
|
|
3663
|
+
patchMessage(assistantId, (message) => ({ ...message, status: "done" }));
|
|
3664
|
+
return assistantId;
|
|
3665
|
+
case "error":
|
|
3666
|
+
setActivity(null);
|
|
3667
|
+
patchMessage(assistantId, (message) => ({
|
|
3668
|
+
...message,
|
|
3669
|
+
status: "error",
|
|
3670
|
+
error: {
|
|
3671
|
+
message: event.code === "RATE_LIMITED" && event.retryAfterSeconds
|
|
3672
|
+
? texts.rateLimited(event.retryAfterSeconds)
|
|
3673
|
+
: event.message || texts.genericError,
|
|
3674
|
+
retryAfterSeconds: event.retryAfterSeconds,
|
|
3675
|
+
},
|
|
3676
|
+
}));
|
|
3677
|
+
return assistantId;
|
|
3678
|
+
default:
|
|
3679
|
+
return assistantId;
|
|
3680
|
+
}
|
|
3681
|
+
}, [patchMessage, texts]);
|
|
3682
|
+
const send = useCallback(async (content) => {
|
|
3683
|
+
if (busy)
|
|
3684
|
+
return false;
|
|
3685
|
+
setBusy(true);
|
|
3686
|
+
const userMessage = { id: generateId("u"), role: "user", content };
|
|
3687
|
+
let assistantId = generateId("a");
|
|
3688
|
+
setMessages((current) => [
|
|
3689
|
+
...current,
|
|
3690
|
+
userMessage,
|
|
3691
|
+
{ id: assistantId, role: "assistant", content: "", status: "streaming" },
|
|
3692
|
+
]);
|
|
3693
|
+
const controller = new AbortController();
|
|
3694
|
+
abortRef.current = controller;
|
|
3695
|
+
try {
|
|
3696
|
+
if (!conversationIdRef.current) {
|
|
3697
|
+
const created = await transport.startConversation(language);
|
|
3698
|
+
conversationIdRef.current = created.id;
|
|
3699
|
+
const summary = {
|
|
3700
|
+
id: created.id,
|
|
3701
|
+
title: conversationTitle(content),
|
|
3702
|
+
createdAt: new Date().toISOString(),
|
|
3703
|
+
};
|
|
3704
|
+
onConversationCreated?.(summary);
|
|
3705
|
+
}
|
|
3706
|
+
for await (const event of transport.sendMessage(conversationIdRef.current, content, {
|
|
3707
|
+
signal: controller.signal,
|
|
3708
|
+
})) {
|
|
3709
|
+
assistantId = applyEvent(assistantId, event);
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
catch (error) {
|
|
3713
|
+
setActivity(null);
|
|
3714
|
+
patchMessage(assistantId, (message) => ({
|
|
3715
|
+
...message,
|
|
3716
|
+
status: "error",
|
|
3717
|
+
error: { message: error instanceof Error && error.message ? error.message : texts.genericError },
|
|
3718
|
+
}));
|
|
3719
|
+
}
|
|
3720
|
+
finally {
|
|
3721
|
+
setBusy(false);
|
|
3722
|
+
abortRef.current = null;
|
|
3723
|
+
}
|
|
3724
|
+
return true;
|
|
3725
|
+
}, [applyEvent, busy, language, onConversationCreated, patchMessage, texts.genericError, transport]);
|
|
3726
|
+
// A prompt queued from outside (the launcher bar) is sent once, as soon as
|
|
3727
|
+
// the component is free.
|
|
3728
|
+
const handledPromptRef = useRef(null);
|
|
3729
|
+
useEffect(() => {
|
|
3730
|
+
if (!queuedPrompt || queuedPrompt.id === handledPromptRef.current || busy)
|
|
3731
|
+
return;
|
|
3732
|
+
handledPromptRef.current = queuedPrompt.id;
|
|
3733
|
+
void send(queuedPrompt.content);
|
|
3734
|
+
}, [busy, queuedPrompt, send]);
|
|
3735
|
+
/** Retry re-sends the user message that preceded the failed answer. */
|
|
3736
|
+
const retry = useCallback((failed) => {
|
|
3737
|
+
const index = messages.findIndex((message) => message.id === failed.id);
|
|
3738
|
+
const previous = index > 0 ? messages[index - 1] : undefined;
|
|
3739
|
+
if (!previous || previous.role !== "user")
|
|
3740
|
+
return;
|
|
3741
|
+
setMessages((current) => current.filter((message) => message.id !== failed.id && message.id !== previous.id));
|
|
3742
|
+
void send(previous.content);
|
|
3743
|
+
}, [messages, send]);
|
|
3744
|
+
const startNewConversation = useCallback(() => {
|
|
3745
|
+
abortRef.current?.abort();
|
|
3746
|
+
conversationIdRef.current = null;
|
|
3747
|
+
setMessages([]);
|
|
3748
|
+
setActivity(null);
|
|
3749
|
+
onNewConversation?.();
|
|
3750
|
+
}, [onNewConversation]);
|
|
3751
|
+
const empty = messages.length === 0;
|
|
3752
|
+
const dir = DIRECTION$1[language];
|
|
3753
|
+
// panel: one compact column; wide: the slide-over expanded, list beside the thread.
|
|
3754
|
+
const compact = layout === "panel";
|
|
3755
|
+
const withList = layout !== "panel";
|
|
3756
|
+
const historyLabel = texts.history ?? texts.recentConversations;
|
|
3757
|
+
return (jsxs("div", { dir: dir, lang: language, className: cn("overflow-hidden bg-white", layout === "page" && "grid min-h-[36rem] grid-cols-1 rounded-2xl border border-ews-gray-200 shadow-sm md:grid-cols-[16rem_1fr]", layout === "wide" && "grid h-full min-h-0 grid-cols-1 md:grid-cols-[16rem_1fr]", compact && "relative flex h-full min-h-0 flex-col", className), "data-layout": layout, "data-testid": "assistant-chat", children: [withList && (jsx(AssistantConversationList, { conversations: conversations, activeConversationId: activeConversationId, onSelect: onSelectConversation, onNew: startNewConversation, texts: texts, signInHref: signInHref, className: "hidden border-e border-ews-gray-200 md:flex" })), compact && historyOpen && (jsxs("div", { className: "absolute inset-0 z-10 flex", "data-testid": "assistant-history", children: [jsx(AssistantConversationList, { conversations: conversations, activeConversationId: activeConversationId, onSelect: (id) => {
|
|
3758
|
+
onSelectConversation?.(id);
|
|
3759
|
+
onHistoryOpenChange?.(false);
|
|
3760
|
+
}, onNew: () => {
|
|
3761
|
+
startNewConversation();
|
|
3762
|
+
onHistoryOpenChange?.(false);
|
|
3763
|
+
}, texts: texts, signInHref: signInHref, className: "w-72 max-w-full border-e border-ews-gray-200 shadow-xl" }), jsx("div", { className: "flex-1 bg-black/20", onClick: () => onHistoryOpenChange?.(false), "data-testid": "assistant-history-overlay" })] })), jsxs("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", children: [jsxs("header", { className: "flex items-center justify-between gap-3 border-b border-ews-gray-200 px-4 py-3", children: [jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [jsx("span", { className: "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-ews-primary text-sm font-bold text-white", "aria-hidden": "true", children: texts.brandName.charAt(0) }), jsx("span", { className: "truncate font-semibold text-ews-gray-900", children: texts.brandName }), jsx("span", { className: "rounded-full bg-ews-gray-100 px-2 py-0.5 text-xs font-medium text-ews-gray-600", children: "AI" }), jsxs("span", { className: "flex items-center gap-1 text-xs text-ews-gray-500", children: [jsx("span", { className: "h-2 w-2 rounded-full bg-ews-success", "aria-hidden": "true" }), texts.online] })] }), jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [compact && onHistoryOpenChange && (jsx("button", { type: "button", onClick: () => onHistoryOpenChange(!historyOpen), "aria-label": historyLabel, "aria-pressed": historyOpen, title: historyLabel, className: cn("rounded-full p-2 hover:bg-ews-gray-100 hover:text-ews-primary focus:outline-none focus:ring-2 focus:ring-ews-primary", historyOpen ? "bg-ews-gray-100 text-ews-primary" : "text-ews-gray-500"), "data-testid": "assistant-history-toggle", children: jsx(History, { className: "h-4 w-4", "aria-hidden": "true" }) })), !withList && !empty && (jsx("button", { type: "button", onClick: startNewConversation, "aria-label": texts.newConversation, title: texts.newConversation, className: "rounded-full p-2 text-ews-gray-500 hover:bg-ews-gray-100 hover:text-ews-primary focus:outline-none focus:ring-2 focus:ring-ews-primary", "data-testid": "assistant-new-conversation", children: jsx(SquarePen, { className: "h-4 w-4", "aria-hidden": "true" }) })), headerAction] })] }), jsx("div", { className: "flex flex-1 flex-col overflow-y-auto", children: empty ? (jsxs(Fragment, { children: [jsxs("section", { className: cn("relative overflow-hidden bg-ews-primary-light text-center", compact ? "px-4 py-6" : "px-6 py-10"), children: [heroBackground && jsx("div", { className: "absolute inset-0", "aria-hidden": "true", children: heroBackground }), jsxs("div", { className: "relative", children: [jsx("h2", { className: cn("font-bold text-ews-gray-900", compact ? "text-xl" : "text-2xl sm:text-3xl"), children: texts.heroTitle }), jsx("p", { className: cn("mx-auto mt-2 max-w-xl text-ews-gray-700", compact && "text-sm"), children: texts.heroSubtitle })] })] }), jsx("div", { className: cn("grid gap-3 px-4 py-5", compact ? "grid-cols-1" : "sm:grid-cols-2 lg:grid-cols-4"), "data-testid": "assistant-suggestions", children: suggestions.map((suggestion) => (jsx(AssistantSuggestionCard, { suggestion: suggestion, onSelect: (selected) => void send(selected.prompt), exampleLabel: texts.exampleLabel, disabled: busy }, suggestion.id))) })] })) : (jsxs("div", { className: "flex-1 px-4 py-5", children: [jsx(AssistantMessages, { messages: messages, activity: activity, onRetry: retry, texts: texts, linkOptions: onNavigate
|
|
3764
|
+
? {
|
|
3765
|
+
onLinkClick: (href, event) => {
|
|
3766
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey)
|
|
3767
|
+
return;
|
|
3768
|
+
if (onNavigate(href) === true)
|
|
3769
|
+
event.preventDefault();
|
|
3770
|
+
},
|
|
3771
|
+
}
|
|
3772
|
+
: undefined }), pendingConfirmation && onConfirm && onCancelConfirmation && (jsx(AssistantConfirmationCard, { confirmation: pendingConfirmation, onConfirm: onConfirm, onCancel: onCancelConfirmation, texts: texts, className: "mt-4" }))] })) }), jsxs("div", { className: "border-t border-ews-gray-200 px-4 pb-3 pt-3", children: [jsx(AssistantComposer, { language: language, onSend: (content) => void send(content), disabled: busy, voice: voice, texts: {
|
|
3773
|
+
placeholder: texts.composerPlaceholder,
|
|
3774
|
+
hint: texts.composerHint,
|
|
3775
|
+
send: texts.send,
|
|
3776
|
+
holdToTalk: texts.holdToTalk,
|
|
3777
|
+
listening: texts.listening,
|
|
3778
|
+
} }), disclaimer && jsx("div", { className: "mt-2 px-2 text-xs text-ews-gray-500", children: disclaimer })] })] })] }));
|
|
3779
|
+
};
|
|
3780
|
+
|
|
3781
|
+
/**
|
|
3782
|
+
* Anonymous memory of the assistant, in the browser only (MED-1784, shared by
|
|
3783
|
+
* every app since MED-1787): a stable client id and the conversations started
|
|
3784
|
+
* here, newest first. Nothing is sent to a server to list them; signing in
|
|
3785
|
+
* (phase 2) will attach them to the account.
|
|
3786
|
+
*/
|
|
3787
|
+
const ASSISTANT_CLIENT_ID_KEY = "assistant.clientId";
|
|
3788
|
+
const ASSISTANT_CONVERSATIONS_KEY = "assistant.conversations";
|
|
3789
|
+
const ASSISTANT_MAX_CONVERSATIONS = 20;
|
|
3790
|
+
function storage() {
|
|
3791
|
+
try {
|
|
3792
|
+
return typeof window !== "undefined" ? window.localStorage : null;
|
|
3793
|
+
}
|
|
3794
|
+
catch {
|
|
3795
|
+
return null;
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
function randomId() {
|
|
3799
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
3800
|
+
return crypto.randomUUID();
|
|
3801
|
+
}
|
|
3802
|
+
return `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
3803
|
+
}
|
|
3804
|
+
/** The browser's client id, created on first use. A fresh id per page when storage is unavailable. */
|
|
3805
|
+
function getAssistantClientId(store = storage()) {
|
|
3806
|
+
const existing = store?.getItem(ASSISTANT_CLIENT_ID_KEY);
|
|
3807
|
+
if (existing)
|
|
3808
|
+
return existing;
|
|
3809
|
+
const id = randomId();
|
|
3810
|
+
try {
|
|
3811
|
+
store?.setItem(ASSISTANT_CLIENT_ID_KEY, id);
|
|
3812
|
+
}
|
|
3813
|
+
catch {
|
|
3814
|
+
// private mode: the id lasts for this page only
|
|
3815
|
+
}
|
|
3816
|
+
return id;
|
|
3817
|
+
}
|
|
3818
|
+
function listAssistantConversations(store = storage()) {
|
|
3819
|
+
try {
|
|
3820
|
+
const raw = store?.getItem(ASSISTANT_CONVERSATIONS_KEY);
|
|
3821
|
+
if (!raw)
|
|
3822
|
+
return [];
|
|
3823
|
+
const parsed = JSON.parse(raw);
|
|
3824
|
+
if (!Array.isArray(parsed))
|
|
3825
|
+
return [];
|
|
3826
|
+
return parsed.filter((entry) => typeof entry === "object" &&
|
|
3827
|
+
entry !== null &&
|
|
3828
|
+
typeof entry.id === "string");
|
|
3829
|
+
}
|
|
3830
|
+
catch {
|
|
3831
|
+
return [];
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
/** Adds or refreshes a conversation at the top of the list, capped at ASSISTANT_MAX_CONVERSATIONS. */
|
|
3835
|
+
function saveAssistantConversation(conversation, store = storage()) {
|
|
3836
|
+
const next = [
|
|
3837
|
+
conversation,
|
|
3838
|
+
...listAssistantConversations(store).filter((entry) => entry.id !== conversation.id),
|
|
3839
|
+
].slice(0, ASSISTANT_MAX_CONVERSATIONS);
|
|
3840
|
+
try {
|
|
3841
|
+
store?.setItem(ASSISTANT_CONVERSATIONS_KEY, JSON.stringify(next));
|
|
3842
|
+
}
|
|
3843
|
+
catch {
|
|
3844
|
+
// private mode: the list lasts for this page only
|
|
3845
|
+
}
|
|
3846
|
+
return next;
|
|
3847
|
+
}
|
|
3848
|
+
|
|
3849
|
+
const DIRECTION = {
|
|
3850
|
+
fr: "ltr",
|
|
3851
|
+
en: "ltr",
|
|
3852
|
+
ar: "rtl",
|
|
3853
|
+
};
|
|
3854
|
+
/**
|
|
3855
|
+
* MED-1787: the in-app entry to the assistant for Medecine360-ui and the
|
|
3856
|
+
* dashboard. A floating bar at the bottom of the page (brand badge, a text
|
|
3857
|
+
* field "Posez une question…", push-to-talk mic, voice button, send) opens a
|
|
3858
|
+
* slide-over panel hosting AssistantChat in its `panel` layout; clicking the
|
|
3859
|
+
* field opens the panel too, a spoken question is sent as it opens. From
|
|
3860
|
+
* tablets up the panel can be expanded to a dashboard reaching the middle of
|
|
3861
|
+
* the screen, with the conversation list beside the thread; in the narrow
|
|
3862
|
+
* layout a history button shows that list over the thread.
|
|
3863
|
+
*
|
|
3864
|
+
* Conversation history is managed here with the shared browser store unless
|
|
3865
|
+
* the host passes `conversations` itself: new conversations are remembered,
|
|
3866
|
+
* selecting one loads it through `transport.getConversation`.
|
|
3867
|
+
*
|
|
3868
|
+
* The chat stays mounted while the panel is closed, so the conversation in
|
|
3869
|
+
* progress survives open/close. Escape and the overlay close the panel; focus
|
|
3870
|
+
* goes to the composer on open and back to the bar on close. In Arabic the
|
|
3871
|
+
* panel slides from the left (`end-0` follows `dir`).
|
|
3872
|
+
*/
|
|
3873
|
+
const AssistantLauncher = ({ launcherTexts, open, defaultOpen = false, onOpenChange, expanded, defaultExpanded = false, onExpandedChange, bottomOffset = 16, hidden = false, className, panelClassName, headerAction, voice = true, conversations: hostConversations, activeConversationId: hostActiveId, initialMessages: hostInitialMessages, onConversationCreated, onSelectConversation, onNewConversation, ...chatProps }) => {
|
|
3874
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
3875
|
+
const isOpen = open ?? uncontrolledOpen;
|
|
3876
|
+
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(defaultExpanded);
|
|
3877
|
+
const isExpanded = expanded ?? uncontrolledExpanded;
|
|
3878
|
+
const [historyOpen, setHistoryOpen] = useState(false);
|
|
3879
|
+
const [draft, setDraft] = useState("");
|
|
3880
|
+
const [queuedPrompt, setQueuedPrompt] = useState(null);
|
|
3881
|
+
const panelId = useId();
|
|
3882
|
+
const barRef = useRef(null);
|
|
3883
|
+
const panelRef = useRef(null);
|
|
3884
|
+
const wasOpenRef = useRef(isOpen);
|
|
3885
|
+
// Managed history (browser store) unless the host provides the list.
|
|
3886
|
+
const hostManaged = hostConversations !== undefined;
|
|
3887
|
+
const [storedConversations, setStoredConversations] = useState([]);
|
|
3888
|
+
const [managedActiveId, setManagedActiveId] = useState(null);
|
|
3889
|
+
const [managedMessages, setManagedMessages] = useState(undefined);
|
|
3890
|
+
useEffect(() => {
|
|
3891
|
+
if (!hostManaged)
|
|
3892
|
+
setStoredConversations(listAssistantConversations());
|
|
3893
|
+
}, [hostManaged]);
|
|
3894
|
+
const setOpen = useCallback((next) => {
|
|
3895
|
+
if (open === undefined)
|
|
3896
|
+
setUncontrolledOpen(next);
|
|
3897
|
+
onOpenChange?.(next);
|
|
3898
|
+
}, [onOpenChange, open]);
|
|
3899
|
+
const setExpanded = useCallback((next) => {
|
|
3900
|
+
if (expanded === undefined)
|
|
3901
|
+
setUncontrolledExpanded(next);
|
|
3902
|
+
onExpandedChange?.(next);
|
|
3903
|
+
if (next)
|
|
3904
|
+
setHistoryOpen(false);
|
|
3905
|
+
}, [expanded, onExpandedChange]);
|
|
3906
|
+
/** Opens the panel; a non-empty text is sent as the first message. */
|
|
3907
|
+
const submitFromBar = useCallback((content) => {
|
|
3908
|
+
const trimmed = content.trim();
|
|
3909
|
+
if (trimmed) {
|
|
3910
|
+
setQueuedPrompt({ id: generateId("q"), content: trimmed.slice(0, MAX_MESSAGE_LENGTH) });
|
|
3911
|
+
}
|
|
3912
|
+
setDraft("");
|
|
3913
|
+
setOpen(true);
|
|
3914
|
+
}, [setOpen]);
|
|
3915
|
+
const speech = useSpeechRecognition({
|
|
3916
|
+
language: chatProps.language,
|
|
3917
|
+
onInterim: (text) => setDraft(text),
|
|
3918
|
+
onResult: (text) => submitFromBar(text),
|
|
3919
|
+
});
|
|
3920
|
+
const handleConversationCreated = useCallback((conversation) => {
|
|
3921
|
+
if (!hostManaged) {
|
|
3922
|
+
setStoredConversations(saveAssistantConversation(conversation));
|
|
3923
|
+
setManagedActiveId(conversation.id);
|
|
3924
|
+
}
|
|
3925
|
+
onConversationCreated?.(conversation);
|
|
3926
|
+
}, [hostManaged, onConversationCreated]);
|
|
3927
|
+
const handleSelectConversation = useCallback((conversationId) => {
|
|
3928
|
+
onSelectConversation?.(conversationId);
|
|
3929
|
+
if (hostManaged || conversationId === managedActiveId)
|
|
3930
|
+
return;
|
|
3931
|
+
const load = async () => {
|
|
3932
|
+
try {
|
|
3933
|
+
const loaded = await chatProps.transport.getConversation?.(conversationId);
|
|
3934
|
+
setManagedMessages(loaded?.messages ?? []);
|
|
3935
|
+
}
|
|
3936
|
+
catch {
|
|
3937
|
+
setManagedMessages([]);
|
|
3938
|
+
}
|
|
3939
|
+
setManagedActiveId(conversationId);
|
|
3940
|
+
};
|
|
3941
|
+
void load();
|
|
3942
|
+
}, [chatProps.transport, hostManaged, managedActiveId, onSelectConversation]);
|
|
3943
|
+
const handleNewConversation = useCallback(() => {
|
|
3944
|
+
if (!hostManaged) {
|
|
3945
|
+
setManagedActiveId(null);
|
|
3946
|
+
setManagedMessages(undefined);
|
|
3947
|
+
}
|
|
3948
|
+
onNewConversation?.();
|
|
3949
|
+
}, [hostManaged, onNewConversation]);
|
|
3950
|
+
// Escape closes; the page behind does not scroll while the panel is open.
|
|
3951
|
+
useEffect(() => {
|
|
3952
|
+
if (!isOpen || hidden)
|
|
3953
|
+
return;
|
|
3954
|
+
const onKeyDown = (event) => {
|
|
3955
|
+
if (event.key === "Escape")
|
|
3956
|
+
setOpen(false);
|
|
3957
|
+
};
|
|
3958
|
+
document.addEventListener("keydown", onKeyDown);
|
|
3959
|
+
const previousOverflow = document.body.style.overflow;
|
|
3960
|
+
document.body.style.overflow = "hidden";
|
|
3961
|
+
return () => {
|
|
3962
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
3963
|
+
document.body.style.overflow = previousOverflow;
|
|
3964
|
+
};
|
|
3965
|
+
}, [hidden, isOpen, setOpen]);
|
|
3966
|
+
// Focus: into the composer on open, back to the bar on close.
|
|
3967
|
+
useEffect(() => {
|
|
3968
|
+
if (isOpen) {
|
|
3969
|
+
panelRef.current?.querySelector("textarea")?.focus();
|
|
3970
|
+
}
|
|
3971
|
+
else if (wasOpenRef.current) {
|
|
3972
|
+
barRef.current?.focus();
|
|
3973
|
+
}
|
|
3974
|
+
wasOpenRef.current = isOpen;
|
|
3975
|
+
}, [isOpen]);
|
|
3976
|
+
if (hidden)
|
|
3977
|
+
return null;
|
|
3978
|
+
const dir = DIRECTION[chatProps.language];
|
|
3979
|
+
const showMic = voice && speech.supported;
|
|
3980
|
+
const expandLabel = launcherTexts.expand ?? launcherTexts.open;
|
|
3981
|
+
const collapseLabel = launcherTexts.collapse ?? launcherTexts.close;
|
|
3982
|
+
const texts = launcherTexts.history ? { ...chatProps.texts, history: launcherTexts.history } : chatProps.texts;
|
|
3983
|
+
return (jsxs(Fragment, { children: [!isOpen && (jsx("div", { dir: dir, className: "pointer-events-none fixed inset-x-0 z-40 flex justify-center px-4", style: { bottom: bottomOffset }, "data-testid": "assistant-launcher-bar", children: jsxs("div", { role: "group", "aria-label": launcherTexts.open, className: cn("pointer-events-auto flex w-full max-w-2xl items-center gap-2 rounded-2xl border border-ews-gray-100 bg-white py-2 pe-2 ps-3", "shadow-[0_8px_30px_rgba(15,23,42,0.14)]", className), children: [jsx("button", { ref: barRef, type: "button", onClick: () => setOpen(true), "aria-label": launcherTexts.open, "aria-haspopup": "dialog", "aria-expanded": false, "aria-controls": panelId, className: "flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-ews-primary to-ews-primary-hover text-base font-bold text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-ews-primary/40", "data-testid": "assistant-launcher", children: chatProps.texts.brandName.charAt(0) }), jsx("input", { type: "text", readOnly: true, value: draft, onMouseDown: (event) => {
|
|
3984
|
+
event.preventDefault();
|
|
3985
|
+
setOpen(true);
|
|
3986
|
+
}, onFocus: () => setOpen(true), onKeyDown: (event) => {
|
|
3987
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
3988
|
+
event.preventDefault();
|
|
3989
|
+
setOpen(true);
|
|
3990
|
+
}
|
|
3991
|
+
}, placeholder: speech.listening ? chatProps.texts.listening : launcherTexts.open, "aria-label": launcherTexts.open, "aria-haspopup": "dialog", "aria-controls": panelId, className: "min-w-0 flex-1 cursor-text bg-transparent py-2 text-lg text-ews-gray-800 placeholder:text-ews-gray-400 focus:outline-none", "data-testid": "assistant-launcher-input" }), showMic && (jsx("button", { type: "button", "aria-label": chatProps.texts.holdToTalk, "aria-pressed": speech.listening, title: chatProps.texts.holdToTalk, onMouseDown: speech.start, onMouseUp: speech.stop, onMouseLeave: speech.listening ? speech.stop : undefined, onTouchStart: speech.start, onTouchEnd: speech.stop, onKeyDown: (event) => {
|
|
3992
|
+
if (event.key === " " || event.key === "Enter") {
|
|
3993
|
+
event.preventDefault();
|
|
3994
|
+
if (speech.listening)
|
|
3995
|
+
speech.stop();
|
|
3996
|
+
else
|
|
3997
|
+
speech.start();
|
|
3998
|
+
}
|
|
3999
|
+
}, className: cn("flex h-10 w-10 shrink-0 items-center justify-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-ews-primary", speech.listening
|
|
4000
|
+
? "bg-ews-error text-white animate-pulse"
|
|
4001
|
+
: "text-ews-gray-600 hover:bg-ews-gray-100 hover:text-ews-primary"), "data-testid": "assistant-launcher-mic", children: jsx(Mic, { className: "h-5 w-5", "aria-hidden": "true" }) })), jsx("button", { type: "button", onClick: () => setOpen(true), "aria-label": launcherTexts.voice ?? launcherTexts.open, title: launcherTexts.voice ?? launcherTexts.open, className: "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-ews-gray-100 text-ews-gray-700 transition-colors hover:bg-ews-gray-200 focus:outline-none focus:ring-2 focus:ring-ews-primary", "data-testid": "assistant-launcher-voice", children: jsx(AudioLines, { className: "h-5 w-5", "aria-hidden": "true" }) }), jsx("button", { type: "button", onClick: () => submitFromBar(draft), "aria-label": chatProps.texts.send, title: chatProps.texts.send, className: "flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-ews-primary text-white shadow-sm transition-colors hover:bg-ews-primary-hover focus:outline-none focus:ring-2 focus:ring-ews-primary focus:ring-offset-1", "data-testid": "assistant-launcher-send", children: jsx(ArrowUp, { className: "h-5 w-5", "aria-hidden": "true" }) })] }) })), jsxs("div", { dir: dir, className: cn("fixed inset-0 z-[90]", !isOpen && "hidden"), "aria-hidden": !isOpen, "data-testid": "assistant-launcher-panel", children: [jsx("div", { className: "absolute inset-0 bg-black/40", onClick: () => setOpen(false), "data-testid": "assistant-launcher-overlay" }), jsx("section", { ref: panelRef, id: panelId, role: "dialog", "aria-modal": "true", "aria-label": launcherTexts.open, "data-expanded": isExpanded, className: cn("absolute inset-y-0 end-0 flex w-full flex-col bg-white shadow-2xl transition-[width,max-width] duration-200",
|
|
4002
|
+
// Expanded: a dashboard reaching the middle of the screen (never full width), tablets and up.
|
|
4003
|
+
isExpanded ? "sm:max-w-md md:w-[max(50vw,40rem)] md:max-w-none" : "sm:max-w-md", panelClassName), children: jsx(AssistantChat, { ...chatProps, texts: texts, voice: voice, layout: isExpanded ? "wide" : "panel", queuedPrompt: queuedPrompt, conversations: hostManaged ? hostConversations : storedConversations, activeConversationId: hostManaged ? hostActiveId : managedActiveId, initialMessages: hostManaged ? hostInitialMessages : managedMessages, onConversationCreated: handleConversationCreated, onSelectConversation: handleSelectConversation, onNewConversation: handleNewConversation, historyOpen: historyOpen, onHistoryOpenChange: setHistoryOpen, className: "flex-1", headerAction: jsxs(Fragment, { children: [headerAction, jsx("button", { type: "button", onClick: () => setExpanded(!isExpanded), "aria-label": isExpanded ? collapseLabel : expandLabel, "aria-pressed": isExpanded, title: isExpanded ? collapseLabel : expandLabel, className: "hidden rounded-full p-2 text-ews-gray-500 hover:bg-ews-gray-100 hover:text-ews-gray-900 focus:outline-none focus:ring-2 focus:ring-ews-primary md:inline-flex", "data-testid": "assistant-launcher-expand", children: isExpanded ? (jsx(Minimize2, { className: "h-5 w-5", "aria-hidden": "true" })) : (jsx(Maximize2, { className: "h-5 w-5", "aria-hidden": "true" })) }), jsx("button", { type: "button", onClick: () => setOpen(false), "aria-label": launcherTexts.close, title: launcherTexts.close, className: "rounded-full p-2 text-ews-gray-500 hover:bg-ews-gray-100 hover:text-ews-gray-900 focus:outline-none focus:ring-2 focus:ring-ews-primary", "data-testid": "assistant-launcher-close", children: jsx(X, { className: "h-5 w-5", "aria-hidden": "true" }) })] }) }) })] })] }));
|
|
4004
|
+
};
|
|
4005
|
+
|
|
4006
|
+
/** Turns one SSE text stream into assistant events; frames may arrive split across chunks. */
|
|
4007
|
+
async function* parseAssistantEventStream(body) {
|
|
4008
|
+
const reader = body.getReader();
|
|
4009
|
+
const decoder = new TextDecoder();
|
|
4010
|
+
let buffer = "";
|
|
4011
|
+
try {
|
|
4012
|
+
while (true) {
|
|
4013
|
+
const { value, done } = await reader.read();
|
|
4014
|
+
if (done)
|
|
4015
|
+
break;
|
|
4016
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
|
|
4017
|
+
let separator = buffer.indexOf("\n\n");
|
|
4018
|
+
while (separator >= 0) {
|
|
4019
|
+
const frame = buffer.slice(0, separator);
|
|
4020
|
+
buffer = buffer.slice(separator + 2);
|
|
4021
|
+
const event = parseFrame(frame);
|
|
4022
|
+
if (event)
|
|
4023
|
+
yield event;
|
|
4024
|
+
separator = buffer.indexOf("\n\n");
|
|
4025
|
+
}
|
|
4026
|
+
}
|
|
4027
|
+
const tail = parseFrame(buffer);
|
|
4028
|
+
if (tail)
|
|
4029
|
+
yield tail;
|
|
4030
|
+
}
|
|
4031
|
+
finally {
|
|
4032
|
+
reader.releaseLock();
|
|
4033
|
+
}
|
|
4034
|
+
}
|
|
4035
|
+
function parseFrame(frame) {
|
|
4036
|
+
let type = "";
|
|
4037
|
+
const dataLines = [];
|
|
4038
|
+
for (const rawLine of frame.split("\n")) {
|
|
4039
|
+
const line = rawLine.replace(/\r$/, "");
|
|
4040
|
+
if (line.startsWith("event:"))
|
|
4041
|
+
type = line.slice(6).trim();
|
|
4042
|
+
else if (line.startsWith("data:"))
|
|
4043
|
+
dataLines.push(line.slice(5).trimStart());
|
|
4044
|
+
}
|
|
4045
|
+
if (!type || dataLines.length === 0)
|
|
4046
|
+
return null;
|
|
4047
|
+
try {
|
|
4048
|
+
const data = JSON.parse(dataLines.join("\n"));
|
|
4049
|
+
return { type, ...data };
|
|
4050
|
+
}
|
|
4051
|
+
catch {
|
|
4052
|
+
return null;
|
|
4053
|
+
}
|
|
4054
|
+
}
|
|
4055
|
+
async function errorEvent(response) {
|
|
4056
|
+
let body = {};
|
|
4057
|
+
try {
|
|
4058
|
+
body = (await response.json());
|
|
4059
|
+
}
|
|
4060
|
+
catch {
|
|
4061
|
+
// no JSON body: keep the status
|
|
4062
|
+
}
|
|
4063
|
+
const retryAfterHeader = Number(response.headers.get("retry-after"));
|
|
4064
|
+
const code = body.code ??
|
|
4065
|
+
(response.status === 429
|
|
4066
|
+
? "RATE_LIMITED"
|
|
4067
|
+
: response.status === 404
|
|
4068
|
+
? "CONVERSATION_NOT_FOUND"
|
|
4069
|
+
: "MODEL_UNAVAILABLE");
|
|
4070
|
+
return {
|
|
4071
|
+
type: "error",
|
|
4072
|
+
code,
|
|
4073
|
+
message: body.message ?? `HTTP ${response.status}`,
|
|
4074
|
+
retryAfterSeconds: body.retryAfterSeconds ??
|
|
4075
|
+
(Number.isFinite(retryAfterHeader) && retryAfterHeader > 0 ? retryAfterHeader : undefined),
|
|
4076
|
+
};
|
|
4077
|
+
}
|
|
4078
|
+
function createAssistantTransport({ baseUrl, clientId, language, fetchImpl, }) {
|
|
4079
|
+
const base = `${baseUrl.replace(/\/+$/, "")}/assistant`;
|
|
4080
|
+
const doFetch = (input, init) => (fetchImpl ?? fetch)(input, { credentials: "include", ...init });
|
|
4081
|
+
const headers = { "Content-Type": "application/json", "Accept-Language": language };
|
|
4082
|
+
return {
|
|
4083
|
+
async startConversation(conversationLanguage) {
|
|
4084
|
+
const response = await doFetch(`${base}/conversations`, {
|
|
4085
|
+
method: "POST",
|
|
4086
|
+
headers,
|
|
4087
|
+
body: JSON.stringify({ language: conversationLanguage, persona: "visitor", clientId }),
|
|
4088
|
+
});
|
|
4089
|
+
if (!response.ok) {
|
|
4090
|
+
const error = await errorEvent(response);
|
|
4091
|
+
throw new Error(error.type === "error" ? error.message : `HTTP ${response.status}`);
|
|
4092
|
+
}
|
|
4093
|
+
const created = (await response.json());
|
|
4094
|
+
return { id: created.id };
|
|
4095
|
+
},
|
|
4096
|
+
async *sendMessage(conversationId, content, options) {
|
|
4097
|
+
const response = await doFetch(`${base}/conversations/${encodeURIComponent(conversationId)}/messages`, {
|
|
4098
|
+
method: "POST",
|
|
4099
|
+
headers: { ...headers, Accept: "text/event-stream" },
|
|
4100
|
+
body: JSON.stringify({ content }),
|
|
4101
|
+
signal: options?.signal,
|
|
4102
|
+
});
|
|
4103
|
+
if (!response.ok) {
|
|
4104
|
+
yield await errorEvent(response);
|
|
4105
|
+
return;
|
|
4106
|
+
}
|
|
4107
|
+
if (!response.body) {
|
|
4108
|
+
yield { type: "error", code: "NETWORK", message: "Empty response" };
|
|
4109
|
+
return;
|
|
4110
|
+
}
|
|
4111
|
+
yield* parseAssistantEventStream(response.body);
|
|
4112
|
+
},
|
|
4113
|
+
async getConversation(conversationId) {
|
|
4114
|
+
const response = await doFetch(`${base}/conversations/${encodeURIComponent(conversationId)}`, {
|
|
4115
|
+
headers,
|
|
4116
|
+
});
|
|
4117
|
+
if (!response.ok)
|
|
4118
|
+
throw new Error(`HTTP ${response.status}`);
|
|
4119
|
+
const data = (await response.json());
|
|
4120
|
+
return {
|
|
4121
|
+
id: data.id,
|
|
4122
|
+
messages: data.messages.map((message) => ({ ...message, status: "done" })),
|
|
4123
|
+
};
|
|
4124
|
+
},
|
|
4125
|
+
};
|
|
4126
|
+
}
|
|
4127
|
+
|
|
3398
4128
|
const FIRST_STEP = {
|
|
3399
4129
|
create: "new",
|
|
3400
4130
|
unlock: "unlock",
|
|
@@ -3632,5 +4362,5 @@ const SpecialtySearchAutocomplete = ({ selectedSpecialties = [], onSpecialtiesCh
|
|
|
3632
4362
|
: `${selectedSpecialties.length} specialty${selectedSpecialties.length !== 1 ? "ies" : ""} selected` }), maxSelections && (jsxs("span", { className: "text-gray-400", children: [selectedSpecialties.length, "/", maxSelections] }))] }))] }));
|
|
3633
4363
|
};
|
|
3634
4364
|
|
|
3635
|
-
export { BLOOD_TYPES, BloodType, Button, COUNTRY_CODES, DoctorIcon, DropdownMultiSelect, Icon, ImageCropModal, Input, LockScreen, Logo, Modal, MultiSearchAutocomplete, PatientIcon, PaymentDueBanner, PhoneInput, PinCodeInput, PinPad, ProfileImageUpload, RegionSelect, SearchAutocomplete, SecureLoader, Select, SpecialtySearchAutocomplete, SupportedCountry, Tag, TagList, ThemeDebugger, ThemeProvider, ThemeToggle, UserIcon, cn, createEnvConfig, debounce, extractCountryCodeFromPhoneNumber, formatCurrency, formatDate, formatNumeric, formatPhoneNumberForDisplay, formatPhoneNumberWithCountryCode, generateId, getDefaultCountryCode, getRegionOptions, getRegionsByCountry, isValidPhoneNumber, parseDateSafe, useDebounce, useDebouncedCallback, useIdleLock, useMediaQuery, useSelectField, useTheme };
|
|
4365
|
+
export { ASSISTANT_CLIENT_ID_KEY, ASSISTANT_CONVERSATIONS_KEY, ASSISTANT_MAX_CONVERSATIONS, AssistantChat, AssistantComposer, AssistantConfirmationCard, AssistantConversationList, AssistantLauncher, AssistantMessages, AssistantSuggestionCard, BLOOD_TYPES, BloodType, Button, COUNTRY_CODES, DoctorIcon, DropdownMultiSelect, Icon, ImageCropModal, Input, LockScreen, Logo, MAX_MESSAGE_LENGTH, Modal, MultiSearchAutocomplete, PatientIcon, PaymentDueBanner, PhoneInput, PinCodeInput, PinPad, ProfileImageUpload, RegionSelect, SearchAutocomplete, SecureLoader, Select, SpecialtySearchAutocomplete, SupportedCountry, Tag, TagList, ThemeDebugger, ThemeProvider, ThemeToggle, UserIcon, cn, conversationTitle, createAssistantTransport, createEnvConfig, debounce, extractCountryCodeFromPhoneNumber, formatCurrency, formatDate, formatNumeric, formatPhoneNumberForDisplay, formatPhoneNumberWithCountryCode, generateId, getAssistantClientId, getDefaultCountryCode, getRegionOptions, getRegionsByCountry, getSpeechRecognition, isExternalLink, isValidPhoneNumber, listAssistantConversations, parseAssistantEventStream, parseDateSafe, renderAssistantMarkdown, saveAssistantConversation, useDebounce, useDebouncedCallback, useIdleLock, useMediaQuery, useSelectField, useSpeechRecognition, useTheme };
|
|
3636
4366
|
//# sourceMappingURL=index.esm.js.map
|