search: slightly better results appearance

This commit is contained in:
2026-02-14 00:33:15 -08:00
parent acca7be7f0
commit e3829e813f
5 changed files with 180 additions and 11 deletions

30
web/package-lock.json generated
View File

@@ -10,7 +10,9 @@
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.3.1",
"lucide-preact": "^0.542.0",
"marked": "^17.0.2",
"preact": "^10.27.2",
"tailwind-merge": "^3.3.1"
},
@@ -1362,6 +1364,13 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -1753,6 +1762,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -2164,6 +2182,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.2.tgz",
"integrity": "sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",

View File

@@ -12,7 +12,9 @@
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.3.1",
"lucide-preact": "^0.542.0",
"marked": "^17.0.2",
"preact": "^10.27.2",
"tailwind-merge": "^3.3.1"
},

View File

@@ -0,0 +1,37 @@
import { useMemo } from "preact/hooks";
import DOMPurify from "dompurify";
import { marked } from "marked";
import { cn } from "@/lib/utils";
type MarkdownMode = "default" | "citationTokens";
type Props = {
markdown: string;
className?: string;
mode?: MarkdownMode;
resolveCitationIndex?: (href: string) => number | undefined;
};
function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitationIndex?: (href: string) => number | undefined) {
if (!resolveCitationIndex) return markdown;
return markdown.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_full, _label, href) => {
const index = resolveCitationIndex(href);
if (!index) return "";
return `<span class="md-cite-token">[${index}]</span>`;
});
}
function renderMarkdown(markdown: string) {
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true }) as string;
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
}
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex }: Props) {
const html = useMemo(() => {
const prepared =
mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown;
return renderMarkdown(prepared);
}, [markdown, mode, resolveCitationIndex]);
return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />;
}

View File

@@ -1,10 +1,25 @@
import { Search } from "lucide-preact";
import type { SearchDetail, SearchResultItem } from "@/lib/api";
import { MarkdownContent } from "@/components/markdown/markdown-content";
function cleanResultText(input: string) {
return input
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, "$1")
.replace(/\[\s*\]/g, " ")
.replace(/(^|\s)#{1,6}\s*/g, "$1")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/(\*\*|__|\*|_|`{1,3}|~~)/g, "")
.replace(/\r?\n+/g, " ")
.replace(/\s{2,}/g, " ")
.trim();
}
function summarizeResult(result: SearchResultItem) {
const highlights = Array.isArray(result.highlights) ? result.highlights.filter(Boolean) : [];
if (highlights.length) return highlights.join(" ").slice(0, 420);
return (result.text ?? "").slice(0, 420);
const raw = highlights.length ? highlights.join(" ") : result.text ?? "";
const cleaned = cleanResultText(raw);
if (cleaned.length <= 680) return cleaned;
return `${cleaned.slice(0, 679).trimEnd()}`;
}
function formatHost(url: string) {
@@ -15,6 +30,17 @@ function formatHost(url: string) {
}
}
function normalizeHref(href: string) {
try {
const parsed = new URL(href);
parsed.hash = "";
const normalized = parsed.toString();
return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
} catch {
return href.trim().replace(/\/$/, "");
}
}
type Props = {
search: SearchDetail | null;
isLoading: boolean;
@@ -24,6 +50,24 @@ type Props = {
};
export function SearchResultsPanel({ search, isLoading, isRunning, showPrompt = true, className }: Props) {
const citationEntries = (search?.answerCitations ?? [])
.map((citation, index) => {
const href = citation.url || citation.id || "";
if (!href) return null;
return {
href,
normalizedHref: normalizeHref(href),
index: index + 1,
label: citation.title?.trim() || formatHost(href),
};
})
.filter((entry): entry is { href: string; normalizedHref: string; index: number; label: string } => !!entry);
const resolveCitationIndex = (href: string) => {
const normalized = normalizeHref(href);
return citationEntries.find((entry) => entry.normalizedHref === normalized)?.index;
};
return (
<div className={className ?? "mx-auto w-full max-w-4xl"}>
{search?.query ? (
@@ -41,22 +85,28 @@ export function SearchResultsPanel({ search, isLoading, isRunning, showPrompt =
<section className="mb-6 rounded-xl border border-slate-600/60 bg-[#121a2e] p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-sky-300/90">Answer</p>
{isRunning && !search?.answerText ? <p className="mt-2 text-sm text-muted-foreground">Generating answer...</p> : null}
{search?.answerText ? <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-100">{search.answerText}</p> : null}
{search?.answerText ? (
<MarkdownContent
markdown={search.answerText}
mode="citationTokens"
resolveCitationIndex={resolveCitationIndex}
className="mt-2 text-sm leading-6 text-slate-100"
/>
) : null}
{search?.answerError ? <p className="mt-2 text-sm text-red-500">{search.answerError}</p> : null}
{!!search?.answerCitations?.length && (
{!!citationEntries.length && (
<div className="mt-3 flex flex-wrap gap-2">
{search.answerCitations.slice(0, 6).map((citation, index) => {
const href = citation.url || citation.id || "";
if (!href) return null;
{citationEntries.slice(0, 8).map((citation) => {
return (
<a
key={`${href}-${index}`}
href={href}
key={`${citation.href}-${citation.index}`}
href={citation.href}
target="_blank"
rel="noreferrer"
className="rounded-md border border-slate-500/60 px-2 py-1 text-xs text-sky-200 hover:bg-slate-700/40"
>
{citation.title?.trim() || formatHost(href)}
<span className="mr-1 rounded bg-slate-700/80 px-1 py-0.5 text-[10px] text-slate-100">{citation.index}</span>
{citation.label}
</a>
);
})}
@@ -93,7 +143,7 @@ export function SearchResultsPanel({ search, isLoading, isRunning, showPrompt =
{(result.publishedDate || result.author) && (
<p className="mt-1 text-xs text-muted-foreground">{[result.publishedDate, result.author].filter(Boolean).join(" • ")}</p>
)}
{summary ? <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-slate-200">{summary}</p> : null}
{summary ? <p className="mt-2 text-sm leading-6 text-slate-200">{summary}</p> : null}
</article>
);
})}

View File

@@ -34,3 +34,53 @@ body {
background-image: radial-gradient(circle at top, hsl(222 30% 18%) 0%, hsl(224 21% 11%) 45%, hsl(224 20% 9%) 100%);
font-family: "Soehne", "Avenir Next", "Segoe UI", sans-serif;
}
.md-content {
word-break: break-word;
}
.md-content p + p {
margin-top: 0.85rem;
}
.md-content ul,
.md-content ol {
margin-top: 0.65rem;
margin-left: 1.25rem;
}
.md-content li + li {
margin-top: 0.3rem;
}
.md-content code {
background: hsl(223 18% 22%);
border-radius: 0.25rem;
padding: 0.05rem 0.3rem;
font-size: 0.86em;
}
.md-content pre {
overflow-x: auto;
border-radius: 0.5rem;
background: hsl(223 18% 12%);
padding: 0.6rem 0.75rem;
}
.md-content a {
color: hsl(198 95% 72%);
text-decoration: underline;
}
.md-cite-token {
display: inline-flex;
align-items: center;
border-radius: 9999px;
border: 1px solid hsl(217 24% 54%);
background: hsl(223 23% 21%);
color: hsl(210 40% 96%);
font-size: 0.72rem;
line-height: 1;
padding: 0.12rem 0.38rem;
vertical-align: baseline;
}