From 9c75236ea1dd437ae705dd828b31a974f1e069a0 Mon Sep 17 00:00:00 2001 From: creed <115765389+97Three@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:51:40 +0800 Subject: [PATCH] fix(frontend): strip closing hashes from web fetch titles (#6103) * fix(frontend): strip closing hashes from web fetch titles Signed-off-by: 97three <2212371308@qq.com> * fix(frontend): scan web fetch heading suffixes in linear time Signed-off-by: 97three <2212371308@qq.com> --------- Signed-off-by: 97three <2212371308@qq.com> --- frontend/AGENTS.md | 9 +++- frontend/src/core/utils/markdown.ts | 21 +++++++++- .../tests/unit/core/utils/markdown.test.ts | 42 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 73899332eb..23b35ddd3b 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -87,8 +87,13 @@ More specific `AGENTS.md` files under `src/` contain the frontend sections split `core/utils/markdown.ts` reads web-fetch titles from the first nonblank line. Match zero to three literal spaces before `# ` without trimming indentation; -mixed space/tab code blocks must fall back to the URL. Keep this local to title -extraction rather than changing the shared streamdown fence parser. +mixed space/tab code blocks must fall back to the URL. Strip optional closing +hashes with a backwards scan so long fetched titles cannot cause quadratic regex +backtracking. Closing hashes require a preceding space/tab (or an empty title) +and only ASCII spaces/tabs afterward, apart from the terminal CR in CRLF input. +Non-ASCII trailing whitespace must not turn literal hashes into closing syntax. +Keep this local to title extraction rather than changing the shared streamdown +fence parser. Custom Agent `display_name` is an optional Unicode UI label, edited in `AgentSettingsDialog`. Use it with a fallback to `name` for gallery/chat text; diff --git a/frontend/src/core/utils/markdown.ts b/frontend/src/core/utils/markdown.ts index fbef29001f..24697c1ab2 100644 --- a/frontend/src/core/utils/markdown.ts +++ b/frontend/src/core/utils/markdown.ts @@ -9,5 +9,24 @@ export function extractTitleFromMarkdown(markdown: string) { if (!headingPrefix) { return undefined; } - return firstLine.slice(headingPrefix[0].length).trim() || undefined; + const title = firstLine.slice(headingPrefix[0].length); + // Scan backwards once: a whitespace-search regex can backtrack quadratically + // on long fetched titles. Only ASCII spaces/tabs may follow closing hashes. + let end = title.endsWith("\r") ? title.length - 1 : title.length; + while (end > 0 && (title[end - 1] === " " || title[end - 1] === "\t")) { + end--; + } + let hashStart = end; + while (hashStart > 0 && title[hashStart - 1] === "#") { + hashStart--; + } + if ( + hashStart < end && + (hashStart === 0 || + title[hashStart - 1] === " " || + title[hashStart - 1] === "\t") + ) { + return title.slice(0, hashStart).trim() || undefined; + } + return title.trim() || undefined; } diff --git a/frontend/tests/unit/core/utils/markdown.test.ts b/frontend/tests/unit/core/utils/markdown.test.ts index 140d64756b..ce77694833 100644 --- a/frontend/tests/unit/core/utils/markdown.test.ts +++ b/frontend/tests/unit/core/utils/markdown.test.ts @@ -6,6 +6,48 @@ test("reads the title from a leading ATX heading", () => { expect(extractTitleFromMarkdown("# Real Title\n\nbody")).toBe("Real Title"); }); +test.each([ + ["# Release Notes ###", "Release Notes"], + ["# Release Notes ### ", "Release Notes"], + ["# Release Notes\t###\t", "Release Notes"], + ["# Release Notes ###\r\nbody", "Release Notes"], + ["# ###", undefined], +])("removes a closing ATX heading sequence from %j", (markdown, expected) => { + expect(extractTitleFromMarkdown(markdown)).toBe(expected); +}); + +test.each([ + ["# C#", "C#"], + ["# Release ### notes", "Release ### notes"], + ["# Release ###x", "Release ###x"], + ["# Release \\###", "Release \\###"], +])("preserves literal hashes in %j", (markdown, expected) => { + expect(extractTitleFromMarkdown(markdown)).toBe(expected); +}); + +test.each(["\u00a0", "\u2003"])( + "preserves literal hashes followed by non-ASCII whitespace %j", + (whitespace) => { + expect(extractTitleFromMarkdown(`# Release ###${whitespace}`)).toBe( + "Release ###", + ); + expect(extractTitleFromMarkdown(`# #${whitespace}\r\nbody`)).toBe("#"); + }, +); + +test("handles long whitespace and hash runs without changing literal content", () => { + const spaces = " ".repeat(100_000); + const hashes = "#".repeat(100_000); + expect(extractTitleFromMarkdown(`# Title${spaces}#x`)).toBe( + `Title${spaces}#x`, + ); + expect(extractTitleFromMarkdown(`# Title${spaces}${hashes}\t\r\nbody`)).toBe( + "Title", + ); + expect(extractTitleFromMarkdown(`# ${hashes}x`)).toBe(`${hashes}x`); + expect(extractTitleFromMarkdown(`# ${hashes}\t`)).toBeUndefined(); +}); + test("skips blank lines before the first heading", () => { expect(extractTitleFromMarkdown("\n# Real Title\n\nbody")).toBe("Real Title"); expect(extractTitleFromMarkdown(" \n\n# Real Title")).toBe("Real Title");