diff --git a/web/package-lock.json b/web/package-lock.json index 7f30b7a..5c9294b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index ebeec1a..a9b9aec 100644 --- a/web/package.json +++ b/web/package.json @@ -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" }, diff --git a/web/src/components/markdown/markdown-content.tsx b/web/src/components/markdown/markdown-content.tsx new file mode 100644 index 0000000..05564b2 --- /dev/null +++ b/web/src/components/markdown/markdown-content.tsx @@ -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 `[${index}]`; + }); +} + +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
; +} diff --git a/web/src/components/search/search-results-panel.tsx b/web/src/components/search/search-results-panel.tsx index 47e9ba9..cab81db 100644 --- a/web/src/components/search/search-results-panel.tsx +++ b/web/src/components/search/search-results-panel.tsx @@ -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 (
{search?.query ? ( @@ -41,22 +85,28 @@ export function SearchResultsPanel({ search, isLoading, isRunning, showPrompt =

Answer

{isRunning && !search?.answerText ?

Generating answer...

: null} - {search?.answerText ?

{search.answerText}

: null} + {search?.answerText ? ( + + ) : null} {search?.answerError ?

{search.answerError}

: null} - {!!search?.answerCitations?.length && ( + {!!citationEntries.length && (
- {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 ( - {citation.title?.trim() || formatHost(href)} + {citation.index} + {citation.label} ); })} @@ -93,7 +143,7 @@ export function SearchResultsPanel({ search, isLoading, isRunning, showPrompt = {(result.publishedDate || result.author) && (

{[result.publishedDate, result.author].filter(Boolean).join(" • ")}

)} - {summary ?

{summary}

: null} + {summary ?

{summary}

: null} ); })} diff --git a/web/src/index.css b/web/src/index.css index 51def7b..a683fc4 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; +}