feat(wiki): make [n] citation markers clickable, linking to wiki pages (#305)

Backend (SourceEvidenceLedger):
- appendWikiSourceTable now normalizes existing source lines in-place to
  canonical "[N] Title - section - page N" format instead of skipping them
- Added replaceSourceLine helper that matches a full source line by regex
  and replaces it with the canonical form
- When source lines exist without a "来源:" header, automatically insert
  one so the frontend preprocessor can locate the source table

Frontend (useMarkdownRenderer):
- Added data-citation-index / data-citation-title to DOMPurify whitelist
- Added preprocessWikiCitations preprocessor: parses the canonical source
  table to build an index-to-title map, replaces [n] markers in the answer
  body with clickable <a> links, and wraps entire source-table rows so the
  full line is clickable
- Integrated into the render pipeline after wikilink substitution and
  before Marked parsing

Frontend (useGlobalWikilinkClick):
- Extended the click delegation selector to match both .wiki-link and
  .wiki-citation elements
- Title extraction falls back: data-citation-title || data-wiki-title

Tests: added three test cases for source-line normalization, idempotency,
and automatic header insertion
This commit is contained in:
SuperCoderMan521 2026-06-20 23:00:44 +08:00 committed by matevip
parent c656aff349
commit cb87569264
6 changed files with 4224 additions and 385 deletions

View File

@ -204,18 +204,43 @@ public record SourceEvidenceLedger(
if (used.isEmpty()) {
return answer;
}
String result = answer;
StringBuilder additions = new StringBuilder();
for (Integer index : used) {
WikiCitation citation = wikiCitation(index);
if (citation == null || sourceLineFor(answer, index) != null) {
if (citation == null) {
continue;
}
if (additions.isEmpty()) {
additions.append("\n\n来源");
String canonicalLine = citation.sourceLine();
if (sourceLineFor(result, index) != null) {
// Normalize in-place: replace the existing (possibly
// non-canonical) source line with the standard format so
// the frontend can reliably parse the source table.
result = replaceSourceLine(result, index, canonicalLine);
} else {
if (additions.isEmpty()) {
additions.append("\n\n来源");
}
additions.append("\n").append(canonicalLine);
}
additions.append("\n").append(citation.sourceLine());
}
return additions.isEmpty() ? answer : answer + additions;
// If source lines were normalized in-place but no 来源 header
// exists, insert one before the first source line so the frontend
// preprocessWikiCitations() can locate the source table.
if (additions.isEmpty() && !result.contains("来源:")) {
java.util.regex.Matcher firstSource = java.util.regex.Pattern
.compile("(?m)^\\[")
.matcher(result);
if (firstSource.find()) {
int pos = firstSource.start();
result = result.substring(0, pos) + "\n\n来源\n" + result.substring(pos);
}
}
return additions.isEmpty() ? result : result + additions;
}
private void validateWikiCitations(String answer, LinkedHashSet<String> unsupported) {
@ -269,6 +294,18 @@ public record SourceEvidenceLedger(
return matcher.find() ? matcher.group(1).trim() : null;
}
/**
* Replace the existing source line for {@code index} with the canonical
* form. Matches a full line starting with optional whitespace, {@code [N]},
* then any content, and replaces it in-place so the frontend can reliably
* parse the source table to build a citation index title map.
*/
private static String replaceSourceLine(String answer, int index, String canonicalLine) {
Pattern pattern = Pattern.compile("(?m)^\\s*\\[" + index + "\\]\\s+.+$");
return pattern.matcher(answer).replaceFirst(
java.util.regex.Matcher.quoteReplacement(canonicalLine));
}
private boolean hasFileName(String fileName) {
String normalized = normalizePath(fileName);
return sourcePaths.stream().anyMatch(p -> p.equals(normalized) || p.endsWith("/" + normalized));

View File

@ -235,6 +235,68 @@ class SourceEvidenceLedgerTest {
assertTrue(ledger.validateAnswer(rendered).valid());
}
@Test
@DisplayName("normalizes non-canonical source lines to standard format")
void normalizesNonCanonicalSourceLines() {
SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of(
new ToolResponseMessage.ToolResponse("c1", "wiki_semantic_search", """
{"chunks":[{"index":1,"chunkId":101,"rawTitle":"Install Guide","section":"Linux","pageNumber":12,"snippet":"Use the package manager."}]}
""")));
String rendered = ledger.appendWikiSourceTable("""
Use the package manager [1].
来源
[1] Install Guide参考文档
""");
assertTrue(rendered.contains("[1] Install Guide - Linux - page 12"),
"non-canonical source line should be normalized: " + rendered);
assertFalse(rendered.contains("(参考文档)"),
"non-canonical text must be removed: " + rendered);
assertTrue(ledger.validateAnswer(rendered).valid());
}
@Test
@DisplayName("canonical source line is left unchanged (idempotent)")
void canonicalSourceLineUnchanged() {
SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of(
new ToolResponseMessage.ToolResponse("c1", "wiki_semantic_search", """
{"chunks":[{"index":1,"chunkId":101,"rawTitle":"Install Guide","section":"Linux","pageNumber":12,"snippet":"Use the package manager."}]}
""")));
String canonical = """
Use the package manager [1].
来源
[1] Install Guide - Linux - page 12
""";
String rendered = ledger.appendWikiSourceTable(canonical);
assertEquals(canonical, rendered);
}
@Test
@DisplayName("inserts 来源: header when source lines exist without one")
void insertsSourceHeaderWhenMissing() {
SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of(
new ToolResponseMessage.ToolResponse("c1", "wiki_semantic_search", """
{"chunks":[{"index":1,"chunkId":101,"rawTitle":"MAST-Data数据集","section":"","pageNumber":null,"snippet":"..."}]}
""")));
String rendered = ledger.appendWikiSourceTable("""
根据数据集 [1] 的描述
[1] MAST-Data数据集
""");
assertTrue("来源: header must be present: " + rendered,
rendered.contains("来源:"));
assertTrue(rendered.contains("[1] MAST-Data数据集"),
"source line content must be preserved: " + rendered);
assertTrue(ledger.validateAnswer(rendered).valid());
}
@Test
@DisplayName("non-wiki tool JSON with a top-level title does not create wiki citations")
void nonWikiToolWithTitleDoesNotForceCitations() {

File diff suppressed because it is too large Load Diff

View File

@ -45,7 +45,8 @@ export function useGlobalWikilinkClick() {
const target = e.target as HTMLElement | null
if (!target) return
// The click might land on a descendant of the <a>; walk up if needed.
const anchor = target.closest<HTMLElement>('a.wiki-link, .wiki-link')
// Matches both [[Title]] wikilinks and [n] wiki-citation markers.
const anchor = target.closest<HTMLElement>('a.wiki-link, a.wiki-citation, .wiki-link, .wiki-citation')
if (!anchor) return
// WikiPageViewer's own postprocess produces <a class="wiki-link"
// data-slug=...> for in-wiki navigation. Its onMounted hook reads
@ -53,7 +54,9 @@ export function useGlobalWikilinkClick() {
// intercept those — only the chat / external surfaces emit
// data-wiki-title without data-slug.
if (anchor.hasAttribute('data-slug')) return
const title = anchor.getAttribute('data-wiki-title')
// Citations use data-citation-title, wikilinks use data-wiki-title.
const title = anchor.getAttribute('data-citation-title')
|| anchor.getAttribute('data-wiki-title')
if (!title) return
// Prevent the no-op href="#" jump and bubbling.

View File

@ -458,6 +458,7 @@ const purifyConfig = {
ADD_ATTR: [
'target', 'rel', 'class',
'data-code', 'data-echarts-option', 'data-wiki-title', 'data-slug',
'data-citation-index', 'data-citation-title',
'data-tex', 'data-mermaid', 'data-mermaid-download',
'x1', 'y1', 'x2', 'y2',
'aria-label', 'open',
@ -557,6 +558,77 @@ export interface RenderMarkdownOptions {
// can never leak across renders.
let streamingRenderMode = false
// ---------------------------------------------------------------------------
// Wiki citation pre-processor
// ---------------------------------------------------------------------------
// Parses the canonical "来源:" source table appended by the backend's
// SourceEvidenceLedger.appendWikiSourceTable() to build a citation index →
// title map, then replaces every [n] marker in the answer body with a
// clickable <a> and wraps entire source-table rows so the full line is
// clickable.
function preprocessWikiCitations(text: string): string {
let sourceIdx = -1
const dblIdx = text.indexOf('\n\n来源')
if (dblIdx >= 0) {
sourceIdx = dblIdx + 2
} else {
const sngIdx = text.indexOf('\n来源')
if (sngIdx >= 0) {
sourceIdx = sngIdx + 1
} else if (text.startsWith('来源:')) {
sourceIdx = 0
}
}
if (sourceIdx < 0) return text
const body = text.slice(0, sourceIdx)
const sourceSection = text.slice(sourceIdx)
const map = new Map<number, string>()
const sourceLineRe = /^\[(\d+)\]\s+(.+)$/gm
let slMatch: RegExpExecArray | null
while ((slMatch = sourceLineRe.exec(sourceSection)) !== null) {
const index = parseInt(slMatch[1], 10)
if (map.has(index)) continue
const fullContent = slMatch[2].trim()
const title = fullContent.split(' - ')[0].trim()
if (title) map.set(index, title)
}
if (map.size === 0) return text
// Body: replace only the [n] marker.
const bodyWithCitations = body.replace(
/\[(\d+)\]/g,
(match, indexStr: string) => {
const idx = parseInt(indexStr, 10)
const title = map.get(idx)
if (!title) return match
return (
'<a class="wiki-citation" href="#" data-citation-index="' + idx +
'" data-citation-title="' + escapeHtml(title) +
'">' + match + '</a>'
)
},
)
// Source table: wrap the entire row.
const sourceWithCitations = sourceSection.replace(
/^(\s*\[(\d+)\]\s+.+)$/gm,
(fullLine, _content, indexStr: string) => {
const idx = parseInt(indexStr, 10)
const title = map.get(idx)
if (!title) return fullLine
return (
'<a class="wiki-citation" href="#" data-citation-index="' + idx +
'" data-citation-title="' + escapeHtml(title) +
'">' + fullLine + '</a>'
)
},
)
return bodyWithCitations + sourceWithCitations
}
export function useMarkdownRenderer() {
function renderMarkdown(content: string, opts?: RenderMarkdownOptions): string {
if (!content) return ''
@ -604,11 +676,13 @@ export function useMarkdownRenderer() {
)
},
)
// 2.5 Wiki citation preprocessing: [n] → <a class="wiki-citation" …>
const withCitations = preprocessWikiCitations(withWikiLinks)
// 3. Marked → 4. DOMPurify.
let rawHtml: string
streamingRenderMode = streaming
try {
rawHtml = markedInstance.parse(withWikiLinks) as string
rawHtml = markedInstance.parse(withCitations) as string
} finally {
streamingRenderMode = false
}

File diff suppressed because it is too large Load Diff