improve topics and chat source display
This commit is contained in:
@@ -19,9 +19,11 @@ public record TopicSummaryResponse(
|
|||||||
String bookId,
|
String bookId,
|
||||||
String bookTitle,
|
String bookTitle,
|
||||||
Integer page,
|
Integer page,
|
||||||
|
String chunkText,
|
||||||
String figureId,
|
String figureId,
|
||||||
String label,
|
String label,
|
||||||
String caption,
|
String caption,
|
||||||
|
String figureType,
|
||||||
String imageUrl
|
String imageUrl
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ public class TopicSummaryService {
|
|||||||
String bookId = book != null ? book.getId().toString() : null;
|
String bookId = book != null ? book.getId().toString() : null;
|
||||||
sources.add(new TopicSummaryResponse.SourceReference(
|
sources.add(new TopicSummaryResponse.SourceReference(
|
||||||
"TEXT", "S" + (i + 1), bookId, title, s.getPageStart(),
|
"TEXT", "S" + (i + 1), bookId, title, s.getPageStart(),
|
||||||
null, null, null, null));
|
truncate(s.getFullText(), 500), null, null, null, null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < figures.size(); i++) {
|
for (int i = 0; i < figures.size(); i++) {
|
||||||
@@ -203,7 +203,8 @@ public class TopicSummaryService {
|
|||||||
String imageUrl = "/api/v1/figures/" + f.getBookId() + "/" + filename;
|
String imageUrl = "/api/v1/figures/" + f.getBookId() + "/" + filename;
|
||||||
sources.add(new TopicSummaryResponse.SourceReference(
|
sources.add(new TopicSummaryResponse.SourceReference(
|
||||||
"FIGURE", "F" + (i + 1), bookId, title, f.getPage(),
|
"FIGURE", "F" + (i + 1), bookId, title, f.getPage(),
|
||||||
f.getId(), f.getLabel(), f.getCaption(), imageUrl));
|
null, f.getId(), f.getLabel(), f.getCaption(),
|
||||||
|
f.getFigureType().name(), imageUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
return sources;
|
return sources;
|
||||||
@@ -218,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,
|
||||||
|
|||||||
@@ -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. {{ 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. {{ 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,12 @@ 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 {
|
function escapeHtml(s: string): string {
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replaces [S1]/[F1]-style labels in the rendered HTML with clickable badges.
|
|
||||||
* For figure citations, also injects an inline illustration below the badge. */
|
|
||||||
const renderedWithBadges = computed(() => {
|
const renderedWithBadges = computed(() => {
|
||||||
const html = marked.parse(props.message.content) as string
|
const html = marked.parse(props.message.content) as string
|
||||||
|
|
||||||
@@ -104,7 +54,7 @@ const renderedWithBadges = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html.replace(/\[(S|F)\d+\]/g, (match) => {
|
return html.replace(/\[(S|F)\d+\]/g, (match) => {
|
||||||
const inner = match.slice(1, -1) // e.g. "F1"
|
const inner = match.slice(1, -1)
|
||||||
const badge = `<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)
|
const fig = figureMap.get(inner)
|
||||||
@@ -125,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' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. {{ 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. {{ 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>
|
||||||
@@ -15,9 +15,11 @@ export interface SourceReference {
|
|||||||
bookId: string | null
|
bookId: string | null
|
||||||
bookTitle: string
|
bookTitle: string
|
||||||
page: number | null
|
page: number | null
|
||||||
|
chunkText?: string
|
||||||
figureId?: string
|
figureId?: string
|
||||||
label?: string
|
label?: string
|
||||||
caption?: string
|
caption?: string
|
||||||
|
figureType?: string
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,57 +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
|
||||||
<!-- TEXT sources -->
|
v-if="showSources"
|
||||||
<div
|
:sources="topicStore.activeSummary.sources"
|
||||||
v-for="(source, idx) in textSources"
|
@open-source="(bookId: string, page: number) => handleOpenSource(bookId, page)"
|
||||||
:key="'text-' + idx"
|
/>
|
||||||
class="source-item"
|
|
||||||
: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 ? handleOpenSource(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">{{ source.bookTitle }}</span>
|
|
||||||
<span v-if="source.page" class="source-page">p. {{ source.page }}</span>
|
|
||||||
<span v-if="source.bookId && source.page" class="source-open-hint">↗</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FIGURE sources -->
|
|
||||||
<div
|
|
||||||
v-for="(source, idx) in figureSources"
|
|
||||||
:key="'fig-' + idx"
|
|
||||||
class="source-item source-item--figure"
|
|
||||||
: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 ? handleOpenSource(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. {{ source.page }}</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 v-if="source.imageUrl" class="source-figure-image">
|
|
||||||
<img
|
|
||||||
:src="source.imageUrl"
|
|
||||||
:alt="source.caption || source.label || 'Figure'"
|
|
||||||
class="figure-img"
|
|
||||||
loading="lazy"
|
|
||||||
@error="onImageError"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BookPagePanel
|
<BookPagePanel
|
||||||
v-if="readerPanel"
|
v-if="readerPanel"
|
||||||
@@ -187,6 +141,7 @@ import { useTopicStore, type SavedSummaryItem, type SourceReference } from '@/st
|
|||||||
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()
|
||||||
@@ -203,24 +158,6 @@ 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'))
|
||||||
|
|
||||||
const textSources = computed(() =>
|
|
||||||
(topicStore.activeSummary?.sources ?? []).filter(s => s.type === 'TEXT' || !s.type)
|
|
||||||
)
|
|
||||||
|
|
||||||
const figureSources = computed(() =>
|
|
||||||
(topicStore.activeSummary?.sources ?? []).filter(s => s.type === 'FIGURE')
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
function onImageError(e: Event) {
|
|
||||||
const img = e.target as HTMLImageElement
|
|
||||||
img.style.display = 'none'
|
|
||||||
const wrapper = img.parentElement
|
|
||||||
if (wrapper) {
|
|
||||||
wrapper.innerHTML = '<span class="figure-missing">Image unavailable</span>'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user