Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/components/chat/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function ChatMessage({
}`}
>
<div
dir={message.role === "user" ? "auto" : undefined}
className={`max-w-[80%] break-words ${
message.role === "user"
? "p-3 rounded-lg bg-gray-800 dark:bg-gray-700 text-white rounded-br-none whitespace-pre-wrap"
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/components/chat/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Copy, Check } from "lucide-react";
import { detectTextDirection } from "@/lib/utils";

function completePartialMarkdown(text: string): string {
const fenceCount = (text.match(/^```/gm) || []).length;
Expand Down Expand Up @@ -83,7 +84,10 @@ const components: Record<string, any> = {
export function MarkdownRenderer({ content }: { content: string }) {
if (!content) return null;
return (
<div className="markdown-body leading-relaxed [&_p]:my-1.5 [&_ul]:my-1.5 [&_ul]:pl-5 [&_ul]:list-disc [&_ol]:my-1.5 [&_ol]:pl-5 [&_ol]:list-decimal [&_li]:my-0.5 [&_h1]:text-lg [&_h1]:font-semibold [&_h1]:mt-3 [&_h1]:mb-1.5 [&_h2]:text-base [&_h2]:font-semibold [&_h2]:mt-2.5 [&_h2]:mb-1 [&_h3]:text-sm [&_h3]:font-semibold [&_h3]:mt-2 [&_h3]:mb-1 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:my-1.5 [&_blockquote]:text-muted-foreground [&_table]:my-2 [&_table]:min-w-full [&_table]:border-collapse [&_table]:text-xs [&_th]:px-2 [&_th]:py-1 [&_th]:bg-muted [&_th]:border [&_th]:border-border [&_th]:text-left [&_th]:font-medium [&_td]:px-2 [&_td]:py-1 [&_td]:border [&_td]:border-border [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<div
dir={detectTextDirection(content)}
className="markdown-body leading-relaxed [&_p]:my-1.5 [&_ul]:my-1.5 [&_ul]:ps-5 [&_ul]:list-disc [&_ol]:my-1.5 [&_ol]:ps-5 [&_ol]:list-decimal [&_li]:my-0.5 [&_h1]:text-lg [&_h1]:font-semibold [&_h1]:mt-3 [&_h1]:mb-1.5 [&_h2]:text-base [&_h2]:font-semibold [&_h2]:mt-2.5 [&_h2]:mb-1 [&_h3]:text-sm [&_h3]:font-semibold [&_h3]:mt-2 [&_h3]:mb-1 [&_blockquote]:border-s-2 [&_blockquote]:border-border [&_blockquote]:ps-3 [&_blockquote]:my-1.5 [&_blockquote]:text-muted-foreground [&_table]:my-2 [&_table]:min-w-full [&_table]:border-collapse [&_table]:text-xs [&_th]:px-2 [&_th]:py-1 [&_th]:bg-muted [&_th]:border [&_th]:border-border [&_th]:text-start [&_th]:font-medium [&_td]:px-2 [&_td]:py-1 [&_td]:border [&_td]:border-border [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{completePartialMarkdown(content)}
</ReactMarkdown>
Expand Down
63 changes: 43 additions & 20 deletions frontend/src/components/chat/ReportMarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, number>();
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
Expand All @@ -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);
Expand Down Expand Up @@ -106,7 +128,7 @@ const markdownComponents: Record<string, any> = {
h2: () => null,
h3({ children }: { children?: React.ReactNode }) {
return (
<h3 className="text-base font-semibold text-foreground mt-4 mb-2 pl-2 border-l-3 border-blue-300 dark:border-blue-600">
<h3 className="text-base font-semibold text-foreground mt-4 mb-2 ps-2 border-s-3 border-blue-300 dark:border-blue-600">
{children}
</h3>
);
Expand All @@ -118,7 +140,7 @@ const markdownComponents: Record<string, any> = {
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 (
Expand Down Expand Up @@ -159,14 +181,14 @@ const markdownComponents: Record<string, any> = {
},
ul({ children }: { children?: React.ReactNode }) {
return (
<ul className="my-2.5 pl-5 list-disc space-y-1.5 text-foreground">
<ul className="my-2.5 ps-5 list-disc space-y-1.5 text-foreground">
{children}
</ul>
);
},
ol({ children }: { children?: React.ReactNode }) {
return (
<ol className="my-2.5 pl-5 list-decimal space-y-1.5 text-foreground">
<ol className="my-2.5 ps-5 list-decimal space-y-1.5 text-foreground">
{children}
</ol>
);
Expand All @@ -176,7 +198,7 @@ const markdownComponents: Record<string, any> = {
},
blockquote({ children }: { children?: React.ReactNode }) {
return (
<blockquote className="my-3 pl-4 border-l-4 border-blue-300 dark:border-blue-600 bg-blue-50/50 dark:bg-blue-950/30 py-2 pr-3 rounded-r-lg text-muted-foreground italic">
<blockquote className="my-3 ps-4 border-s-4 border-blue-300 dark:border-blue-600 bg-blue-50/50 dark:bg-blue-950/30 py-2 pe-3 rounded-e-lg text-muted-foreground italic">
{children}
</blockquote>
);
Expand All @@ -193,7 +215,7 @@ const markdownComponents: Record<string, any> = {
},
th({ children }: { children?: React.ReactNode }) {
return (
<th className="px-4 py-2.5 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<th className="px-4 py-2.5 text-start text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{children}
</th>
);
Expand Down Expand Up @@ -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",
};
Expand All @@ -295,19 +317,19 @@ function CollapsibleSection({
>
<button onClick={onToggle} className={styles.button}>
{isCollapsed ? (
<ChevronRight className={`${styles.icon} shrink-0`} />
<ChevronRight className={`${styles.icon} shrink-0 rtl:rotate-180`} />
) : (
<ChevronDown className={`${styles.icon} shrink-0`} />
)}
<span className={styles.text}>{section.title}</span>
{isNew && (
<span className="ml-2 px-2 py-0.5 text-xs font-medium bg-green-100 text-green-700 rounded-full">
<span className="ms-2 px-2 py-0.5 text-xs font-medium bg-green-100 text-green-700 rounded-full">
Updated
</span>
)}
</button>
{!isCollapsed && section.content && (
<div className="pl-6 pt-2">
<div className="ps-6 pt-2">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
Expand Down Expand Up @@ -340,6 +362,7 @@ export function ReportMarkdownRenderer({
const prevSectionsRef = useRef<Map<string, string>>(new Map());

const sections = useMemo(() => parseIntoSections(content), [content]);
const direction = useMemo(() => detectTextDirection(content), [content]);

// Track changes between versions
useEffect(() => {
Expand Down Expand Up @@ -414,7 +437,7 @@ export function ReportMarkdownRenderer({
if (!content) return null;

return (
<div className="report-markdown">
<div className="report-markdown" dir={direction}>
{/* Table of Contents */}
{toc.length > 3 && (
<nav className="mb-6 p-4 bg-muted/50 rounded-xl border border-border">
Expand All @@ -425,15 +448,15 @@ export function ReportMarkdownRenderer({
{toc.map((section) => (
<li
key={section.id}
style={{ paddingLeft: `${(section.level - 1) * 12}px` }}
style={{ paddingInlineStart: `${(section.level - 1) * 12}px` }}
>
<button
onClick={() => scrollToSection(section.id)}
className="text-sm text-muted-foreground hover:text-blue-600 dark:hover:text-blue-400 transition-colors text-left"
className="text-sm text-muted-foreground hover:text-blue-600 dark:hover:text-blue-400 transition-colors text-start"
>
{section.title}
{changedSectionIds.has(section.id) && (
<span className="ml-2 inline-block w-2 h-2 bg-green-500 rounded-full" />
<span className="ms-2 inline-block w-2 h-2 bg-green-500 rounded-full" />
)}
</button>
</li>
Expand Down Expand Up @@ -481,7 +504,7 @@ export function ReportMarkdownRenderer({
// Non-collapsible fallback for H2
return (
<div key={section.id} id={section.id} className="mb-4 scroll-mt-4">
<h2 className="text-lg font-semibold text-foreground mt-5 mb-3 px-3 py-2 rounded-lg bg-gradient-to-r from-blue-50 to-transparent dark:from-blue-950/30 dark:to-transparent">
<h2 className="text-lg font-semibold text-foreground mt-5 mb-3 px-3 py-2 rounded-lg bg-gradient-to-r rtl:bg-gradient-to-l from-blue-50 to-transparent dark:from-blue-950/30 dark:to-transparent">
{section.title}
</h2>
{section.content && (
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/chat/ResearchReportPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export function ResearchReportPanel({
<FileText className="w-5 h-5 text-blue-600" />
</div>
<h2
dir="auto"
className="font-semibold text-foreground truncate"
title={reportTitle}
>
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,15 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

const RTL_CHARS = /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/g;
const LTR_CHARS = /[A-Za-z\u00C0-\u024F]/g;

// Base direction for a block of (markdown) text: RTL when Hebrew/Arabic letters
// outnumber Latin ones. URLs are ignored so citations don't skew the count.
export function detectTextDirection(text: string): "rtl" | "ltr" {
const stripped = text.replace(/https?:\/\/\S+/g, "");
const rtl = stripped.match(RTL_CHARS)?.length ?? 0;
const ltr = stripped.match(LTR_CHARS)?.length ?? 0;
return rtl > ltr ? "rtl" : "ltr";
}
68 changes: 68 additions & 0 deletions frontend/src/test/rtl-report.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: MIT-0

import { describe, it, expect } from "vitest";
import { parseIntoSections } from "../components/chat/ReportMarkdownRenderer";
import { detectTextDirection } from "../lib/utils";

const ARABIC_SKELETON = `# مستقبل الذكاء الاصطناعي

## الملخص التنفيذي
[placeholder]

## الخلفية
[placeholder]

## أهم النتائج

### النتيجة 1: المسار التقني
[placeholder]

## التحليل
[placeholder]`;

describe("Report section parsing", () => {
it("gives Arabic headings distinct ids", () => {
const ids = parseIntoSections(ARABIC_SKELETON).map((s) => s.id);
expect(new Set(ids).size).toBe(ids.length);
expect(ids).toContain("الملخص-التنفيذي");
});

it("keeps section ids stable when a placeholder is filled in", () => {
const before = parseIntoSections(ARABIC_SKELETON).map((s) => s.id);
const filled = ARABIC_SKELETON.replace(
"## الخلفية\n[placeholder]",
"## الخلفية\nنص جديد للقسم.",
);
const after = parseIntoSections(filled);
expect(after.map((s) => s.id)).toEqual(before);
expect(after.find((s) => s.title === "الخلفية")?.content).toBe(
"نص جديد للقسم.",
);
});

it("disambiguates repeated headings", () => {
const ids = parseIntoSections("## Notes\na\n## Notes\nb").map((s) => s.id);
expect(ids).toEqual(["notes", "notes-2"]);
});

it("keeps the existing slug for English headings", () => {
const [section] = parseIntoSections("## Executive Summary\ntext");
expect(section.id).toBe("executive-summary");
});
});

describe("detectTextDirection", () => {
it("detects Arabic text with embedded English terms as rtl", () => {
expect(
detectTextDirection(
"تتوقع شركة McKinsey أن يضيف الذكاء الاصطناعي (AGI) قيمة كبيرة [Source: https://www.mckinsey.com/featured-insights]",
),
).toBe("rtl");
});

it("detects English text as ltr", () => {
expect(detectTextDirection("The market grew by 15% in 2025.")).toBe("ltr");
expect(detectTextDirection("")).toBe("ltr");
});
});
33 changes: 27 additions & 6 deletions patterns/strands-deep-research/pdf_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,18 @@
margin-bottom: 0.4em;
border-bottom: 2px solid #333;
padding-bottom: 0.2em;
text-align: left;
text-align: start;
}
h2 {
font-size: 16pt;
margin-top: 1.2em;
margin-bottom: 0.4em;
border-bottom: 1px solid #ccc;
padding-bottom: 0.15em;
text-align: left;
text-align: start;
}
h3 { font-size: 13pt; margin-top: 1em; margin-bottom: 0.3em; text-align: left; }
h4 { font-size: 11pt; margin-top: 0.8em; margin-bottom: 0.3em; text-align: left; }
h3 { font-size: 13pt; margin-top: 1em; margin-bottom: 0.3em; text-align: start; }
h4 { font-size: 11pt; margin-top: 0.8em; margin-bottom: 0.3em; text-align: start; }
p { margin: 0.4em 0; }
ul, ol { margin: 0.4em 0; padding-left: 1.5em; }
li { margin: 0.15em 0; }
Expand All @@ -55,7 +55,7 @@
th, td {
border: 1px solid #ccc;
padding: 5px 8px;
text-align: left;
text-align: start;
font-size: 9pt;
}
th { background: #f0f0f0; font-weight: bold; }
Expand Down Expand Up @@ -105,13 +105,33 @@
.references h2 { border-bottom: none; font-size: 14pt; }
.references ol { font-size: 9pt; padding-left: 1.5em; }
.references li { margin: 0.3em 0; word-break: break-all; }
/* Right-to-left documents (Arabic, Hebrew): mirror the box-model offsets */
html[dir="rtl"] body { direction: rtl; }
html[dir="rtl"] ul, html[dir="rtl"] ol,
html[dir="rtl"] .references ol { padding-left: 0; padding-right: 1.5em; }
html[dir="rtl"] blockquote {
border-left: none;
border-right: 3px solid #ccc;
}
html[dir="rtl"] .references li a { direction: ltr; unicode-bidi: embed; }
"""

_MD_EXTENSIONS = ["tables", "fenced_code", "toc", "nl2br"]

# matches [Source: ...] patterns (URL or plain text)
_SOURCE_RE = re.compile(r"\[Source:\s*([^\]]+)\]")
_URL_RE = re.compile(r"^https?://\S+$")
_ANY_URL_RE = re.compile(r"https?://\S+")
# Hebrew, Arabic (incl. supplements/extended) and their presentation forms
_RTL_CHARS_RE = re.compile(r"[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]")
_LTR_CHARS_RE = re.compile(r"[A-Za-z\u00C0-\u024F]")


def _text_direction(text: str) -> str:
"""Return "rtl" when Hebrew/Arabic letters outnumber Latin ones, else "ltr"."""
text = _ANY_URL_RE.sub("", text)
rtl = len(_RTL_CHARS_RE.findall(text))
return "rtl" if rtl > len(_LTR_CHARS_RE.findall(text)) else "ltr"


def _process_citations(html_body: str) -> str:
Expand Down Expand Up @@ -162,9 +182,10 @@ def generate_pdf(markdown_content: str) -> bytes:
"""
html_body = markdown.markdown(markdown_content, extensions=_MD_EXTENSIONS)
html_body = _process_citations(html_body)
direction = _text_direction(markdown_content)
html_doc = (
"<!DOCTYPE html>"
'<html><head><meta charset="utf-8">'
f'<html dir="{direction}"><head><meta charset="utf-8">'
f"<style>{_CSS}</style></head>"
f"<body>{html_body}</body></html>"
)
Expand Down
Loading
Loading