diff --git a/web/src/app/chat/ChatBanner.tsx b/web/src/app/chat/ChatBanner.tsx index d5cd8b3af..f6529fb7c 100644 --- a/web/src/app/chat/ChatBanner.tsx +++ b/web/src/app/chat/ChatBanner.tsx @@ -3,7 +3,7 @@ import { SettingsContext } from "@/components/settings/SettingsProvider"; import { useContext, useState, useRef, useLayoutEffect } from "react"; import { ChevronDownIcon } from "@/components/icons/icons"; -import { MinimalMarkdown } from "@/components/chat/MinimalMarkdown"; +import MinimalMarkdown from "@/components/chat/MinimalMarkdown"; export function ChatBanner() { const settings = useContext(SettingsContext); diff --git a/web/src/app/chat/ChatPage.tsx b/web/src/app/chat/ChatPage.tsx index 5e1d0c480..e1545d522 100644 --- a/web/src/app/chat/ChatPage.tsx +++ b/web/src/app/chat/ChatPage.tsx @@ -109,7 +109,6 @@ import { } from "@/components/resizable/constants"; import FixedLogo from "../../components/logo/FixedLogo"; -import { MinimalMarkdown } from "@/components/chat/MinimalMarkdown"; import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal"; import { @@ -138,6 +137,7 @@ import { useSidebarShortcut } from "@/lib/browserUtilities"; import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal"; import { ChatSearchModal } from "./chat_search/ChatSearchModal"; import { ErrorBanner } from "./message/Resubmit"; +import MinimalMarkdown from "@/components/chat/MinimalMarkdown"; const TEMP_USER_MESSAGE_ID = -1; const TEMP_ASSISTANT_MESSAGE_ID = -2; diff --git a/web/src/app/chat/ChatPopup.tsx b/web/src/app/chat/ChatPopup.tsx index 9a05523ff..c2e15c543 100644 --- a/web/src/app/chat/ChatPopup.tsx +++ b/web/src/app/chat/ChatPopup.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { useContext, useEffect, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { transformLinkUri } from "@/lib/utils"; const ALL_USERS_INITIAL_POPUP_FLOW_COMPLETED = "allUsersInitialPopupFlowCompleted"; @@ -44,23 +45,26 @@ export function ChatPopup() { return ( <> - ( - - ), - p: ({ node, ...props }) =>

, - }} - remarkPlugins={[remarkGfm]} - > - {popupContent} - +

+ ( + + ), + p: ({ node, ...props }) =>

, + }} + remarkPlugins={[remarkGfm]} + urlTransform={transformLinkUri} + > + {popupContent} + +

