feat(webui): polish agent output and app discovery

This commit is contained in:
Xubin Ren
2026-07-22 22:42:31 +08:00
parent b189a37648
commit aa8387fb4d
87 changed files with 6225 additions and 2379 deletions
+120 -34
View File
@@ -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>
);
}