feat(webui): polish agent output and app discovery
This commit is contained in:
@@ -6,13 +6,13 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import { Check, Globe2 } from "lucide-react";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import { Streamdown, type Components, type StreamdownProps } from "streamdown";
|
||||
|
||||
import { AttachmentTile } from "@/components/AttachmentTile";
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
@@ -27,16 +27,18 @@ import {
|
||||
} from "@/components/FileReferenceChip";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { inferMediaKind } from "@/lib/media";
|
||||
import { faviconUrls } from "@/lib/provider-brand";
|
||||
import { browserSafeFaviconUrls } from "@/lib/provider-brand";
|
||||
import { remarkTexMath } from "@/lib/remark-tex-math";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import "katex/dist/katex.min.css";
|
||||
import "streamdown/styles.css";
|
||||
|
||||
interface MarkdownTextRendererProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
highlightCode?: boolean;
|
||||
streaming?: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
@@ -235,18 +237,45 @@ function remarkSafeHtmlSubset() {
|
||||
};
|
||||
}
|
||||
|
||||
const remarkPlugins: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = [
|
||||
const remarkPlugins: NonNullable<StreamdownProps["remarkPlugins"]> = [
|
||||
remarkBreaks,
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
remarkTexMath,
|
||||
remarkSafeHtmlSubset,
|
||||
];
|
||||
const rehypePlugins: NonNullable<ReactMarkdownOptions["rehypePlugins"]> = [rehypeKatex];
|
||||
const rehypePlugins: NonNullable<StreamdownProps["rehypePlugins"]> = [rehypeKatex];
|
||||
|
||||
const DIRECT_LINKS = { enabled: false } as const;
|
||||
const SAFE_MARKDOWN_PROTOCOL = /^(https?|ircs?|mailto|xmpp)$/i;
|
||||
const STREAMING_ANIMATION = {
|
||||
animation: "fadeIn",
|
||||
duration: 180,
|
||||
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
sep: "word",
|
||||
stagger: 18,
|
||||
} as const;
|
||||
|
||||
/** Preserve react-markdown's URL policy when rendering through Streamdown. */
|
||||
const safeMarkdownUrl: NonNullable<StreamdownProps["urlTransform"]> = (url) => {
|
||||
const colon = url.indexOf(":");
|
||||
const questionMark = url.indexOf("?");
|
||||
const hash = url.indexOf("#");
|
||||
const slash = url.indexOf("/");
|
||||
const relative = colon === -1
|
||||
|| (slash !== -1 && colon > slash)
|
||||
|| (questionMark !== -1 && colon > questionMark)
|
||||
|| (hash !== -1 && colon > hash);
|
||||
return relative || SAFE_MARKDOWN_PROTOCOL.test(url.slice(0, colon)) ? url : "";
|
||||
};
|
||||
|
||||
function nodeText(value: ReactNode): string {
|
||||
return Children.toArray(value)
|
||||
.map((child) => (typeof child === "string" || typeof child === "number" ? String(child) : ""))
|
||||
.map((child) => {
|
||||
if (typeof child === "string" || typeof child === "number") return String(child);
|
||||
if (!isValidElement<{ children?: ReactNode }>(child)) return "";
|
||||
return nodeText(child.props.children);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
@@ -272,7 +301,7 @@ function cleanFileReferenceTarget(value: string): string {
|
||||
function isPreviewableFileTarget(value: string): boolean {
|
||||
if (isFilePatternReference(value)) return false;
|
||||
if (isLikelyFilePath(value)) return true;
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return false;
|
||||
if (/[\\/]/.test(value)) return false;
|
||||
return /^[^?#]+\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(value);
|
||||
}
|
||||
@@ -382,6 +411,8 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
||||
className="h-3 w-3 rounded-[2px] object-contain"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
draggable={false}
|
||||
onLoad={onFaviconLoad}
|
||||
onError={onFaviconError}
|
||||
/>
|
||||
@@ -397,7 +428,7 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
||||
}
|
||||
|
||||
function useFaviconFallback(host: string) {
|
||||
const faviconCandidates = useMemo(() => faviconUrls(host), [host]);
|
||||
const faviconCandidates = useMemo(() => browserSafeFaviconUrls(host), [host]);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(faviconCandidates);
|
||||
|
||||
return {
|
||||
@@ -433,11 +464,14 @@ export default function MarkdownTextRenderer({
|
||||
children,
|
||||
className,
|
||||
highlightCode = true,
|
||||
streaming = false,
|
||||
onOpenFilePreview,
|
||||
}: MarkdownTextRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
const components = useMemo<Components>(
|
||||
() => ({
|
||||
code({ className: cls, children: kids, ...props }) {
|
||||
code({ className: cls, children: kids, node: _node, ...props }) {
|
||||
void _node;
|
||||
const match = /language-(\w+)/.exec(cls || "");
|
||||
if (match) {
|
||||
const code = String(kids).replace(/\n$/, "");
|
||||
@@ -447,6 +481,7 @@ export default function MarkdownTextRenderer({
|
||||
code={code}
|
||||
className="my-3"
|
||||
highlight={highlightCode}
|
||||
showLineNumbers={code.includes("\n")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -502,6 +537,7 @@ export default function MarkdownTextRenderer({
|
||||
code={fence.code}
|
||||
className="my-3"
|
||||
highlight={highlightCode}
|
||||
showLineNumbers={fence.code.includes("\n")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -517,7 +553,14 @@ export default function MarkdownTextRenderer({
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
a({ href, children: markdownChildren, ...props }) {
|
||||
a({ href, children: markdownChildren, node: _node, ...props }) {
|
||||
void _node;
|
||||
if (!href) {
|
||||
return <>{markdownChildren}</>;
|
||||
}
|
||||
if (href === "streamdown:incomplete-link") {
|
||||
return <>{markdownChildren}</>;
|
||||
}
|
||||
const filePath = fileReferenceFromLink(href);
|
||||
if (filePath) {
|
||||
const label = nodeText(markdownChildren).trim();
|
||||
@@ -545,15 +588,49 @@ export default function MarkdownTextRenderer({
|
||||
</a>
|
||||
);
|
||||
},
|
||||
table({ children, ...props }) {
|
||||
// Wrap wide markdown tables in a horizontal-scroll container (the
|
||||
// pattern used by DeepSeek/others) so a 6+ column table scrolls inside
|
||||
// the conversation column instead of forcing the page wider than 100vw.
|
||||
// min-w-max keeps natural column widths; w-full stretches narrow tables.
|
||||
// Streamdown decorates emphasis with spans by default. Preserve native
|
||||
// semantics for accessibility and predictable typography.
|
||||
strong({ children: markdownChildren, node: _node, ...props }) {
|
||||
void _node;
|
||||
return <strong {...props}>{markdownChildren}</strong>;
|
||||
},
|
||||
em({ children: markdownChildren, node: _node, ...props }) {
|
||||
void _node;
|
||||
return <em {...props}>{markdownChildren}</em>;
|
||||
},
|
||||
del({ children: markdownChildren, node: _node, ...props }) {
|
||||
void _node;
|
||||
return <del {...props}>{markdownChildren}</del>;
|
||||
},
|
||||
table({ children: tableChildren, node: _node, ...props }) {
|
||||
void _node;
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full min-w-max" {...props}>
|
||||
{children}
|
||||
<div
|
||||
data-testid="markdown-data-table"
|
||||
data-table-kind="data"
|
||||
role="region"
|
||||
tabIndex={0}
|
||||
aria-label={t("message.dataTable", { defaultValue: "Data table" })}
|
||||
className={cn(
|
||||
"not-prose mb-5 mt-3 w-full max-w-full overflow-x-auto rounded-lg",
|
||||
"border border-border/65 bg-muted/20",
|
||||
"overscroll-x-contain focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<table
|
||||
className={cn(
|
||||
"w-full min-w-max border-collapse text-[13px] leading-5",
|
||||
"[&_thead]:bg-muted/45 [&_thead]:text-muted-foreground",
|
||||
"[&_th]:border-b [&_th]:border-border/65 [&_th]:px-3 [&_th]:py-2",
|
||||
"[&_th]:text-left [&_th]:font-medium",
|
||||
"[&_td]:border-b [&_td]:border-border/55 [&_td]:px-3 [&_td]:py-2",
|
||||
"[&_th:not(:last-child)]:border-r [&_th:not(:last-child)]:border-border/45",
|
||||
"[&_td:not(:last-child)]:border-r [&_td:not(:last-child)]:border-border/45",
|
||||
"[&_tbody_tr:last-child_td]:border-b-0",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{tableChildren}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
@@ -567,8 +644,14 @@ export default function MarkdownTextRenderer({
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const taskItem = itemClassName?.includes("task-list-item");
|
||||
return (
|
||||
<li className={itemClassName}>
|
||||
<li
|
||||
className={cn(
|
||||
itemClassName,
|
||||
taskItem && "flex min-w-0 items-start gap-2 text-[13px] leading-5 [&>p]:m-0",
|
||||
)}
|
||||
>
|
||||
{markdownChildren}
|
||||
</li>
|
||||
);
|
||||
@@ -579,10 +662,11 @@ export default function MarkdownTextRenderer({
|
||||
<span
|
||||
aria-hidden
|
||||
data-testid="markdown-task-checkbox"
|
||||
data-task-checked={checked ? "true" : "false"}
|
||||
className={cn(
|
||||
"mr-2 inline-grid h-4 w-4 translate-y-[2px] place-items-center rounded-[4px]",
|
||||
"border border-border/70 bg-muted/55 text-background",
|
||||
checked && "border-foreground/55 bg-foreground/65",
|
||||
"mt-0.5 inline-grid h-4 w-4 shrink-0 place-items-center rounded-full",
|
||||
"border border-dashed border-muted-foreground/55 bg-background text-background",
|
||||
checked && "border-solid border-emerald-500 bg-emerald-500 text-white",
|
||||
)}
|
||||
>
|
||||
{checked ? <Check className="h-3 w-3 stroke-[3]" /> : null}
|
||||
@@ -636,11 +720,21 @@ export default function MarkdownTextRenderer({
|
||||
);
|
||||
},
|
||||
}),
|
||||
[highlightCode, onOpenFilePreview],
|
||||
[highlightCode, onOpenFilePreview, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<Streamdown
|
||||
mode={streaming ? "streaming" : "static"}
|
||||
parseIncompleteMarkdown
|
||||
isAnimating={streaming}
|
||||
animated={streaming ? STREAMING_ANIMATION : false}
|
||||
caret={streaming ? "block" : undefined}
|
||||
linkSafety={DIRECT_LINKS}
|
||||
urlTransform={safeMarkdownUrl}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
className={cn(
|
||||
"markdown-content prose max-w-none dark:prose-invert",
|
||||
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold prose-headings:tracking-tight",
|
||||
@@ -653,18 +747,10 @@ export default function MarkdownTextRenderer({
|
||||
"prose-hr:my-6",
|
||||
"prose-pre:my-0 prose-pre:bg-transparent prose-pre:p-0",
|
||||
"prose-code:before:content-none prose-code:after:content-none prose-code:font-normal",
|
||||
"prose-table:my-3 prose-th:text-left prose-th:font-medium",
|
||||
className,
|
||||
)}
|
||||
style={{ lineHeight: "var(--cjk-line-height)" }}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
{children}
|
||||
</Streamdown>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user