3 Commits

Author SHA1 Message Date
Adrien 5f03e1f41b improve topics and chat source display 2026-04-12 18:56:18 +02:00
Adrien c98fe9ceaa update readme 2026-04-12 18:25:12 +02:00
Adrien 767d1e2dbc enhance illustration being taken into account in the response 2026-04-12 16:26:25 +02:00
8 changed files with 557 additions and 127 deletions
+26
View File
@@ -179,6 +179,32 @@ mvn -Pnative package jib:build -DskipTests
mvn -Pnative jib:build -Djib.to.auth.username=admin -Djib.to.auth.password="" mvn -Pnative jib:build -Djib.to.auth.username=admin -Djib.to.auth.password=""
``` ```
### Backend build (buildah)
**JVM image** (`Dockerfile` — Eclipse Temurin 21):
```bash
buildah build \
--platform linux/arm64 \
--tag zot.immich-ad.ovh/ai-teacher-backend:latest \
backend/
buildah login zot.immich-ad.ovh
buildah push --tls-verify=false zot.immich-ad.ovh/ai-teacher-backend:latest
```
**Native image** (`Dockerfile.native` — GraalVM 25, produces a minimal Debian-slim image):
```bash
buildah build \
--platform linux/arm64 \
--file backend/Dockerfile.native \
--tag zot.immich-ad.ovh/ai-teacher-backend-native:latest \
backend/
buildah push --tls-verify=false zot.immich-ad.ovh/ai-teacher-backend-native:latest
```
### Frontend build ### Frontend build
``` ```
buildah build \ buildah build \
@@ -29,7 +29,7 @@ public class ChatService {
- Use clear structure: headings, bullet points, or numbered steps where appropriate to maximize clarity - Use clear structure: headings, bullet points, or numbered steps where appropriate to maximize clarity
- Only say you cannot answer if the context is entirely unrelated to the question - Only say you cannot answer if the context is entirely unrelated to the question
- Cite sources for each major claim using the reference labels from the context (e.g. [S1], [F2]). Prefer these labels over inventing page numbers, but you may also describe the source naturally if needed. - Cite sources for each major claim using the reference labels from the context (e.g. [S1], [F2]). Prefer these labels over inventing page numbers, but you may also describe the source naturally if needed.
- When referencing diagrams or figures, prefer their label from the context (e.g. [F1]) - Figures (labeled [F1], [F2], etc.) are actual images and drawings from the textbook — they will be rendered as inline illustrations in your response. Use them actively to support your explanations: reference a figure when it visually demonstrates anatomy, a surgical step, or a clinical concept you are describing.
- Maintain continuity with the conversation history - Maintain continuity with the conversation history
- Never fabricate clinical information not present in the context - Never fabricate clinical information not present in the context
"""; """;
@@ -14,9 +14,17 @@ public record TopicSummaryResponse(
Instant generatedAt Instant generatedAt
) { ) {
public record SourceReference( public record SourceReference(
String type,
String refLabel,
String bookId, String bookId,
String bookTitle, String bookTitle,
Integer page Integer page,
String chunkText,
String figureId,
String label,
String caption,
String figureType,
String imageUrl
) { ) {
} }
} }
@@ -35,6 +35,7 @@ public class TopicSummaryService {
- Structure your response clearly with key points - Structure your response clearly with key points
- Cite claims using ONLY the reference labels provided in the context (e.g. [S1], [F2]). - Cite claims using ONLY the reference labels provided in the context (e.g. [S1], [F2]).
Do not invent page numbers, section titles, or labels not present in the CONTEXT block. Do not invent page numbers, section titles, or labels not present in the CONTEXT block.
- Figures (labeled [F1], [F2], etc.) are actual images and drawings from the textbook — they will be rendered as inline illustrations in your response. Use them actively to support your explanations: reference a figure when it visually demonstrates anatomy, a surgical step, or a clinical concept you are describing.
- If the retrieved context does not contain sufficient information on the topic, - If the retrieved context does not contain sufficient information on the topic,
explicitly state: "The uploaded books do not contain sufficient information on this topic." explicitly state: "The uploaded books do not contain sufficient information on this topic."
- Never hallucinate or fabricate clinical information - Never hallucinate or fabricate clinical information
@@ -135,7 +136,7 @@ public class TopicSummaryService {
return String.format( return String.format(
"Provide a comprehensive educational summary of the following neurosurgery topic: " + "Provide a comprehensive educational summary of the following neurosurgery topic: " +
"%s. Topic description: %s. " + "%s. Topic description: %s. " +
"Include key concepts, clinical considerations, and important details that a neurosurgeon should know.", "Include key concepts, diagrams, illustations and clinical considerations, and important details that a neurosurgeon should know.",
topic.getName(), topic.getDescription() topic.getName(), topic.getDescription()
); );
} }
@@ -165,7 +166,7 @@ public class TopicSummaryService {
.append(f.getCaption() != null ? f.getCaption() : "") .append(f.getCaption() != null ? f.getCaption() : "")
.append("\n"); .append("\n");
} }
sb.append("\n"); sb.append("\nWhen referencing diagrams, use their label from the context (e.g. [F1]).\n\n");
} }
sb.append("QUESTION:\n").append(question); sb.append("QUESTION:\n").append(question);
@@ -177,27 +178,36 @@ public class TopicSummaryService {
List<Book> readyBooks) { List<Book> readyBooks) {
List<TopicSummaryResponse.SourceReference> sources = new ArrayList<>(); List<TopicSummaryResponse.SourceReference> sources = new ArrayList<>();
for (SectionEntity s : sections) { for (int i = 0; i < sections.size(); i++) {
SectionEntity s = sections.get(i);
Book book = readyBooks.stream() Book book = readyBooks.stream()
.filter(b -> b.getId().equals(s.getBookId())) .filter(b -> b.getId().equals(s.getBookId()))
.findFirst() .findFirst()
.orElse(null); .orElse(null);
String title = book != null ? book.getTitle() : "Book"; String title = book != null ? book.getTitle() : "Book";
String bookId = book != null ? book.getId().toString() : null; String bookId = book != null ? book.getId().toString() : null;
sources.add(new TopicSummaryResponse.SourceReference(bookId, title, s.getPageStart())); sources.add(new TopicSummaryResponse.SourceReference(
"TEXT", "S" + (i + 1), bookId, title, s.getPageStart(),
truncate(s.getFullText(), 500), null, null, null, null, null));
} }
for (FigureEntity f : figures) { for (int i = 0; i < figures.size(); i++) {
FigureEntity f = figures.get(i);
Book book = readyBooks.stream() Book book = readyBooks.stream()
.filter(b -> b.getId().equals(f.getBookId())) .filter(b -> b.getId().equals(f.getBookId()))
.findFirst() .findFirst()
.orElse(null); .orElse(null);
String title = book != null ? book.getTitle() : "Book"; String title = book != null ? book.getTitle() : "Book";
String bookId = book != null ? book.getId().toString() : null; String bookId = book != null ? book.getId().toString() : null;
sources.add(new TopicSummaryResponse.SourceReference(bookId, title, f.getPage())); String filename = f.getImagePath().substring(f.getImagePath().lastIndexOf('/') + 1);
String imageUrl = "/api/v1/figures/" + f.getBookId() + "/" + filename;
sources.add(new TopicSummaryResponse.SourceReference(
"FIGURE", "F" + (i + 1), bookId, title, f.getPage(),
null, f.getId(), f.getLabel(), f.getCaption(),
f.getFigureType().name(), imageUrl));
} }
return sources.stream().distinct().toList(); return sources;
} }
private String serializeSources(List<TopicSummaryResponse.SourceReference> sources) { private String serializeSources(List<TopicSummaryResponse.SourceReference> sources) {
@@ -209,6 +219,11 @@ public class TopicSummaryService {
} }
} }
private String truncate(String text, int maxChars) {
if (text == null) return "";
return text.length() <= maxChars ? text : text.substring(0, maxChars) + "";
}
private List<TopicSummaryResponse.SourceReference> deserializeSources(String json) { private List<TopicSummaryResponse.SourceReference> deserializeSources(String json) {
try { try {
return objectMapper.readValue(json, return objectMapper.readValue(json,
+61 -95
View File
@@ -8,61 +8,12 @@
<!-- Sources for assistant messages --> <!-- Sources for assistant messages -->
<div v-if="!isUser && message.sources && message.sources.length > 0" class="message-sources"> <div v-if="!isUser && message.sources && message.sources.length > 0" class="message-sources">
<div class="sources-label">Sources:</div> <div class="sources-label">Sources:</div>
<div class="source-list" ref="sourceListEl"> <SourceList
<!-- TEXT sources --> ref="sourceListEl"
<div :sources="message.sources"
v-for="(source, idx) in textSources" :active-ref="activeRef"
:key="'text-' + idx" @open-source="(bookId: string, page: number) => emit('open-source', bookId, page)"
class="source-item" />
:class="{ 'source-item--active': activeRef === source.refLabel }"
:data-ref-label="source.refLabel"
>
<div
class="source-chip source-chip--text"
:class="{ 'source-chip--clickable': source.bookId && source.page }"
@click="source.bookId && source.page ? emit('open-source', source.bookId, source.page) : undefined"
>
<span class="source-icon">📖</span>
<span v-if="source.refLabel" class="source-ref-label">{{ source.refLabel }}</span>
<span class="source-book-title">{{ source.bookTitle }}</span>
<span v-if="source.page" class="source-page">p.&nbsp;{{ source.page }}</span>
<span v-if="source.bookId && source.page" class="source-open-hint"></span>
</div>
<div v-if="source.chunkText" class="source-chunk">{{ source.chunkText }}</div>
</div>
<!-- FIGURE sources -->
<div
v-for="(source, idx) in figureSources"
:key="'fig-' + idx"
class="source-item source-item--figure"
:class="{ 'source-item--active': activeRef === source.refLabel }"
:data-ref-label="source.refLabel"
>
<div
class="source-chip source-chip--figure"
:class="{ 'source-chip--clickable': source.bookId && source.page }"
@click="source.bookId && source.page ? emit('open-source', source.bookId, source.page) : undefined"
>
<span class="source-icon">🖼</span>
<span v-if="source.refLabel" class="source-ref-label source-ref-label--figure">{{ source.refLabel }}</span>
<span class="source-figure-label">{{ source.label || 'Figure' }}</span>
<span v-if="source.page" class="source-page">p.&nbsp;{{ source.page }}</span>
<span v-if="source.figureType" class="source-figure-type">{{ formatFigureType(source.figureType) }}</span>
<span v-if="source.bookId && source.page" class="source-open-hint"></span>
</div>
<div v-if="source.caption" class="source-caption">{{ source.caption }}</div>
<div class="source-figure-image">
<img
:src="source.imageUrl"
:alt="source.caption || source.label || 'Figure'"
class="figure-img"
loading="lazy"
@error="onImageError"
/>
</div>
</div>
</div>
</div> </div>
<div class="message-timestamp">{{ formatTime(message.createdAt) }}</div> <div class="message-timestamp">{{ formatTime(message.createdAt) }}</div>
@@ -74,6 +25,7 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { marked } from 'marked' import { marked } from 'marked'
import type { ChatMessage, ChatSource } from '@/stores/chatStore' import type { ChatMessage, ChatSource } from '@/stores/chatStore'
import SourceList from '@/components/SourceList.vue'
const props = defineProps<{ const props = defineProps<{
message: ChatMessage message: ChatMessage
@@ -85,14 +37,37 @@ const emit = defineEmits<{
const isUser = computed(() => props.message.role === 'USER') const isUser = computed(() => props.message.role === 'USER')
const activeRef = ref<string | null>(null) const activeRef = ref<string | null>(null)
const sourceListEl = ref<HTMLElement | null>(null) const sourceListEl = ref<InstanceType<typeof SourceList> | null>(null)
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
/** Replaces [S1]/[F1]-style labels in the rendered HTML with clickable badges. */
const renderedWithBadges = computed(() => { const renderedWithBadges = computed(() => {
const html = marked.parse(props.message.content) as string const html = marked.parse(props.message.content) as string
const figureMap = new Map<string, ChatSource>()
for (const src of (props.message.sources ?? [])) {
if (src.type === 'FIGURE' && src.refLabel) {
figureMap.set(src.refLabel, src)
}
}
return html.replace(/\[(S|F)\d+\]/g, (match) => { return html.replace(/\[(S|F)\d+\]/g, (match) => {
const inner = match.slice(1, -1) // e.g. "S1" const inner = match.slice(1, -1)
return `<span class="citation-badge" data-ref="${inner}" title="Jump to source ${inner}">${match}</span>` const badge = `<span class="citation-badge" data-ref="${inner}" title="Jump to source ${inner}">${match}</span>`
const fig = figureMap.get(inner)
if (fig?.imageUrl) {
const alt = escapeHtml(fig.caption || fig.label || 'Figure')
const captionText = [fig.label, fig.caption].filter(Boolean).map(escapeHtml).join(' — ')
const captionHtml = captionText
? `<figcaption class="inline-figure-caption">${captionText}</figcaption>`
: ''
return `${badge}<figure class="inline-figure"><img src="${fig.imageUrl}" alt="${alt}" class="inline-figure-img" loading="lazy" onerror="this.parentElement.style.display='none'" />${captionHtml}</figure>`
}
return badge
}) })
}) })
@@ -100,53 +75,20 @@ function onContentClick(e: MouseEvent) {
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (!target.classList.contains('citation-badge')) return if (!target.classList.contains('citation-badge')) return
const label = target.getAttribute('data-ref') // e.g. "S1" or "F1" const label = target.getAttribute('data-ref')
if (!label) return if (!label) return
activeRef.value = activeRef.value === label ? null : label activeRef.value = activeRef.value === label ? null : label
// Scroll to the matching source chip const sourceEl = sourceListEl.value?.$el?.querySelector(`[data-ref-label="${label}"]`) as HTMLElement | null
const sourceEl = sourceListEl.value?.querySelector(`[data-ref-label="${label}"]`) as HTMLElement | null
sourceEl?.scrollIntoView({ behavior: 'smooth', block: 'start' }) sourceEl?.scrollIntoView({ behavior: 'smooth', block: 'start' })
// Open the book at the referenced page const source = (props.message.sources ?? []).find((s: ChatSource) => s.refLabel === label)
const allSources = props.message.sources ?? []
const source = allSources.find((s: ChatSource) => s.refLabel === label)
if (source?.bookId && source.page) { if (source?.bookId && source.page) {
emit('open-source', source.bookId, source.page) emit('open-source', source.bookId, source.page)
} }
} }
const textSources = computed(() =>
(props.message.sources ?? []).filter((s: ChatSource) => s.type === 'TEXT' || !s.type)
)
const figureSources = computed(() =>
(props.message.sources ?? []).filter((s: ChatSource) => s.type === 'FIGURE')
)
function formatFigureType(type: string): string {
const labels: Record<string, string> = {
ANATOMICAL_DIAGRAM: 'Anatomical Diagram',
SURGICAL_PHOTOGRAPH: 'Surgical Photo',
MRI_CT_SCAN: 'MRI / CT',
TABLE: 'Table',
CHART: 'Chart',
INTRAOPERATIVE_IMAGE: 'Intraoperative'
}
return labels[type] ?? type
}
function onImageError(e: Event) {
const img = e.target as HTMLImageElement
img.alt = 'Image unavailable'
img.style.display = 'none'
const wrapper = img.parentElement
if (wrapper) {
wrapper.innerHTML = '<span class="figure-missing">Image unavailable</span>'
}
}
function formatTime(iso: string): string { function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
} }
@@ -426,6 +368,30 @@ function formatTime(iso: string): string {
color: #276749; color: #276749;
} }
.message-content--markdown :deep(.inline-figure) {
display: block;
margin: 0.75rem 0;
text-align: center;
}
.message-content--markdown :deep(.inline-figure-img) {
max-width: 100%;
max-height: 400px;
border-radius: 6px;
border: 1px solid #e2e8f0;
object-fit: contain;
display: block;
margin: 0 auto;
}
.message-content--markdown :deep(.inline-figure-caption) {
font-size: 0.78rem;
color: #718096;
font-style: italic;
margin-top: 0.3rem;
text-align: center;
}
.message-timestamp { .message-timestamp {
font-size: 0.7rem; font-size: 0.7rem;
opacity: 0.6; opacity: 0.6;
+298
View File
@@ -0,0 +1,298 @@
<template>
<div class="source-list">
<!-- TEXT sources -->
<div
v-for="(source, idx) in textSources"
:key="'text-' + idx"
class="source-item"
:class="{ 'source-item--active': activeRef === source.refLabel }"
:data-ref-label="source.refLabel"
>
<div class="source-chip-wrapper">
<div
class="source-chip source-chip--text"
:class="{ 'source-chip--clickable': source.bookId && source.page }"
@click="source.bookId && source.page ? emit('open-source', source.bookId, source.page) : undefined"
>
<span class="source-icon">📖</span>
<span v-if="source.refLabel" class="source-ref-label">{{ source.refLabel }}</span>
<span class="source-book-title">{{ source.bookTitle }}</span>
<span v-if="source.page" class="source-page">p.&nbsp;{{ source.page }}</span>
<span v-if="source.bookId && source.page" class="source-open-hint"></span>
</div>
<div v-if="source.chunkText" class="tooltip tooltip--text">
<p class="tooltip-chunk">{{ source.chunkText }}</p>
</div>
</div>
</div>
<!-- FIGURE sources -->
<div
v-for="(source, idx) in figureSources"
:key="'fig-' + idx"
class="source-item source-item--figure"
:class="{ 'source-item--active': activeRef === source.refLabel }"
:data-ref-label="source.refLabel"
>
<div class="source-chip-wrapper">
<div
class="source-chip source-chip--figure"
:class="{ 'source-chip--clickable': source.bookId && source.page }"
@click="source.bookId && source.page ? emit('open-source', source.bookId, source.page) : undefined"
>
<span class="source-icon">🖼</span>
<span v-if="source.refLabel" class="source-ref-label source-ref-label--figure">{{ source.refLabel }}</span>
<span class="source-figure-label">{{ source.label || 'Figure' }}</span>
<span v-if="source.page" class="source-page">p.&nbsp;{{ source.page }}</span>
<span v-if="source.figureType" class="source-figure-type">{{ formatFigureType(source.figureType) }}</span>
<span v-if="source.bookId && source.page" class="source-open-hint"></span>
</div>
<div v-if="source.imageUrl || source.caption" class="tooltip tooltip--figure">
<img
v-if="source.imageUrl"
:src="source.imageUrl"
:alt="source.caption || source.label || 'Figure'"
class="tooltip-figure-img"
loading="lazy"
@error="onImageError"
/>
<p v-if="source.caption" class="tooltip-caption">{{ source.caption }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
export interface SourceItem {
type?: 'TEXT' | 'FIGURE'
refLabel?: string
bookId?: string | null
bookTitle: string
page?: number | null
chunkText?: string
figureId?: string
label?: string
caption?: string
figureType?: string
imageUrl?: string
}
const props = defineProps<{
sources: SourceItem[]
activeRef?: string | null
}>()
const emit = defineEmits<{
'open-source': [bookId: string, page: number]
}>()
const textSources = computed(() =>
props.sources.filter(s => s.type === 'TEXT' || !s.type)
)
const figureSources = computed(() =>
props.sources.filter(s => s.type === 'FIGURE')
)
function formatFigureType(type: string): string {
const labels: Record<string, string> = {
ANATOMICAL_DIAGRAM: 'Anatomical Diagram',
SURGICAL_PHOTOGRAPH: 'Surgical Photo',
MRI_CT_SCAN: 'MRI / CT',
TABLE: 'Table',
CHART: 'Chart',
INTRAOPERATIVE_IMAGE: 'Intraoperative'
}
return labels[type] ?? type
}
function onImageError(e: Event) {
const img = e.target as HTMLImageElement
img.style.display = 'none'
const wrapper = img.parentElement
if (wrapper) {
const missing = document.createElement('span')
missing.className = 'figure-missing'
missing.textContent = 'Image unavailable'
wrapper.appendChild(missing)
}
}
</script>
<style scoped>
.source-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.source-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.source-item--active {
outline: 2px solid #4299e1;
border-radius: 6px;
}
/* Wrapper provides the positioning context for the tooltip */
.source-chip-wrapper {
position: relative;
display: inline-block;
}
/* ── Chip base ── */
.source-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 4px;
padding: 0.2rem 0.5rem;
font-size: 0.78rem;
}
.source-chip--text {
background: #ebf8ff;
border: 1px solid #bee3f8;
}
.source-chip--figure {
background: #f0fff4;
border: 1px solid #9ae6b4;
}
.source-chip--clickable {
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.source-chip--clickable:hover {
background: #bee3f8;
border-color: #90cdf4;
}
.source-chip--figure.source-chip--clickable:hover {
background: #c6f6d5;
border-color: #68d391;
}
/* ── Tooltip ── */
.tooltip {
display: none;
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 100;
background: #1a202c;
border-radius: 6px;
padding: 0.6rem 0.75rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
/* Keep it from overflowing too far */
max-width: min(340px, 80vw);
pointer-events: none;
}
/* Show on chip hover */
.source-chip-wrapper:hover .tooltip {
display: block;
}
/* Small arrow pointing up */
.tooltip::before {
content: '';
position: absolute;
top: -5px;
left: 14px;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 5px solid #1a202c;
}
.tooltip--text .tooltip-chunk {
margin: 0;
font-size: 0.78rem;
color: #e2e8f0;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.tooltip--figure {
max-width: min(300px, 80vw);
}
.tooltip-figure-img {
display: block;
max-width: 100%;
max-height: 220px;
border-radius: 4px;
object-fit: contain;
margin-bottom: 0.4rem;
}
.tooltip-caption {
margin: 0;
font-size: 0.75rem;
color: #cbd5e0;
font-style: italic;
line-height: 1.4;
}
/* ── Chip internals ── */
.source-icon {
font-size: 0.8rem;
}
.source-ref-label {
font-size: 0.72rem;
font-weight: 700;
background: #bee3f8;
color: #2b6cb0;
border-radius: 3px;
padding: 0 0.3rem;
}
.source-ref-label--figure {
background: #9ae6b4;
color: #276749;
}
.source-book-title {
color: #2b6cb0;
font-weight: 500;
}
.source-figure-label {
color: #276749;
font-weight: 600;
}
.source-figure-type {
color: #718096;
font-size: 0.72rem;
background: #e2e8f0;
border-radius: 3px;
padding: 0 0.3rem;
}
.source-page {
color: #718096;
}
.source-open-hint {
font-size: 0.75rem;
color: #3182ce;
margin-left: 0.1rem;
}
.figure-missing {
font-size: 0.78rem;
color: #a0aec0;
font-style: italic;
}
</style>
+8
View File
@@ -10,9 +10,17 @@ export interface Topic {
} }
export interface SourceReference { export interface SourceReference {
type?: 'TEXT' | 'FIGURE'
refLabel?: string
bookId: string | null bookId: string | null
bookTitle: string bookTitle: string
page: number | null page: number | null
chunkText?: string
figureId?: string
label?: string
caption?: string
figureType?: string
imageUrl?: string
} }
export interface TopicSummary { export interface TopicSummary {
+132 -23
View File
@@ -96,20 +96,11 @@
Sources ({{ topicStore.activeSummary.sources.length }}) Sources ({{ topicStore.activeSummary.sources.length }})
<span>{{ showSources ? '▲' : '▼' }}</span> <span>{{ showSources ? '▲' : '▼' }}</span>
</button> </button>
<div v-if="showSources" class="sources-list"> <SourceList
<div v-if="showSources"
v-for="(source, idx) in topicStore.activeSummary.sources" :sources="topicStore.activeSummary.sources"
:key="idx" @open-source="(bookId: string, page: number) => handleOpenSource(bookId, page)"
class="source-chip" />
:class="{ 'source-chip--clickable': source.bookId && source.page }"
@click="source.bookId && source.page ? handleOpenSource(source.bookId, source.page) : undefined"
>
<span class="source-icon">📖</span>
<span class="source-book">{{ source.bookTitle }}</span>
<span v-if="source.page" class="source-page">p.&nbsp;{{ source.page }}</span>
<span v-if="source.bookId && source.page" class="source-open-hint"></span>
</div>
</div>
<BookPagePanel <BookPagePanel
v-if="readerPanel" v-if="readerPanel"
@@ -146,10 +137,11 @@
import { ref, computed, onMounted, inject } from 'vue' import { ref, computed, onMounted, inject } from 'vue'
import { marked } from 'marked' import { marked } from 'marked'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { useTopicStore, type SavedSummaryItem } from '@/stores/topicStore' import { useTopicStore, type SavedSummaryItem, type SourceReference } from '@/stores/topicStore'
import { useBookStore } from '@/stores/bookStore' import { useBookStore } from '@/stores/bookStore'
import TopicCard from '@/components/TopicCard.vue' import TopicCard from '@/components/TopicCard.vue'
import BookPagePanel from '@/components/BookPagePanel.vue' import BookPagePanel from '@/components/BookPagePanel.vue'
import SourceList from '@/components/SourceList.vue'
const topicStore = useTopicStore() const topicStore = useTopicStore()
const bookStore = useBookStore() const bookStore = useBookStore()
@@ -166,10 +158,37 @@ const readerPanel = ref<ReaderPanel | null>(null)
const summaryTopics = computed(() => topicStore.topics.filter(t => t.id !== 'free-form')) const summaryTopics = computed(() => topicStore.topics.filter(t => t.id !== 'free-form'))
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
const renderedSummary = computed(() => { const renderedSummary = computed(() => {
if (!topicStore.activeSummary) return '' if (!topicStore.activeSummary) return ''
const html = marked.parse(topicStore.activeSummary.summary) as string const html = marked.parse(topicStore.activeSummary.summary) as string
return html.replace(/\[S(\d+)\]/g, '<span class="source-ref">[S$1]</span>')
const figureMap = new Map<string, SourceReference>()
for (const src of topicStore.activeSummary.sources) {
if (src.type === 'FIGURE' && src.refLabel) {
figureMap.set(src.refLabel, src)
}
}
return html.replace(/\[(S|F)\d+\]/g, (match) => {
const inner = match.slice(1, -1)
const badge = `<span class="source-ref" data-ref="${inner}" title="Jump to source ${inner}">${match}</span>`
const fig = figureMap.get(inner)
if (fig?.imageUrl) {
const alt = escapeHtml(fig.caption || fig.label || 'Figure')
const captionText = [fig.label, fig.caption].filter(Boolean).map(escapeHtml).join(' — ')
const captionHtml = captionText
? `<figcaption class="inline-figure-caption">${captionText}</figcaption>`
: ''
return `${badge}<figure class="inline-figure"><img src="${fig.imageUrl}" alt="${alt}" class="inline-figure-img" loading="lazy" onerror="this.parentElement.style.display='none'" />${captionHtml}</figure>`
}
return badge
})
}) })
function handleSummaryClick(e: MouseEvent) { function handleSummaryClick(e: MouseEvent) {
@@ -484,19 +503,37 @@ function formatDateShort(iso: string): string {
.sources-list { .sources-list {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.source-chip { .source-item {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 0.25rem;
}
.source-item--figure {
gap: 0.4rem; gap: 0.4rem;
}
.source-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 4px;
padding: 0.2rem 0.5rem;
font-size: 0.78rem;
}
.source-chip--text {
background: #ebf8ff; background: #ebf8ff;
border: 1px solid #bee3f8; border: 1px solid #bee3f8;
border-radius: 6px; }
padding: 0.3rem 0.7rem;
font-size: 0.8rem; .source-chip--figure {
background: #f0fff4;
border: 1px solid #9ae6b4;
} }
.source-chip--clickable { .source-chip--clickable {
@@ -504,20 +541,44 @@ function formatDateShort(iso: string): string {
transition: background 0.15s, border-color 0.15s; transition: background 0.15s, border-color 0.15s;
} }
.source-chip--clickable:hover { .source-chip--text.source-chip--clickable:hover {
background: #bee3f8; background: #bee3f8;
border-color: #90cdf4; border-color: #90cdf4;
} }
.source-chip--figure.source-chip--clickable:hover {
background: #c6f6d5;
border-color: #68d391;
}
.source-icon { .source-icon {
font-size: 0.8rem; font-size: 0.8rem;
} }
.source-ref-label {
font-size: 0.72rem;
font-weight: 700;
background: #bee3f8;
color: #2b6cb0;
border-radius: 3px;
padding: 0 0.3rem;
}
.source-ref-label--figure {
background: #9ae6b4;
color: #276749;
}
.source-book { .source-book {
color: #2b6cb0; color: #2b6cb0;
font-weight: 500; font-weight: 500;
} }
.source-figure-label {
color: #276749;
font-weight: 600;
}
.source-page { .source-page {
color: #718096; color: #718096;
} }
@@ -528,6 +589,30 @@ function formatDateShort(iso: string): string {
margin-left: 0.1rem; margin-left: 0.1rem;
} }
.source-caption {
font-size: 0.78rem;
color: #4a5568;
font-style: italic;
}
.source-figure-image {
max-width: 100%;
}
.figure-img {
max-width: 100%;
max-height: 300px;
border-radius: 6px;
border: 1px solid #e2e8f0;
object-fit: contain;
}
.figure-missing {
font-size: 0.78rem;
color: #a0aec0;
font-style: italic;
}
.no-sources { .no-sources {
font-size: 0.85rem; font-size: 0.85rem;
color: #a0aec0; color: #a0aec0;
@@ -563,6 +648,30 @@ function formatDateShort(iso: string): string {
font-style: italic; font-style: italic;
} }
.summary-text--markdown :deep(.inline-figure) {
display: block;
margin: 0.75rem 0;
text-align: center;
}
.summary-text--markdown :deep(.inline-figure-img) {
max-width: 100%;
max-height: 400px;
border-radius: 6px;
border: 1px solid #e2e8f0;
object-fit: contain;
display: block;
margin: 0 auto;
}
.summary-text--markdown :deep(.inline-figure-caption) {
font-size: 0.78rem;
color: #718096;
font-style: italic;
margin-top: 0.3rem;
text-align: center;
}
.summary-text--markdown :deep(.source-ref) { .summary-text--markdown :deep(.source-ref) {
color: #3182ce; color: #3182ce;
font-weight: 600; font-weight: 600;