mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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()) {
|
if (used.isEmpty()) {
|
||||||
return answer;
|
return answer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String result = answer;
|
||||||
StringBuilder additions = new StringBuilder();
|
StringBuilder additions = new StringBuilder();
|
||||||
|
|
||||||
for (Integer index : used) {
|
for (Integer index : used) {
|
||||||
WikiCitation citation = wikiCitation(index);
|
WikiCitation citation = wikiCitation(index);
|
||||||
if (citation == null || sourceLineFor(answer, index) != null) {
|
if (citation == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (additions.isEmpty()) {
|
String canonicalLine = citation.sourceLine();
|
||||||
additions.append("\n\n来源:");
|
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) {
|
private void validateWikiCitations(String answer, LinkedHashSet<String> unsupported) {
|
||||||
@ -269,6 +294,18 @@ public record SourceEvidenceLedger(
|
|||||||
return matcher.find() ? matcher.group(1).trim() : null;
|
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) {
|
private boolean hasFileName(String fileName) {
|
||||||
String normalized = normalizePath(fileName);
|
String normalized = normalizePath(fileName);
|
||||||
return sourcePaths.stream().anyMatch(p -> p.equals(normalized) || p.endsWith("/" + normalized));
|
return sourcePaths.stream().anyMatch(p -> p.equals(normalized) || p.endsWith("/" + normalized));
|
||||||
|
|||||||
@ -235,6 +235,68 @@ class SourceEvidenceLedgerTest {
|
|||||||
assertTrue(ledger.validateAnswer(rendered).valid());
|
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
|
@Test
|
||||||
@DisplayName("non-wiki tool JSON with a top-level title does not create wiki citations")
|
@DisplayName("non-wiki tool JSON with a top-level title does not create wiki citations")
|
||||||
void nonWikiToolWithTitleDoesNotForceCitations() {
|
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
|
const target = e.target as HTMLElement | null
|
||||||
if (!target) return
|
if (!target) return
|
||||||
// The click might land on a descendant of the <a>; walk up if needed.
|
// 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
|
if (!anchor) return
|
||||||
// WikiPageViewer's own postprocess produces <a class="wiki-link"
|
// WikiPageViewer's own postprocess produces <a class="wiki-link"
|
||||||
// data-slug=...> for in-wiki navigation. Its onMounted hook reads
|
// 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
|
// intercept those — only the chat / external surfaces emit
|
||||||
// data-wiki-title without data-slug.
|
// data-wiki-title without data-slug.
|
||||||
if (anchor.hasAttribute('data-slug')) return
|
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
|
if (!title) return
|
||||||
|
|
||||||
// Prevent the no-op href="#" jump and bubbling.
|
// Prevent the no-op href="#" jump and bubbling.
|
||||||
|
|||||||
@ -458,6 +458,7 @@ const purifyConfig = {
|
|||||||
ADD_ATTR: [
|
ADD_ATTR: [
|
||||||
'target', 'rel', 'class',
|
'target', 'rel', 'class',
|
||||||
'data-code', 'data-echarts-option', 'data-wiki-title', 'data-slug',
|
'data-code', 'data-echarts-option', 'data-wiki-title', 'data-slug',
|
||||||
|
'data-citation-index', 'data-citation-title',
|
||||||
'data-tex', 'data-mermaid', 'data-mermaid-download',
|
'data-tex', 'data-mermaid', 'data-mermaid-download',
|
||||||
'x1', 'y1', 'x2', 'y2',
|
'x1', 'y1', 'x2', 'y2',
|
||||||
'aria-label', 'open',
|
'aria-label', 'open',
|
||||||
@ -557,6 +558,77 @@ export interface RenderMarkdownOptions {
|
|||||||
// can never leak across renders.
|
// can never leak across renders.
|
||||||
let streamingRenderMode = false
|
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() {
|
export function useMarkdownRenderer() {
|
||||||
function renderMarkdown(content: string, opts?: RenderMarkdownOptions): string {
|
function renderMarkdown(content: string, opts?: RenderMarkdownOptions): string {
|
||||||
if (!content) return ''
|
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.
|
// 3. Marked → 4. DOMPurify.
|
||||||
let rawHtml: string
|
let rawHtml: string
|
||||||
streamingRenderMode = streaming
|
streamingRenderMode = streaming
|
||||||
try {
|
try {
|
||||||
rawHtml = markedInstance.parse(withWikiLinks) as string
|
rawHtml = markedInstance.parse(withCitations) as string
|
||||||
} finally {
|
} finally {
|
||||||
streamingRenderMode = false
|
streamingRenderMode = false
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user