diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/ImageRef.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/ImageRef.java new file mode 100644 index 00000000..340b64c2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/ImageRef.java @@ -0,0 +1,20 @@ +package vip.mate.wiki.dto; + +/** + * One image reference parsed out of a markdown body. + * + *

Maps to a single {@code ![alt](url)} occurrence. Search results carry + * a list of these so the UI can render thumbnails alongside the page hit + * without re-scanning the markdown on the client. + * + * @param fullMatch verbatim {@code ![alt](url)} as it appeared in the source + * @param alt alt text (may be empty if the markdown wrote {@code ![](url)}) + * @param url resource URL — local path, absolute http(s) URL, or data URI + * + * @author MateClaw Team + */ +public record ImageRef( + String fullMatch, + String alt, + String url +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/dto/PageSearchResult.java b/mateclaw-server/src/main/java/vip/mate/wiki/dto/PageSearchResult.java index d0e458ae..cf1b8395 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/dto/PageSearchResult.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/dto/PageSearchResult.java @@ -3,7 +3,15 @@ package vip.mate.wiki.dto; import java.util.List; /** - * RFC-032: Enhanced search result with snippet, match metadata, and relevance reason. + * Enhanced search hit returned by hybrid retrieval. + * + *

Carries the matched page identity ({@link #slug}, {@link #title}, + * {@link #summary}), a query-specific {@link #snippet} extracted from the + * page body, the per-mode score breakdown ({@link #matchedBy}) and a + * human-readable {@link #reason} for why the page surfaced. The + * {@link #imageRefs} field is populated with up to N image references + * pulled from the page body so the UI can render thumbnails inline with + * the hit list. */ public record PageSearchResult( String slug, @@ -12,5 +20,13 @@ public record PageSearchResult( String snippet, List matchedBy, String reason, - double score -) {} + double score, + List imageRefs +) { + + /** Convenience factory used by callers that have not (yet) computed image refs. */ + public static PageSearchResult of(String slug, String title, String summary, String snippet, + List matchedBy, String reason, double score) { + return new PageSearchResult(slug, title, summary, snippet, matchedBy, reason, score, List.of()); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java index 3be05b6e..e63cf404 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java @@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.dto.ImageRef; import vip.mate.wiki.dto.PageSearchResult; import vip.mate.wiki.dto.RelatedPageResult; import vip.mate.wiki.dto.WikiPageLite; @@ -35,24 +36,31 @@ public class HybridRetriever { private final WikiProperties properties; private final WikiPageMapper pageMapper; private final WikiMetrics metrics; + private final WikiContentNormalizer contentNormalizer; @Autowired(required = false) private WikiRelationService relationService; private static final double RELATION_BOOST = 0.15; + /** Cap how many image references one search hit carries — large pages can have + * dozens; we only need a couple of thumbnails for the result panel. */ + private static final int MAX_IMAGE_REFS_PER_HIT = 3; + public HybridRetriever(WikiPageService pageService, WikiChunkService chunkService, WikiEmbeddingService embeddingService, WikiProperties properties, WikiPageMapper pageMapper, - WikiMetrics metrics) { + WikiMetrics metrics, + WikiContentNormalizer contentNormalizer) { this.pageService = pageService; this.chunkService = chunkService; this.embeddingService = embeddingService; this.properties = properties; this.pageMapper = pageMapper; this.metrics = metrics; + this.contentNormalizer = contentNormalizer; } public enum Mode { KEYWORD, SEMANTIC, HYBRID } @@ -132,10 +140,17 @@ public class HybridRetriever { if (lite.isSystem()) continue; String snippet = null; + List imageRefs = List.of(); if (!ri.matchedBy.contains("relation_boost")) { String content = pageMapper.selectContentById(ri.pageId); if (content != null) { snippet = SnippetExtractor.extract(content, query); + // Surface up to N image references inline with the hit so the UI + // can render thumbnails without re-fetching the page body. + List all = contentNormalizer.extractImageRefs(content); + imageRefs = all.size() > MAX_IMAGE_REFS_PER_HIT + ? all.subList(0, MAX_IMAGE_REFS_PER_HIT) + : all; } } @@ -151,7 +166,7 @@ public class HybridRetriever { results.add(new PageSearchResult( lite.slug(), lite.title(), lite.summary(), snippet != null ? snippet : lite.summary(), - ri.matchedBy, reason, ri.score)); + ri.matchedBy, reason, ri.score, imageRefs)); } metrics.recordRetrieval(mode.name().toLowerCase(), Duration.ofNanos(System.nanoTime() - startNanos), results.size()); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java index 7b9275bd..010cfb30 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContentNormalizer.java @@ -6,6 +6,14 @@ import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import org.springframework.stereotype.Component; +import vip.mate.wiki.dto.ImageRef; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * RFC-051 PR-1c: source-type-specific text normalization. @@ -101,4 +109,34 @@ public class WikiContentNormalizer { String unified = text.replace("\r\n", "\n").replace('\r', '\n'); return unified.replaceAll("\n{3,}", "\n\n"); } + + /** Markdown {@code ![alt](url)} pattern. {@code url} captures up to the first + * whitespace or closing paren so titles like {@code ![alt](url "title")} stop + * at the URL boundary; the title segment is intentionally discarded. */ + private static final Pattern MD_IMAGE_REF = Pattern.compile("!\\[([^\\]]*)]\\(([^)\\s]+)\\)"); + + /** + * Extracts every {@code ![alt](url)} occurrence in the input as an + * {@link ImageRef}. URLs are deduplicated — the first occurrence wins + * so the alt text closest to the document start is preserved when the + * same image appears multiple times. + * + * @param markdown raw markdown body; null/blank input returns an empty list + * @return ordered, URL-deduplicated list of references, never null + */ + public List extractImageRefs(String markdown) { + if (markdown == null || markdown.isBlank()) { + return List.of(); + } + List refs = new ArrayList<>(); + Set seenUrls = new HashSet<>(); + Matcher m = MD_IMAGE_REF.matcher(markdown); + while (m.find()) { + String url = m.group(2); + if (url == null || url.isBlank()) continue; + if (!seenUrls.add(url)) continue; + refs.add(new ImageRef(m.group(0), m.group(1), url)); + } + return refs; + } } diff --git a/mateclaw-ui/src/views/Wiki/components/ImageLightbox.vue b/mateclaw-ui/src/views/Wiki/components/ImageLightbox.vue new file mode 100644 index 00000000..e096e0ca --- /dev/null +++ b/mateclaw-ui/src/views/Wiki/components/ImageLightbox.vue @@ -0,0 +1,71 @@ + + + diff --git a/mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue b/mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue index 90e488b9..5ce06cfb 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiPageViewer.vue @@ -46,9 +46,17 @@ -

+
+ + +