From bf620a5e35870b03d2ab95c1effcc5d75b0d9683 Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Mon, 4 Mar 2024 03:01:01 +0200 Subject: [PATCH 01/51] feat: Add immer dependency --- copilot-widget/package.json | 1 + copilot-widget/pnpm-lock.yaml | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/copilot-widget/package.json b/copilot-widget/package.json index 5baa3ebdb..41ff20fa7 100644 --- a/copilot-widget/package.json +++ b/copilot-widget/package.json @@ -79,6 +79,7 @@ } }, "dependencies": { + "immer": "^10.0.3", "lucide-react": "^0.298.0" } } diff --git a/copilot-widget/pnpm-lock.yaml b/copilot-widget/pnpm-lock.yaml index 48b0359bd..451b7c3ca 100644 --- a/copilot-widget/pnpm-lock.yaml +++ b/copilot-widget/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + immer: + specifier: ^10.0.3 + version: 10.0.3 lucide-react: specifier: ^0.298.0 version: 0.298.0(react@18.2.0) @@ -3389,6 +3392,10 @@ packages: engines: {node: '>= 4'} dev: true + /immer@10.0.3: + resolution: {integrity: sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==} + dev: false + /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} From e8e1046c138ed4de2b646f6f2d446a0e3a1d3aea Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Mon, 4 Mar 2024 03:01:20 +0200 Subject: [PATCH 02/51] feat: Add SocketProvider and createSocket functionality --- .../lib/contexts/SocketProvider.tsx | 33 +++++++++++++++++++ copilot-widget/lib/utils/createSocket.ts | 22 +++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 copilot-widget/lib/contexts/SocketProvider.tsx create mode 100644 copilot-widget/lib/utils/createSocket.ts diff --git a/copilot-widget/lib/contexts/SocketProvider.tsx b/copilot-widget/lib/contexts/SocketProvider.tsx new file mode 100644 index 000000000..15677dbfb --- /dev/null +++ b/copilot-widget/lib/contexts/SocketProvider.tsx @@ -0,0 +1,33 @@ +import { Socket } from "socket.io-client"; +import { createSafeContext } from "./create-safe-context"; +import { ReactNode, useMemo } from "react"; +import { createSocketClient } from "@lib/utils/createSocket"; +import { useConfigData } from "./ConfigData"; +import { useSessionId } from "@lib/hooks/useSessionId"; + +type SocketContextData = { + __socket: Socket; +}; +const [useSocket, SocketSafeProvider] = createSafeContext(); + +function SocketProvider({ children }: { children: ReactNode }) { + const options = useConfigData(); + const { sessionId } = useSessionId(options.token); + const socket = useMemo( + () => + createSocketClient({ + socketUrl: options.socketUrl, + token: options.token, + sessionId, + }), + [options, sessionId] + ); + return ( + + {children} + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components +export { useSocket, SocketProvider }; diff --git a/copilot-widget/lib/utils/createSocket.ts b/copilot-widget/lib/utils/createSocket.ts new file mode 100644 index 000000000..9eac61c12 --- /dev/null +++ b/copilot-widget/lib/utils/createSocket.ts @@ -0,0 +1,22 @@ +import { io } from "socket.io-client"; + +interface ConfigType { + socketUrl: string; + token: string; + sessionId: string; +} + +export function createSocketClient({ + socketUrl, + token, + sessionId, +}: ConfigType) { + return io(socketUrl, { + autoConnect: false, + transports: ["websocket"], + extraHeaders: { + "X-Bot-Token": token, + "X-Session-Id": sessionId, + }, + }); +} From fc5b5e75d380781580311b52a3bb5e87a6e171f6 Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Mon, 4 Mar 2024 03:01:33 +0200 Subject: [PATCH 03/51] feat: Add SocketProvider in Root component --- copilot-widget/lib/Root.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/copilot-widget/lib/Root.tsx b/copilot-widget/lib/Root.tsx index 398c45a11..2e4b67f08 100644 --- a/copilot-widget/lib/Root.tsx +++ b/copilot-widget/lib/Root.tsx @@ -8,6 +8,7 @@ import root from "react-shadow"; import css from "../styles/index.css?inline"; import { LanguageProvider } from "./contexts/LocalesProvider"; import { get } from "./utils/pkg"; +import { SocketProvider } from "./contexts/SocketProvider"; const version = get("version"); const cssColors = { @@ -39,7 +40,9 @@ function Root({ children, options, containerProps }: RootProps) { - {children} + + {children} + From dd16e2c4c421a8f59ea06bdcda37e5371dec9b15 Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Mon, 4 Mar 2024 05:06:56 +0200 Subject: [PATCH 04/51] Update autoConnect value in createSocket.ts --- copilot-widget/lib/utils/createSocket.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/copilot-widget/lib/utils/createSocket.ts b/copilot-widget/lib/utils/createSocket.ts index 9eac61c12..966b7aaa7 100644 --- a/copilot-widget/lib/utils/createSocket.ts +++ b/copilot-widget/lib/utils/createSocket.ts @@ -12,7 +12,7 @@ export function createSocketClient({ sessionId, }: ConfigType) { return io(socketUrl, { - autoConnect: false, + autoConnect: true, transports: ["websocket"], extraHeaders: { "X-Bot-Token": token, From 613d89c41fbf9c38a12260db7cb00274fd148e3a Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Mon, 4 Mar 2024 05:07:22 +0200 Subject: [PATCH 05/51] Delete ChatProvider and Controller.tsx --- copilot-widget/lib/contexts/Controller.tsx | 205 --------------------- 1 file changed, 205 deletions(-) delete mode 100644 copilot-widget/lib/contexts/Controller.tsx diff --git a/copilot-widget/lib/contexts/Controller.tsx b/copilot-widget/lib/contexts/Controller.tsx deleted file mode 100644 index ea55748de..000000000 --- a/copilot-widget/lib/contexts/Controller.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { - ReactNode, - useCallback, - useEffect, - useMemo, - useState, -} from "react"; -import { now } from "../utils/time"; -import { useConfigData } from "./ConfigData"; -import { Message } from "@lib/types"; -import { getId } from "@lib/utils/utils"; -import io from "socket.io-client"; -import { useSessionId } from "@lib/hooks/useSessionId"; -import { BotResponse, UserMessage } from "@lib/types/messageTypes"; -import { createSafeContext } from "./createSafeContext"; -import { useWidgetState } from "./WidgetState"; - -export type FailedMessage = { - message: Message; - reason?: string; -}; - -interface ChatContextData { - messages: Message[]; - conversationInfo: string | null; - sendMessage: (message: Extract) => void; - loading: boolean; - failedMessage: FailedMessage | null; - reset: () => void; - setLastMessageId: (id: number | null) => void; - lastMessageToVote: number | null; -} -const [useChat, ChatSafeProvider] = createSafeContext(); - -const ChatProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const [isOpen] = useWidgetState(); - const [currentMessagePair, setCurrentMessagePair] = useState<{ - user: string; - bot: string; - } | null>(null); - const [messages, setMessages] = useState([]); - const config = useConfigData(); - const { sessionId } = useSessionId(config.token); - const [conversationInfo, setConversationInfo] = useState(null); - const [lastMessageToVote, setLastMessageToVote] = useState( - null - ); - const setLastMessageId = useCallback((id: number | null) => { - setLastMessageToVote(id); - }, []); - const socket = useMemo( - () => - io(config.socketUrl, { - autoConnect: false, - transports: ["websocket"], - extraHeaders: { - "X-Bot-Token": config.token, - "X-Session-Id": sessionId, - }, - }), - [config, sessionId] - ); - - useEffect(() => { - if (isOpen) { - socket.connect(); - } else { - socket.disconnect(); - } - }, [isOpen, socket]); - - const [failedMessage, setError] = useState(null); - const loading = currentMessagePair !== null; - - const sendMessage = async (message: Extract) => { - // abort - const userMessageId = getId(); - const botMessageId = getId(); - const userMessage: UserMessage & { - session_id: string; - headers: Record; - bot_token: string; - } = { - timestamp: now(), - id: userMessageId, - content: message.content, - from: "user", - session_id: sessionId, - headers: config.headers ?? {}, - bot_token: config.token, - }; - socket.emit("send_chat", userMessage); - - setCurrentMessagePair({ - user: userMessageId, - bot: botMessageId, - }); - - createUserMessage(userMessage); - createEmptyBotMessage(botMessageId); - }; - - const createUserMessage = useCallback( - (message: Extract) => { - setMessages((m) => [...m, message]); - }, - [setMessages] - ); - - const createEmptyBotMessage = useCallback( - (id: string) => { - setMessages((m) => [ - ...m, - { - timestamp: now(), - from: "bot", - id: id, - type: "text", - response: { - text: "", - }, - }, - ]); - }, - [setMessages] - ); - - const updateBotMessage = useCallback( - (id: string, text: string) => { - const botMessage = messages.find((m) => m.id === id) as BotResponse; - if (botMessage) { - // append the text to the bot message - const textt = botMessage.response.text + text; - botMessage.response.text = textt; - setMessages([...messages]); - } - }, - [messages] - ); - - useEffect(() => { - socket.on(`${sessionId}_info`, (msg: string) => { - setConversationInfo(msg); - }); - - return () => { - socket.off(`${sessionId}_info`); - }; - }, [sessionId, socket]); - - useEffect(() => { - const current = currentMessagePair; - // the content is the message.content from the server - try { - socket.on(sessionId, (content: string) => { - if (current) { - if (content === "|im_end|") { - setCurrentMessagePair(null); - return; - } - setConversationInfo(null); - updateBotMessage(current.bot, content); - } - }); - return () => { - socket.off(sessionId); - }; - } catch (error) { - setConversationInfo(null); - } - }, [currentMessagePair, sessionId, socket, updateBotMessage]); - useEffect(() => { - socket.on(`${sessionId}_vote`, (content) => { - console.log(`${sessionId}_vote ==>`, content); - if (content) { - setLastMessageToVote(content); - } - }); - return () => { - socket.off(`${sessionId}_vote`); - setLastMessageToVote(null); - }; - }, [sessionId, socket]); - function reset() { - setMessages([]); - } - - const chatContextValue: ChatContextData = { - conversationInfo, - messages, - sendMessage, - loading, - failedMessage, - reset, - setLastMessageId, - lastMessageToVote, - }; - - return ( - {children} - ); -}; - -// eslint-disable-next-line react-refresh/only-export-components -export { ChatProvider, useChat }; From 258fbb3bb41924b3168eb68d529cf04f4321656d Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Mon, 4 Mar 2024 05:07:52 +0200 Subject: [PATCH 06/51] feat: Add ChatController and MessageHandlerProvider --- .../lib/contexts/messageHandler.tsx | 252 ++++++++++++++++++ .../lib/contexts/statefulMessageHandler.tsx | 72 +++++ 2 files changed, 324 insertions(+) create mode 100644 copilot-widget/lib/contexts/messageHandler.tsx create mode 100644 copilot-widget/lib/contexts/statefulMessageHandler.tsx diff --git a/copilot-widget/lib/contexts/messageHandler.tsx b/copilot-widget/lib/contexts/messageHandler.tsx new file mode 100644 index 000000000..c71b9ce74 --- /dev/null +++ b/copilot-widget/lib/contexts/messageHandler.tsx @@ -0,0 +1,252 @@ +import { produce } from "immer"; +import { Socket } from "socket.io-client"; + +export type UserMessageType = { + from: "user"; + id: string; + content: string; + timestamp: string; + session_id: string; + headers: Record; + bot_token: string; +}; + +export type BotMessageType = { + from: "bot"; + id: string; + message: string; + timestamp: string; + responseFor: string; // id of the user message + isFailed?: boolean; + isLoading?: boolean; +}; +export type MessageType = UserMessageType | BotMessageType; + +export type State = { + currentUserMessage: null | UserMessageType; + conversationInfo: null | string; + lastServerMessageId: null | string; + messages: MessageType[]; + clientState: Record; // @for future use + components: Record; // @for future use + submittedForms: Record; // @for future use +}; +type Listener = (s: T) => void; + +export class ChatController { + sessionId: string | null = null; + listeners = new Set>(); + + private state: State = { + currentUserMessage: null, + conversationInfo: null, + lastServerMessageId: null, + messages: [], + clientState: {}, // @for future use + components: {}, // @for future use + submittedForms: {}, // @for future use + }; + + constructor(sessionId: string) { + if (!sessionId) { + throw new Error("sessionId is not set"); + } + this.sessionId = sessionId; + } + + listen = (fn: Listener) => { + this.listeners.add(fn); + return () => { + this.listeners.delete(fn); + }; + }; + + notify = () => { + for (const listener of this.listeners) { + console.log("notifying"); + listener(this.state); + } + }; + + getSnapshot = () => { + return this.state; + }; + + reset() { + this.imm((draft) => { + draft.messages = []; + draft.currentUserMessage = null; + draft.conversationInfo = null; + draft.lastServerMessageId = null; + }); + this.notify(); + } + + genId = (len = 7) => { + return Math.random().toString(36).substring(len); + }; + + getTimeStamp = () => { + return new Date().toLocaleTimeString("en-US", { + formatMatcher: "best fit", + hour: "numeric", + minute: "numeric", + }); + }; + + isLoading = () => { + return ( + this.state.messages.find((m) => m.from === "bot" && m.isLoading) !== + undefined + ); + }; + + protected imm(func: (draft: State) => void) { + produce(this.state, func); + } + + select = (key: KT) => { + return this.state[key]; + }; + + setConversationInfo = (info: string) => { + this.imm((draft) => { + draft.conversationInfo = info; + }); + }; + + settle = (idFor: string) => { + this.imm((draft) => { + draft.currentUserMessage = null; + const botMessage = draft.messages.find( + (m) => m.from === "bot" && m.responseFor === idFor + ) as BotMessageType; + if (botMessage) { + botMessage.isLoading = false; + } + }); + this.notify(); + }; + + createEmptyLoadingBotMessage = (messageFor: string) => { + this.imm((draft) => { + draft.messages.push({ + from: "bot", + message: "", + timestamp: this.getTimeStamp(), + responseFor: messageFor, + isLoading: true, + id: this.genId(), + }); + }); + this.notify(); + }; + + setLastServerMessageId = (id: string | null) => { + this.imm((draft) => { + draft.lastServerMessageId = id; + }); + this.notify(); + }; + + handleMessage = ( + message: Omit, + socket: Socket + ) => { + const sessionId = this.sessionId; + if (!sessionId) { + return; + } + this.setLastServerMessageId(null); + const id = this.genId(); + const userMessage: UserMessageType = { + ...message, + from: "user", + session_id: sessionId, + id: id, + timestamp: this.getTimeStamp(), + }; + + this.imm((draft) => { + draft.messages.push(userMessage); + draft.currentUserMessage = userMessage; + }); + + socket.emit("send_chat", userMessage); + this.createEmptyLoadingBotMessage(userMessage.id); + this.notify(); + }; + + appendToCurrentBotMessage = (message: string) => { + const curretUserMessage = this.state.currentUserMessage; + + if (message === "|im_end|") { + curretUserMessage && this.settle(curretUserMessage.id); + return; + } + + this.imm((draft) => { + const userMessage = draft.messages.find( + (m) => m.from === "user" && m.id === curretUserMessage?.id + ) as UserMessageType; + + if (userMessage) { + const botMessage = draft.messages.find( + (m) => m.from === "bot" && m.responseFor === userMessage.id + ) as BotMessageType; + + if (botMessage) { + botMessage.message += message; + } + } + }); + this.notify(); + }; + // socket handlers impl. + socketChatInfoHandler = (socket: Socket) => { + const sessionId = this.sessionId; + const setConversationInfo = this.setConversationInfo; + function handler(msg: string) { + // extra logic + setConversationInfo(msg); + } + socket.on(`${sessionId}_info`, handler); + return () => { + socket.off(`${sessionId}_info`, handler); + }; + }; + + socketChatVoteHandler = (socket: Socket) => { + const sessionId = this.sessionId; + if (!sessionId) { + return; + } + const setLastServerMessageId = this.setLastServerMessageId; + function handle(msg: string) { + setLastServerMessageId(msg); + } + + socket.on(`${sessionId}_vote`, handle); + + return () => { + socket.off(`${sessionId}_vote`, handle); + }; + }; + + socketMessageRespHandler = (socket: Socket) => { + const sessionId = this.sessionId; + if (!sessionId) { + return; + } + const appendToCurrentBotMessage = this.appendToCurrentBotMessage; + function handle(content: string) { + appendToCurrentBotMessage(content); + } + + socket.on(sessionId, handle); + + return () => { + socket.off(sessionId, handle); + }; + }; +} diff --git a/copilot-widget/lib/contexts/statefulMessageHandler.tsx b/copilot-widget/lib/contexts/statefulMessageHandler.tsx new file mode 100644 index 000000000..2a7af3c65 --- /dev/null +++ b/copilot-widget/lib/contexts/statefulMessageHandler.tsx @@ -0,0 +1,72 @@ +import { useEffect, useMemo, useSyncExternalStore } from "react"; +import { ChatController } from "./messageHandler"; +import { createSafeContext } from "./createSafeContext"; +import { useSessionId } from "@lib/hooks/useSessionId"; +import { useConfigData } from "./ConfigData"; +import { useSocket } from "./SocketProvider"; + +const [useMessageHandler, MessageHandlerSafeProvider] = createSafeContext<{ + __handler: ChatController; +}>(); + +function MessageHandlerProvider(props: { children: React.ReactNode }) { + const { token } = useConfigData(); + const { sessionId } = useSessionId(token); + const { __socket } = useSocket(); + + const handler = useMemo(() => new ChatController(sessionId), [sessionId]); + + useEffect(() => { + return handler.socketChatInfoHandler(__socket); + }, [__socket, handler]); + + useEffect(() => { + return handler.socketChatVoteHandler(__socket); + }, [__socket, handler]); + + useEffect(() => { + return handler.socketMessageRespHandler(__socket); + }, [__socket, handler]); + + return ( + + ); +} + +function useChatState() { + const { __handler } = useMessageHandler(); + return useSyncExternalStore(__handler.listen, __handler.getSnapshot); +} + +function useSendMessage() { + const { __handler } = useMessageHandler(); + const { headers, token } = useConfigData(); + const socket = useSocket(); + + function send(content: string) { + __handler.handleMessage( + { + headers: headers ?? {}, + content, + bot_token: token, + }, + socket.__socket + ); + } + + return { + send, + }; +} +// eslint-disable-next-line react-refresh/only-export-components +export { + useMessageHandler, + MessageHandlerProvider, + useChatState, + useSendMessage, +}; From 8ee15ef3dcd16b144f85e79fcda1deb1fbeea16a Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Mon, 4 Mar 2024 05:08:06 +0200 Subject: [PATCH 07/51] feat: Add MessageHandlerProvider to Root component --- copilot-widget/lib/Root.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/copilot-widget/lib/Root.tsx b/copilot-widget/lib/Root.tsx index 87a8357d4..28d882086 100644 --- a/copilot-widget/lib/Root.tsx +++ b/copilot-widget/lib/Root.tsx @@ -9,6 +9,7 @@ import css from "../styles/index.css?inline"; import { LanguageProvider } from "./contexts/LocalesProvider"; import { get } from "./utils/pkg"; import { SocketProvider } from "./contexts/SocketProvider"; +import { MessageHandlerProvider } from "./contexts/statefulMessageHandler"; const version = get("version"); const cssColors = { @@ -44,7 +45,9 @@ function Root({ children, options, containerProps }: RootProps) { - {children} + + {children} + From b471088f56a6cfb20fd1977ab85c46deb4f2233d Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Mon, 4 Mar 2024 05:08:33 +0200 Subject: [PATCH 08/51] Fix import order in SocketProvider.tsx --- copilot-widget/lib/contexts/SocketProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/copilot-widget/lib/contexts/SocketProvider.tsx b/copilot-widget/lib/contexts/SocketProvider.tsx index 15677dbfb..a6ef61de3 100644 --- a/copilot-widget/lib/contexts/SocketProvider.tsx +++ b/copilot-widget/lib/contexts/SocketProvider.tsx @@ -1,9 +1,9 @@ import { Socket } from "socket.io-client"; -import { createSafeContext } from "./create-safe-context"; import { ReactNode, useMemo } from "react"; import { createSocketClient } from "@lib/utils/createSocket"; import { useConfigData } from "./ConfigData"; import { useSessionId } from "@lib/hooks/useSessionId"; +import { createSafeContext } from "./createSafeContext"; type SocketContextData = { __socket: Socket; From d3fc90470583d6c1a4bdace2636d18a09031d08f Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Mon, 4 Mar 2024 05:08:59 +0200 Subject: [PATCH 09/51] feat: Add new message handling functions and state vars --- .../lib/components/ChatInputFooter.tsx | 36 +++++++---------- copilot-widget/lib/components/Messages.tsx | 10 +++-- copilot-widget/lib/screens/ChatScreen.tsx | 39 ++++--------------- 3 files changed, 26 insertions(+), 59 deletions(-) diff --git a/copilot-widget/lib/components/ChatInputFooter.tsx b/copilot-widget/lib/components/ChatInputFooter.tsx index 8cf5ccd1c..475719458 100644 --- a/copilot-widget/lib/components/ChatInputFooter.tsx +++ b/copilot-widget/lib/components/ChatInputFooter.tsx @@ -1,9 +1,7 @@ import TextareaAutosize from "react-textarea-autosize"; import { SendHorizonal, AlertTriangle, RotateCcw } from "lucide-react"; -import { useChat } from "../contexts/Controller"; import { useRef, useState } from "react"; -import { getId, isEmpty } from "@lib/utils/utils"; -import { now } from "@lib/utils/time"; +import { isEmpty } from "@lib/utils/utils"; import { useDocumentDirection } from "@lib/hooks/useDocumentDirection"; import { VoiceRecorder } from "./VoiceRecorder"; import { useInitialData } from "@lib/hooks/useInitialData"; @@ -16,11 +14,16 @@ import { } from "./Dialog"; import { Button } from "./Button"; import { useLang } from "@lib/contexts/LocalesProvider"; +import { + useChatState, + useMessageHandler, + useSendMessage, +} from "@lib/contexts/statefulMessageHandler"; function MessageSuggestions() { const { data } = useInitialData(); - const { messages, sendMessage } = useChat(); - + const { messages } = useChatState(); + const { send } = useSendMessage(); return ( <> {isEmpty(messages) && !isEmpty(data?.initial_questions) && ( @@ -30,12 +33,7 @@ function MessageSuggestions() { className="text-sm font-medium whitespace-nowrap px-2.5 py-1.5 rounded-lg bg-accent text-primary" key={index} onClick={() => { - sendMessage({ - from: "user", - content: q, - id: getId(), - timestamp: now(), - }); + send(q); }} > {q} @@ -47,7 +45,7 @@ function MessageSuggestions() { ); } function ResetButtonWithConfirmation() { - const { reset } = useChat(); + const { __handler: mh } = useMessageHandler(); const [open, setOpen] = useState(false); const { get } = useLang(); return ( @@ -69,7 +67,7 @@ function ResetButtonWithConfirmation() { asChild variant="destructive" className="font-semibold" - onClick={reset} + onClick={mh.reset} > {get("yes-reset")} @@ -84,8 +82,7 @@ function ResetButtonWithConfirmation() { function ChatInputFooter() { const [input, setInput] = useState(""); const textAreaRef = useRef(null); - const { sendMessage } = useChat(); - const { loading } = useChat(); + const { send } = useSendMessage(); const canSend = input.trim().length > 0; const { direction } = useDocumentDirection(); @@ -99,12 +96,7 @@ function ChatInputFooter() { function handleInputSubmit() { if (input.trim().length > 0) { setInput(""); - sendMessage({ - from: "user", - content: input, - id: getId(), - timestamp: now(), - }); + send(input); } } return ( @@ -123,7 +115,6 @@ function ChatInputFooter() { handleInputSubmit(); } }} - disabled={loading} maxRows={4} rows={1} value={input} @@ -140,7 +131,6 @@ function ChatInputFooter() { diff --git a/copilot-widget/lib/components/Messages.tsx b/copilot-widget/lib/components/Messages.tsx index 59da750f5..feb599238 100644 --- a/copilot-widget/lib/components/Messages.tsx +++ b/copilot-widget/lib/components/Messages.tsx @@ -4,12 +4,12 @@ import remarkGfm from "remark-gfm"; import { UserIcon } from "lucide-react"; import cn from "../utils/cn"; import { formatTimeFromTimestamp } from "../utils/time"; -import { useChat } from "@lib/contexts/Controller"; import { getLast, isEmpty } from "@lib/utils/utils"; import { useConfigData } from "@lib/contexts/ConfigData"; import useTypeWriter from "@lib/hooks/useTypeWriter"; import { Vote } from "./Vote"; import { Grid } from "react-loader-spinner"; +import { useChatState } from "@lib/contexts/statefulMessageHandler"; function BotIcon({ error }: { error?: boolean }) { const config = useConfigData(); @@ -73,8 +73,8 @@ export function BotTextMessage({ timestamp?: number | Date; id?: string | number; }) { - const { messages, lastMessageToVote } = useChat(); - const isLast = getLast(messages)?.id === id; + const { messages, lastServerMessageId } = useChatState(); + const isLast = getLast(messages.filter((m) => m.from === "bot"))?.id === id; const config = useConfigData(); if (isEmpty(message)) return null; @@ -100,7 +100,9 @@ export function BotTextMessage({ {isLast && (
{config?.bot?.name ?? "Bot"} - {lastMessageToVote && } + {lastServerMessageId && ( + + )}
)} diff --git a/copilot-widget/lib/screens/ChatScreen.tsx b/copilot-widget/lib/screens/ChatScreen.tsx index 75deae97b..799e51bf1 100644 --- a/copilot-widget/lib/screens/ChatScreen.tsx +++ b/copilot-widget/lib/screens/ChatScreen.tsx @@ -1,24 +1,19 @@ import ChatHeader from "../components/ChatHeader"; import { useEffect, useRef } from "react"; -import { - BotMessageError, - BotMessageLoading, - BotTextMessage, - UserMessage, -} from "../components/Messages"; +import { BotTextMessage, UserMessage } from "../components/Messages"; import useScrollToPercentage from "../hooks/useScrollTo"; import ChatInputFooter from "../components/ChatInputFooter"; -import { ChatProvider, useChat } from "../contexts/Controller"; import { useConfigData } from "../contexts/ConfigData"; import { Map } from "../utils/map"; +import { useChatState } from "@lib/contexts/statefulMessageHandler"; -function ChatScreen() { +export default function ChatScreen() { const scrollElementRef = useRef(null); const [setPos] = useScrollToPercentage(scrollElementRef); - const { messages, loading, failedMessage, conversationInfo } = useChat(); + const { messages } = useChatState(); const config = useConfigData(); const initialMessage = config?.initialMessage; - + console.log(messages); useEffect(() => { setPos(0, 100); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -44,42 +39,22 @@ function ChatScreen() { fallback={
} data={messages} render={(message, index) => { - if (message.from === "bot" && message.type === "text") { - return ( - - ); + if (message.from === "bot") { + return ; } else if (message.from === "user") { return ( ); } }} /> - {loading && conversationInfo && ( - - )} - {failedMessage && } ); } - -export default function Widget() { - return ( - - - - ); -} From d0fd19a3a8a833e6cf18bdacf3e8dcffd9524de4 Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Mon, 4 Mar 2024 05:09:11 +0200 Subject: [PATCH 10/51] docs: Remove unnecessary empty line in messageTypes file --- copilot-widget/lib/types/messageTypes.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/copilot-widget/lib/types/messageTypes.ts b/copilot-widget/lib/types/messageTypes.ts index 331bb19ef..d53b4b796 100644 --- a/copilot-widget/lib/types/messageTypes.ts +++ b/copilot-widget/lib/types/messageTypes.ts @@ -9,7 +9,6 @@ export type BotResponse = { text: string; }; }; - export type UserMessage = { id: string | number; timestamp: TS; From d60bc74cda828c1636d9ff59fe32ddfbace3b0d2 Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Wed, 6 Mar 2024 01:20:49 +0200 Subject: [PATCH 11/51] Update useSyncExternalStore to use __handler.subscribe instead of __handler.listen --- copilot-widget/lib/contexts/statefulMessageHandler.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/copilot-widget/lib/contexts/statefulMessageHandler.tsx b/copilot-widget/lib/contexts/statefulMessageHandler.tsx index 2a7af3c65..17a907e29 100644 --- a/copilot-widget/lib/contexts/statefulMessageHandler.tsx +++ b/copilot-widget/lib/contexts/statefulMessageHandler.tsx @@ -40,7 +40,7 @@ function MessageHandlerProvider(props: { children: React.ReactNode }) { function useChatState() { const { __handler } = useMessageHandler(); - return useSyncExternalStore(__handler.listen, __handler.getSnapshot); + return useSyncExternalStore(__handler.subscribe, __handler.getSnapshot); } function useSendMessage() { @@ -49,7 +49,7 @@ function useSendMessage() { const socket = useSocket(); function send(content: string) { - __handler.handleMessage( + __handler.handleTextMessage( { headers: headers ?? {}, content, From 047e9141823450e95fda3ea84d0eaa71c5dfa911 Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Wed, 6 Mar 2024 01:21:51 +0200 Subject: [PATCH 12/51] refactor: Remove unnecessary console.log from LanguageProvider --- copilot-widget/lib/contexts/LocalesProvider.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/copilot-widget/lib/contexts/LocalesProvider.tsx b/copilot-widget/lib/contexts/LocalesProvider.tsx index 526b1e76c..3b9045af9 100644 --- a/copilot-widget/lib/contexts/LocalesProvider.tsx +++ b/copilot-widget/lib/contexts/LocalesProvider.tsx @@ -9,7 +9,6 @@ const [useLang, SafeLanguageProvider] = createSafeContext<{ function LanguageProvider({ children }: { children: React.ReactNode }) { const config = useConfigData(); - console.log(config); return ( Date: Wed, 6 Mar 2024 01:22:04 +0200 Subject: [PATCH 13/51] feat: Add ComponentRegistery in ChatController --- .../lib/contexts/messageHandler.tsx | 77 +++++++++++-------- 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/copilot-widget/lib/contexts/messageHandler.tsx b/copilot-widget/lib/contexts/messageHandler.tsx index c71b9ce74..03d82e405 100644 --- a/copilot-widget/lib/contexts/messageHandler.tsx +++ b/copilot-widget/lib/contexts/messageHandler.tsx @@ -1,5 +1,6 @@ import { produce } from "immer"; import { Socket } from "socket.io-client"; +import { ComponentRegistery } from "./componentRegistery"; export type UserMessageType = { from: "user"; @@ -20,31 +21,35 @@ export type BotMessageType = { isFailed?: boolean; isLoading?: boolean; }; + export type MessageType = UserMessageType | BotMessageType; +export type ComponentType
= { + key: string; + data?: DT; +}; + export type State = { currentUserMessage: null | UserMessageType; conversationInfo: null | string; lastServerMessageId: null | string; messages: MessageType[]; clientState: Record; // @for future use - components: Record; // @for future use - submittedForms: Record; // @for future use }; -type Listener = (s: T) => void; +type Listener = (value: T) => void; +type UpdaterFunction = (oldValue: T) => T; export class ChatController { sessionId: string | null = null; - listeners = new Set>(); + listeners = new Set(); + components = new ComponentRegistery({}); private state: State = { currentUserMessage: null, conversationInfo: null, lastServerMessageId: null, messages: [], - clientState: {}, // @for future use - components: {}, // @for future use - submittedForms: {}, // @for future use + clientState: {}, }; constructor(sessionId: string) { @@ -54,18 +59,19 @@ export class ChatController { this.sessionId = sessionId; } - listen = (fn: Listener) => { - this.listeners.add(fn); - return () => { - this.listeners.delete(fn); - }; + notify = () => { + this.listeners.forEach((l) => l(this.state)); }; - notify = () => { - for (const listener of this.listeners) { - console.log("notifying"); - listener(this.state); - } + unSubscribe = (listener: Listener) => { + this.listeners.delete(listener); + }; + + subscribe = (listener: Listener) => { + this.listeners.add(listener); + return () => { + this.unSubscribe(listener); + }; }; getSnapshot = () => { @@ -73,13 +79,12 @@ export class ChatController { }; reset() { - this.imm((draft) => { + this.setValueImmer((draft) => { draft.messages = []; draft.currentUserMessage = null; draft.conversationInfo = null; draft.lastServerMessageId = null; }); - this.notify(); } genId = (len = 7) => { @@ -101,22 +106,31 @@ export class ChatController { ); }; - protected imm(func: (draft: State) => void) { - produce(this.state, func); - } + setValue = (newValue: State | UpdaterFunction) => { + if (typeof newValue === "function") { + this.state = (newValue as UpdaterFunction)(this.state); + } else { + this.state = newValue; + } + this.notify(); + }; + + setValueImmer = (updater: (draft: State) => void) => { + this.setValue(produce(this.state, updater)); + }; select = (key: KT) => { return this.state[key]; }; setConversationInfo = (info: string) => { - this.imm((draft) => { + this.setValueImmer((draft) => { draft.conversationInfo = info; }); }; settle = (idFor: string) => { - this.imm((draft) => { + this.setValueImmer((draft) => { draft.currentUserMessage = null; const botMessage = draft.messages.find( (m) => m.from === "bot" && m.responseFor === idFor @@ -125,11 +139,10 @@ export class ChatController { botMessage.isLoading = false; } }); - this.notify(); }; createEmptyLoadingBotMessage = (messageFor: string) => { - this.imm((draft) => { + this.setValueImmer((draft) => { draft.messages.push({ from: "bot", message: "", @@ -139,17 +152,15 @@ export class ChatController { id: this.genId(), }); }); - this.notify(); }; setLastServerMessageId = (id: string | null) => { - this.imm((draft) => { + this.setValueImmer((draft) => { draft.lastServerMessageId = id; }); - this.notify(); }; - handleMessage = ( + handleTextMessage = ( message: Omit, socket: Socket ) => { @@ -167,14 +178,13 @@ export class ChatController { timestamp: this.getTimeStamp(), }; - this.imm((draft) => { + this.setValueImmer((draft) => { draft.messages.push(userMessage); draft.currentUserMessage = userMessage; }); socket.emit("send_chat", userMessage); this.createEmptyLoadingBotMessage(userMessage.id); - this.notify(); }; appendToCurrentBotMessage = (message: string) => { @@ -185,7 +195,7 @@ export class ChatController { return; } - this.imm((draft) => { + this.setValueImmer((draft) => { const userMessage = draft.messages.find( (m) => m.from === "user" && m.id === curretUserMessage?.id ) as UserMessageType; @@ -200,7 +210,6 @@ export class ChatController { } } }); - this.notify(); }; // socket handlers impl. socketChatInfoHandler = (socket: Socket) => { From 7d92664301457c815a30aa83d76d9248674b8e24 Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Wed, 6 Mar 2024 01:22:52 +0200 Subject: [PATCH 14/51] first step toward making the widget completly customizable through components --- .../lib/@components/Text.component.tsx | 30 ++++++++++++++ copilot-widget/lib/components/Messages.tsx | 30 +++++++++++++- .../lib/contexts/componentRegistery.ts | 40 +++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 copilot-widget/lib/@components/Text.component.tsx create mode 100644 copilot-widget/lib/contexts/componentRegistery.ts diff --git a/copilot-widget/lib/@components/Text.component.tsx b/copilot-widget/lib/@components/Text.component.tsx new file mode 100644 index 000000000..509258f42 --- /dev/null +++ b/copilot-widget/lib/@components/Text.component.tsx @@ -0,0 +1,30 @@ +import { BotMessageWrapper } from "@lib/components/Messages"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +type Props = { + message: string; + id: string | number; +}; +/** + * The Basic Text component + */ +export function Text(props: Props) { + const { message, id } = props; + return ( + +
+
+
+ + {message} + +
+
+
+
+ ); +} diff --git a/copilot-widget/lib/components/Messages.tsx b/copilot-widget/lib/components/Messages.tsx index feb599238..4fa8f8a8a 100644 --- a/copilot-widget/lib/components/Messages.tsx +++ b/copilot-widget/lib/components/Messages.tsx @@ -11,7 +11,7 @@ import { Vote } from "./Vote"; import { Grid } from "react-loader-spinner"; import { useChatState } from "@lib/contexts/statefulMessageHandler"; -function BotIcon({ error }: { error?: boolean }) { +export function BotIcon({ error }: { error?: boolean }) { const config = useConfigData(); return ( @@ -64,7 +64,35 @@ function User() { ); } +export function BotMessageWrapper({ + children, + id, +}: { + children: React.ReactNode; + id: string | number; +}) { + const { messages, lastServerMessageId } = useChatState(); + const isLast = getLast(messages.filter((m) => m.from === "bot"))?.id === id; + const config = useConfigData(); + + return ( +
+
+ +
{children}
+
+ {isLast && ( +
+ {config?.bot?.name ?? "Bot"} + {lastServerMessageId && ( + + )} +
+ )} +
+ ); +} export function BotTextMessage({ message, id, diff --git a/copilot-widget/lib/contexts/componentRegistery.ts b/copilot-widget/lib/contexts/componentRegistery.ts new file mode 100644 index 000000000..5d5798629 --- /dev/null +++ b/copilot-widget/lib/contexts/componentRegistery.ts @@ -0,0 +1,40 @@ +import { Text } from "@lib/@components/Text.component"; +import React from "react"; + +export type ComponentType = { + key: string; + component: React.ElementType; +}; +type OptionsType = { + components?: ComponentType[]; +}; +/** + * this a singleton class helps me to easily control the components present/available in the widget. + * it also manges the various types of components to be added along the way. + */ +export class ComponentRegistery { + components: ComponentType[] = [ + { + key: "TEXT", + component: Text, + }, + ]; + + constructor(opts: OptionsType) { + const { components } = opts; + + if (components) { + components.forEach((c) => this.register(c)); + } + } + + register(com: ComponentType) { + const c = this.components.find((f) => f.key === com.key); + if (c) return; + this.components.push(com); + } + + get(key: string) { + return this.components.find((c) => c.key === key); + } +} From 8000da6483ec36db81a596d6de75f5387f2f59bc Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Wed, 6 Mar 2024 02:46:34 +0200 Subject: [PATCH 15/51] refactor: Update handling of bot messages in ChatController --- copilot-widget/lib/components/Messages.tsx | 4 +- .../lib/contexts/messageHandler.tsx | 101 ++++++++++-------- copilot-widget/lib/screens/ChatScreen.tsx | 4 +- 3 files changed, 60 insertions(+), 49 deletions(-) diff --git a/copilot-widget/lib/components/Messages.tsx b/copilot-widget/lib/components/Messages.tsx index 4fa8f8a8a..1784bb44a 100644 --- a/copilot-widget/lib/components/Messages.tsx +++ b/copilot-widget/lib/components/Messages.tsx @@ -17,7 +17,7 @@ export function BotIcon({ error }: { error?: boolean }) { return ( ); diff --git a/copilot-widget/lib/contexts/messageHandler.tsx b/copilot-widget/lib/contexts/messageHandler.tsx index 03d82e405..fb25f08dd 100644 --- a/copilot-widget/lib/contexts/messageHandler.tsx +++ b/copilot-widget/lib/contexts/messageHandler.tsx @@ -14,12 +14,12 @@ export type UserMessageType = { export type BotMessageType = { from: "bot"; + type: string; id: string; - message: string; + props?: Record; timestamp: string; responseFor: string; // id of the user message isFailed?: boolean; - isLoading?: boolean; }; export type MessageType = UserMessageType | BotMessageType; @@ -100,10 +100,7 @@ export class ChatController { }; isLoading = () => { - return ( - this.state.messages.find((m) => m.from === "bot" && m.isLoading) !== - undefined - ); + return this.state.currentUserMessage !== null; }; setValue = (newValue: State | UpdaterFunction) => { @@ -129,28 +126,9 @@ export class ChatController { }); }; - settle = (idFor: string) => { + settle = () => { this.setValueImmer((draft) => { draft.currentUserMessage = null; - const botMessage = draft.messages.find( - (m) => m.from === "bot" && m.responseFor === idFor - ) as BotMessageType; - if (botMessage) { - botMessage.isLoading = false; - } - }); - }; - - createEmptyLoadingBotMessage = (messageFor: string) => { - this.setValueImmer((draft) => { - draft.messages.push({ - from: "bot", - message: "", - timestamp: this.getTimeStamp(), - responseFor: messageFor, - isLoading: true, - id: this.genId(), - }); }); }; @@ -165,9 +143,8 @@ export class ChatController { socket: Socket ) => { const sessionId = this.sessionId; - if (!sessionId) { - return; - } + if (!sessionId) return; + this.setLastServerMessageId(null); const id = this.genId(); const userMessage: UserMessageType = { @@ -184,32 +161,59 @@ export class ChatController { }); socket.emit("send_chat", userMessage); - this.createEmptyLoadingBotMessage(userMessage.id); }; + // Called for every character recived from the bot appendToCurrentBotMessage = (message: string) => { - const curretUserMessage = this.state.currentUserMessage; + const currentUserMessage = this.state.currentUserMessage; - if (message === "|im_end|") { - curretUserMessage && this.settle(curretUserMessage.id); + if (!currentUserMessage) { return; } - this.setValueImmer((draft) => { - const userMessage = draft.messages.find( - (m) => m.from === "user" && m.id === curretUserMessage?.id - ) as UserMessageType; - - if (userMessage) { - const botMessage = draft.messages.find( - (m) => m.from === "bot" && m.responseFor === userMessage.id + // Append the message content to the existing botmessage.type=TEXT or create a new one + const botMessage = this.select("messages").find( + (msg) => msg.id === currentUserMessage.id + ) as BotMessageType; + if (botMessage) { + this.setValueImmer((draft) => { + const d = draft.messages.find( + (msg) => + msg.id === currentUserMessage.id && + msg.from === "bot" && + msg.type === "TEXT" ) as BotMessageType; - - if (botMessage) { - botMessage.message += message; + if (d) { + d.props = { + message: (d.props?.message || "") + message, + }; + } else { + draft.messages.push({ + from: "bot", + id: currentUserMessage.id, + type: "TEXT", + responseFor: currentUserMessage.id, + timestamp: this.getTimeStamp(), + props: { + message: message, + }, + }); } - } - }); + }); + } else { + this.setValueImmer((draft) => { + draft.messages.push({ + from: "bot", + id: currentUserMessage.id, + type: "TEXT", + responseFor: currentUserMessage.id, + timestamp: this.getTimeStamp(), + props: { + content: message, + }, + }); + }); + } }; // socket handlers impl. socketChatInfoHandler = (socket: Socket) => { @@ -248,7 +252,12 @@ export class ChatController { return; } const appendToCurrentBotMessage = this.appendToCurrentBotMessage; + const settle = this.settle; function handle(content: string) { + if (content === "|im_end|") { + settle(); + return; + } appendToCurrentBotMessage(content); } diff --git a/copilot-widget/lib/screens/ChatScreen.tsx b/copilot-widget/lib/screens/ChatScreen.tsx index 799e51bf1..826f19ee4 100644 --- a/copilot-widget/lib/screens/ChatScreen.tsx +++ b/copilot-widget/lib/screens/ChatScreen.tsx @@ -40,7 +40,9 @@ export default function ChatScreen() { data={messages} render={(message, index) => { if (message.from === "bot") { - return ; + return ( + + ); } else if (message.from === "user") { return ( Date: Wed, 6 Mar 2024 19:49:15 +0200 Subject: [PATCH 16/51] feat: Add component registration to ChatController --- copilot-widget/lib/contexts/componentRegistery.ts | 11 ++++++++--- copilot-widget/lib/contexts/messageHandler.tsx | 5 +++-- .../lib/contexts/statefulMessageHandler.tsx | 13 ++++++++++--- copilot-widget/lib/types/options.ts | 2 ++ copilot-widget/src/main.tsx | 8 +++++--- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/copilot-widget/lib/contexts/componentRegistery.ts b/copilot-widget/lib/contexts/componentRegistery.ts index 5d5798629..a81ae4362 100644 --- a/copilot-widget/lib/contexts/componentRegistery.ts +++ b/copilot-widget/lib/contexts/componentRegistery.ts @@ -29,9 +29,14 @@ export class ComponentRegistery { } register(com: ComponentType) { - const c = this.components.find((f) => f.key === com.key); - if (c) return; - this.components.push(com); + // Replace the key if already exists + const index = this.components.findIndex((c) => c.key === com.key); + if (index !== -1) { + this.components[index] = com; + } else { + this.components.push(com); + } + return this; } get(key: string) { diff --git a/copilot-widget/lib/contexts/messageHandler.tsx b/copilot-widget/lib/contexts/messageHandler.tsx index fb25f08dd..84e7ec893 100644 --- a/copilot-widget/lib/contexts/messageHandler.tsx +++ b/copilot-widget/lib/contexts/messageHandler.tsx @@ -42,7 +42,7 @@ type UpdaterFunction = (oldValue: T) => T; export class ChatController { sessionId: string | null = null; listeners = new Set(); - components = new ComponentRegistery({}); + components: ComponentRegistery | undefined; private state: State = { currentUserMessage: null, @@ -52,11 +52,12 @@ export class ChatController { clientState: {}, }; - constructor(sessionId: string) { + constructor(sessionId: string, components?: ComponentRegistery) { if (!sessionId) { throw new Error("sessionId is not set"); } this.sessionId = sessionId; + this.components = components; } notify = () => { diff --git a/copilot-widget/lib/contexts/statefulMessageHandler.tsx b/copilot-widget/lib/contexts/statefulMessageHandler.tsx index 17a907e29..ffe9cda06 100644 --- a/copilot-widget/lib/contexts/statefulMessageHandler.tsx +++ b/copilot-widget/lib/contexts/statefulMessageHandler.tsx @@ -1,19 +1,25 @@ +/* eslint-disable react-refresh/only-export-components */ import { useEffect, useMemo, useSyncExternalStore } from "react"; import { ChatController } from "./messageHandler"; import { createSafeContext } from "./createSafeContext"; import { useSessionId } from "@lib/hooks/useSessionId"; import { useConfigData } from "./ConfigData"; import { useSocket } from "./SocketProvider"; +import { ComponentRegistery } from "./componentRegistery"; const [useMessageHandler, MessageHandlerSafeProvider] = createSafeContext<{ __handler: ChatController; + __components: ComponentRegistery; }>(); function MessageHandlerProvider(props: { children: React.ReactNode }) { - const { token } = useConfigData(); + const { token, components } = useConfigData(); const { sessionId } = useSessionId(token); const { __socket } = useSocket(); - + const __components = useMemo( + () => new ComponentRegistery({ components }), + [components] + ); const handler = useMemo(() => new ChatController(sessionId), [sessionId]); useEffect(() => { @@ -32,6 +38,7 @@ function MessageHandlerProvider(props: { children: React.ReactNode }) { @@ -63,7 +70,7 @@ function useSendMessage() { send, }; } -// eslint-disable-next-line react-refresh/only-export-components + export { useMessageHandler, MessageHandlerProvider, diff --git a/copilot-widget/lib/types/options.ts b/copilot-widget/lib/types/options.ts index 8e41e0c67..68dea5193 100644 --- a/copilot-widget/lib/types/options.ts +++ b/copilot-widget/lib/types/options.ts @@ -1,3 +1,4 @@ +import { ComponentType } from "@lib/contexts/componentRegistery"; import type { LangType } from "@lib/locales"; export type Options = { @@ -14,6 +15,7 @@ export type Options = { React.HTMLAttributes, HTMLDivElement >; + components?: ComponentType[]; user?: { name?: string; avatarUrl?: string; diff --git a/copilot-widget/src/main.tsx b/copilot-widget/src/main.tsx index 2cf64360b..1b281d8a9 100644 --- a/copilot-widget/src/main.tsx +++ b/copilot-widget/src/main.tsx @@ -1,11 +1,13 @@ import { createRoot } from "react-dom/client"; -import { Options } from "../lib/types"; +import type { Options as BaseOptions } from "../lib/types"; import Root from "../lib/Root"; import { CopilotWidget } from "../lib/CopilotWidget"; import { composeRoot } from "./utils"; const defaultRootId = "opencopilot-root"; - +interface Options extends Omit { + rootId?: string; +} declare global { interface Window { initAiCoPilot: typeof initAiCoPilot; @@ -21,7 +23,7 @@ function initAiCoPilot({ containerProps, rootId, ...options -}: Options & { rootId?: string }) { +}: Options) { const container = composeRoot(rootId ?? defaultRootId, rootId === undefined); createRoot(container).render( Date: Wed, 6 Mar 2024 21:40:53 +0200 Subject: [PATCH 17/51] refactor: Improve component retrieval in get method --- copilot-widget/lib/contexts/componentRegistery.ts | 4 +++- copilot-widget/lib/screens/ChatScreen.tsx | 12 +++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/copilot-widget/lib/contexts/componentRegistery.ts b/copilot-widget/lib/contexts/componentRegistery.ts index a81ae4362..d6333e66e 100644 --- a/copilot-widget/lib/contexts/componentRegistery.ts +++ b/copilot-widget/lib/contexts/componentRegistery.ts @@ -40,6 +40,8 @@ export class ComponentRegistery { } get(key: string) { - return this.components.find((c) => c.key === key); + const c = this.components.find((c) => c.key === key); + if (c) return c; + return null; } } diff --git a/copilot-widget/lib/screens/ChatScreen.tsx b/copilot-widget/lib/screens/ChatScreen.tsx index 826f19ee4..e30b5661c 100644 --- a/copilot-widget/lib/screens/ChatScreen.tsx +++ b/copilot-widget/lib/screens/ChatScreen.tsx @@ -5,7 +5,10 @@ import useScrollToPercentage from "../hooks/useScrollTo"; import ChatInputFooter from "../components/ChatInputFooter"; import { useConfigData } from "../contexts/ConfigData"; import { Map } from "../utils/map"; -import { useChatState } from "@lib/contexts/statefulMessageHandler"; +import { + useChatState, + useMessageHandler, +} from "@lib/contexts/statefulMessageHandler"; export default function ChatScreen() { const scrollElementRef = useRef(null); @@ -13,7 +16,7 @@ export default function ChatScreen() { const { messages } = useChatState(); const config = useConfigData(); const initialMessage = config?.initialMessage; - console.log(messages); + const { __components } = useMessageHandler(); useEffect(() => { setPos(0, 100); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -40,9 +43,8 @@ export default function ChatScreen() { data={messages} render={(message, index) => { if (message.from === "bot") { - return ( - - ); + const Component = __components.get(message.type)?.component; + return ; } else if (message.from === "user") { return ( Date: Wed, 6 Mar 2024 22:11:19 +0200 Subject: [PATCH 18/51] fix: Add error handling for missing render function --- copilot-widget/lib/utils/map.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/copilot-widget/lib/utils/map.ts b/copilot-widget/lib/utils/map.ts index fb30c388d..846a9efa4 100644 --- a/copilot-widget/lib/utils/map.ts +++ b/copilot-widget/lib/utils/map.ts @@ -1,14 +1,18 @@ import { ReactNode } from "react"; +type RenderFunction = (item: T, index: number) => ReactNode; interface MapProps { data: T[]; - render: (item: T, index: number) => ReactNode; + render: RenderFunction; fallback?: ReactNode; } export function Map({ data, render, fallback }: MapProps) { + if (typeof render !== "function") { + throw new Error("render function is required"); + } if (data.length === 0) { - return fallback; + return fallback || null; } return data.map((item, index) => render(item, index)); From 9e16ab42cb20715e86c045dd9ca73a6c05849757 Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Wed, 6 Mar 2024 22:11:34 +0200 Subject: [PATCH 19/51] feat: Add Fallback component for copilot widget --- .../lib/@components/Fallback.component.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 copilot-widget/lib/@components/Fallback.component.tsx diff --git a/copilot-widget/lib/@components/Fallback.component.tsx b/copilot-widget/lib/@components/Fallback.component.tsx new file mode 100644 index 000000000..dc96d253f --- /dev/null +++ b/copilot-widget/lib/@components/Fallback.component.tsx @@ -0,0 +1,21 @@ +import { BotMessageWrapper } from "@lib/components/Messages"; + +type Props = { + id: string | number; + data: any; +}; +/** + * The Basic Text component + */ +export function Fallback(props: Props) { + const { data, id } = props; + return ( + +
+
+
{JSON.stringify(data, null, 2)}
+
+
+
+ ); +} From cefe9d86726a5fbd850a4be8e4935d73e7d17dd0 Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Wed, 6 Mar 2024 22:11:44 +0200 Subject: [PATCH 20/51] refactor: Update component retrieval method in ChatScreen --- copilot-widget/lib/screens/ChatScreen.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/copilot-widget/lib/screens/ChatScreen.tsx b/copilot-widget/lib/screens/ChatScreen.tsx index e30b5661c..d90d49328 100644 --- a/copilot-widget/lib/screens/ChatScreen.tsx +++ b/copilot-widget/lib/screens/ChatScreen.tsx @@ -39,11 +39,12 @@ export default function ChatScreen() { initialMessage && } } data={messages} render={(message, index) => { if (message.from === "bot") { - const Component = __components.get(message.type)?.component; + const Component = __components.getOrFallback( + message.type + )?.component; return ; } else if (message.from === "user") { return ( From f98c8642b51f895273896e106572f41e11035148 Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Wed, 6 Mar 2024 22:11:54 +0200 Subject: [PATCH 21/51] feat: Add Fallback component to ComponentRegistery --- .../lib/contexts/componentRegistery.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/copilot-widget/lib/contexts/componentRegistery.ts b/copilot-widget/lib/contexts/componentRegistery.ts index d6333e66e..813017608 100644 --- a/copilot-widget/lib/contexts/componentRegistery.ts +++ b/copilot-widget/lib/contexts/componentRegistery.ts @@ -1,3 +1,4 @@ +import { Fallback } from "@lib/@components/Fallback.component"; import { Text } from "@lib/@components/Text.component"; import React from "react"; @@ -18,7 +19,11 @@ export class ComponentRegistery { key: "TEXT", component: Text, }, - ]; + { + key: "FALLBACK", + component: Fallback, + }, + ] as const; constructor(opts: OptionsType) { const { components } = opts; @@ -26,6 +31,11 @@ export class ComponentRegistery { if (components) { components.forEach((c) => this.register(c)); } + if (this.components.length === 0) { + throw new Error("No components registered"); + } else if (!this.get("FALLBACK")) { + throw new Error("No fallback component registered"); + } } register(com: ComponentType) { @@ -44,4 +54,9 @@ export class ComponentRegistery { if (c) return c; return null; } + + getOrFallback(key?: string) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return key ? this.get(key) || this.get("FALLBACK")! : this.get("FALLBACK")!; + } } From 3beeced14fae4f0bc191402e06f1a72cf3c4c147 Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Thu, 7 Mar 2024 20:27:44 +0200 Subject: [PATCH 22/51] Add timeout for handling incomplete bot messages --- .../lib/contexts/messageHandler.tsx | 46 +++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/copilot-widget/lib/contexts/messageHandler.tsx b/copilot-widget/lib/contexts/messageHandler.tsx index 84e7ec893..bb87a3f5d 100644 --- a/copilot-widget/lib/contexts/messageHandler.tsx +++ b/copilot-widget/lib/contexts/messageHandler.tsx @@ -41,6 +41,10 @@ type Listener = (value: T) => void; type UpdaterFunction = (oldValue: T) => T; export class ChatController { sessionId: string | null = null; + // sometimes the im_end message is not received from the bot, so we have to set a timeout to end the current message + // this is the timeout id + private timeoutId: NodeJS.Timeout | null = null; + private timeoutDuration = 10000; // 10 seconds listeners = new Set(); components: ComponentRegistery | undefined; @@ -252,15 +256,19 @@ export class ChatController { if (!sessionId) { return; } - const appendToCurrentBotMessage = this.appendToCurrentBotMessage; const settle = this.settle; - function handle(content: string) { + const handle = (content: string) => { if (content === "|im_end|") { settle(); return; } - appendToCurrentBotMessage(content); - } + + this.startTimeout(() => { + settle(); + }); + + this.appendToCurrentBotMessage(content); + }; socket.on(sessionId, handle); @@ -268,4 +276,34 @@ export class ChatController { socket.off(sessionId, handle); }; }; + + socketVisualizeHandler = (socket: Socket) => { + const sessionId = this.sessionId; + if (!sessionId) { + return; + } + function handle(msg: string) { + // + } + + socket.on(`${sessionId}_vote`, handle); + + return () => { + socket.off(`${sessionId}_vote`, handle); + }; + }; + + private startTimeout = (callback: () => void) => { + this.timeoutId = setTimeout(() => { + callback(); + this.timeoutId = null; + }, this.timeoutDuration); + }; + + private clearTimeout = () => { + if (this.timeoutId !== null) { + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + }; } From 223659a5e7135da61f2807fb7e6aea9500785090 Mon Sep 17 00:00:00 2001 From: ah7255703 Date: Thu, 7 Mar 2024 20:28:11 +0200 Subject: [PATCH 23/51] Update ChatScreen component to include ComponentType in imports and add key prop to rendered components --- copilot-widget/lib/screens/ChatScreen.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/copilot-widget/lib/screens/ChatScreen.tsx b/copilot-widget/lib/screens/ChatScreen.tsx index d90d49328..7a266d16e 100644 --- a/copilot-widget/lib/screens/ChatScreen.tsx +++ b/copilot-widget/lib/screens/ChatScreen.tsx @@ -1,5 +1,5 @@ import ChatHeader from "../components/ChatHeader"; -import { useEffect, useRef } from "react"; +import { ComponentType, useEffect, useRef } from "react"; import { BotTextMessage, UserMessage } from "../components/Messages"; import useScrollToPercentage from "../hooks/useScrollTo"; import ChatInputFooter from "../components/ChatInputFooter"; @@ -42,10 +42,9 @@ export default function ChatScreen() { data={messages} render={(message, index) => { if (message.from === "bot") { - const Component = __components.getOrFallback( - message.type - )?.component; - return ; + const Component = __components.getOrFallback(message.type) + ?.component as ComponentType; + return ; } else if (message.from === "user") { return ( Date: Thu, 7 Mar 2024 20:59:13 +0200 Subject: [PATCH 24/51] feat: Add Form component and related files --- .../lib/@components/Form.component/index.tsx | 24 +++ .../Form.component/rjfs/AddButton.tsx | 26 +++ .../rjfs/ArrayFieldItemTemplate.tsx | 98 +++++++++++ .../rjfs/ArrayFieldTemplate.tsx | 102 +++++++++++ .../Form.component/rjfs/BaseInputTemplate.tsx | 87 +++++++++ .../Form.component/rjfs/CheckboxWidget.tsx | 88 ++++++++++ .../Form.component/rjfs/CheckboxesWidget.tsx | 92 ++++++++++ .../Form.component/rjfs/DescriptionField.tsx | 24 +++ .../Form.component/rjfs/ErrorList.tsx | 34 ++++ .../rjfs/FieldErrorTemplate.tsx | 35 ++++ .../Form.component/rjfs/FieldHelpTemplate.tsx | 31 ++++ .../Form.component/rjfs/FieldTemplate.tsx | 90 ++++++++++ .../@components/Form.component/rjfs/Form.tsx | 14 ++ .../Form.component/rjfs/IconButton.tsx | 103 +++++++++++ .../rjfs/ObjectFieldTemplate.tsx | 94 ++++++++++ .../Form.component/rjfs/RadioWidget.tsx | 76 ++++++++ .../Form.component/rjfs/RangeWidget.tsx | 29 +++ .../Form.component/rjfs/SelectWidget.tsx | 117 +++++++++++++ .../Form.component/rjfs/SubmitButton.tsx | 35 ++++ .../Form.component/rjfs/Templates.ts | 55 ++++++ .../Form.component/rjfs/TextareaWidget.tsx | 62 +++++++ .../@components/Form.component/rjfs/Theme.tsx | 18 ++ .../Form.component/rjfs/TitleField.tsx | 24 +++ .../Form.component/rjfs/Widgets.ts | 29 +++ .../rjfs/WrapIfAdditionalTemplate.tsx | 81 +++++++++ .../@components/Form.component/rjfs/index.ts | 8 + .../lib/contexts/componentRegistery.ts | 5 + .../lib/contexts/messageHandler.tsx | 9 +- copilot-widget/package.json | 3 + copilot-widget/pnpm-lock.yaml | 165 +++++++++++++++++- 30 files changed, 1641 insertions(+), 17 deletions(-) create mode 100644 copilot-widget/lib/@components/Form.component/index.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/AddButton.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/ArrayFieldItemTemplate.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/ArrayFieldTemplate.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/BaseInputTemplate.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/CheckboxWidget.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/CheckboxesWidget.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/DescriptionField.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/ErrorList.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/FieldErrorTemplate.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/FieldHelpTemplate.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/FieldTemplate.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/Form.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/IconButton.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/ObjectFieldTemplate.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/RadioWidget.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/RangeWidget.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/SelectWidget.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/SubmitButton.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/Templates.ts create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/TextareaWidget.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/Theme.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/TitleField.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/Widgets.ts create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/WrapIfAdditionalTemplate.tsx create mode 100644 copilot-widget/lib/@components/Form.component/rjfs/index.ts diff --git a/copilot-widget/lib/@components/Form.component/index.tsx b/copilot-widget/lib/@components/Form.component/index.tsx new file mode 100644 index 000000000..ff1221bee --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/index.tsx @@ -0,0 +1,24 @@ +import { BotMessageWrapper } from "../../components/Messages"; +import validator from "@rjsf/validator-ajv8"; +import Form from "./rjfs"; + +type Props = { + id: string | number; +}; +/** + * The Basic Form component + */ +export function FormComponent(props: Props) { + const { id } = props; + return ( + +
+
+
+
+
+
+
+
+ ); +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/AddButton.tsx b/copilot-widget/lib/@components/Form.component/rjfs/AddButton.tsx new file mode 100644 index 000000000..d03501ddd --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/AddButton.tsx @@ -0,0 +1,26 @@ +import { + FormContextType, + IconButtonProps, + RJSFSchema, + StrictRJSFSchema, + TranslatableString, +} from "@rjsf/utils"; +import { PlusIcon } from "lucide-react"; + +export default function AddButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>({ uiSchema, registry, ...props }: IconButtonProps) { + const { translateString } = registry; + return ( + + ); +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/ArrayFieldItemTemplate.tsx b/copilot-widget/lib/@components/Form.component/rjfs/ArrayFieldItemTemplate.tsx new file mode 100644 index 000000000..c9ea32777 --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/ArrayFieldItemTemplate.tsx @@ -0,0 +1,98 @@ +import { + ArrayFieldTemplateItemType, + FormContextType, + RJSFSchema, + StrictRJSFSchema, +} from "@rjsf/utils" +import { CSSProperties } from "react" + +export default function ArrayFieldItemTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(props: ArrayFieldTemplateItemType) { + const { + children, + disabled, + hasToolbar, + hasCopy, + hasMoveDown, + hasMoveUp, + hasRemove, + index, + onCopyIndexClick, + onDropIndexClick, + onReorderClick, + readonly, + registry, + uiSchema, + } = props + + const { CopyButton, MoveDownButton, MoveUpButton, RemoveButton } = + registry.templates.ButtonTemplates + const btnStyle: CSSProperties = { + flex: 1, + paddingLeft: 6, + paddingRight: 6, + fontWeight: "bold", + } + + return ( +
+
+
{children}
+
+ {hasToolbar && ( +
+ {(hasMoveUp || hasMoveDown) && ( +
+ +
+ )} + {(hasMoveUp || hasMoveDown) && ( +
+ +
+ )} + {hasCopy && ( +
+ +
+ )} + {hasRemove && ( +
+ +
+ )} +
+ )} +
+
+
+ ) +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/ArrayFieldTemplate.tsx b/copilot-widget/lib/@components/Form.component/rjfs/ArrayFieldTemplate.tsx new file mode 100644 index 000000000..428bd8167 --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/ArrayFieldTemplate.tsx @@ -0,0 +1,102 @@ +import { + ArrayFieldTemplateItemType, + ArrayFieldTemplateProps, + FormContextType, + getTemplate, + getUiOptions, + RJSFSchema, + StrictRJSFSchema, +} from "@rjsf/utils" + +export default function ArrayFieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(props: ArrayFieldTemplateProps) { + const { + canAdd, + disabled, + idSchema, + uiSchema, + items, + onAddClick, + readonly, + registry, + required, + schema, + title, + } = props + const uiOptions = getUiOptions(uiSchema) + const ArrayFieldDescriptionTemplate = getTemplate< + "ArrayFieldDescriptionTemplate", + T, + S, + F + >("ArrayFieldDescriptionTemplate", registry, uiOptions) + const ArrayFieldItemTemplate = getTemplate<"ArrayFieldItemTemplate", T, S, F>( + "ArrayFieldItemTemplate", + registry, + uiOptions, + ) + const ArrayFieldTitleTemplate = getTemplate< + "ArrayFieldTitleTemplate", + T, + S, + F + >("ArrayFieldTitleTemplate", registry, uiOptions) + // Button templates are not overridden in the uiSchema + const { + ButtonTemplates: { AddButton }, + } = registry.templates + + return ( +
+
+
+ + +
+ {items && + items.map( + ({ + key, + ...itemProps + }: ArrayFieldTemplateItemType) => ( + + ), + )} + {canAdd && ( +
+
+
+
+ +
+
+
+ )} +
+
+
+
+ ) +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/BaseInputTemplate.tsx b/copilot-widget/lib/@components/Form.component/rjfs/BaseInputTemplate.tsx new file mode 100644 index 000000000..a7170a1d6 --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/BaseInputTemplate.tsx @@ -0,0 +1,87 @@ +import { + ariaDescribedByIds, + BaseInputTemplateProps, + examplesId, + FormContextType, + getInputProps, + RJSFSchema, + StrictRJSFSchema, +} from "@rjsf/utils" +import { ChangeEvent, FocusEvent } from "react" + +export default function BaseInputTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ + id, + placeholder, + required, + readonly, + disabled, + type, + value, + onChange, + onChangeOverride, + onBlur, + onFocus, + autofocus, + options, + schema, + rawErrors = [], + children, + extraProps, +}: BaseInputTemplateProps) { + const inputProps = { + ...extraProps, + ...getInputProps(schema, type, options), + } + const _onChange = ({ target: { value } }: ChangeEvent) => + onChange(value === "" ? options.emptyValue : value) + const _onBlur = ({ target: { value } }: FocusEvent) => + onBlur(id, value) + const _onFocus = ({ target: { value } }: FocusEvent) => + onFocus(id, value) + + const inputClass = ` + border rounded-lg p-2 focus:border-primary focus:outline-none w-full bg-background + ${rawErrors.length > 0 ? "border-red-500" : "border-muted-foreground"} + ` + + return ( + <> + (id) : undefined} + {...inputProps} + value={value || value === 0 ? value : ""} + onChange={onChangeOverride || _onChange} + onBlur={_onBlur} + onFocus={_onFocus} + aria-describedby={ariaDescribedByIds(id, !!schema.examples)} + /> + {children} + {Array.isArray(schema.examples) ? ( + (id)}> + {(schema.examples as string[]) + .concat( + schema.default && !schema.examples.includes(schema.default) + ? ([schema.default] as string[]) + : [], + ) + .map((example: any) => { + return + ) : null} + + ) +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/CheckboxWidget.tsx b/copilot-widget/lib/@components/Form.component/rjfs/CheckboxWidget.tsx new file mode 100644 index 000000000..200eecf6c --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/CheckboxWidget.tsx @@ -0,0 +1,88 @@ +import { + ariaDescribedByIds, + descriptionId, + FormContextType, + getTemplate, + labelValue, + RJSFSchema, + schemaRequiresTrueValue, + StrictRJSFSchema, + WidgetProps, +} from "@rjsf/utils" +import { FocusEvent } from "react" + +export default function CheckboxWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(props: WidgetProps) { + const { + id, + value, + disabled, + readonly, + label, + hideLabel, + schema, + autofocus, + options, + onChange, + onBlur, + onFocus, + registry, + uiSchema, + } = props + // Because an unchecked checkbox will cause html5 validation to fail, only add + // the "required" attribute if the field value must be "true", due to the + // "const" or "enum" keywords + const required = schemaRequiresTrueValue(schema) + const DescriptionFieldTemplate = getTemplate< + "DescriptionFieldTemplate", + T, + S, + F + >("DescriptionFieldTemplate", registry, options) + + const _onChange = ({ target: { checked } }: FocusEvent) => + onChange(checked) + const _onBlur = ({ target: { checked } }: FocusEvent) => + onBlur(id, checked) + const _onFocus = ({ target: { checked } }: FocusEvent) => + onFocus(id, checked) + + const description = options.description || schema.description + return ( +
(id)} + > + {!hideLabel && !!description && ( + (id)} + description={description} + schema={schema} + uiSchema={uiSchema} + registry={registry} + /> + )} + +
+ ) +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/CheckboxesWidget.tsx b/copilot-widget/lib/@components/Form.component/rjfs/CheckboxesWidget.tsx new file mode 100644 index 000000000..58cae84f7 --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/CheckboxesWidget.tsx @@ -0,0 +1,92 @@ +import { + ariaDescribedByIds, + enumOptionsDeselectValue, + enumOptionsIsSelected, + enumOptionsSelectValue, + enumOptionsValueForIndex, + FormContextType, + optionId, + RJSFSchema, + StrictRJSFSchema, + WidgetProps, +} from "@rjsf/utils" +import { ChangeEvent, FocusEvent } from "react" + +export default function CheckboxesWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ + id, + disabled, + options, + value, + autofocus, + readonly, + required, + onChange, + onBlur, + onFocus, +}: WidgetProps) { + const { enumOptions, enumDisabled, inline, emptyValue } = options + const checkboxesValues = Array.isArray(value) ? value : [value] + + const _onChange = + (index: number) => + ({ target: { checked } }: ChangeEvent) => { + if (checked) { + onChange( + enumOptionsSelectValue(index, checkboxesValues, enumOptions), + ) + } else { + onChange( + enumOptionsDeselectValue(index, checkboxesValues, enumOptions), + ) + } + } + + const _onBlur = ({ target: { value } }: FocusEvent) => + onBlur(id, enumOptionsValueForIndex(value, enumOptions, emptyValue)) + const _onFocus = ({ target: { value } }: FocusEvent) => + onFocus(id, enumOptionsValueForIndex(value, enumOptions, emptyValue)) + + return ( +
+ {Array.isArray(enumOptions) && + enumOptions.map((option, index: number) => { + const checked = enumOptionsIsSelected( + option.value, + checkboxesValues, + ) + const itemDisabled = + Array.isArray(enumDisabled) && + enumDisabled.indexOf(option.value) !== -1 + + return ( +
+ (id)} + /> + +
+ ) + })} +
+ ) +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/DescriptionField.tsx b/copilot-widget/lib/@components/Form.component/rjfs/DescriptionField.tsx new file mode 100644 index 000000000..302e6c0f6 --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/DescriptionField.tsx @@ -0,0 +1,24 @@ +import { + DescriptionFieldProps, + FormContextType, + RJSFSchema, + StrictRJSFSchema, +} from "@rjsf/utils" + +export default function DescriptionField< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>({ id, description }: DescriptionFieldProps) { + if (description) { + return ( +
+
+ {description} +
+
+ ) + } + + return null +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/ErrorList.tsx b/copilot-widget/lib/@components/Form.component/rjfs/ErrorList.tsx new file mode 100644 index 000000000..b26dc1e53 --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/ErrorList.tsx @@ -0,0 +1,34 @@ +import { + ErrorListProps, + FormContextType, + RJSFSchema, + StrictRJSFSchema, + TranslatableString, +} from "@rjsf/utils" + +export default function ErrorList< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ errors, registry }: ErrorListProps) { + const { translateString } = registry + + return ( +
+
+ {translateString(TranslatableString.ErrorsLabel)} +
+
+
    + {errors.map((error, i: number) => { + return ( +
  • + {error.stack} +
  • + ) + })} +
+
+
+ ) +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/FieldErrorTemplate.tsx b/copilot-widget/lib/@components/Form.component/rjfs/FieldErrorTemplate.tsx new file mode 100644 index 000000000..d75136a93 --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/FieldErrorTemplate.tsx @@ -0,0 +1,35 @@ +import { + errorId, + FieldErrorProps, + FormContextType, + RJSFSchema, + StrictRJSFSchema, +} from "@rjsf/utils" + +/** The `FieldErrorTemplate` component renders the errors local to the particular field + * + * @param props - The `FieldErrorProps` for the errors being rendered + */ +export default function FieldErrorTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(props: FieldErrorProps) { + const { errors = [], idSchema } = props + if (errors.length === 0) { + return null + } + const id = errorId(idSchema) + + return ( +
    + {errors.map((error, i) => { + return ( +
  • + {error}{" "} +
  • + ) + })} +
+ ) +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/FieldHelpTemplate.tsx b/copilot-widget/lib/@components/Form.component/rjfs/FieldHelpTemplate.tsx new file mode 100644 index 000000000..d5699da0b --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/FieldHelpTemplate.tsx @@ -0,0 +1,31 @@ +import { + FieldHelpProps, + FormContextType, + helpId, + RJSFSchema, + StrictRJSFSchema, +} from "@rjsf/utils" + +/** The `FieldHelpTemplate` component renders any help desired for a field + * + * @param props - The `FieldHelpProps` to be rendered + */ +export default function FieldHelpTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(props: FieldHelpProps) { + const { idSchema, help, hasErrors } = props + if (!help) { + return null + } + const id = helpId(idSchema) + return ( + + {help} + + ) +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/FieldTemplate.tsx b/copilot-widget/lib/@components/Form.component/rjfs/FieldTemplate.tsx new file mode 100644 index 000000000..42d33b4ba --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/FieldTemplate.tsx @@ -0,0 +1,90 @@ +import { + FieldTemplateProps, + FormContextType, + getTemplate, + getUiOptions, + RJSFSchema, + StrictRJSFSchema, +} from "@rjsf/utils"; + +export default function FieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>({ + id, + children, + displayLabel, + rawErrors = [], + errors, + help, + description, + rawDescription, + classNames, + style, + disabled, + label, + hidden, + onDropPropertyClick, + onKeyChange, + readonly, + required, + schema, + uiSchema, + registry, +}: FieldTemplateProps) { + const uiOptions = getUiOptions(uiSchema); + const WrapIfAdditionalTemplate = getTemplate< + "WrapIfAdditionalTemplate", + T, + S, + F + >("WrapIfAdditionalTemplate", registry, uiOptions); + if (hidden) { + return
{children}
; + } + return ( + +
+ {displayLabel && ( + + )} + {children} + {displayLabel && rawDescription && ( + +
0 ? "text-red-500" : "text-muted-foreground" + }`} + > + {description} +
+
+ )} + {errors} + {help} +
+
+ ); +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/Form.tsx b/copilot-widget/lib/@components/Form.component/rjfs/Form.tsx new file mode 100644 index 000000000..3579c801d --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/Form.tsx @@ -0,0 +1,14 @@ +import { FormProps, withTheme } from "@rjsf/core"; +import { FormContextType, RJSFSchema, StrictRJSFSchema } from "@rjsf/utils"; +import { ComponentType } from "react"; +import { generateTheme } from "./Theme"; + +export function generateForm< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(): ComponentType> { + return withTheme(generateTheme()); +} + +export default generateForm(); diff --git a/copilot-widget/lib/@components/Form.component/rjfs/IconButton.tsx b/copilot-widget/lib/@components/Form.component/rjfs/IconButton.tsx new file mode 100644 index 000000000..efef3a3c0 --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/IconButton.tsx @@ -0,0 +1,103 @@ +import { + FormContextType, + IconButtonProps, + RJSFSchema, + StrictRJSFSchema, + TranslatableString, +} from "@rjsf/utils"; +import { ArrowDown, ArrowUp, CopyIcon, Trash2 } from "lucide-react"; + +export default function IconButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: IconButtonProps) { + const { icon, iconType, className, disabled, ...otherProps } = props; + const buttonClass = iconType === "block" ? "w-full" : ""; + const variantClass = + // @ts-expect-error incomplete type from rjsf + props.variant === "danger" + ? "bg-red-500 hover:bg-red-700 text-white" + : disabled + ? "bg-gray-100 text-gray-300" + : "bg-gray-200 hover:bg-gray-500 text-gray-700"; + + return ( + + ); +} + +export function CopyButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: IconButtonProps) { + const { + registry: { translateString }, + } = props; + return ( + } + /> + ); +} + +export function MoveDownButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: IconButtonProps) { + const { + registry: { translateString }, + } = props; + return ( + } + /> + ); +} + +export function MoveUpButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: IconButtonProps) { + const { + registry: { translateString }, + } = props; + return ( + } + /> + ); +} + +export function RemoveButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: IconButtonProps) { + const { + registry: { translateString }, + } = props; + return ( + } + /> + ); +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/ObjectFieldTemplate.tsx b/copilot-widget/lib/@components/Form.component/rjfs/ObjectFieldTemplate.tsx new file mode 100644 index 000000000..e19a8befe --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/ObjectFieldTemplate.tsx @@ -0,0 +1,94 @@ +import { + canExpand, + descriptionId, + FormContextType, + getTemplate, + getUiOptions, + ObjectFieldTemplateProps, + RJSFSchema, + StrictRJSFSchema, + titleId, +} from "@rjsf/utils" + +export default function ObjectFieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>({ + description, + title, + properties, + required, + uiSchema, + idSchema, + schema, + formData, + onAddClick, + disabled, + readonly, + registry, +}: ObjectFieldTemplateProps) { + const uiOptions = getUiOptions(uiSchema) + const TitleFieldTemplate = getTemplate<"TitleFieldTemplate", T, S, F>( + "TitleFieldTemplate", + registry, + uiOptions, + ) + const DescriptionFieldTemplate = getTemplate< + "DescriptionFieldTemplate", + T, + S, + F + >("DescriptionFieldTemplate", registry, uiOptions) + // Button templates are not overridden in the uiSchema + const { + ButtonTemplates: { AddButton }, + } = registry.templates + + return ( + <> + {title && ( + (idSchema)} + title={title} + required={required} + schema={schema} + uiSchema={uiSchema} + registry={registry} + /> + )} + {description && ( + (idSchema)} + description={description} + schema={schema} + uiSchema={uiSchema} + registry={registry} + /> + )} +
+ {properties.map((element: any, index: number) => ( +
+
{element.content}
+
+ ))} + {canExpand(schema, uiSchema, formData) ? ( +
+
+ +
+
+ ) : null} +
+ + ) +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/RadioWidget.tsx b/copilot-widget/lib/@components/Form.component/rjfs/RadioWidget.tsx new file mode 100644 index 000000000..3c0fcfad1 --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/RadioWidget.tsx @@ -0,0 +1,76 @@ +import { + ariaDescribedByIds, + enumOptionsIsSelected, + enumOptionsValueForIndex, + FormContextType, + optionId, + RJSFSchema, + StrictRJSFSchema, + WidgetProps, +} from "@rjsf/utils"; +import { ChangeEvent, FocusEvent } from "react"; + +export default function RadioWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>({ + id, + options, + value, + required, + disabled, + readonly, + onChange, + onBlur, + onFocus, +}: WidgetProps) { + const { enumOptions, enumDisabled, emptyValue } = options; + + const _onChange = ({ target: { value } }: ChangeEvent) => + onChange(enumOptionsValueForIndex(value, enumOptions, emptyValue)); + const _onBlur = ({ target: { value } }: FocusEvent) => + onBlur(id, enumOptionsValueForIndex(value, enumOptions, emptyValue)); + const _onFocus = ({ target: { value } }: FocusEvent) => + onFocus(id, enumOptionsValueForIndex(value, enumOptions, emptyValue)); + + const inline = Boolean(options && options.inline); + + return ( +
+ {Array.isArray(enumOptions) && + enumOptions.map((option, index) => { + const itemDisabled = + Array.isArray(enumDisabled) && + enumDisabled.indexOf(option.value) !== -1; + const checked = enumOptionsIsSelected(option.value, value); + + const radio = ( + + ); + return radio; + })} +
+ ); +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/RangeWidget.tsx b/copilot-widget/lib/@components/Form.component/rjfs/RangeWidget.tsx new file mode 100644 index 000000000..3e4b3edc2 --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/RangeWidget.tsx @@ -0,0 +1,29 @@ +import { + FormContextType, + getTemplate, + labelValue, + RJSFSchema, + StrictRJSFSchema, + WidgetProps, +} from "@rjsf/utils"; + +export default function RangeWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: WidgetProps) { + const { value, label, hideLabel, options, registry } = props; + const BaseInputTemplate = getTemplate<"BaseInputTemplate", T, S, F>( + "BaseInputTemplate", + registry, + options + ); + return ( + + {value} + + ); +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/SelectWidget.tsx b/copilot-widget/lib/@components/Form.component/rjfs/SelectWidget.tsx new file mode 100644 index 000000000..7146e0908 --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/SelectWidget.tsx @@ -0,0 +1,117 @@ +import { + ariaDescribedByIds, + enumOptionsIndexForValue, + enumOptionsValueForIndex, + FormContextType, + RJSFSchema, + StrictRJSFSchema, + WidgetProps, +} from "@rjsf/utils"; +import { ChangeEvent, FocusEvent } from "react"; + +export default function SelectWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>({ + schema, + id, + options, + required, + disabled, + readonly, + value, + multiple, + autofocus, + onChange, + onBlur, + onFocus, + placeholder, + rawErrors = [], +}: WidgetProps) { + const { enumOptions, enumDisabled, emptyValue: optEmptyValue } = options; + + const emptyValue = multiple ? [] : ""; + + function getValue(event: FocusEvent | ChangeEvent | any, multiple?: boolean) { + if (multiple) { + return [].slice + .call(event.target.options as any) + .filter((o: any) => o.selected) + .map((o: any) => o.value); + } else { + return event.target.value; + } + } + const selectedIndexes = enumOptionsIndexForValue( + value, + enumOptions, + multiple + ); + + return ( + + ); +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/SubmitButton.tsx b/copilot-widget/lib/@components/Form.component/rjfs/SubmitButton.tsx new file mode 100644 index 000000000..39a4e75e4 --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/SubmitButton.tsx @@ -0,0 +1,35 @@ +import { + FormContextType, + getSubmitButtonOptions, + RJSFSchema, + StrictRJSFSchema, + SubmitButtonProps, +} from "@rjsf/utils"; + +export default function SubmitButton< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(props: SubmitButtonProps) { + const { + submitText, + norender, + props: submitButtonProps, + } = getSubmitButtonOptions(props.uiSchema); + + if (norender) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/copilot-widget/lib/@components/Form.component/rjfs/Templates.ts b/copilot-widget/lib/@components/Form.component/rjfs/Templates.ts new file mode 100644 index 000000000..c26ec5ea3 --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/Templates.ts @@ -0,0 +1,55 @@ +import AddButton from "./AddButton"; +import ArrayFieldItemTemplate from "./ArrayFieldItemTemplate"; +import ArrayFieldTemplate from "./ArrayFieldTemplate"; +import BaseInputTemplate from "./BaseInputTemplate"; +import DescriptionField from "./DescriptionField"; +import ErrorList from "./ErrorList"; +import { + CopyButton, + MoveDownButton, + MoveUpButton, + RemoveButton, +} from "./IconButton"; +import FieldErrorTemplate from "./FieldErrorTemplate"; +import FieldHelpTemplate from "./FieldHelpTemplate"; +import FieldTemplate from "./FieldTemplate"; +import ObjectFieldTemplate from "./ObjectFieldTemplate"; +import SubmitButton from "./SubmitButton"; +import TitleField from "./TitleField"; +import WrapIfAdditionalTemplate from "./WrapIfAdditionalTemplate"; +import { + FormContextType, + RJSFSchema, + StrictRJSFSchema, + TemplatesType, +} from "@rjsf/utils"; + +export function generateTemplates< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>(): Partial> { + return { + ArrayFieldItemTemplate, + ArrayFieldTemplate, + BaseInputTemplate, + ButtonTemplates: { + AddButton, + CopyButton, + MoveDownButton, + MoveUpButton, + RemoveButton, + SubmitButton, + }, + DescriptionFieldTemplate: DescriptionField, + ErrorListTemplate: ErrorList, + FieldErrorTemplate, + FieldHelpTemplate, + FieldTemplate, + ObjectFieldTemplate, + TitleFieldTemplate: TitleField, + WrapIfAdditionalTemplate, + }; +} + +export default generateTemplates(); diff --git a/copilot-widget/lib/@components/Form.component/rjfs/TextareaWidget.tsx b/copilot-widget/lib/@components/Form.component/rjfs/TextareaWidget.tsx new file mode 100644 index 000000000..8df9ff7d7 --- /dev/null +++ b/copilot-widget/lib/@components/Form.component/rjfs/TextareaWidget.tsx @@ -0,0 +1,62 @@ +import { + ariaDescribedByIds, + FormContextType, + RJSFSchema, + StrictRJSFSchema, + WidgetProps, +} from "@rjsf/utils"; +import { ChangeEvent, FocusEvent } from "react"; + +type CustomWidgetProps< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +> = WidgetProps & { + options: any; +}; + +export default function TextareaWidget< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>({ + id, + placeholder, + value, + required, + disabled, + autofocus, + readonly, + onBlur, + onFocus, + onChange, + options, +}: CustomWidgetProps) { + const _onChange = ({ target: { value } }: ChangeEvent) => + onChange(value === "" ? options.emptyValue : value); + const _onBlur = ({ target: { value } }: FocusEvent) => + onBlur(id, value); + const _onFocus = ({ target: { value } }: FocusEvent) => + onFocus(id, value); + + return ( +
+