Files
tutti/packages/agent/gui/shared/AgentMessageMarkdownRenderers.tsx
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

236 lines
6.0 KiB
TypeScript

import {
Children,
isValidElement,
useCallback,
useRef,
useState,
type CSSProperties,
type JSX,
type ReactNode
} from "react";
import { Check, Copy } from "lucide-react";
import { translate } from "../i18n/index";
import { cn } from "../app/renderer/lib/utils";
import type { MarkdownDomProps } from "./AgentMessageMarkdown";
import { AgentMessageMermaid } from "./AgentMessageMermaid";
export function MarkdownCode({
node: _node,
children,
className,
...props
}: MarkdownDomProps<"code">): JSX.Element {
"use memo";
return (
<code {...props} className={className}>
{children}
</code>
);
}
const MARKDOWN_ORDERED_LIST_STYLE: CSSProperties = {
listStylePosition: "outside",
margin: "12px 0 8px",
paddingInlineStart: 34,
paddingInlineEnd: 16
};
const MARKDOWN_UNORDERED_LIST_STYLE: CSSProperties = {
margin: "12px 0 8px",
paddingInlineStart: 0
};
const MARKDOWN_LIST_ITEM_STYLE: CSSProperties = {
margin: "4px 0"
};
export function MarkdownUnorderedList({
node: _node,
className,
style,
...props
}: MarkdownDomProps<"ul">): JSX.Element {
"use memo";
return (
<ul
{...props}
className={cn(
'[&_li]:relative [&_li]:list-none [&_li]:pl-[34px] [&_li::before]:absolute [&_li::before]:left-4 [&_li::before]:top-[0.78em] [&_li::before]:h-1.5 [&_li::before]:w-1.5 [&_li::before]:-translate-y-1/2 [&_li::before]:rounded-full [&_li::before]:bg-[var(--text-tertiary)] [&_li::before]:content-[""]',
className
)}
style={{ ...MARKDOWN_UNORDERED_LIST_STYLE, ...style }}
/>
);
}
export function MarkdownOrderedList({
node: _node,
style,
...props
}: MarkdownDomProps<"ol">): JSX.Element {
"use memo";
return (
<ol
{...props}
style={{
...MARKDOWN_ORDERED_LIST_STYLE,
listStyleType: "decimal",
...style
}}
/>
);
}
export function MarkdownListItem({
node: _node,
style,
...props
}: MarkdownDomProps<"li">): JSX.Element {
"use memo";
return <li {...props} style={{ ...MARKDOWN_LIST_ITEM_STYLE, ...style }} />;
}
export function MarkdownParagraph({
node: _node,
inline,
...props
}: MarkdownDomProps<"p"> & {
inline: boolean;
}): JSX.Element {
"use memo";
if (inline) {
return <span {...props} />;
}
return <p {...props} />;
}
export function textFromReactNode(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(textFromReactNode).join("");
}
return "";
}
export function MarkdownPre({
children,
mermaidEnabled = true,
node: _node,
streaming = false,
...props
}: MarkdownDomProps<"pre"> & {
mermaidEnabled?: boolean;
streaming?: boolean;
}): JSX.Element {
"use memo";
const preRef = useRef<HTMLPreElement>(null);
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mermaidSource = mermaidEnabled
? resolveMermaidCodeBlockSource(children)
: null;
const streamingMermaidSource =
mermaidEnabled && streaming
? resolveStreamingMermaidCodeBlockSource(children)
: null;
const effectiveMermaidSource = mermaidSource ?? streamingMermaidSource;
const handleCopy = useCallback(() => {
const text = preRef.current?.textContent?.trim();
if (!text) {
return;
}
void navigator.clipboard?.writeText(text).then(() => {
setCopied(true);
if (copyResetRef.current) {
clearTimeout(copyResetRef.current);
}
// timing: revert the "copied" affordance after the confirmation window
copyResetRef.current = setTimeout(() => setCopied(false), 1500);
});
}, []);
const codeBlock = (
<div className="group relative">
<button
type="button"
data-testid="markdown-code-copy"
className="absolute right-1.5 top-1.5 z-10 inline-flex size-5 items-center justify-center rounded-[4px] text-[var(--text-tertiary)] opacity-0 transition-opacity hover:bg-[var(--transparency-hover)] hover:text-[var(--text-secondary)] group-hover:opacity-100"
aria-label={translate("agentHost.agentGui.copyCode")}
title={translate("agentHost.agentGui.copyCode")}
onClick={handleCopy}
>
{copied ? (
<Check size={13} strokeWidth={2} aria-hidden="true" />
) : (
<Copy size={13} strokeWidth={2} aria-hidden="true" />
)}
</button>
<pre {...props} ref={preRef}>
{children}
</pre>
</div>
);
if (effectiveMermaidSource === null) {
return codeBlock;
}
return (
<AgentMessageMermaid
source={effectiveMermaidSource}
streaming={streaming}
/>
);
}
export function resolveMermaidCodeBlockSource(
children: ReactNode
): string | null {
return resolveMermaidCodeBlockSourceWithLanguage(children, (language) => {
return language === "mermaid";
});
}
function resolveStreamingMermaidCodeBlockSource(
children: ReactNode
): string | null {
return resolveMermaidCodeBlockSourceWithLanguage(children, (language) => {
return (
language.length > 0 &&
language.length < "mermaid".length &&
"mermaid".startsWith(language)
);
});
}
function resolveMermaidCodeBlockSourceWithLanguage(
children: ReactNode,
acceptsLanguage: (language: string) => boolean
): string | null {
const childElements = Children.toArray(children);
if (childElements.length !== 1 || !isValidElement(childElements[0])) {
return null;
}
const codeElement = childElements[0] as {
props: {
children?: ReactNode;
className?: unknown;
};
};
const className =
typeof codeElement.props.className === "string"
? codeElement.props.className
: "";
const languageClass = className
.split(/\s+/)
.find((candidate) => candidate.toLowerCase().startsWith("language-"));
const language = languageClass?.slice("language-".length).toLowerCase() ?? "";
if (!acceptsLanguage(language)) {
return null;
}
return textFromReactNode(codeElement.props.children).replace(/\n$/, "");
}