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 } 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 } as it appeared in the source
+ * @param alt alt text (may be empty if the markdown wrote {@code })
+ * @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 } pattern. {@code url} captures up to the first
+ * whitespace or closing paren so titles like {@code } stop
+ * at the URL boundary; the title segment is intentionally discarded. */
+ private static final Pattern MD_IMAGE_REF = Pattern.compile("!\\[([^\\]]*)]\\(([^)\\s]+)\\)");
+
+ /**
+ * Extracts every {@code } 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 @@
-
+
+
+
+