diff --git a/frontend/src/components/chat/ChatMessage.tsx b/frontend/src/components/chat/ChatMessage.tsx index ba353f7..98d1bdb 100644 --- a/frontend/src/components/chat/ChatMessage.tsx +++ b/frontend/src/components/chat/ChatMessage.tsx @@ -78,6 +78,7 @@ export function ChatMessage({ }`} >
= { export function MarkdownRenderer({ content }: { content: string }) { if (!content) return null; return ( -
+
{completePartialMarkdown(content)} diff --git a/frontend/src/components/chat/ReportMarkdownRenderer.tsx b/frontend/src/components/chat/ReportMarkdownRenderer.tsx index c8f6863..5d934d1 100644 --- a/frontend/src/components/chat/ReportMarkdownRenderer.tsx +++ b/frontend/src/components/chat/ReportMarkdownRenderer.tsx @@ -15,6 +15,7 @@ import { ChevronRight, ExternalLink, } from "lucide-react"; +import { detectTextDirection } from "@/lib/utils"; function completePartialMarkdown(text: string): string { const fenceCount = (text.match(/^```/gm) || []).length; @@ -53,12 +54,30 @@ export function extractReportTitle(content: string): string | null { return match ? match[1] : null; } +// Unicode-aware so non-Latin headings (e.g. Arabic) keep distinct ids; an +// ASCII-only slug collapses them all to "-", and the duplicate React keys make +// in-place section updates render as appended blocks. +function slugify(title: string): string { + return ( + title + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, "-") + .replace(/^-+|-+$/g, "") || "section" + ); +} + // Parse markdown into sections based on headings -function parseIntoSections(content: string): Section[] { +export function parseIntoSections(content: string): Section[] { const lines = content.split("\n"); const sections: Section[] = []; let currentSection: Section | null = null; let contentLines: string[] = []; + const idCounts = new Map(); + const uniqueId = (base: string) => { + const n = idCounts.get(base) ?? 0; + idCounts.set(base, n + 1); + return n === 0 ? base : `${base}-${n + 1}`; + }; for (const line of lines) { // Check H2 first (more specific) before H1 @@ -74,9 +93,12 @@ function parseIntoSections(content: string): Section[] { const level = h1Match ? 1 : 2; const title = h1Match ? h1Match[1] : h2Match![1]; - const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-"); - - currentSection = { id, level, title, content: "" }; + currentSection = { + id: uniqueId(slugify(title)), + level, + title, + content: "", + }; contentLines = []; } else if (currentSection) { contentLines.push(line); @@ -106,7 +128,7 @@ const markdownComponents: Record = { h2: () => null, h3({ children }: { children?: React.ReactNode }) { return ( -

+

{children}

); @@ -118,7 +140,7 @@ const markdownComponents: Record = { const text = String(children); const isAnchor = href?.startsWith("#"); const isSource = text.startsWith("http") || text.includes("Source"); - // Citation superscript links like [1], [2] — open source URL or scroll to ref + // Citation superscript links like [1], [2] open the source URL or scroll to the ref const isCitation = /^\[\d+\]$/.test(text); if (isCitation) { return ( @@ -159,14 +181,14 @@ const markdownComponents: Record = { }, ul({ children }: { children?: React.ReactNode }) { return ( -
    +
      {children}
    ); }, ol({ children }: { children?: React.ReactNode }) { return ( -
      +
        {children}
      ); @@ -176,7 +198,7 @@ const markdownComponents: Record = { }, blockquote({ children }: { children?: React.ReactNode }) { return ( -
      +
      {children}
      ); @@ -193,7 +215,7 @@ const markdownComponents: Record = { }, th({ children }: { children?: React.ReactNode }) { return ( - + {children} ); @@ -271,14 +293,14 @@ function CollapsibleSection({ ? { wrapper: "mt-6 mb-4 scroll-mt-4", button: - "w-full flex items-center gap-2 px-3 py-3 rounded-xl bg-gradient-to-r from-blue-100 via-blue-50 to-transparent dark:from-blue-900/40 dark:via-blue-950/20 dark:to-transparent hover:from-blue-200 dark:hover:from-blue-900/60 transition-all text-left group border-b-2 border-blue-200 dark:border-blue-800", + "w-full flex items-center gap-2 px-3 py-3 rounded-xl bg-gradient-to-r rtl:bg-gradient-to-l from-blue-100 via-blue-50 to-transparent dark:from-blue-900/40 dark:via-blue-950/20 dark:to-transparent hover:from-blue-200 dark:hover:from-blue-900/60 transition-all text-start group border-b-2 border-blue-200 dark:border-blue-800", text: "text-xl font-bold text-foreground group-hover:text-blue-800 dark:group-hover:text-blue-300 transition-colors", icon: "w-5 h-5 text-blue-600", } : { wrapper: "mt-5 mb-3 scroll-mt-4", button: - "w-full flex items-center gap-2 px-3 py-2.5 rounded-lg bg-gradient-to-r from-blue-50 via-blue-50/50 to-transparent dark:from-blue-950/30 dark:via-blue-950/15 dark:to-transparent hover:from-blue-100 dark:hover:from-blue-950/50 transition-all text-left group", + "w-full flex items-center gap-2 px-3 py-2.5 rounded-lg bg-gradient-to-r rtl:bg-gradient-to-l from-blue-50 via-blue-50/50 to-transparent dark:from-blue-950/30 dark:via-blue-950/15 dark:to-transparent hover:from-blue-100 dark:hover:from-blue-950/50 transition-all text-start group", text: "text-lg font-semibold text-foreground group-hover:text-blue-700 dark:group-hover:text-blue-300 transition-colors", icon: "w-5 h-5 text-blue-500", }; @@ -295,19 +317,19 @@ function CollapsibleSection({ > {!isCollapsed && section.content && ( -
      +
      >(new Map()); const sections = useMemo(() => parseIntoSections(content), [content]); + const direction = useMemo(() => detectTextDirection(content), [content]); // Track changes between versions useEffect(() => { @@ -414,7 +437,7 @@ export function ReportMarkdownRenderer({ if (!content) return null; return ( -
      +
      {/* Table of Contents */} {toc.length > 3 && (