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>
This commit is contained in:
creed 2026-10-01 08:51:40 +08:00 committed by GitHub
parent 52b21d13ac
commit 9c75236ea1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 69 additions and 3 deletions

View File

@ -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;

View File

@ -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;
}

View File

@ -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");