mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): inline image refs in search results + click-to-zoom lightbox
This commit is contained in:
parent
51995275bd
commit
bcefe43234
@ -0,0 +1,20 @@
|
||||
package vip.mate.wiki.dto;
|
||||
|
||||
/**
|
||||
* One image reference parsed out of a markdown body.
|
||||
*
|
||||
* <p>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
|
||||
) {}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
71
mateclaw-ui/src/views/Wiki/components/ImageLightbox.vue
Normal file
71
mateclaw-ui/src/views/Wiki/components/ImageLightbox.vue
Normal 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>
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user