From 5f03e1f41bd3c859bfc61452fa5a0b3d911c9fc5 Mon Sep 17 00:00:00 2001 From: Adrien Date: Sun, 12 Apr 2026 18:56:18 +0200 Subject: [PATCH] improve topics and chat source display --- .../aiteacher/topic/TopicSummaryResponse.java | 2 + .../aiteacher/topic/TopicSummaryService.java | 10 +- frontend/src/components/ChatMessage.vue | 107 +------ frontend/src/components/SourceList.vue | 298 ++++++++++++++++++ frontend/src/stores/topicStore.ts | 2 + frontend/src/views/TopicsView.vue | 75 +---- 6 files changed, 328 insertions(+), 166 deletions(-) create mode 100644 frontend/src/components/SourceList.vue diff --git a/backend/src/main/java/com/aiteacher/topic/TopicSummaryResponse.java b/backend/src/main/java/com/aiteacher/topic/TopicSummaryResponse.java index 7f093fb..6d616df 100644 --- a/backend/src/main/java/com/aiteacher/topic/TopicSummaryResponse.java +++ b/backend/src/main/java/com/aiteacher/topic/TopicSummaryResponse.java @@ -19,9 +19,11 @@ public record TopicSummaryResponse( String bookId, String bookTitle, Integer page, + String chunkText, String figureId, String label, String caption, + String figureType, String imageUrl ) { } diff --git a/backend/src/main/java/com/aiteacher/topic/TopicSummaryService.java b/backend/src/main/java/com/aiteacher/topic/TopicSummaryService.java index 43c1ee0..e6ddfc8 100644 --- a/backend/src/main/java/com/aiteacher/topic/TopicSummaryService.java +++ b/backend/src/main/java/com/aiteacher/topic/TopicSummaryService.java @@ -188,7 +188,7 @@ public class TopicSummaryService { String bookId = book != null ? book.getId().toString() : null; sources.add(new TopicSummaryResponse.SourceReference( "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++) { @@ -203,7 +203,8 @@ public class TopicSummaryService { String imageUrl = "/api/v1/figures/" + f.getBookId() + "/" + filename; sources.add(new TopicSummaryResponse.SourceReference( "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; @@ -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 deserializeSources(String json) { try { return objectMapper.readValue(json, diff --git a/frontend/src/components/ChatMessage.vue b/frontend/src/components/ChatMessage.vue index e646380..e6e1235 100644 --- a/frontend/src/components/ChatMessage.vue +++ b/frontend/src/components/ChatMessage.vue @@ -8,61 +8,12 @@
Sources:
-
- -
-
- 📖 - {{ source.refLabel }} - {{ source.bookTitle }} - p. {{ source.page }} - -
-
{{ source.chunkText }}
-
- - -
-
- 🖼️ - {{ source.refLabel }} - {{ source.label || 'Figure' }} - p. {{ source.page }} - {{ formatFigureType(source.figureType) }} - -
-
{{ source.caption }}
-
- -
-
-
+
{{ formatTime(message.createdAt) }}
@@ -74,6 +25,7 @@ import { computed, ref } from 'vue' import { marked } from 'marked' import type { ChatMessage, ChatSource } from '@/stores/chatStore' +import SourceList from '@/components/SourceList.vue' const props = defineProps<{ message: ChatMessage @@ -85,14 +37,12 @@ const emit = defineEmits<{ const isUser = computed(() => props.message.role === 'USER') const activeRef = ref(null) -const sourceListEl = ref(null) +const sourceListEl = ref | null>(null) function escapeHtml(s: string): string { return s.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 html = marked.parse(props.message.content) as string @@ -104,7 +54,7 @@ const renderedWithBadges = computed(() => { } 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 = `${match}` const fig = figureMap.get(inner) @@ -125,53 +75,20 @@ function onContentClick(e: MouseEvent) { const target = e.target as HTMLElement 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 activeRef.value = activeRef.value === label ? null : label - // Scroll to the matching source chip - const sourceEl = sourceListEl.value?.querySelector(`[data-ref-label="${label}"]`) as HTMLElement | null + const sourceEl = sourceListEl.value?.$el?.querySelector(`[data-ref-label="${label}"]`) as HTMLElement | null sourceEl?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - // Open the book at the referenced page - const allSources = props.message.sources ?? [] - const source = allSources.find((s: ChatSource) => s.refLabel === label) + const source = (props.message.sources ?? []).find((s: ChatSource) => s.refLabel === label) if (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 = { - 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 = 'Image unavailable' - } -} - function formatTime(iso: string): string { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } diff --git a/frontend/src/components/SourceList.vue b/frontend/src/components/SourceList.vue new file mode 100644 index 0000000..58863d3 --- /dev/null +++ b/frontend/src/components/SourceList.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/frontend/src/stores/topicStore.ts b/frontend/src/stores/topicStore.ts index 4f8c7ab..06302f2 100644 --- a/frontend/src/stores/topicStore.ts +++ b/frontend/src/stores/topicStore.ts @@ -15,9 +15,11 @@ export interface SourceReference { bookId: string | null bookTitle: string page: number | null + chunkText?: string figureId?: string label?: string caption?: string + figureType?: string imageUrl?: string } diff --git a/frontend/src/views/TopicsView.vue b/frontend/src/views/TopicsView.vue index 3d0dde6..6c18e41 100644 --- a/frontend/src/views/TopicsView.vue +++ b/frontend/src/views/TopicsView.vue @@ -96,57 +96,11 @@ Sources ({{ topicStore.activeSummary.sources.length }}) {{ showSources ? '▲' : '▼' }} -
- -
-
- 📖 - {{ source.refLabel }} - {{ source.bookTitle }} - p. {{ source.page }} - -
-
- - -
-
- 🖼️ - {{ source.refLabel }} - {{ source.label || 'Figure' }} - p. {{ source.page }} - -
-
{{ source.caption }}
-
- -
-
-
+ (null) 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 = 'Image unavailable' - } -} - function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') }