import { Children, MutableRefObject, ReactElement, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Message, MessageRole, MessageStatus, Quota, useAiChat, } from "./use-ai"; import { AiHelpBanner, AiUpsellBanner } from "./banners"; import { useUserData } from "../../user-context"; import Container from "../../ui/atoms/container"; import { FeatureId, MDN_PLUS_TITLE } from "../../constants"; import { useLocale, useScrollToTop, useViewedState } from "../../hooks"; import { Icon } from "../../ui/atoms/icon"; import { collectCode } from "../../document/code/playground"; import "./index.scss"; import { Avatar } from "../../ui/atoms/avatar"; import { Button } from "../../ui/atoms/button"; import { GleanThumbs } from "../../ui/atoms/thumbs"; import NoteCard from "../../ui/molecules/notecards"; import { Loading } from "../../ui/atoms/loading"; import { useLocation } from "react-router-dom"; import { isExternalUrl } from "./utils"; import { useGleanClick } from "../../telemetry/glean-context"; import { AI_HELP } from "../../telemetry/constants"; import MDNModal from "../../ui/atoms/modal"; import { AI_FEEDBACK_GITHUB_REPO } from "../../env"; import ExpandingTextarea from "../../ui/atoms/form/expanding-textarea"; import React from "react"; import { SESSION_KEY } from "../../playground/utils"; import { PlayQueue, createQueueEntry } from "../../playground/queue"; import { AIHelpHistory } from "./history"; import { useUIStatus } from "../../ui-context"; import { QueueEntry } from "../../types/playground"; import { AIHelpLanding } from "./landing"; import { MESSAGE_SEARCHING, MESSAGE_ANSWERING, MESSAGE_FAILED, MESSAGE_ANSWERED, MESSAGE_SEARCHED, MESSAGE_STOPPED, OFF_TOPIC_PREFIX, OFF_TOPIC_MESSAGE, } from "./constants"; import InternalLink from "../../ui/atoms/internal-link"; import { isPlusSubscriber } from "../../utils"; import { CodeWithSyntaxHighlight } from "../../document/code/syntax-highlight"; type Category = "apis" | "css" | "html" | "http" | "js" | "learn"; const EXAMPLES: { category: Category; query: string }[] = [ { category: "css", query: "How to center a div with CSS?", }, { category: "html", query: "How do I create a form in HTML?", }, { category: "js", query: "How can I sort an Array in JavaScript?", }, { category: "apis", query: "How can I use the Fetch API to make HTTP requests in JavaScript?", }, { category: "http", query: "How can I redirect using HTTP?", }, { category: "learn", query: "What are some accessibility best practices?", }, ]; export default function AiHelp() { document.title = `AI Help | ${MDN_PLUS_TITLE}`; useScrollToTop(); const user = useUserData(); const { setViewed } = useViewedState(); useEffect(() => setViewed(FeatureId.PLUS_AI_HELP)); return (
{user?.isAuthenticated ? : }
); } function AIHelpAuthenticated() { const gleanClick = useGleanClick(); return (

AI Help

Get answers using generative AI based on MDN content.

gleanClick(`${AI_HELP}: report feedback`)} > Report Feedback

); } function AIHelpUserQuestion({ message, canEdit, submit, nextPrev, siblingCount, }) { const gleanClick = useGleanClick(); const [editing, setEditing] = useState(false); const [question, setQuestion] = useState(message.content); const inputRef = useRef(null); const { pos, total } = siblingCount(message.messageId); useEffect(() => { setQuestion(message.content); }, [message.content]); return editing ? (
{ event.preventDefault(); if (canEdit && question?.trim()) { gleanClick(`${AI_HELP}: edit submit`); setEditing(false); submit(question, message.chatId, message.parentId, message.messageId); } }} > { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); if (canEdit && question?.trim()) { gleanClick(`${AI_HELP}: edit submit`); setEditing(false); submit( question, message.chatId, message.parentId, message.messageId ); } } }} onChange={(e) => setQuestion(e.target.value)} value={question} rows={1} />
{canEdit && ( <> {question && ( )} )}
) : (
{total > 1 && ( )}
{message.content}
{canEdit && (
); } function AIHelpAssistantResponse({ message, queuedExamples, setQueue, messages, retryLastQuestion, }: { message: Message; queuedExamples: Set; setQueue: React.Dispatch>; messages: Message[]; retryLastQuestion: () => void; }) { const gleanClick = useGleanClick(); const locale = useLocale(); const { highlightedQueueExample } = useUIStatus(); let sample = 0; const isOffTopic = message.role === MessageRole.Assistant && (message.content?.startsWith(OFF_TOPIC_PREFIX) || (message.status === MessageStatus.Complete && OFF_TOPIC_PREFIX.startsWith(message.content))); function messageForStatus(status: MessageStatus) { switch (status) { case MessageStatus.Errored: return ( <> {MESSAGE_FAILED} Please{" "} . ); case MessageStatus.Stopped: return MESSAGE_STOPPED; case MessageStatus.InProgress: return MESSAGE_ANSWERING; default: return MESSAGE_ANSWERED; } } if (isOffTopic) { message = { ...message, content: OFF_TOPIC_MESSAGE, sources: [], }; } return ( <> {!isOffTopic && } {!isOffTopic && (message.content || message.status === MessageStatus.InProgress || message.status === MessageStatus.Errored) && (
{messageForStatus(message.status)}
)} {message.content && (
{ if (props.href?.startsWith("https://developer.mozilla.org/")) { props.href = props.href.replace( "https://developer.mozilla.org", "" ); } const isExternal = isExternalUrl(props.href ?? ""); if (isExternal) { props.className = "external"; props.rel = "noopener noreferrer"; } // Measure. props.onClick = () => gleanClick( `${AI_HELP}: link ${ isExternal ? "external" : "internal" } -> ${props.href}` ); // Always open in new tab. props.target = "_blank"; // eslint-disable-next-line jsx-a11y/anchor-has-content return ; }, pre: ({ node, className, children, ...props }) => { const code = Children.toArray(children) .map( (child) => /language-(\w+)/.exec( (child as ReactElement)?.props?.className || "" )?.[1] ) .find(Boolean); if (!code) { return (
                      {children}
                    
); } const key = sample; const id = `${message.messageId}--${key}`; const isQueued = queuedExamples.has(id); sample += 1; return (
{code} {message.status === MessageStatus.Complete && ["html", "js", "javascript", "css"].includes( code.toLowerCase() ) && (
{ gleanClick( `${AI_HELP}: example ${ isQueued ? "dequeue" : "queue" } -> ${id}` ); setQueue((old) => !old.some((item) => item.id === id) ? [...old, createQueueEntry(id)].sort( (a, b) => a.key - b.key ) : [...old].filter((item) => item.id !== id) ); }} id={id} />
)}
{children}
); }, code: ({ className, children, ...props }) => { const match = /language-(\w+)/.exec(className || ""); const lang = match?.[1]; return ( {children} ); }, }} > {message.content}
{message.status === "stopped" && (
{"■\u00a0Stopped answering"}
)} {(message.status === "complete" || isOffTopic) && ( <>
{!isOffTopic && ( )} Report an issue with this answer on GitHub
)}
)} ); } function AIHelpAssistantResponseSources({ message, }: { message: Pick; }) { const gleanClick = useGleanClick(); return ( <>
{message.status === MessageStatus.Pending ? MESSAGE_SEARCHING : MESSAGE_SEARCHED}
{message.sources && message.sources.length > 0 && (
    {message.sources.map(({ url, title }, index) => (
  • gleanClick(`${AI_HELP}: link source -> ${url}`)} target="_blank" > {title}
  • ))}
)} ); } export function AIHelpInner() { const formRef = useRef(null); const inputRef = useRef(null); const bodyRef = useRef(null); const footerRef = useRef(null); const [query, setQuery] = useState(""); const [showDisclaimer, setShowDisclaimer] = useState(false); const { queuedExamples, setQueue, setHighlightedQueueExample } = useUIStatus(); const { hash } = useLocation(); const gleanClick = useGleanClick(); const user = useUserData(); const { isFinished, isLoading, isHistoryLoading, isResponding, isInitializing, hasError, datas, messages, quota, reset, unReset, stop, submit, chatId, previousChatId, nextPrev, siblingCount, lastUpdate, } = useAiChat(); const isQuotaLoading = quota === undefined; const hasQuota = !isQuotaLoading && quota !== null; const hasConversation = messages.length > 0; const gptVersion = isPlusSubscriber(user) ? "GPT-4o" : "GPT-4o mini"; function isQuotaExceeded(quota: Quota | null | undefined): quota is Quota { return quota ? quota.remaining <= 0 : false; } function placeholder(status: string) { if (!hasQuota) { return status; } return `${status} (${quota.remaining} ${ quota.remaining === 1 ? "question" : "questions" } remaining today)`; } const { autoScroll, setAutoScroll } = useAutoScroll(messages, { bodyRef, footerRef, }); useEffect(() => { // Focus input: // - When the user loads the page (-> isQuotaLoading). // - When the user starts a "New chat" (-> hasConversation). // Do not focus while we figure out wether we're loading history (-> isInitializing). const input = inputRef.current; if (input && !isInitializing && !hasConversation) { window.setTimeout(() => input.focus()); } }, [isQuotaLoading, hasConversation, isInitializing]); useEffect(() => { const messageIds = new Set(messages.map((m) => m.messageId)); setQueue((old) => { const fresh = [...old].filter(({ id }) => messageIds.has(id.split("--")[0]) ); return fresh; }); }, [messages, setQueue]); const submitQuestion = (parentId) => { if (query.trim()) { submit(query.trim(), chatId, parentId); setQuery(""); setAutoScroll(true); } }; const lastUserQuestion = useMemo( () => messages.filter((message) => message.role === "user").at(-1), [messages] ); const retryLastQuestion = useCallback(() => { if (!lastUserQuestion) { return; } const { content: question, chatId, parentId, messageId } = lastUserQuestion; submit(question, chatId, parentId, messageId); }, [lastUserQuestion, submit]); return ( <> {isQuotaLoading || isHistoryLoading ? ( ) : (
{hasConversation && (
    {messages.map((message, index) => { return (
  • {message.role === "user" ? ( ) : ( )}
  • ); })}
)}
{hasError && (

Error

An error occurred.{" "} {lastUserQuestion && ( <> Please{" "} . )}

)}
{(isLoading || isResponding) && (
)} {isQuotaExceeded(quota) ? ( ) : ( <>
{hasConversation && ( )}
{ event.preventDefault(); submitQuestion(messages.at(-1)?.messageId); }} > { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); submitQuestion(messages.at(-1)?.messageId); } }} onChange={(event) => setQuery(event.target.value)} value={query} rows={1} placeholder={placeholder( isLoading ? MESSAGE_SEARCHING : isResponding ? MESSAGE_ANSWERING : hasConversation ? "Ask your follow up question" : "Ask your question" )} />
{!query && !hasConversation ? ( ) : query ? ( ) : null}
Results based on MDN's most recent documentation and powered by {gptVersion}, an LLM by{" "} OpenAI . Please verify information independently as LLM responses may not be 100% accurate. Read our{" "} {" "} for more details. setShowDisclaimer(false)} >

AI Help Usage Guidance

Our AI Help feature integrates GPT-4o mini for MDN Plus free users and GPT-4o for paying subscribers, leveraging Large Language Models (LLMs) developed by{" "} OpenAI . This tool is designed to enhance your experience by providing relevant insights from MDN's extensive documentation. However, given the nature of LLMs, it's crucial to approach the generated information with a discerning eye, especially for complex or critical subjects.

We encourage users to verify the AI Help's output. For convenience and accuracy, links for further reading and verification are provided at the beginning of responses, directing you to the relevant MDN documentation. This ensures immediate access to deeper insights and broader context.

Remember, while AI Help aims to be a valuable resource, its responses, influenced by the complexities of AI, might not always hit the mark with absolute precision. We invite you to explore this feature, designed to complement your MDN exploration. Your feedback is invaluable as we continue to refine AI Help to better serve your needs.

)}
{!hasConversation && !query && !isQuotaExceeded(quota) && (
Examples
{EXAMPLES.map(({ category, query }, index) => ( ))}
)} {hash === "#debug" && (
{JSON.stringify({ datas, messages, quota }, null, 2)}
)}
)} ); } export function RoleIcon({ role }: { role: "user" | "assistant" }) { const userData = useUserData(); switch (role) { case "user": return ; case "assistant": return ; } } function useAutoScroll( dependency, { bodyRef, footerRef, }: { bodyRef: MutableRefObject; footerRef: MutableRefObject; } ) { const [autoScroll, setAutoScroll] = useState(false); const lastScrollY = useRef(0); const lastHeight = useRef(0); useEffect(() => { const body = (bodyRef.current ??= document.querySelector(".ai-help-body")); const footer = (footerRef.current ??= document.querySelector(".ai-help-footer")); if (!body || !footer) { return; } window.setTimeout(() => { const { offsetTop, offsetHeight } = body; const elementBottom = offsetTop + offsetHeight + footer.offsetHeight; const targetScrollY = elementBottom - window.innerHeight; // Only scroll if our element grew and the target scroll position is further down. const shouldScroll = lastHeight.current < offsetHeight && lastScrollY.current < targetScrollY; lastHeight.current = offsetHeight; lastScrollY.current = window.scrollY; if (autoScroll && shouldScroll) { window.scrollTo(0, targetScrollY); } }); const scrollListener = () => { const { offsetTop, offsetHeight } = body; const { innerHeight, scrollY } = window; const elementBottom = offsetTop + offsetHeight + footer.offsetHeight; const windowBottom = scrollY + innerHeight; const isBottomVisible = scrollY <= elementBottom && elementBottom <= windowBottom; const scrollOffset = scrollY - lastScrollY.current; if (autoScroll && scrollOffset < 0 && !isBottomVisible) { // User scrolled up. setAutoScroll(false); } else if (!autoScroll && scrollOffset > 0 && isBottomVisible) { // User scrolled down again. setAutoScroll(true); } lastScrollY.current = scrollY; }; window.addEventListener("scroll", scrollListener); return () => window.removeEventListener("scroll", scrollListener); }, [autoScroll, bodyRef, dependency, footerRef]); return { autoScroll, setAutoScroll, }; } function ReportIssueOnGitHubLink({ messages, currentMessage, children, }: { messages: Message[]; currentMessage: Message; children: React.ReactNode; }) { const user = useUserData(); const isSubscriber = useMemo(() => isPlusSubscriber(user), [user]); const gleanClick = useGleanClick(); const currentMessageIndex = messages.indexOf(currentMessage); const questions = messages .slice(0, currentMessageIndex) .filter((message) => message.role === MessageRole.User) .map(({ content }) => content); const lastQuestion = questions.at(-1); const url = new URL("https://github.com/"); url.pathname = `/${AI_FEEDBACK_GITHUB_REPO}/issues/new`; const sp = new URLSearchParams(); sp.set("title", `[AI Help] Question: ${lastQuestion}`); sp.set("questions", questions.map((question) => `1. ${question}`).join("\n")); sp.set("answer", currentMessage.content); sp.set( "sources", currentMessage.sources ?.map( (source) => `- [${source.title}](https://developer.mozilla.org${source.url})` ) .join("\n") || "(None)" ); // TODO Persist model in messages and read it from there. sp.set("model", isSubscriber ? "gpt-4o" : "gpt-4o mini"); sp.set("template", "ai-help-answer.yml"); url.search = sp.toString(); return ( gleanClick(`${AI_HELP}: report issue`)} > {children} ); }