mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
c656aff349
commit
cb87569264
@ -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));
|
||||
|
||||
@ -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() {
|
||||
|
||||
2766
mateclaw-ui/package-lock.json
generated
2766
mateclaw-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user