fix(webui): simplify rendered source links

This commit is contained in:
Xubin Ren 2026-05-31 23:02:43 +08:00
parent 33a13b701b
commit cba9ff1f57
2 changed files with 53 additions and 29 deletions

View File

@ -30,9 +30,10 @@ type MarkdownAstNode = {
}; };
}; };
type CitationLink = { type InlineLinkPreview = {
href: string; href: string;
origin: string; origin: string;
prefix?: string;
title: string; title: string;
initials: string; initials: string;
}; };
@ -186,7 +187,7 @@ function nodeText(value: ReactNode): string {
.join(""); .join("");
} }
function citationParts(value: ReactNode): { text: string; href?: string } { function linkPreviewParts(value: ReactNode): { text: string; href?: string } {
let text = ""; let text = "";
let href: string | undefined; let href: string | undefined;
for (const child of Children.toArray(value)) { for (const child of Children.toArray(value)) {
@ -201,21 +202,21 @@ function citationParts(value: ReactNode): { text: string; href?: string } {
if (!href && typeof props.href === "string" && /^https?:\/\//i.test(props.href)) { if (!href && typeof props.href === "string" && /^https?:\/\//i.test(props.href)) {
href = props.href; href = props.href;
} }
const nested = citationParts(props.children); const nested = linkPreviewParts(props.children);
text += nested.text; text += nested.text;
href ||= nested.href; href ||= nested.href;
} }
return { text, href }; return { text, href };
} }
function cleanCitationText(value: string): string { function cleanLinkPreviewText(value: string): string {
return value return value
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.replace(/^[\s"'“”‘’]+|[\s"'“”‘’]+$/g, "") .replace(/^[\s"'“”‘’]+|[\s"'“”‘’]+$/g, "")
.trim(); .trim();
} }
function citationInitials(value: string): string { function linkPreviewInitials(value: string): string {
const clean = value const clean = value
.replace(/^https?:\/\//i, "") .replace(/^https?:\/\//i, "")
.replace(/^www\./i, "") .replace(/^www\./i, "")
@ -225,8 +226,8 @@ function citationInitials(value: string): string {
.toUpperCase(); .toUpperCase();
} }
function sourceLinkFromChildren(children: ReactNode): CitationLink | null { function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview | null {
const { text: rawText, href } = citationParts(children); const { text: rawText, href } = linkPreviewParts(children);
if (!href) return null; if (!href) return null;
let url: URL; let url: URL;
@ -246,50 +247,54 @@ function sourceLinkFromChildren(children: ReactNode): CitationLink | null {
if (!strippedUrl || strippedUrl.length < 4) return null; if (!strippedUrl || strippedUrl.length < 4) return null;
const sourceMatch = /^(.*?)\s*(?:[—–]| - |:)\s*(.+)$/.exec(strippedUrl); const sourceMatch = /^(.*?)\s*(?:[—–]| - |:)\s*(.+)$/.exec(strippedUrl);
const sourceLabel = sourceMatch?.[1] ? cleanCitationText(sourceMatch[1]) : undefined; const prefix = sourceMatch?.[1] ? cleanLinkPreviewText(sourceMatch[1]) : undefined;
const title = cleanCitationText(sourceMatch?.[2] ?? strippedUrl); const title = cleanLinkPreviewText(sourceMatch?.[2] ?? strippedUrl);
if (!title || /^https?:\/\//i.test(title)) return null; if (!title || /^https?:\/\//i.test(title)) return null;
return { return {
href, href,
origin: url.origin, origin: url.origin,
prefix,
title, title,
initials: citationInitials(sourceLabel || url.hostname), initials: linkPreviewInitials(prefix || url.hostname),
}; };
} }
function CitationRow({ citation }: { citation: CitationLink }) { function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
const label = link.prefix
? `${link.prefix}${link.title}`
: link.title;
return ( return (
<a <a
href={citation.href} href={link.href}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
aria-label={`Open source: ${citation.title}`} aria-label={`Open link: ${label}`}
className={cn( className={cn(
"not-prose my-0.5 inline-flex max-w-full items-center gap-2 rounded-md", "not-prose inline-flex max-w-full items-center gap-2 align-baseline",
"text-primary no-underline underline-offset-2 hover:underline", "text-primary no-underline underline-offset-2 hover:underline",
)} )}
> >
<span <span
className={cn( className={cn(
"relative grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-md", "relative grid h-4 w-4 shrink-0 place-items-center overflow-hidden rounded-[4px]",
"border border-border/65 bg-background text-[0.5625rem] font-semibold text-muted-foreground", "border border-border/65 bg-background text-[0.5rem] font-semibold text-muted-foreground",
)} )}
aria-hidden aria-hidden
> >
{citation.initials} {link.initials}
<img <img
src={`${citation.origin}/favicon.ico`} src={`${link.origin}/favicon.ico`}
alt="" alt=""
className="absolute h-3.5 w-3.5 rounded-[3px] object-contain" className="absolute h-3 w-3 rounded-[2px] object-contain"
loading="lazy" loading="lazy"
onError={(event) => { onError={(event) => {
event.currentTarget.style.display = "none"; event.currentTarget.style.display = "none";
}} }}
/> />
</span> </span>
<span className="min-w-0 truncate text-[0.95em] leading-normal"> <span className="min-w-0 truncate leading-normal">
{citation.title} {label}
</span> </span>
</a> </a>
); );
@ -412,13 +417,12 @@ export default function MarkdownTextRenderer({
</a> </a>
); );
}, },
li({ children: markdownChildren, className: itemClassName, node: _node }) { li({ children: markdownChildren, className: itemClassName }) {
void _node; const link = inlineLinkPreviewFromChildren(markdownChildren);
const citation = sourceLinkFromChildren(markdownChildren); if (link) {
if (citation) {
return ( return (
<li className={cn("list-none pl-0", itemClassName)}> <li className={cn("list-none pl-0", itemClassName)}>
<CitationRow citation={citation} /> <InlineLinkPreviewRow link={link} />
</li> </li>
); );
} }

View File

@ -68,7 +68,7 @@ describe("MarkdownTextRenderer", () => {
expect(screen.queryByRole("img", { name: "index.html" })).not.toBeInTheDocument(); expect(screen.queryByRole("img", { name: "index.html" })).not.toBeInTheDocument();
}); });
it("renders source-style link lists as citation rows", () => { it("renders title plus url list items as compact link rows", () => {
render( render(
<MarkdownTextRenderer> <MarkdownTextRenderer>
{ {
@ -78,17 +78,37 @@ describe("MarkdownTextRenderer", () => {
); );
expect( expect(
screen.getByRole("link", { name: "Open source: When will GPT-5.6 be released?" }), screen.getByRole("link", {
name: "Open link: Polymarket — When will GPT-5.6 be released?",
}),
).toHaveAttribute( ).toHaveAttribute(
"href", "href",
"https://polymarket.com/event/when-will-gpt-5pt6-be-released", "https://polymarket.com/event/when-will-gpt-5pt6-be-released",
); );
expect( expect(
screen.getByRole("link", { name: "Open source: GPT-5.6 released by...?" }), screen.getByRole("link", {
name: "Open link: Polymarket — GPT-5.6 released by...?",
}),
).toHaveAttribute("href", "https://polymarket.com/event/gpt-5pt6-released-by"); ).toHaveAttribute("href", "https://polymarket.com/event/gpt-5pt6-released-by");
expect(screen.queryByText("Polymarket · polymarket.com")).not.toBeInTheDocument(); expect(screen.queryByText("Polymarket · polymarket.com")).not.toBeInTheDocument();
}); });
it("does not require a source heading for compact link rows", () => {
render(
<MarkdownTextRenderer>
{
"Useful links:\n\n- Polymarket — “When will GPT-5.6 be released?”\n https://polymarket.com/event/when-will-gpt-5pt6-be-released"
}
</MarkdownTextRenderer>,
);
expect(
screen.getByRole("link", {
name: "Open link: Polymarket — When will GPT-5.6 be released?",
}),
).toHaveAttribute("href", "https://polymarket.com/event/when-will-gpt-5pt6-be-released");
});
it("renders media attachments without an extra preview/code wrapper", () => { it("renders media attachments without an extra preview/code wrapper", () => {
render(<MarkdownTextRenderer>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>); render(<MarkdownTextRenderer>![Diagram](/api/media/sig/payload)</MarkdownTextRenderer>);