feat(content): add Images content type with masonry grid layout

Extract markdown images and raw image URLs from AI responses into a
browsable image gallery. Masonry (columns) layout in the grid,
full-size detail view with source links. Also fix ContextLoader to
accept all content types for loading state display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 07:15:02 +00:00
co-authored by Claude Opus 4.6
parent 1973b24eaa
commit e8ebe56d9e
10 changed files with 394 additions and 13 deletions
@@ -99,6 +99,13 @@
>
View all {{ inlineTVSeries.length }} series
</button>
<button
v-else-if="inlineImages.length > 1"
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineImages.length }} images
</button>
<button
v-else-if="inlineSongs.length > 1"
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
@@ -142,7 +149,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Message, WebSearchResult } from '@aiui/core/types/message'
import type { Film, Song, Podcast, Book, TVSeries } from '@aiui/core/types/content'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel'
import { useArticleOverlayStore } from '@/stores/articleOverlay'
@@ -163,13 +170,13 @@ const props = withDefaults(
)
const { isDark } = useTheme()
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, openFilmDetail, openBookDetail, openTVSeriesDetail, openSongDetail, openPodcastDetail, openArticleDetail, closeFilmDetail, closeBookDetail, closeTVSeriesDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, openFilmDetail, openBookDetail, openTVSeriesDetail, openImageDetail, openSongDetail, openPodcastDetail, openArticleDetail, closeFilmDetail, closeBookDetail, closeTVSeriesDetail, closeImageDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
const overlayStore = useArticleOverlayStore()
const isUser = computed(() => props.message.role === 'user')
const inlineContent = computed(() => {
if (isUser.value) return { films: [] as Film[], books: [] as Book[], tvSeries: [] as TVSeries[], songs: [] as Song[], podcasts: [] as Podcast[], newsLinks: [] as WebSearchResult[], websitesLinks: [] as WebSearchResult[], magazineSections: [] as MagazineSection[] }
if (isUser.value) return { films: [] as Film[], books: [] as Book[], tvSeries: [] as TVSeries[], images: [] as ImageItem[], songs: [] as Song[], podcasts: [] as Podcast[], newsLinks: [] as WebSearchResult[], websitesLinks: [] as WebSearchResult[], magazineSections: [] as MagazineSection[] }
return getContextualInlineContent(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
})
@@ -182,6 +189,7 @@ const bubbleClasses = computed(() =>
const inlineFilms = computed(() => inlineContent.value.films)
const inlineBooks = computed(() => inlineContent.value.books ?? [])
const inlineTVSeries = computed(() => inlineContent.value.tvSeries ?? [])
const inlineImages = computed(() => inlineContent.value.images ?? [])
const inlineSongs = computed(() => inlineContent.value.songs)
const inlinePodcasts = computed(() => inlineContent.value.podcasts)
const inlineNewsLinks = computed(() => inlineContent.value.newsLinks ?? [])
@@ -192,6 +200,7 @@ const hasContext = computed(() => !isUser.value && (
inlineFilms.value.length > 0 ||
inlineBooks.value.length > 0 ||
inlineTVSeries.value.length > 0 ||
inlineImages.value.length > 0 ||
inlineSongs.value.length > 0 ||
inlinePodcasts.value.length > 0 ||
inlineNewsLinks.value.length > 0 ||