{showConsentError && (

diff --git a/web/src/app/chat/message/AgenticMessage.tsx b/web/src/app/chat/message/AgenticMessage.tsx index d7347cecb..ed6d119bd 100644 --- a/web/src/app/chat/message/AgenticMessage.tsx +++ b/web/src/app/chat/message/AgenticMessage.tsx @@ -53,6 +53,7 @@ import { copyAll, handleCopy } from "./copyingUtils"; import { Button } from "@/components/ui/button"; import { RefreshCw } from "lucide-react"; import { ErrorBanner, Resubmit } from "./Resubmit"; +import { transformLinkUri } from "@/lib/utils"; export const AgenticMessage = ({ isStreamingQuestions, @@ -336,6 +337,7 @@ export const AgenticMessage = ({ }} remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[[rehypePrism, { ignoreMissing: true }], rehypeKatex]} + urlTransform={transformLinkUri} > {finalAlternativeContent} @@ -349,6 +351,7 @@ export const AgenticMessage = ({ components={markdownComponents} remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[[rehypePrism, { ignoreMissing: true }], rehypeKatex]} + urlTransform={transformLinkUri} > {streamedContent + (!isComplete && !secondLevelGenerating ? " [*]() " : "")} diff --git a/web/src/app/chat/message/MemoizedTextComponents.tsx b/web/src/app/chat/message/MemoizedTextComponents.tsx index 6fe826f21..7a440964d 100644 --- a/web/src/app/chat/message/MemoizedTextComponents.tsx +++ b/web/src/app/chat/message/MemoizedTextComponents.tsx @@ -160,8 +160,9 @@ export const MemoizedLink = memo( const handleMouseDown = () => { let url = href || rest.children?.toString(); - if (url && !url.startsWith("http://") && !url.startsWith("https://")) { - // Try to construct a valid URL + + if (url && !url.includes("://")) { + // Only add https:// if the URL doesn't already have a protocol const httpsUrl = `https://${url}`; try { new URL(httpsUrl); diff --git a/web/src/app/chat/message/Messages.tsx b/web/src/app/chat/message/Messages.tsx index 95af613d2..06d3d1550 100644 --- a/web/src/app/chat/message/Messages.tsx +++ b/web/src/app/chat/message/Messages.tsx @@ -71,6 +71,7 @@ import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; import "katex/dist/katex.min.css"; import { copyAll, handleCopy } from "./copyingUtils"; +import { transformLinkUri } from "@/lib/utils"; const TOOLS_WITH_CUSTOM_HANDLING = [ SEARCH_TOOL_NAME, @@ -348,7 +349,7 @@ export const AIMessage = ({ a: anchorCallback, p: paragraphCallback, b: ({ node, className, children }: any) => { - return ||||{children}; + return {children}; }, code: ({ node, className, children }: any) => { const codeText = extractCodeText( @@ -381,6 +382,7 @@ export const AIMessage = ({ components={markdownComponents} remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[[rehypePrism, { ignoreMissing: true }], rehypeKatex]} + urlTransform={transformLinkUri} > {finalContent} diff --git a/web/src/app/chat/message/SubQuestionsDisplay.tsx b/web/src/app/chat/message/SubQuestionsDisplay.tsx index a4f4c86ec..7f5c7e054 100644 --- a/web/src/app/chat/message/SubQuestionsDisplay.tsx +++ b/web/src/app/chat/message/SubQuestionsDisplay.tsx @@ -16,15 +16,15 @@ import ReactMarkdown from "react-markdown"; import { MemoizedAnchor } from "./MemoizedTextComponents"; import { MemoizedParagraph } from "./MemoizedTextComponents"; import { extractCodeText, preprocessLaTeX } from "./codeUtils"; - +import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; -import remarkGfm from "remark-gfm"; import { CodeBlock } from "./CodeBlock"; import { CheckIcon, ChevronDown } from "lucide-react"; import { PHASE_MIN_MS, useStreamingMessages } from "./StreamingMessages"; import { CirclingArrowIcon } from "@/components/icons/icons"; import { handleCopy } from "./copyingUtils"; +import { transformLinkUri } from "@/lib/utils"; export const StatusIndicator = ({ status }: { status: ToggleState }) => { return ( @@ -301,6 +301,7 @@ const SubQuestionDisplay: React.FC<{ components={markdownComponents} remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} + urlTransform={transformLinkUri} > {finalContent} diff --git a/web/src/components/admin/connectors/Field.tsx b/web/src/components/admin/connectors/Field.tsx index b302073d9..eeec14446 100644 --- a/web/src/components/admin/connectors/Field.tsx +++ b/web/src/components/admin/connectors/Field.tsx @@ -33,6 +33,8 @@ import Link from "next/link"; import { CheckboxField } from "@/components/ui/checkbox"; import { CheckedState } from "@radix-ui/react-checkbox"; +import { transformLinkUri } from "@/lib/utils"; + export function SectionHeader({ children, }: { @@ -432,6 +434,7 @@ export const MarkdownFormField = ({ {field.value} diff --git a/web/src/components/chat/MinimalMarkdown.tsx b/web/src/components/chat/MinimalMarkdown.tsx index 79f3ad029..99a1ec8d2 100644 --- a/web/src/components/chat/MinimalMarkdown.tsx +++ b/web/src/components/chat/MinimalMarkdown.tsx @@ -4,19 +4,26 @@ import { MemoizedLink, MemoizedParagraph, } from "@/app/chat/message/MemoizedTextComponents"; -import React, { useMemo } from "react"; +import React, { useMemo, CSSProperties } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import rehypePrism from "rehype-prism-plus"; +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; +import "katex/dist/katex.min.css"; +import { transformLinkUri } from "@/lib/utils"; interface MinimalMarkdownProps { content: string; className?: string; + style?: CSSProperties; } -export const MinimalMarkdown: React.FC = ({ +export default function MinimalMarkdown({ content, className = "", -}) => { + style, +}: MinimalMarkdownProps) { const markdownComponents = useMemo( () => ({ a: MemoizedLink, @@ -34,12 +41,16 @@ export const MinimalMarkdown: React.FC = ({ ); return ( - - {content} - +

+ + {content} + +
); -}; +} diff --git a/web/src/components/chat/TextView.tsx b/web/src/components/chat/TextView.tsx index 4cd3c0420..e31ae902f 100644 --- a/web/src/components/chat/TextView.tsx +++ b/web/src/components/chat/TextView.tsx @@ -10,7 +10,7 @@ import { } from "@/components/ui/dialog"; import { Download, XIcon, ZoomIn, ZoomOut } from "lucide-react"; import { OnyxDocument } from "@/lib/search/interfaces"; -import { MinimalMarkdown } from "./MinimalMarkdown"; +import MinimalMarkdown from "@/components/chat/MinimalMarkdown"; interface TextViewProps { presentingDocument: OnyxDocument; diff --git a/web/src/components/search/results/AnswerSection.tsx b/web/src/components/search/results/AnswerSection.tsx index ca8f44dcd..755fe3d09 100644 --- a/web/src/components/search/results/AnswerSection.tsx +++ b/web/src/components/search/results/AnswerSection.tsx @@ -1,6 +1,6 @@ import { Quote } from "@/lib/search/interfaces"; import { ResponseSection, StatusOptions } from "./ResponseSection"; -import { MinimalMarkdown } from "@/components/chat/MinimalMarkdown"; +import MinimalMarkdown from "@/components/chat/MinimalMarkdown"; const TEMP_STRING = "__$%^TEMP$%^__"; diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 43da4f8c7..3cb9aea55 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -91,3 +91,17 @@ export const NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK = export const NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; + +// Add support for custom URL protocols in markdown links +export const ALLOWED_URL_PROTOCOLS = [ + "http:", + "https:", + "mailto:", + "tel:", + "slack:", + "vscode:", + "file:", + "sms:", + "spotify:", + "zoommtg:", +]; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index fb1fcfa57..de74db091 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -1,5 +1,6 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; +import { ALLOWED_URL_PROTOCOLS } from "./constants"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -8,3 +9,28 @@ export function cn(...inputs: ClassValue[]) { export const truncateString = (str: string, maxLength: number) => { return str.length > maxLength ? str.slice(0, maxLength - 1) + "..." : str; }; + +/** + * Custom URL transformer function for ReactMarkdown + * Allows specific protocols to be used in markdown links + * We use this with the urlTransform prop in ReactMarkdown + */ +export function transformLinkUri(href: string) { + if (!href) return href; + + const url = href.trim(); + try { + const parsedUrl = new URL(url); + if ( + ALLOWED_URL_PROTOCOLS.some((protocol) => + parsedUrl.protocol.startsWith(protocol) + ) + ) { + return url; + } + } catch (e) { + // If it's not a valid URL with protocol, return the original href + return href; + } + return href; +}