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 (
);
}
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 ? (
) : (
{total > 1 && (
{
gleanClick(`${AI_HELP}: question prev`);
nextPrev(message.messageId, "prev");
}}
>
Previous Question
{pos} / {total}
{
gleanClick(`${AI_HELP}: question next`);
nextPrev(message.messageId, "next");
}}
>
Next Question
)}
{message.content}
{canEdit && (
{
gleanClick(`${AI_HELP}: edit start`);
setEditing(true);
}}
/>
)}
);
}
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{" "}
try again
.
>
);
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}
/>
{isQueued ? "queued" : "queue"}
{
gleanClick(`${AI_HELP}: example play -> ${id}`);
const input = (e.target as HTMLElement)
.previousElementSibling
?.previousElementSibling as HTMLInputElement;
const code = collectCode(input);
sessionStorage.setItem(
SESSION_KEY,
JSON.stringify(code)
);
const url = new URL(window?.location.href);
url.pathname = `/${locale}/play`;
url.hash = "";
url.search = "";
if (e.shiftKey === true) {
window.location.href = url.href;
} else {
window.open(url, "_blank");
}
}}
>
play
)}
{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{" "}
try again
.
>
)}
)}
{(isLoading || isResponding) && (
{
gleanClick(`${AI_HELP}: stop`);
stop();
}}
>
■ Stop answering
setAutoScroll(true)}
>
↓ Enable auto-scroll
)}
{isQuotaExceeded(quota) ? (
) : (
<>
{hasConversation && (
{
gleanClick(`${AI_HELP}: topic new`);
setQuery("");
setQueue([]);
setHighlightedQueueExample(null);
reset();
window.setTimeout(() => window.scrollTo(0, 0));
}}
>
New Topic
)}
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{" "}
setShowDisclaimer(true)}
>
full guidance
{" "}
for more details.
setShowDisclaimer(false)}
>
AI Help Usage Guidance
setShowDisclaimer(false)}
type="action"
icon="cancel"
extraClasses="close-button"
/>
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.map(({ category, query }, index) => (
{
gleanClick(`${AI_HELP}: example ${1 + index}`);
setQuery(query);
inputRef.current?.focus();
window.setTimeout(() => window.scrollTo(0, 0));
}}
>
{query}
))}
)}
{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}
);
}