diff --git a/package-lock.json b/package-lock.json index 2a23f618b58458f..edec0708336fb35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@iarna/toml": "2.2.5", "@lottiefiles/dotlottie-react": "0.13.5", "@marsidev/react-turnstile": "1.1.0", + "@microsoft/fetch-event-source": "2.0.1", "@nanostores/react": "1.0.0", "@octokit/webhooks-types": "7.6.1", "@stoplight/json-schema-tree": "4.0.0", @@ -59,6 +60,7 @@ "hastscript": "9.0.1", "he": "1.2.0", "jsonc-parser": "3.3.1", + "ldrs": "1.1.7", "lz-string": "1.5.0", "marked": "15.0.12", "mdast-util-from-markdown": "2.0.2", @@ -90,6 +92,7 @@ "rehype-stringify": "10.0.1", "rehype-title-figure": "0.1.2", "remark": "15.0.1", + "remark-breaks": "4.0.0", "remark-gfm": "4.0.1", "remark-stringify": "11.0.0", "sharp": "0.34.2", @@ -4425,6 +4428,13 @@ "langium": "3.3.1" } }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", + "dev": true, + "license": "MIT" + }, "node_modules/@nanostores/react": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@nanostores/react/-/react-1.0.0.tgz", @@ -13817,6 +13827,13 @@ "dev": true, "license": "MIT" }, + "node_modules/ldrs": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/ldrs/-/ldrs-1.1.7.tgz", + "integrity": "sha512-rZnfveeY1SeS3F3ifUVd9AVGTFHmQ0qzp5fuszAirnrVkjqJBLrm99vtr/Mxbby4XgadUYv+DsFqyk2p4FV40Q==", + "dev": true, + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -14582,6 +14599,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-phrasing": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", @@ -17649,6 +17681,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-directive": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-3.0.0.tgz", diff --git a/package.json b/package.json index 459b6ed352875e0..1d4d31b4d7fcdd4 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@iarna/toml": "2.2.5", "@lottiefiles/dotlottie-react": "0.13.5", "@marsidev/react-turnstile": "1.1.0", + "@microsoft/fetch-event-source": "2.0.1", "@nanostores/react": "1.0.0", "@octokit/webhooks-types": "7.6.1", "@stoplight/json-schema-tree": "4.0.0", @@ -77,6 +78,7 @@ "hastscript": "9.0.1", "he": "1.2.0", "jsonc-parser": "3.3.1", + "ldrs": "1.1.7", "lz-string": "1.5.0", "marked": "15.0.12", "mdast-util-from-markdown": "2.0.2", @@ -108,6 +110,7 @@ "rehype-stringify": "10.0.1", "rehype-title-figure": "0.1.2", "remark": "15.0.1", + "remark-breaks": "4.0.0", "remark-gfm": "4.0.1", "remark-stringify": "11.0.0", "sharp": "0.34.2", diff --git a/src/components/DocsAI.tsx b/src/components/DocsAI.tsx new file mode 100644 index 000000000000000..f949f0923f7e4a8 --- /dev/null +++ b/src/components/DocsAI.tsx @@ -0,0 +1,252 @@ +import { useState } from "react"; +import Markdown from "react-markdown"; +import remarkBreaks from "remark-breaks"; +import remarkGfm from "remark-gfm"; +import { fetchEventSource } from "@microsoft/fetch-event-source"; +import { Ring } from "ldrs/react"; +import { MdOutlineThumbUp, MdOutlineThumbDown } from "react-icons/md"; +import "ldrs/react/Ring.css"; + +type Messages = { + role: "user" | "assistant"; + content: string; + queryId?: string; + sources?: { title: string; file_path: string }[]; +}[]; + +async function sendCSATFeedback(queryId: string, positive: boolean) { + try { + await fetch("https://support-ai.cloudflaresupport.workers.dev/csat", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + queryId, + positive, + }), + }); + } catch (error) { + console.error("Failed to send CSAT feedback:", error); + } +} + +function Messages({ + messages, + loading, +}: { + messages: Messages; + loading: boolean; +}) { + const [feedbackGiven, setFeedbackGiven] = useState>(new Set()); + + const classes = { + base: "w-fit max-w-3/4 rounded p-4", + user: "bg-cl1-brand-orange text-cl1-black self-end", + assistant: "self-start bg-(--sl-color-bg-nav)", + }; + + const handleFeedback = async (queryId: string, positive: boolean) => { + await sendCSATFeedback(queryId, positive); + setFeedbackGiven((prev) => new Set(prev).add(queryId)); + }; + + return ( +
+ {messages + .filter((message) => Boolean(message.content)) + .map((message, index) => ( +
+
+ + {message.content} + + {message.sources && ( + <> +

+ I used these sources to answer your question, please review + them if you need more information: +

+ + + )} + {message.role === "assistant" && message.queryId && ( +
+ {feedbackGiven.has(message.queryId) ? ( + Thanks for your feedback! + ) : ( + <> + + + + )} +
+ )} +
+
+ ))} + {loading && ( +
+ +
+ )} +
+ ); +} + +export default function SupportAI() { + const [threadId, setThreadId] = useState(); + const [question, setQuestion] = useState(""); + const [loading, setLoading] = useState(false); + + const [messages, setMessages] = useState([]); + + async function handleSubmit() { + setLoading(true); + setMessages((messages) => [ + ...messages, + { role: "user", content: question }, + { role: "assistant", content: "" }, + ]); + setQuestion(""); + + const controller = new AbortController(); + const { signal } = controller; + + let chunkedAnswer = ""; + let sources: Messages[number]["sources"] = []; + let currentQueryId: string | undefined; + + await fetchEventSource( + // "http://localhost:8010/proxy/devdocs/ask", + "https://support-ai.cloudflaresupport.workers.dev/devdocs/ask", + { + method: "POST", + body: JSON.stringify({ + question, + threadId, + }), + signal, + openWhenHidden: true, + async onopen(response) { + if (!response.ok) { + throw new Error(response.status.toString()); + } + + return; + }, + onerror(error) { + if (error instanceof Error) { + setLoading(false); + setMessages((messages) => [ + ...messages, + { + role: "assistant", + content: + "I'm unable to provide an answer to that at the moment. Please rephrase your query and I'll try again.", + }, + ]); + throw error; + } + }, + onmessage(ev) { + if (ev.data === "[DONE]") { + controller.abort(); + + setMessages((messages) => { + const newMessages = [...messages]; + const lastMessage = newMessages[newMessages.length - 1]; + + if (sources) { + lastMessage.sources = sources; + } + + if (currentQueryId) { + lastMessage.queryId = currentQueryId; + } + + return newMessages; + }); + } + + const { threadId, response, queryId, botResponse } = JSON.parse( + ev.data, + ); + + if (queryId) { + currentQueryId = queryId; + } + + if (botResponse?.sources) { + sources = botResponse.sources; + } + + if (threadId) { + setThreadId(threadId); + } + + if (!response) return; + + chunkedAnswer += response; + + setLoading(false); + setMessages((messages) => { + const newMessages = [...messages]; + newMessages[newMessages.length - 1].content = chunkedAnswer; + return newMessages; + }); + }, + }, + ); + } + + return ( +
+ +
+