first implementation - image/drawing integration

This commit is contained in:
Adrien
2026-04-04 12:56:56 +02:00
parent fc5b22fba1
commit 5acfdd33c1
42 changed files with 2854 additions and 151 deletions
+125 -21
View File
@@ -5,22 +5,47 @@
<div v-if="isUser" class="message-content">{{ message.content }}</div>
<div v-else class="message-content message-content--markdown" v-html="renderedContent"></div>
<!-- Source chips for assistant messages -->
<!-- Sources for assistant messages -->
<div v-if="!isUser && message.sources && message.sources.length > 0" class="message-sources">
<div class="sources-label">Sources:</div>
<div class="source-list">
<!-- TEXT sources -->
<div
v-for="(source, idx) in message.sources"
:key="idx"
v-for="(source, idx) in textSources"
:key="'text-' + idx"
class="source-item"
>
<div class="source-chip">
<span class="source-book-icon">📖</span>
<div class="source-chip source-chip--text">
<span class="source-icon">📖</span>
<span class="source-book-title">{{ source.bookTitle }}</span>
<span v-if="source.page" class="source-page">p.&nbsp;{{ source.page }}</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"
>
<div class="source-chip source-chip--figure">
<span class="source-icon">🖼</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>
</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>
@@ -32,7 +57,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
import type { ChatMessage } from '@/stores/chatStore'
import type { ChatMessage, ChatSource } from '@/stores/chatStore'
const props = defineProps<{
message: ChatMessage
@@ -41,6 +66,36 @@ const props = defineProps<{
const isUser = computed(() => props.message.role === 'USER')
const renderedContent = computed(() => marked.parse(props.message.content) as string)
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 {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
@@ -182,6 +237,55 @@ function formatTime(iso: string): string {
gap: 0.25rem;
}
.source-item--figure {
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;
border: 1px solid #bee3f8;
}
.source-chip--figure {
background: #f0fff4;
border: 1px solid #9ae6b4;
}
.source-icon {
font-size: 0.8rem;
}
.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-chunk {
font-size: 0.78rem;
color: #4a5568;
@@ -194,28 +298,28 @@ function formatTime(iso: string): string {
line-height: 1.5;
}
.source-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #ebf8ff;
border: 1px solid #bee3f8;
border-radius: 4px;
padding: 0.2rem 0.5rem;
.source-caption {
font-size: 0.78rem;
color: #4a5568;
font-style: italic;
}
.source-book-icon {
font-size: 0.8rem;
.source-figure-image {
max-width: 100%;
}
.source-book-title {
color: #2b6cb0;
font-weight: 500;
.figure-img {
max-width: 100%;
max-height: 300px;
border-radius: 6px;
border: 1px solid #e2e8f0;
object-fit: contain;
}
.source-page {
color: #718096;
.figure-missing {
font-size: 0.78rem;
color: #a0aec0;
font-style: italic;
}
.message-timestamp {
+15 -1
View File
@@ -2,11 +2,25 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '@/services/api'
export interface ChatSource {
type: 'TEXT' | 'FIGURE'
bookTitle: string
page: number | null
// TEXT-specific
chunkText?: string
// FIGURE-specific
figureId?: string
label?: string
caption?: string
figureType?: string
imageUrl?: string
}
export interface ChatMessage {
id: string
role: 'USER' | 'ASSISTANT'
content: string
sources: Array<{ bookTitle: string; page: number | null; chunkText?: string }>
sources: ChatSource[]
createdAt: string
}