@ews-admin/global-design-system 1.35.0 → 1.36.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
|
@@ -2,6 +2,7 @@ export type Environment = "local" | "dev" | "qa" | "prod";
|
|
|
2
2
|
export interface EnvConfigOverrides {
|
|
3
3
|
bffUrl?: string;
|
|
4
4
|
loginBffUrl?: string;
|
|
5
|
+
assistantBffUrl?: string;
|
|
5
6
|
appUrl?: string;
|
|
6
7
|
loginUrl?: string;
|
|
7
8
|
dashboardUrl?: string;
|
|
@@ -11,6 +12,8 @@ export interface EnvConfig {
|
|
|
11
12
|
env: Environment;
|
|
12
13
|
bffUrl: string;
|
|
13
14
|
loginBffUrl: string;
|
|
15
|
+
/** MED-1783: the assistant BFF (medecine360-assistant-bff), base path included. */
|
|
16
|
+
assistantBffUrl: string;
|
|
14
17
|
appUrl: string;
|
|
15
18
|
loginUrl: string;
|
|
16
19
|
dashboardUrl: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"env-config.d.ts","sourceRoot":"","sources":["../../src/utils/env-config.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,KAAK,GAAG,IAAI,GAAG,MAAM,CAAC;AAE1D,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,WAAW,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;
|
|
1
|
+
{"version":3,"file":"env-config.d.ts","sourceRoot":"","sources":["../../src/utils/env-config.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,KAAK,GAAG,IAAI,GAAG,MAAM,CAAC;AAE1D,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,WAAW,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,mFAAmF;IACnF,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAgED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,EACX,SAAS,CAAC,EAAE,kBAAkB,GAC7B,SAAS,CAeX"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ews-admin/global-design-system",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.36.0",
|
|
4
4
|
"description": "EWS Global Design System - Reusable components for EWS applications",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.esm.js",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"storybook": "^7.5.0",
|
|
66
66
|
"tailwindcss": "^3.4.0",
|
|
67
67
|
"ts-jest": "29",
|
|
68
|
+
"tslib": "^2.9.1",
|
|
68
69
|
"typescript": "^5.2.2",
|
|
69
70
|
"vite": "^4.5.0"
|
|
70
71
|
},
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { createAssistantTransport, parseAssistantEventStream } from "./transport";
|
|
2
|
+
export type { AssistantTransportOptions } from "./transport";
|
|
3
|
+
export {
|
|
4
|
+
ASSISTANT_CLIENT_ID_KEY,
|
|
5
|
+
ASSISTANT_CONVERSATIONS_KEY,
|
|
6
|
+
ASSISTANT_MAX_CONVERSATIONS,
|
|
7
|
+
getAssistantClientId,
|
|
8
|
+
listAssistantConversations,
|
|
9
|
+
saveAssistantConversation,
|
|
10
|
+
} from "./store";
|
|
11
|
+
export type { AssistantStorage } from "./store";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { AssistantConversationSummary } from "../components/AssistantChat/AssistantChat.types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Anonymous memory of the assistant, in the browser only (MED-1784, shared by
|
|
5
|
+
* every app since MED-1787): a stable client id and the conversations started
|
|
6
|
+
* here, newest first. Nothing is sent to a server to list them; signing in
|
|
7
|
+
* (phase 2) will attach them to the account.
|
|
8
|
+
*/
|
|
9
|
+
export const ASSISTANT_CLIENT_ID_KEY = "assistant.clientId";
|
|
10
|
+
export const ASSISTANT_CONVERSATIONS_KEY = "assistant.conversations";
|
|
11
|
+
export const ASSISTANT_MAX_CONVERSATIONS = 20;
|
|
12
|
+
|
|
13
|
+
export type AssistantStorage = Pick<Storage, "getItem" | "setItem">;
|
|
14
|
+
|
|
15
|
+
function storage(): AssistantStorage | null {
|
|
16
|
+
try {
|
|
17
|
+
return typeof window !== "undefined" ? window.localStorage : null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function randomId(): string {
|
|
24
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
25
|
+
return crypto.randomUUID();
|
|
26
|
+
}
|
|
27
|
+
return `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The browser's client id, created on first use. A fresh id per page when storage is unavailable. */
|
|
31
|
+
export function getAssistantClientId(store: AssistantStorage | null = storage()): string {
|
|
32
|
+
const existing = store?.getItem(ASSISTANT_CLIENT_ID_KEY);
|
|
33
|
+
if (existing) return existing;
|
|
34
|
+
const id = randomId();
|
|
35
|
+
try {
|
|
36
|
+
store?.setItem(ASSISTANT_CLIENT_ID_KEY, id);
|
|
37
|
+
} catch {
|
|
38
|
+
// private mode: the id lasts for this page only
|
|
39
|
+
}
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function listAssistantConversations(
|
|
44
|
+
store: AssistantStorage | null = storage()
|
|
45
|
+
): AssistantConversationSummary[] {
|
|
46
|
+
try {
|
|
47
|
+
const raw = store?.getItem(ASSISTANT_CONVERSATIONS_KEY);
|
|
48
|
+
if (!raw) return [];
|
|
49
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
50
|
+
if (!Array.isArray(parsed)) return [];
|
|
51
|
+
return parsed.filter(
|
|
52
|
+
(entry): entry is AssistantConversationSummary =>
|
|
53
|
+
typeof entry === "object" &&
|
|
54
|
+
entry !== null &&
|
|
55
|
+
typeof (entry as { id?: unknown }).id === "string"
|
|
56
|
+
);
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Adds or refreshes a conversation at the top of the list, capped at ASSISTANT_MAX_CONVERSATIONS. */
|
|
63
|
+
export function saveAssistantConversation(
|
|
64
|
+
conversation: AssistantConversationSummary,
|
|
65
|
+
store: AssistantStorage | null = storage()
|
|
66
|
+
): AssistantConversationSummary[] {
|
|
67
|
+
const next = [
|
|
68
|
+
conversation,
|
|
69
|
+
...listAssistantConversations(store).filter((entry) => entry.id !== conversation.id),
|
|
70
|
+
].slice(0, ASSISTANT_MAX_CONVERSATIONS);
|
|
71
|
+
try {
|
|
72
|
+
store?.setItem(ASSISTANT_CONVERSATIONS_KEY, JSON.stringify(next));
|
|
73
|
+
} catch {
|
|
74
|
+
// private mode: the list lasts for this page only
|
|
75
|
+
}
|
|
76
|
+
return next;
|
|
77
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssistantEvent,
|
|
3
|
+
AssistantLanguage,
|
|
4
|
+
AssistantMessage,
|
|
5
|
+
AssistantTransport,
|
|
6
|
+
} from "../components/AssistantChat/AssistantChat.types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The assistant BFF client shared by every app (MED-1787; contract in
|
|
10
|
+
* projects/medecine360-mcp/docs/assistant-api-contract.md). Fetch rather than
|
|
11
|
+
* EventSource: the message endpoint is a POST. Requests carry the platform
|
|
12
|
+
* session cookie (`credentials: "include"`) so the assistant BFF can derive
|
|
13
|
+
* the persona server-side once it supports it.
|
|
14
|
+
*/
|
|
15
|
+
export interface AssistantTransportOptions {
|
|
16
|
+
/** Base URL up to and including /api/v1 (createEnvConfig().assistantBffUrl). */
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
/** Stable browser identifier, for rate limiting and conversation listing. */
|
|
19
|
+
clientId: string;
|
|
20
|
+
language: AssistantLanguage;
|
|
21
|
+
fetchImpl?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ErrorBody {
|
|
25
|
+
code?: string;
|
|
26
|
+
message?: string;
|
|
27
|
+
retryAfterSeconds?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Turns one SSE text stream into assistant events; frames may arrive split across chunks. */
|
|
31
|
+
export async function* parseAssistantEventStream(
|
|
32
|
+
body: ReadableStream<Uint8Array>
|
|
33
|
+
): AsyncGenerator<AssistantEvent> {
|
|
34
|
+
const reader = body.getReader();
|
|
35
|
+
const decoder = new TextDecoder();
|
|
36
|
+
let buffer = "";
|
|
37
|
+
try {
|
|
38
|
+
while (true) {
|
|
39
|
+
const { value, done } = await reader.read();
|
|
40
|
+
if (done) break;
|
|
41
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
|
|
42
|
+
let separator = buffer.indexOf("\n\n");
|
|
43
|
+
while (separator >= 0) {
|
|
44
|
+
const frame = buffer.slice(0, separator);
|
|
45
|
+
buffer = buffer.slice(separator + 2);
|
|
46
|
+
const event = parseFrame(frame);
|
|
47
|
+
if (event) yield event;
|
|
48
|
+
separator = buffer.indexOf("\n\n");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const tail = parseFrame(buffer);
|
|
52
|
+
if (tail) yield tail;
|
|
53
|
+
} finally {
|
|
54
|
+
reader.releaseLock();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseFrame(frame: string): AssistantEvent | null {
|
|
59
|
+
let type = "";
|
|
60
|
+
const dataLines: string[] = [];
|
|
61
|
+
for (const rawLine of frame.split("\n")) {
|
|
62
|
+
const line = rawLine.replace(/\r$/, "");
|
|
63
|
+
if (line.startsWith("event:")) type = line.slice(6).trim();
|
|
64
|
+
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
|
|
65
|
+
}
|
|
66
|
+
if (!type || dataLines.length === 0) return null;
|
|
67
|
+
try {
|
|
68
|
+
const data = JSON.parse(dataLines.join("\n")) as Record<string, unknown>;
|
|
69
|
+
return { type, ...data } as AssistantEvent;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function errorEvent(response: Response): Promise<AssistantEvent> {
|
|
76
|
+
let body: ErrorBody = {};
|
|
77
|
+
try {
|
|
78
|
+
body = (await response.json()) as ErrorBody;
|
|
79
|
+
} catch {
|
|
80
|
+
// no JSON body: keep the status
|
|
81
|
+
}
|
|
82
|
+
const retryAfterHeader = Number(response.headers.get("retry-after"));
|
|
83
|
+
const code =
|
|
84
|
+
body.code ??
|
|
85
|
+
(response.status === 429
|
|
86
|
+
? "RATE_LIMITED"
|
|
87
|
+
: response.status === 404
|
|
88
|
+
? "CONVERSATION_NOT_FOUND"
|
|
89
|
+
: "MODEL_UNAVAILABLE");
|
|
90
|
+
return {
|
|
91
|
+
type: "error",
|
|
92
|
+
code,
|
|
93
|
+
message: body.message ?? `HTTP ${response.status}`,
|
|
94
|
+
retryAfterSeconds:
|
|
95
|
+
body.retryAfterSeconds ??
|
|
96
|
+
(Number.isFinite(retryAfterHeader) && retryAfterHeader > 0 ? retryAfterHeader : undefined),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createAssistantTransport({
|
|
101
|
+
baseUrl,
|
|
102
|
+
clientId,
|
|
103
|
+
language,
|
|
104
|
+
fetchImpl,
|
|
105
|
+
}: AssistantTransportOptions): AssistantTransport {
|
|
106
|
+
const base = `${baseUrl.replace(/\/+$/, "")}/assistant`;
|
|
107
|
+
const doFetch: typeof fetch = (input, init) =>
|
|
108
|
+
(fetchImpl ?? fetch)(input, { credentials: "include", ...init });
|
|
109
|
+
const headers = { "Content-Type": "application/json", "Accept-Language": language };
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
async startConversation(conversationLanguage) {
|
|
113
|
+
const response = await doFetch(`${base}/conversations`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers,
|
|
116
|
+
body: JSON.stringify({ language: conversationLanguage, persona: "visitor", clientId }),
|
|
117
|
+
});
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
const error = await errorEvent(response);
|
|
120
|
+
throw new Error(error.type === "error" ? error.message : `HTTP ${response.status}`);
|
|
121
|
+
}
|
|
122
|
+
const created = (await response.json()) as { id: string };
|
|
123
|
+
return { id: created.id };
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async *sendMessage(conversationId, content, options) {
|
|
127
|
+
const response = await doFetch(
|
|
128
|
+
`${base}/conversations/${encodeURIComponent(conversationId)}/messages`,
|
|
129
|
+
{
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { ...headers, Accept: "text/event-stream" },
|
|
132
|
+
body: JSON.stringify({ content }),
|
|
133
|
+
signal: options?.signal,
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
yield await errorEvent(response);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!response.body) {
|
|
141
|
+
yield { type: "error", code: "NETWORK", message: "Empty response" };
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
yield* parseAssistantEventStream(response.body);
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async getConversation(conversationId) {
|
|
148
|
+
const response = await doFetch(`${base}/conversations/${encodeURIComponent(conversationId)}`, {
|
|
149
|
+
headers,
|
|
150
|
+
});
|
|
151
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
152
|
+
const data = (await response.json()) as { id: string; messages: AssistantMessage[] };
|
|
153
|
+
return {
|
|
154
|
+
id: data.id,
|
|
155
|
+
messages: data.messages.map((message) => ({ ...message, status: "done" as const })),
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { History, SquarePen } from "lucide-react";
|
|
2
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { cn, generateId } from "../../utils";
|
|
4
|
+
import type {
|
|
5
|
+
AssistantChatProps,
|
|
6
|
+
AssistantConversationSummary,
|
|
7
|
+
AssistantEvent,
|
|
8
|
+
AssistantMessage,
|
|
9
|
+
} from "./AssistantChat.types";
|
|
10
|
+
import { AssistantComposer } from "./AssistantComposer";
|
|
11
|
+
import { AssistantConfirmationCard } from "./AssistantConfirmationCard";
|
|
12
|
+
import { AssistantConversationList } from "./AssistantConversationList";
|
|
13
|
+
import { AssistantMessages } from "./AssistantMessages";
|
|
14
|
+
import { AssistantSuggestionCard } from "./AssistantSuggestionCard";
|
|
15
|
+
|
|
16
|
+
const DIRECTION: Record<AssistantChatProps["language"], "ltr" | "rtl"> = {
|
|
17
|
+
fr: "ltr",
|
|
18
|
+
en: "ltr",
|
|
19
|
+
ar: "rtl",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** A conversation title is its first message, cut like the mockup's sidebar. */
|
|
23
|
+
export function conversationTitle(firstMessage: string, max = 60): string {
|
|
24
|
+
const oneLine = firstMessage.replace(/\s+/g, " ").trim();
|
|
25
|
+
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The chat home of the Medecine360 assistant (MED-1783, mockup med1):
|
|
30
|
+
* conversation list, header with brand badge, hero with suggestion cards,
|
|
31
|
+
* streamed thread, composer with push-to-talk. UI only: the app provides the
|
|
32
|
+
* transport, the texts, the conversation memory and the links.
|
|
33
|
+
*
|
|
34
|
+
* Server-rendering safe: nothing touches `window` at render time, the
|
|
35
|
+
* speech API is detected after mount.
|
|
36
|
+
*/
|
|
37
|
+
export const AssistantChat: React.FC<AssistantChatProps> = ({
|
|
38
|
+
transport,
|
|
39
|
+
language,
|
|
40
|
+
texts,
|
|
41
|
+
suggestions,
|
|
42
|
+
conversations = [],
|
|
43
|
+
activeConversationId = null,
|
|
44
|
+
initialMessages,
|
|
45
|
+
onConversationCreated,
|
|
46
|
+
onSelectConversation,
|
|
47
|
+
onNewConversation,
|
|
48
|
+
signInHref,
|
|
49
|
+
headerAction,
|
|
50
|
+
disclaimer,
|
|
51
|
+
heroBackground,
|
|
52
|
+
voice = true,
|
|
53
|
+
pendingConfirmation = null,
|
|
54
|
+
onConfirm,
|
|
55
|
+
onCancelConfirmation,
|
|
56
|
+
layout = "page",
|
|
57
|
+
queuedPrompt = null,
|
|
58
|
+
onNavigate,
|
|
59
|
+
historyOpen = false,
|
|
60
|
+
onHistoryOpenChange,
|
|
61
|
+
className,
|
|
62
|
+
}) => {
|
|
63
|
+
const [messages, setMessages] = useState<AssistantMessage[]>(initialMessages ?? []);
|
|
64
|
+
const [activity, setActivity] = useState<string | null>(null);
|
|
65
|
+
const [busy, setBusy] = useState(false);
|
|
66
|
+
const conversationIdRef = useRef<string | null>(activeConversationId);
|
|
67
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
68
|
+
|
|
69
|
+
// The app switched conversation: show its messages and talk to it from now on.
|
|
70
|
+
// Not when the app merely echoes the id this component just created (send()
|
|
71
|
+
// already set the ref): the thread in progress must stay on screen.
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (conversationIdRef.current === activeConversationId) return;
|
|
74
|
+
conversationIdRef.current = activeConversationId;
|
|
75
|
+
setMessages(initialMessages ?? []);
|
|
76
|
+
setActivity(null);
|
|
77
|
+
}, [activeConversationId, initialMessages]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => () => abortRef.current?.abort(), []);
|
|
80
|
+
|
|
81
|
+
const patchMessage = useCallback((id: string, patch: (message: AssistantMessage) => AssistantMessage) => {
|
|
82
|
+
setMessages((current) => current.map((message) => (message.id === id ? patch(message) : message)));
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
const applyEvent = useCallback(
|
|
86
|
+
(assistantId: string, event: AssistantEvent) => {
|
|
87
|
+
switch (event.type) {
|
|
88
|
+
case "message_start":
|
|
89
|
+
patchMessage(assistantId, (message) => ({ ...message, id: event.messageId || message.id }));
|
|
90
|
+
return event.messageId || assistantId;
|
|
91
|
+
case "text_delta":
|
|
92
|
+
patchMessage(assistantId, (message) => ({ ...message, content: message.content + event.text }));
|
|
93
|
+
return assistantId;
|
|
94
|
+
case "tool_call":
|
|
95
|
+
setActivity(event.status === "started" ? event.label : null);
|
|
96
|
+
return assistantId;
|
|
97
|
+
case "citation":
|
|
98
|
+
patchMessage(assistantId, (message) => ({
|
|
99
|
+
...message,
|
|
100
|
+
citations: [...(message.citations ?? []), { title: event.title, url: event.url }],
|
|
101
|
+
}));
|
|
102
|
+
return assistantId;
|
|
103
|
+
case "message_end":
|
|
104
|
+
setActivity(null);
|
|
105
|
+
patchMessage(assistantId, (message) => ({ ...message, status: "done" }));
|
|
106
|
+
return assistantId;
|
|
107
|
+
case "error":
|
|
108
|
+
setActivity(null);
|
|
109
|
+
patchMessage(assistantId, (message) => ({
|
|
110
|
+
...message,
|
|
111
|
+
status: "error",
|
|
112
|
+
error: {
|
|
113
|
+
message:
|
|
114
|
+
event.code === "RATE_LIMITED" && event.retryAfterSeconds
|
|
115
|
+
? texts.rateLimited(event.retryAfterSeconds)
|
|
116
|
+
: event.message || texts.genericError,
|
|
117
|
+
retryAfterSeconds: event.retryAfterSeconds,
|
|
118
|
+
},
|
|
119
|
+
}));
|
|
120
|
+
return assistantId;
|
|
121
|
+
default:
|
|
122
|
+
return assistantId;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
[patchMessage, texts]
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const send = useCallback(
|
|
129
|
+
async (content: string) => {
|
|
130
|
+
if (busy) return false;
|
|
131
|
+
setBusy(true);
|
|
132
|
+
const userMessage: AssistantMessage = { id: generateId("u"), role: "user", content };
|
|
133
|
+
let assistantId = generateId("a");
|
|
134
|
+
setMessages((current) => [
|
|
135
|
+
...current,
|
|
136
|
+
userMessage,
|
|
137
|
+
{ id: assistantId, role: "assistant", content: "", status: "streaming" },
|
|
138
|
+
]);
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
abortRef.current = controller;
|
|
141
|
+
try {
|
|
142
|
+
if (!conversationIdRef.current) {
|
|
143
|
+
const created = await transport.startConversation(language);
|
|
144
|
+
conversationIdRef.current = created.id;
|
|
145
|
+
const summary: AssistantConversationSummary = {
|
|
146
|
+
id: created.id,
|
|
147
|
+
title: conversationTitle(content),
|
|
148
|
+
createdAt: new Date().toISOString(),
|
|
149
|
+
};
|
|
150
|
+
onConversationCreated?.(summary);
|
|
151
|
+
}
|
|
152
|
+
for await (const event of transport.sendMessage(conversationIdRef.current, content, {
|
|
153
|
+
signal: controller.signal,
|
|
154
|
+
})) {
|
|
155
|
+
assistantId = applyEvent(assistantId, event);
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
setActivity(null);
|
|
159
|
+
patchMessage(assistantId, (message) => ({
|
|
160
|
+
...message,
|
|
161
|
+
status: "error",
|
|
162
|
+
error: { message: error instanceof Error && error.message ? error.message : texts.genericError },
|
|
163
|
+
}));
|
|
164
|
+
} finally {
|
|
165
|
+
setBusy(false);
|
|
166
|
+
abortRef.current = null;
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
},
|
|
170
|
+
[applyEvent, busy, language, onConversationCreated, patchMessage, texts.genericError, transport]
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// A prompt queued from outside (the launcher bar) is sent once, as soon as
|
|
174
|
+
// the component is free.
|
|
175
|
+
const handledPromptRef = useRef<string | null>(null);
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (!queuedPrompt || queuedPrompt.id === handledPromptRef.current || busy) return;
|
|
178
|
+
handledPromptRef.current = queuedPrompt.id;
|
|
179
|
+
void send(queuedPrompt.content);
|
|
180
|
+
}, [busy, queuedPrompt, send]);
|
|
181
|
+
|
|
182
|
+
/** Retry re-sends the user message that preceded the failed answer. */
|
|
183
|
+
const retry = useCallback(
|
|
184
|
+
(failed: AssistantMessage) => {
|
|
185
|
+
const index = messages.findIndex((message) => message.id === failed.id);
|
|
186
|
+
const previous = index > 0 ? messages[index - 1] : undefined;
|
|
187
|
+
if (!previous || previous.role !== "user") return;
|
|
188
|
+
setMessages((current) => current.filter((message) => message.id !== failed.id && message.id !== previous.id));
|
|
189
|
+
void send(previous.content);
|
|
190
|
+
},
|
|
191
|
+
[messages, send]
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const startNewConversation = useCallback(() => {
|
|
195
|
+
abortRef.current?.abort();
|
|
196
|
+
conversationIdRef.current = null;
|
|
197
|
+
setMessages([]);
|
|
198
|
+
setActivity(null);
|
|
199
|
+
onNewConversation?.();
|
|
200
|
+
}, [onNewConversation]);
|
|
201
|
+
|
|
202
|
+
const empty = messages.length === 0;
|
|
203
|
+
const dir = DIRECTION[language];
|
|
204
|
+
// panel: one compact column; wide: the slide-over expanded, list beside the thread.
|
|
205
|
+
const compact = layout === "panel";
|
|
206
|
+
const withList = layout !== "panel";
|
|
207
|
+
const historyLabel = texts.history ?? texts.recentConversations;
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div
|
|
211
|
+
dir={dir}
|
|
212
|
+
lang={language}
|
|
213
|
+
className={cn(
|
|
214
|
+
"overflow-hidden bg-white",
|
|
215
|
+
layout === "page" && "grid min-h-[36rem] grid-cols-1 rounded-2xl border border-ews-gray-200 shadow-sm md:grid-cols-[16rem_1fr]",
|
|
216
|
+
layout === "wide" && "grid h-full min-h-0 grid-cols-1 md:grid-cols-[16rem_1fr]",
|
|
217
|
+
compact && "relative flex h-full min-h-0 flex-col",
|
|
218
|
+
className
|
|
219
|
+
)}
|
|
220
|
+
data-layout={layout}
|
|
221
|
+
data-testid="assistant-chat"
|
|
222
|
+
>
|
|
223
|
+
{withList && (
|
|
224
|
+
<AssistantConversationList
|
|
225
|
+
conversations={conversations}
|
|
226
|
+
activeConversationId={activeConversationId}
|
|
227
|
+
onSelect={onSelectConversation}
|
|
228
|
+
onNew={startNewConversation}
|
|
229
|
+
texts={texts}
|
|
230
|
+
signInHref={signInHref}
|
|
231
|
+
className="hidden border-e border-ews-gray-200 md:flex"
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{compact && historyOpen && (
|
|
236
|
+
<div className="absolute inset-0 z-10 flex" data-testid="assistant-history">
|
|
237
|
+
<AssistantConversationList
|
|
238
|
+
conversations={conversations}
|
|
239
|
+
activeConversationId={activeConversationId}
|
|
240
|
+
onSelect={(id) => {
|
|
241
|
+
onSelectConversation?.(id);
|
|
242
|
+
onHistoryOpenChange?.(false);
|
|
243
|
+
}}
|
|
244
|
+
onNew={() => {
|
|
245
|
+
startNewConversation();
|
|
246
|
+
onHistoryOpenChange?.(false);
|
|
247
|
+
}}
|
|
248
|
+
texts={texts}
|
|
249
|
+
signInHref={signInHref}
|
|
250
|
+
className="w-72 max-w-full border-e border-ews-gray-200 shadow-xl"
|
|
251
|
+
/>
|
|
252
|
+
<div
|
|
253
|
+
className="flex-1 bg-black/20"
|
|
254
|
+
onClick={() => onHistoryOpenChange?.(false)}
|
|
255
|
+
data-testid="assistant-history-overlay"
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
261
|
+
<header className="flex items-center justify-between gap-3 border-b border-ews-gray-200 px-4 py-3">
|
|
262
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
263
|
+
<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">
|
|
264
|
+
{texts.brandName.charAt(0)}
|
|
265
|
+
</span>
|
|
266
|
+
<span className="truncate font-semibold text-ews-gray-900">{texts.brandName}</span>
|
|
267
|
+
<span className="rounded-full bg-ews-gray-100 px-2 py-0.5 text-xs font-medium text-ews-gray-600">AI</span>
|
|
268
|
+
<span className="flex items-center gap-1 text-xs text-ews-gray-500">
|
|
269
|
+
<span className="h-2 w-2 rounded-full bg-ews-success" aria-hidden="true" />
|
|
270
|
+
{texts.online}
|
|
271
|
+
</span>
|
|
272
|
+
</div>
|
|
273
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
274
|
+
{compact && onHistoryOpenChange && (
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
onClick={() => onHistoryOpenChange(!historyOpen)}
|
|
278
|
+
aria-label={historyLabel}
|
|
279
|
+
aria-pressed={historyOpen}
|
|
280
|
+
title={historyLabel}
|
|
281
|
+
className={cn(
|
|
282
|
+
"rounded-full p-2 hover:bg-ews-gray-100 hover:text-ews-primary focus:outline-none focus:ring-2 focus:ring-ews-primary",
|
|
283
|
+
historyOpen ? "bg-ews-gray-100 text-ews-primary" : "text-ews-gray-500"
|
|
284
|
+
)}
|
|
285
|
+
data-testid="assistant-history-toggle"
|
|
286
|
+
>
|
|
287
|
+
<History className="h-4 w-4" aria-hidden="true" />
|
|
288
|
+
</button>
|
|
289
|
+
)}
|
|
290
|
+
{!withList && !empty && (
|
|
291
|
+
<button
|
|
292
|
+
type="button"
|
|
293
|
+
onClick={startNewConversation}
|
|
294
|
+
aria-label={texts.newConversation}
|
|
295
|
+
title={texts.newConversation}
|
|
296
|
+
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"
|
|
297
|
+
data-testid="assistant-new-conversation"
|
|
298
|
+
>
|
|
299
|
+
<SquarePen className="h-4 w-4" aria-hidden="true" />
|
|
300
|
+
</button>
|
|
301
|
+
)}
|
|
302
|
+
{headerAction}
|
|
303
|
+
</div>
|
|
304
|
+
</header>
|
|
305
|
+
|
|
306
|
+
<div className="flex flex-1 flex-col overflow-y-auto">
|
|
307
|
+
{empty ? (
|
|
308
|
+
<>
|
|
309
|
+
<section
|
|
310
|
+
className={cn(
|
|
311
|
+
"relative overflow-hidden bg-ews-primary-light text-center",
|
|
312
|
+
compact ? "px-4 py-6" : "px-6 py-10"
|
|
313
|
+
)}
|
|
314
|
+
>
|
|
315
|
+
{heroBackground && <div className="absolute inset-0" aria-hidden="true">{heroBackground}</div>}
|
|
316
|
+
<div className="relative">
|
|
317
|
+
<h2 className={cn("font-bold text-ews-gray-900", compact ? "text-xl" : "text-2xl sm:text-3xl")}>
|
|
318
|
+
{texts.heroTitle}
|
|
319
|
+
</h2>
|
|
320
|
+
<p className={cn("mx-auto mt-2 max-w-xl text-ews-gray-700", compact && "text-sm")}>
|
|
321
|
+
{texts.heroSubtitle}
|
|
322
|
+
</p>
|
|
323
|
+
</div>
|
|
324
|
+
</section>
|
|
325
|
+
<div
|
|
326
|
+
className={cn("grid gap-3 px-4 py-5", compact ? "grid-cols-1" : "sm:grid-cols-2 lg:grid-cols-4")}
|
|
327
|
+
data-testid="assistant-suggestions"
|
|
328
|
+
>
|
|
329
|
+
{suggestions.map((suggestion) => (
|
|
330
|
+
<AssistantSuggestionCard
|
|
331
|
+
key={suggestion.id}
|
|
332
|
+
suggestion={suggestion}
|
|
333
|
+
onSelect={(selected) => void send(selected.prompt)}
|
|
334
|
+
exampleLabel={texts.exampleLabel}
|
|
335
|
+
disabled={busy}
|
|
336
|
+
/>
|
|
337
|
+
))}
|
|
338
|
+
</div>
|
|
339
|
+
</>
|
|
340
|
+
) : (
|
|
341
|
+
<div className="flex-1 px-4 py-5">
|
|
342
|
+
<AssistantMessages
|
|
343
|
+
messages={messages}
|
|
344
|
+
activity={activity}
|
|
345
|
+
onRetry={retry}
|
|
346
|
+
texts={texts}
|
|
347
|
+
linkOptions={
|
|
348
|
+
onNavigate
|
|
349
|
+
? {
|
|
350
|
+
onLinkClick: (href, event) => {
|
|
351
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey) return;
|
|
352
|
+
if (onNavigate(href) === true) event.preventDefault();
|
|
353
|
+
},
|
|
354
|
+
}
|
|
355
|
+
: undefined
|
|
356
|
+
}
|
|
357
|
+
/>
|
|
358
|
+
{pendingConfirmation && onConfirm && onCancelConfirmation && (
|
|
359
|
+
<AssistantConfirmationCard
|
|
360
|
+
confirmation={pendingConfirmation}
|
|
361
|
+
onConfirm={onConfirm}
|
|
362
|
+
onCancel={onCancelConfirmation}
|
|
363
|
+
texts={texts}
|
|
364
|
+
className="mt-4"
|
|
365
|
+
/>
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
<div className="border-t border-ews-gray-200 px-4 pb-3 pt-3">
|
|
372
|
+
<AssistantComposer
|
|
373
|
+
language={language}
|
|
374
|
+
onSend={(content) => void send(content)}
|
|
375
|
+
disabled={busy}
|
|
376
|
+
voice={voice}
|
|
377
|
+
texts={{
|
|
378
|
+
placeholder: texts.composerPlaceholder,
|
|
379
|
+
hint: texts.composerHint,
|
|
380
|
+
send: texts.send,
|
|
381
|
+
holdToTalk: texts.holdToTalk,
|
|
382
|
+
listening: texts.listening,
|
|
383
|
+
}}
|
|
384
|
+
/>
|
|
385
|
+
{disclaimer && <div className="mt-2 px-2 text-xs text-ews-gray-500">{disclaimer}</div>}
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
);
|
|
390
|
+
};
|