agent-ecosystem/src/renderer/components/chat/markdownComponents.tsx
2026-04-11 09:07:16 +03:00

257 lines
7.2 KiB
TypeScript

import React from 'react';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { PROSE_BODY } from '@renderer/constants/cssVariables';
import { extractTextFromReactNode } from './markdownCopyUtils';
import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils';
import { FileLink, isRelativeUrl } from './viewers/FileLink';
import type { Components } from 'react-markdown';
interface MarkdownComponentOptions {
copyCodeBlocks?: boolean;
}
/**
* Create inline markdown components for rendering prose content.
* When searchCtx is provided, search term highlighting is applied
* to text nodes while preserving full markdown rendering.
*/
export function createMarkdownComponents(
searchCtx: SearchContext | null,
options: MarkdownComponentOptions = {}
): Components {
const { copyCodeBlocks = false } = options;
const hl = (children: React.ReactNode): React.ReactNode =>
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
return {
// Headings - Bold text with generous spacing to break up content
h1: ({ children }) => (
<h1
className="mb-3 mt-6 text-lg font-semibold first:mt-0"
style={{ color: 'var(--prose-heading)' }}
>
{hl(children)}
</h1>
),
h2: ({ children }) => (
<h2
className="mb-2 mt-5 text-base font-semibold first:mt-0"
style={{ color: 'var(--prose-heading)' }}
>
{hl(children)}
</h2>
),
h3: ({ children }) => (
<h3
className="mb-2 mt-4 text-sm font-semibold first:mt-0"
style={{ color: 'var(--prose-heading)' }}
>
{hl(children)}
</h3>
),
h4: ({ children }) => (
<h4
className="mb-1.5 mt-3 text-sm font-semibold first:mt-0"
style={{ color: 'var(--prose-heading)' }}
>
{hl(children)}
</h4>
),
h5: ({ children }) => (
<h5
className="mb-1 mt-2 text-sm font-medium first:mt-0"
style={{ color: 'var(--prose-heading)' }}
>
{hl(children)}
</h5>
),
h6: ({ children }) => (
<h6
className="mb-1 mt-2 text-xs font-medium first:mt-0"
style={{ color: 'var(--prose-heading)' }}
>
{hl(children)}
</h6>
),
// Paragraphs
p: ({ children }) => (
<p
className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"
style={{ color: PROSE_BODY }}
>
{hl(children)}
</p>
),
// Links — inline element, no hl(); parent block element's hl() descends here
a: ({ href, children }) => {
if (href && isRelativeUrl(href)) {
return <FileLink href={href}>{children}</FileLink>;
}
return (
<a
href={href}
className="no-underline hover:underline"
style={{ color: 'var(--prose-link)' }}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
);
},
// Strong/Bold — inline element, no hl()
strong: ({ children }) => (
<strong className="font-semibold" style={{ color: 'var(--prose-heading)' }}>
{children}
</strong>
),
// Emphasis/Italic — inline element, no hl()
em: ({ children }) => (
<em className="italic" style={{ color: PROSE_BODY }}>
{children}
</em>
),
// Strikethrough — inline element, no hl()
del: ({ children }) => (
<del className="line-through" style={{ color: PROSE_BODY }}>
{children}
</del>
),
// Inline code vs block code (block is highlighted by rehype-highlight; preserve hljs class)
code: ({ className, children }) => {
const hasLanguageClass = className?.includes('language-');
const content = typeof children === 'string' ? children : '';
const isMultiLine = content.includes('\n');
const isBlock = (hasLanguageClass ?? false) || isMultiLine;
if (isBlock) {
return (
<code
className={`block font-mono text-xs ${className ?? ''}`.trim()}
style={{ color: 'var(--color-text)' }}
>
{hl(children)}
</code>
);
}
// Inline code — no hl(); parent block element's hl() descends here
return (
<code
className="rounded px-1.5 py-0.5 font-mono text-xs"
style={{
backgroundColor: 'var(--prose-code-bg)',
color: 'var(--prose-code-text)',
}}
>
{children}
</code>
);
},
// Code blocks
pre: ({ children }) => {
const codeText = copyCodeBlocks ? extractTextFromReactNode(children).trim() : '';
return (
<pre
className={`my-3 overflow-x-auto rounded-lg p-3 font-mono text-xs leading-relaxed ${codeText ? 'group relative' : ''}`.trim()}
style={{
backgroundColor: 'var(--prose-pre-bg)',
border: '1px solid var(--prose-pre-border)',
color: 'var(--color-text)',
}}
>
{codeText ? <CopyButton text={codeText} /> : null}
{children}
</pre>
);
},
// Blockquotes
blockquote: ({ children }) => (
<blockquote
className="my-3 border-l-4 pl-4 italic"
style={{
borderColor: 'var(--prose-blockquote-border)',
color: 'var(--prose-muted)',
}}
>
{hl(children)}
</blockquote>
),
// Lists
ul: ({ children }) => (
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
{children}
</ul>
),
ol: ({ children }) => (
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
{children}
</ol>
),
li: ({ children }) => (
<li className="text-sm" style={{ color: PROSE_BODY }}>
{hl(children)}
</li>
),
// Tables
table: ({ children }) => (
<div className="my-3 overflow-x-auto">
<table
className="min-w-full border-collapse text-sm"
style={{ borderColor: 'var(--prose-table-border)' }}
>
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead style={{ backgroundColor: 'var(--prose-table-header-bg)' }}>{children}</thead>
),
th: ({ children }) => (
<th
className="px-3 py-2 text-left font-semibold"
style={{
border: '1px solid var(--prose-table-border)',
color: 'var(--prose-heading)',
}}
>
{hl(children)}
</th>
),
td: ({ children }) => (
<td
className="px-3 py-2"
style={{
border: '1px solid var(--prose-table-border)',
color: PROSE_BODY,
}}
>
{hl(children)}
</td>
),
// Horizontal rule
hr: () => <hr className="my-4" style={{ borderColor: 'var(--prose-table-border)' }} />,
};
}
/** Default markdown components without search highlighting (used by CompactBoundary) */
export const markdownComponents: Components = createMarkdownComponents(null);
/** Markdown components for message-style content with both whole-message and code-block copy */
export const markdownComponentsWithCodeCopy: Components = createMarkdownComponents(null, {
copyCodeBlocks: true,
});