@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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** Languages the assistant speaks; `ar` renders right-to-left. */
|
|
4
|
+
export type AssistantLanguage = "fr" | "en" | "ar";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Events streamed by the assistant BFF for one answer
|
|
8
|
+
* (projects/medecine360-mcp/docs/assistant-api-contract.md).
|
|
9
|
+
*/
|
|
10
|
+
export type AssistantEvent =
|
|
11
|
+
| { type: "message_start"; messageId: string }
|
|
12
|
+
| { type: "text_delta"; text: string }
|
|
13
|
+
| {
|
|
14
|
+
type: "tool_call";
|
|
15
|
+
name: string;
|
|
16
|
+
status: "started" | "done" | "failed";
|
|
17
|
+
label: string;
|
|
18
|
+
}
|
|
19
|
+
| { type: "citation"; title: string; url: string }
|
|
20
|
+
| {
|
|
21
|
+
type: "message_end";
|
|
22
|
+
messageId: string;
|
|
23
|
+
usage?: { inputTokens: number; outputTokens: number };
|
|
24
|
+
}
|
|
25
|
+
| {
|
|
26
|
+
type: "error";
|
|
27
|
+
code:
|
|
28
|
+
| "MODEL_UNAVAILABLE"
|
|
29
|
+
| "RATE_LIMITED"
|
|
30
|
+
| "CONVERSATION_NOT_FOUND"
|
|
31
|
+
| "INVALID_MESSAGE"
|
|
32
|
+
| "NETWORK"
|
|
33
|
+
| string;
|
|
34
|
+
message: string;
|
|
35
|
+
retryAfterSeconds?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export interface AssistantCitation {
|
|
39
|
+
title: string;
|
|
40
|
+
url: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AssistantMessage {
|
|
44
|
+
id: string;
|
|
45
|
+
role: "user" | "assistant";
|
|
46
|
+
content: string;
|
|
47
|
+
citations?: AssistantCitation[];
|
|
48
|
+
/** Streaming state of an assistant message. */
|
|
49
|
+
status?: "streaming" | "done" | "error";
|
|
50
|
+
/** Set when `status` is `error`: what to show, and whether a retry makes sense. */
|
|
51
|
+
error?: { message: string; retryAfterSeconds?: number };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AssistantConversationSummary {
|
|
55
|
+
id: string;
|
|
56
|
+
title: string;
|
|
57
|
+
createdAt: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The transport is the app's job: the component never knows a URL. The
|
|
62
|
+
* async iterable returned by `sendMessage` must end after `message_end` or
|
|
63
|
+
* `error`; a thrown error is treated as a network failure.
|
|
64
|
+
*/
|
|
65
|
+
export interface AssistantTransport {
|
|
66
|
+
startConversation(language: AssistantLanguage): Promise<{ id: string }>;
|
|
67
|
+
sendMessage(
|
|
68
|
+
conversationId: string,
|
|
69
|
+
content: string,
|
|
70
|
+
options?: { signal?: AbortSignal }
|
|
71
|
+
): AsyncIterable<AssistantEvent>;
|
|
72
|
+
getConversation?(
|
|
73
|
+
conversationId: string
|
|
74
|
+
): Promise<{ id: string; messages: AssistantMessage[] }>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AssistantSuggestion {
|
|
78
|
+
id: string;
|
|
79
|
+
title: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
/** What is sent when the card is clicked. */
|
|
82
|
+
prompt: string;
|
|
83
|
+
icon?: ReactNode;
|
|
84
|
+
/** Marks the "example question" card of the mockup. */
|
|
85
|
+
kind?: "action" | "example";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Every string the component shows; the app translates them. */
|
|
89
|
+
export interface AssistantChatTexts {
|
|
90
|
+
brandName: string;
|
|
91
|
+
online: string;
|
|
92
|
+
newConversation: string;
|
|
93
|
+
recentConversations: string;
|
|
94
|
+
signIn: string;
|
|
95
|
+
signInHint: string;
|
|
96
|
+
heroTitle: string;
|
|
97
|
+
heroSubtitle: string;
|
|
98
|
+
composerPlaceholder: string;
|
|
99
|
+
composerHint: string;
|
|
100
|
+
send: string;
|
|
101
|
+
/** Mic button: hold to talk. */
|
|
102
|
+
holdToTalk: string;
|
|
103
|
+
listening: string;
|
|
104
|
+
thinking: string;
|
|
105
|
+
retry: string;
|
|
106
|
+
rateLimited: (seconds: number) => string;
|
|
107
|
+
genericError: string;
|
|
108
|
+
sources: string;
|
|
109
|
+
exampleLabel: string;
|
|
110
|
+
confirm: string;
|
|
111
|
+
cancel: string;
|
|
112
|
+
/** The history button of the panel layout; falls back to `recentConversations`. */
|
|
113
|
+
history?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface AssistantConfirmation {
|
|
117
|
+
id: string;
|
|
118
|
+
title: string;
|
|
119
|
+
details: Array<{ label: string; value: string }>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface AssistantChatProps {
|
|
123
|
+
transport: AssistantTransport;
|
|
124
|
+
language: AssistantLanguage;
|
|
125
|
+
texts: AssistantChatTexts;
|
|
126
|
+
suggestions: AssistantSuggestion[];
|
|
127
|
+
/** Conversations the app remembers (local storage or account). */
|
|
128
|
+
conversations?: AssistantConversationSummary[];
|
|
129
|
+
activeConversationId?: string | null;
|
|
130
|
+
/** Messages of the active conversation, when the app restores one. */
|
|
131
|
+
initialMessages?: AssistantMessage[];
|
|
132
|
+
onConversationCreated?: (conversation: AssistantConversationSummary) => void;
|
|
133
|
+
onSelectConversation?: (conversationId: string) => void;
|
|
134
|
+
onNewConversation?: () => void;
|
|
135
|
+
/** Where "Se connecter" goes. Hidden when absent. */
|
|
136
|
+
signInHref?: string;
|
|
137
|
+
/** Rendered in the header, right side (e.g. "Réserver un appel"). */
|
|
138
|
+
headerAction?: ReactNode;
|
|
139
|
+
/** Rendered under the composer (sources, no medical advice, privacy link). */
|
|
140
|
+
disclaimer?: ReactNode;
|
|
141
|
+
/** Decorative image or gradient behind the hero. */
|
|
142
|
+
heroBackground?: ReactNode;
|
|
143
|
+
/** Push-to-talk through the browser speech API. Default true; hidden anyway where unsupported. */
|
|
144
|
+
voice?: boolean;
|
|
145
|
+
/** Phase 2: a confirmation the assistant asks before a write. */
|
|
146
|
+
pendingConfirmation?: AssistantConfirmation | null;
|
|
147
|
+
onConfirm?: (confirmation: AssistantConfirmation) => void;
|
|
148
|
+
onCancelConfirmation?: (confirmation: AssistantConfirmation) => void;
|
|
149
|
+
/**
|
|
150
|
+
* `page` (default): the chat home with the conversation list beside the
|
|
151
|
+
* thread. `panel` (MED-1787): one narrow column for a slide-over, the
|
|
152
|
+
* conversation list behind a history button, a "new conversation" button
|
|
153
|
+
* in the header. `wide`: the expanded slide-over, list beside the thread.
|
|
154
|
+
*/
|
|
155
|
+
layout?: "page" | "panel" | "wide";
|
|
156
|
+
/** Panel layout: the conversation list is shown over the thread. */
|
|
157
|
+
historyOpen?: boolean;
|
|
158
|
+
onHistoryOpenChange?: (open: boolean) => void;
|
|
159
|
+
/**
|
|
160
|
+
* MED-1787: a message typed outside the chat (the launcher bar) to send as
|
|
161
|
+
* soon as possible. Each `id` is sent once; a new id sends again.
|
|
162
|
+
*/
|
|
163
|
+
queuedPrompt?: { id: string; content: string } | null;
|
|
164
|
+
/**
|
|
165
|
+
* Called when a link in an answer is clicked. Return true when the host
|
|
166
|
+
* navigated itself (e.g. its router): the browser then does not follow the
|
|
167
|
+
* link. Links to other origins always open in a new tab.
|
|
168
|
+
*/
|
|
169
|
+
onNavigate?: (href: string) => boolean | void;
|
|
170
|
+
className?: string;
|
|
171
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Mic, SendHorizontal } from "lucide-react";
|
|
2
|
+
import React, { useCallback, useRef, useState } from "react";
|
|
3
|
+
import { useSpeechRecognition } from "../../hooks/useSpeechRecognition";
|
|
4
|
+
import { cn } from "../../utils";
|
|
5
|
+
import type { AssistantLanguage } from "./AssistantChat.types";
|
|
6
|
+
|
|
7
|
+
export interface AssistantComposerProps {
|
|
8
|
+
language: AssistantLanguage;
|
|
9
|
+
onSend: (content: string) => void;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
/** Mic button; hidden anyway where the browser has no speech API. */
|
|
12
|
+
voice?: boolean;
|
|
13
|
+
texts: {
|
|
14
|
+
placeholder: string;
|
|
15
|
+
hint: string;
|
|
16
|
+
send: string;
|
|
17
|
+
holdToTalk: string;
|
|
18
|
+
listening: string;
|
|
19
|
+
};
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const MAX_MESSAGE_LENGTH = 2000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The composer of the mockup: textarea, hint, push-to-talk mic (hold to
|
|
27
|
+
* record, release to send) and a send button. Enter sends, Shift+Enter breaks.
|
|
28
|
+
*/
|
|
29
|
+
export const AssistantComposer: React.FC<AssistantComposerProps> = ({
|
|
30
|
+
language,
|
|
31
|
+
onSend,
|
|
32
|
+
disabled = false,
|
|
33
|
+
voice = true,
|
|
34
|
+
texts,
|
|
35
|
+
className,
|
|
36
|
+
}) => {
|
|
37
|
+
const [value, setValue] = useState("");
|
|
38
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
39
|
+
|
|
40
|
+
const submit = useCallback(
|
|
41
|
+
(content: string) => {
|
|
42
|
+
const trimmed = content.trim();
|
|
43
|
+
if (!trimmed || disabled) return;
|
|
44
|
+
onSend(trimmed.slice(0, MAX_MESSAGE_LENGTH));
|
|
45
|
+
setValue("");
|
|
46
|
+
},
|
|
47
|
+
[disabled, onSend]
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const speech = useSpeechRecognition({
|
|
51
|
+
language,
|
|
52
|
+
onInterim: (text) => setValue(text),
|
|
53
|
+
onResult: (text) => submit(text),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
57
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
submit(value);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const showMic = voice && speech.supported;
|
|
64
|
+
|
|
65
|
+
// A plain div rather than a <form>: Enter and the send button submit, and a
|
|
66
|
+
// form followed by a sibling trips React hydration under happy-dom (the apps'
|
|
67
|
+
// server-rendering tests) although browsers are fine.
|
|
68
|
+
return (
|
|
69
|
+
<div className={cn("w-full", className)}>
|
|
70
|
+
<div
|
|
71
|
+
className={cn(
|
|
72
|
+
"flex items-end gap-2 rounded-2xl border border-ews-gray-200 bg-white px-3 py-2 shadow-sm",
|
|
73
|
+
"focus-within:border-ews-primary focus-within:ring-2 focus-within:ring-ews-primary/20"
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
<textarea
|
|
77
|
+
ref={textareaRef}
|
|
78
|
+
value={value}
|
|
79
|
+
onChange={(event) => setValue(event.target.value)}
|
|
80
|
+
onKeyDown={onKeyDown}
|
|
81
|
+
rows={1}
|
|
82
|
+
maxLength={MAX_MESSAGE_LENGTH}
|
|
83
|
+
placeholder={speech.listening ? texts.listening : texts.placeholder}
|
|
84
|
+
aria-label={texts.placeholder}
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
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"
|
|
87
|
+
/>
|
|
88
|
+
{showMic && (
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
aria-label={texts.holdToTalk}
|
|
92
|
+
aria-pressed={speech.listening}
|
|
93
|
+
title={texts.holdToTalk}
|
|
94
|
+
disabled={disabled}
|
|
95
|
+
onMouseDown={speech.start}
|
|
96
|
+
onMouseUp={speech.stop}
|
|
97
|
+
onMouseLeave={speech.listening ? speech.stop : undefined}
|
|
98
|
+
onTouchStart={speech.start}
|
|
99
|
+
onTouchEnd={speech.stop}
|
|
100
|
+
onKeyDown={(event) => {
|
|
101
|
+
if (event.key === " " || event.key === "Enter") {
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
if (speech.listening) speech.stop();
|
|
104
|
+
else speech.start();
|
|
105
|
+
}
|
|
106
|
+
}}
|
|
107
|
+
className={cn(
|
|
108
|
+
"rounded-full p-2 transition-colors focus:outline-none focus:ring-2 focus:ring-ews-primary",
|
|
109
|
+
speech.listening
|
|
110
|
+
? "bg-ews-error text-white animate-pulse"
|
|
111
|
+
: "text-ews-gray-500 hover:bg-ews-gray-100 hover:text-ews-primary",
|
|
112
|
+
"disabled:opacity-50"
|
|
113
|
+
)}
|
|
114
|
+
data-testid="assistant-mic"
|
|
115
|
+
>
|
|
116
|
+
<Mic className="h-5 w-5" aria-hidden="true" />
|
|
117
|
+
</button>
|
|
118
|
+
)}
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
onClick={() => submit(value)}
|
|
122
|
+
aria-label={texts.send}
|
|
123
|
+
title={texts.send}
|
|
124
|
+
disabled={disabled || value.trim().length === 0}
|
|
125
|
+
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"
|
|
126
|
+
data-testid="assistant-send"
|
|
127
|
+
>
|
|
128
|
+
<SendHorizontal className="h-5 w-5 rtl:-scale-x-100" aria-hidden="true" />
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
<p className="mt-1.5 px-2 text-xs text-ews-gray-400">{texts.hint}</p>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ShieldCheck } from "lucide-react";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { cn } from "../../utils";
|
|
4
|
+
import { Button } from "../Button/Button";
|
|
5
|
+
import type { AssistantConfirmation } from "./AssistantChat.types";
|
|
6
|
+
|
|
7
|
+
export interface AssistantConfirmationCardProps {
|
|
8
|
+
confirmation: AssistantConfirmation;
|
|
9
|
+
onConfirm: (confirmation: AssistantConfirmation) => void;
|
|
10
|
+
onCancel: (confirmation: AssistantConfirmation) => void;
|
|
11
|
+
texts: { confirm: string; cancel: string };
|
|
12
|
+
busy?: boolean;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Reserved for phase 2 (MED-1779): the card the assistant shows before a
|
|
18
|
+
* write (booking, cancellation). Title, details, confirm and cancel wired to
|
|
19
|
+
* the app, so phase 2 needs no component change.
|
|
20
|
+
*/
|
|
21
|
+
export const AssistantConfirmationCard: React.FC<AssistantConfirmationCardProps> = ({
|
|
22
|
+
confirmation,
|
|
23
|
+
onConfirm,
|
|
24
|
+
onCancel,
|
|
25
|
+
texts,
|
|
26
|
+
busy = false,
|
|
27
|
+
className,
|
|
28
|
+
}) => (
|
|
29
|
+
<section
|
|
30
|
+
aria-label={confirmation.title}
|
|
31
|
+
className={cn(
|
|
32
|
+
"rounded-xl border border-ews-primary/30 bg-ews-primary-light/30 p-4",
|
|
33
|
+
className
|
|
34
|
+
)}
|
|
35
|
+
data-testid="assistant-confirmation"
|
|
36
|
+
>
|
|
37
|
+
<h3 className="flex items-center gap-2 font-semibold text-ews-gray-900">
|
|
38
|
+
<ShieldCheck className="h-5 w-5 text-ews-primary" aria-hidden="true" />
|
|
39
|
+
{confirmation.title}
|
|
40
|
+
</h3>
|
|
41
|
+
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
|
|
42
|
+
{confirmation.details.map((detail) => (
|
|
43
|
+
<React.Fragment key={detail.label}>
|
|
44
|
+
<dt className="text-ews-gray-500">{detail.label}</dt>
|
|
45
|
+
<dd className="font-medium text-ews-gray-900">{detail.value}</dd>
|
|
46
|
+
</React.Fragment>
|
|
47
|
+
))}
|
|
48
|
+
</dl>
|
|
49
|
+
<div className="mt-4 flex gap-2">
|
|
50
|
+
<Button size="sm" loading={busy} onClick={() => onConfirm(confirmation)}>
|
|
51
|
+
{texts.confirm}
|
|
52
|
+
</Button>
|
|
53
|
+
<Button size="sm" variant="ghost" disabled={busy} onClick={() => onCancel(confirmation)}>
|
|
54
|
+
{texts.cancel}
|
|
55
|
+
</Button>
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
58
|
+
);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { MessageSquare, SquarePen, UserRound } from "lucide-react";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { cn } from "../../utils";
|
|
4
|
+
import type { AssistantConversationSummary } from "./AssistantChat.types";
|
|
5
|
+
|
|
6
|
+
export interface AssistantConversationListProps {
|
|
7
|
+
conversations: AssistantConversationSummary[];
|
|
8
|
+
activeConversationId?: string | null;
|
|
9
|
+
onSelect?: (conversationId: string) => void;
|
|
10
|
+
onNew?: () => void;
|
|
11
|
+
texts: {
|
|
12
|
+
newConversation: string;
|
|
13
|
+
recentConversations: string;
|
|
14
|
+
signIn: string;
|
|
15
|
+
signInHint: string;
|
|
16
|
+
};
|
|
17
|
+
signInHref?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Left column of the mockup: new discussion, recent ones, sign-in prompt at the bottom. */
|
|
22
|
+
export const AssistantConversationList: React.FC<AssistantConversationListProps> = ({
|
|
23
|
+
conversations,
|
|
24
|
+
activeConversationId,
|
|
25
|
+
onSelect,
|
|
26
|
+
onNew,
|
|
27
|
+
texts,
|
|
28
|
+
signInHref,
|
|
29
|
+
className,
|
|
30
|
+
}) => (
|
|
31
|
+
<nav
|
|
32
|
+
aria-label={texts.recentConversations}
|
|
33
|
+
className={cn("flex h-full flex-col gap-4 bg-ews-gray-50 p-3", className)}
|
|
34
|
+
>
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
onClick={onNew}
|
|
38
|
+
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"
|
|
39
|
+
>
|
|
40
|
+
<SquarePen className="h-4 w-4" aria-hidden="true" />
|
|
41
|
+
{texts.newConversation}
|
|
42
|
+
</button>
|
|
43
|
+
|
|
44
|
+
{conversations.length > 0 && (
|
|
45
|
+
<ul className="flex-1 space-y-1 overflow-y-auto" data-testid="assistant-conversation-list">
|
|
46
|
+
{conversations.map((conversation) => {
|
|
47
|
+
const active = conversation.id === activeConversationId;
|
|
48
|
+
return (
|
|
49
|
+
<li key={conversation.id}>
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={() => onSelect?.(conversation.id)}
|
|
53
|
+
aria-current={active ? "true" : undefined}
|
|
54
|
+
className={cn(
|
|
55
|
+
"flex w-full items-center gap-2 truncate rounded-md px-2 py-1.5 text-start text-sm",
|
|
56
|
+
active
|
|
57
|
+
? "bg-white font-medium text-ews-primary shadow-sm"
|
|
58
|
+
: "text-ews-gray-700 hover:bg-white"
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
<MessageSquare className="h-3.5 w-3.5 shrink-0 text-ews-gray-400" aria-hidden="true" />
|
|
62
|
+
<span className="truncate">{conversation.title}</span>
|
|
63
|
+
</button>
|
|
64
|
+
</li>
|
|
65
|
+
);
|
|
66
|
+
})}
|
|
67
|
+
</ul>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
{signInHref && (
|
|
71
|
+
<a
|
|
72
|
+
href={signInHref}
|
|
73
|
+
className="mt-auto flex items-start gap-2 rounded-lg px-2 py-2 text-sm hover:bg-white"
|
|
74
|
+
>
|
|
75
|
+
<span className="rounded-full bg-ews-primary-light p-1.5 text-ews-primary" aria-hidden="true">
|
|
76
|
+
<UserRound className="h-4 w-4" />
|
|
77
|
+
</span>
|
|
78
|
+
<span>
|
|
79
|
+
<span className="block font-medium text-ews-gray-900">{texts.signIn}</span>
|
|
80
|
+
<span className="block text-xs text-ews-gray-500">{texts.signInHint}</span>
|
|
81
|
+
</span>
|
|
82
|
+
</a>
|
|
83
|
+
)}
|
|
84
|
+
</nav>
|
|
85
|
+
);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { AlertCircle, Loader2, RotateCcw } from "lucide-react";
|
|
2
|
+
import React, { useEffect, useRef } from "react";
|
|
3
|
+
import { cn } from "../../utils";
|
|
4
|
+
import type { AssistantMessage } from "./AssistantChat.types";
|
|
5
|
+
import { renderAssistantMarkdown } from "./assistantMarkdown";
|
|
6
|
+
import type { AssistantMarkdownOptions } from "./assistantMarkdown";
|
|
7
|
+
|
|
8
|
+
export interface AssistantMessagesProps {
|
|
9
|
+
messages: AssistantMessage[];
|
|
10
|
+
/** The tool activity line shown under the streaming answer, e.g. "Recherche de médecins…". */
|
|
11
|
+
activity?: string | null;
|
|
12
|
+
onRetry?: (message: AssistantMessage) => void;
|
|
13
|
+
texts: { thinking: string; retry: string; sources: string; brandName: string };
|
|
14
|
+
/** Link handling of the answers (same-tab navigation through the host router). */
|
|
15
|
+
linkOptions?: AssistantMarkdownOptions;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The thread: user bubbles on the end side, assistant answers on the start side, auto-scrolled. */
|
|
20
|
+
export const AssistantMessages: React.FC<AssistantMessagesProps> = ({
|
|
21
|
+
messages,
|
|
22
|
+
activity,
|
|
23
|
+
onRetry,
|
|
24
|
+
texts,
|
|
25
|
+
linkOptions,
|
|
26
|
+
className,
|
|
27
|
+
}) => {
|
|
28
|
+
const endRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
const last = messages[messages.length - 1];
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
endRef.current?.scrollIntoView?.({ block: "end" });
|
|
33
|
+
}, [messages.length, last?.content, activity]);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
className={cn("flex flex-col gap-4", className)}
|
|
38
|
+
role="log"
|
|
39
|
+
aria-live="polite"
|
|
40
|
+
aria-relevant="additions text"
|
|
41
|
+
data-testid="assistant-messages"
|
|
42
|
+
>
|
|
43
|
+
{messages.map((message) => {
|
|
44
|
+
const isUser = message.role === "user";
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
key={message.id}
|
|
48
|
+
className={cn("flex", isUser ? "justify-end" : "justify-start")}
|
|
49
|
+
data-testid={`assistant-message-${message.role}`}
|
|
50
|
+
>
|
|
51
|
+
<div
|
|
52
|
+
className={cn(
|
|
53
|
+
"max-w-[85%] rounded-2xl px-4 py-3 text-base leading-relaxed",
|
|
54
|
+
isUser
|
|
55
|
+
? "rounded-ee-md bg-ews-primary text-white"
|
|
56
|
+
: "rounded-es-md bg-ews-gray-100 text-ews-gray-900"
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
{isUser ? (
|
|
60
|
+
<p className="whitespace-pre-wrap">{message.content}</p>
|
|
61
|
+
) : (
|
|
62
|
+
<>
|
|
63
|
+
{message.content.length > 0 && renderAssistantMarkdown(message.content, linkOptions)}
|
|
64
|
+
{message.status === "streaming" && message.content.length === 0 && (
|
|
65
|
+
<span className="flex items-center gap-2 text-ews-gray-500">
|
|
66
|
+
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
|
67
|
+
{texts.thinking}
|
|
68
|
+
</span>
|
|
69
|
+
)}
|
|
70
|
+
{message.status === "streaming" && activity && (
|
|
71
|
+
<p className="mt-2 flex items-center gap-2 text-sm text-ews-gray-500" data-testid="assistant-activity">
|
|
72
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
|
73
|
+
{activity}
|
|
74
|
+
</p>
|
|
75
|
+
)}
|
|
76
|
+
{message.citations && message.citations.length > 0 && (
|
|
77
|
+
<p className="mt-3 border-t border-ews-gray-200 pt-2 text-xs text-ews-gray-500">
|
|
78
|
+
<span className="font-medium">{texts.sources}: </span>
|
|
79
|
+
{message.citations.map((citation, index) => (
|
|
80
|
+
<React.Fragment key={`${citation.url}-${index}`}>
|
|
81
|
+
{index > 0 && ", "}
|
|
82
|
+
<a
|
|
83
|
+
href={citation.url}
|
|
84
|
+
className="underline underline-offset-2 hover:text-ews-primary"
|
|
85
|
+
target="_blank"
|
|
86
|
+
rel="noopener noreferrer"
|
|
87
|
+
>
|
|
88
|
+
{citation.title}
|
|
89
|
+
</a>
|
|
90
|
+
</React.Fragment>
|
|
91
|
+
))}
|
|
92
|
+
</p>
|
|
93
|
+
)}
|
|
94
|
+
{message.status === "error" && message.error && (
|
|
95
|
+
<div
|
|
96
|
+
role="alert"
|
|
97
|
+
className="mt-2 flex flex-wrap items-center gap-2 text-sm text-ews-error"
|
|
98
|
+
data-testid="assistant-error"
|
|
99
|
+
>
|
|
100
|
+
<AlertCircle className="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
101
|
+
<span>{message.error.message}</span>
|
|
102
|
+
{onRetry && (
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
onClick={() => onRetry(message)}
|
|
106
|
+
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"
|
|
107
|
+
>
|
|
108
|
+
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
|
109
|
+
{texts.retry}
|
|
110
|
+
</button>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
</>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
})}
|
|
120
|
+
<div ref={endRef} />
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ArrowRight, Sparkles } from "lucide-react";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { cn } from "../../utils";
|
|
4
|
+
import type { AssistantSuggestion } from "./AssistantChat.types";
|
|
5
|
+
|
|
6
|
+
export interface AssistantSuggestionCardProps {
|
|
7
|
+
suggestion: AssistantSuggestion;
|
|
8
|
+
onSelect: (suggestion: AssistantSuggestion) => void;
|
|
9
|
+
exampleLabel: string;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One of the cards under the hero (mockup med1): title, description, sent as a prompt on click. */
|
|
15
|
+
export const AssistantSuggestionCard: React.FC<AssistantSuggestionCardProps> = ({
|
|
16
|
+
suggestion,
|
|
17
|
+
onSelect,
|
|
18
|
+
exampleLabel,
|
|
19
|
+
disabled = false,
|
|
20
|
+
className,
|
|
21
|
+
}) => {
|
|
22
|
+
const isExample = suggestion.kind === "example";
|
|
23
|
+
return (
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
disabled={disabled}
|
|
27
|
+
onClick={() => onSelect(suggestion)}
|
|
28
|
+
className={cn(
|
|
29
|
+
"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",
|
|
30
|
+
"hover:border-ews-primary hover:bg-ews-gray-50 focus:outline-none focus:ring-2 focus:ring-ews-primary focus:ring-offset-1",
|
|
31
|
+
"disabled:cursor-not-allowed disabled:opacity-60",
|
|
32
|
+
className
|
|
33
|
+
)}
|
|
34
|
+
data-testid={`assistant-suggestion-${suggestion.id}`}
|
|
35
|
+
>
|
|
36
|
+
<span className="text-ews-gray-400" aria-hidden="true">
|
|
37
|
+
{suggestion.icon ?? (isExample ? <Sparkles className="h-4 w-4" /> : <ArrowRight className="h-4 w-4" />)}
|
|
38
|
+
</span>
|
|
39
|
+
<span className="font-semibold text-ews-gray-900">{suggestion.title}</span>
|
|
40
|
+
{suggestion.description && (
|
|
41
|
+
<span className="text-sm text-ews-gray-600">{suggestion.description}</span>
|
|
42
|
+
)}
|
|
43
|
+
{isExample && (
|
|
44
|
+
<span className="mt-auto text-xs uppercase tracking-wide text-ews-gray-400">{exampleLabel}</span>
|
|
45
|
+
)}
|
|
46
|
+
</button>
|
|
47
|
+
);
|
|
48
|
+
};
|