feat(wiki): inline image refs in search results + click-to-zoom lightbox

This commit is contained in:
matevip 2026-05-02 19:04:51 +08:00
parent 51995275bd
commit bcefe43234
6 changed files with 189 additions and 7 deletions

View File

@ -0,0 +1,20 @@
package vip.mate.wiki.dto;
/**
* One image reference parsed out of a markdown body.
*
* <p>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
) {}

View File

@ -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.
*
* <p>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<String> matchedBy,
String reason,
double score
) {}
double score,
List<ImageRef> 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<String> matchedBy, String reason, double score) {
return new PageSearchResult(slug, title, summary, snippet, matchedBy, reason, score, List.of());
}
}

View File

@ -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<ImageRef> 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<ImageRef> 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());

View File

@ -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<ImageRef> extractImageRefs(String markdown) {
if (markdown == null || markdown.isBlank()) {
return List.of();
}
List<ImageRef> refs = new ArrayList<>();
Set<String> 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;
}
}

View File

@ -0,0 +1,71 @@
<template>
<el-image-viewer
v-if="open"
:url-list="urls"
:initial-index="activeIndex"
:hide-on-click-modal="true"
:teleported="true"
@close="close"
/>
</template>
<script setup lang="ts">
/**
* Lightbox overlay for inline wiki page images.
*
* <p>The component is mounted once per wiki page view and exposes a
* single imperative entry point {@link attach} that the parent
* calls after a markdown render lands. attach() walks every <img>
* inside the supplied container, attaches a click handler that opens
* the lightbox at the image's index, applies the cursor cue, and
* marks the element as bound so subsequent attach() calls are
* idempotent.
*
* <p>Element Plus ships {@code ElImageViewer} as a teleported
* fullscreen overlay; the heavyweight scroll/zoom UI comes from
* upstream so this component stays a thin coordinator.
*/
import { ref } from 'vue'
import { ElImageViewer } from 'element-plus'
const open = ref(false)
const urls = ref<string[]>([])
const activeIndex = ref(0)
const BOUND_FLAG = 'wikiLightboxBound'
function close() {
open.value = false
}
/**
* Walks all <img> elements in {@code container} and binds click open
* lightbox. Safe to call repeatedly: already-bound elements are skipped.
*
* @param container any HTMLElement that holds rendered markdown
*/
function attach(container: HTMLElement | null) {
if (!container) return
const images = Array.from(container.querySelectorAll('img'))
if (images.length === 0) return
// Snapshot URLs so the order is stable for the duration of this view.
const newUrls = images
.map(img => img.getAttribute('src'))
.filter((u): u is string => !!u && u.length > 0)
urls.value = newUrls
images.forEach((img, idx) => {
if (img.dataset[BOUND_FLAG] === 'true') return
img.dataset[BOUND_FLAG] = 'true'
img.style.cursor = 'zoom-in'
img.addEventListener('click', (event) => {
event.preventDefault()
activeIndex.value = idx
open.value = true
})
})
}
defineExpose({ attach })
</script>

View File

@ -46,9 +46,17 @@
</div>
<!-- Content -->
<article v-if="!editing" class="page-content markdown-body" v-html="renderedContent"></article>
<article
v-if="!editing"
ref="articleRef"
class="page-content markdown-body"
v-html="renderedContent"
></article>
<textarea v-else v-model="editContent" class="page-editor" rows="30"></textarea>
<!-- Click-to-zoom overlay for inline images. Bound after each render via attach(). -->
<ImageLightbox ref="lightboxRef" />
<!-- RFC-033: Related Pages Panel (replaces backlinks) -->
<RelatedPagesPanel
v-if="!editing && store.currentKB"
@ -88,7 +96,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore, isProtectedPage, type WikiPage } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
@ -97,6 +105,7 @@ import { Link, SetUp } from '@element-plus/icons-vue'
import PageHeader from './PageHeader.vue'
import RelatedPagesPanel from './RelatedPagesPanel.vue'
import CitationDrawer from './CitationDrawer.vue'
import ImageLightbox from './ImageLightbox.vue'
const { t } = useI18n()
const store = useWikiStore()
@ -108,6 +117,10 @@ const backlinks = ref<WikiPage[]>([])
const citationDrawerOpen = ref(false)
const enrichToast = ref('')
// Refs for the lightbox post-render binding step.
const articleRef = ref<HTMLElement | null>(null)
const lightboxRef = ref<{ attach: (el: HTMLElement | null) => void } | null>(null)
// RFC-051 PR-8: protection state for delete-button gating + badge rendering.
const isSystem = computed(() => store.currentPage?.pageType === 'system')
const isProtected = computed(() => isProtectedPage(store.currentPage))
@ -131,6 +144,15 @@ const renderedContent = computed(() => {
return renderMarkdown(content)
})
// Bind the image lightbox to the rendered article on every content swap.
// Awaits a microtask so v-html has a chance to repopulate the DOM, then
// asks the lightbox to walk <img> tags and attach click handlers. Already-
// bound elements are skipped by the lightbox itself.
watch(renderedContent, async () => {
await nextTick()
lightboxRef.value?.attach(articleRef.value)
})
watch(() => store.currentPage, async (page) => {
if (page && store.currentKB) {
editing.value = false