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:
co-authored by
Claude Opus 4.6
parent
1973b24eaa
commit
e8ebe56d9e
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
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 type { WebSearchResult } from '@aiui/core/types/message'
|
||||
import { mockFilms } from '@/mocks/films'
|
||||
import { mockSongs } from '@/mocks/songs'
|
||||
@@ -7,7 +7,7 @@ import { mockPodcasts } from '@/mocks/podcasts'
|
||||
import { generatePosterFallback, generateSongCoverFallback, generateBookCoverFallback } from '@/composables/useImageFallback'
|
||||
import { fetchRssFromUrls } from '@/composables/useRssFetch'
|
||||
|
||||
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'news' | 'websites' | 'magazine'
|
||||
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'news' | 'websites' | 'magazine'
|
||||
|
||||
export interface MagazineSection {
|
||||
title: string
|
||||
@@ -31,12 +31,14 @@ const panelMagazineSections = ref<MagazineSection[]>([])
|
||||
const panelMagazineHeroImage = ref<string | null>(null)
|
||||
const panelSongs = ref<Song[]>([])
|
||||
const panelPodcasts = ref<Podcast[]>([])
|
||||
const panelImages = ref<ImageItem[]>([])
|
||||
const selectedFilm = ref<Film | null>(null)
|
||||
const selectedBook = ref<Book | null>(null)
|
||||
const selectedTVSeries = ref<TVSeries | null>(null)
|
||||
const selectedSong = ref<Song | null>(null)
|
||||
const selectedPodcast = ref<Podcast | null>(null)
|
||||
const selectedArticle = ref<WebSearchResult | null>(null)
|
||||
const selectedImage = ref<ImageItem | null>(null)
|
||||
const panelTitle = ref('Recommended Films')
|
||||
const panelQuery = ref('')
|
||||
const contentType = ref<'film' | 'song' | 'podcast'>('film')
|
||||
@@ -260,6 +262,7 @@ function preferredFirstTab(userQuery: string): ContentTab | null {
|
||||
if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
|
||||
if (/\b(book|books|read|reading|novel|author|nonfiction|non-fiction)\b/.test(q)) return 'book'
|
||||
if (/\b(tv show|tv series|series|television|streaming|binge|watch)\b/.test(q)) return 'tvshow'
|
||||
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
|
||||
if (isNewsQuery(q)) return 'news'
|
||||
if (isWebsitesQuery(q)) return 'websites'
|
||||
return null
|
||||
@@ -274,6 +277,7 @@ function filterTabsByContext(
|
||||
hasPodcasts: boolean,
|
||||
hasBooks: boolean,
|
||||
hasTVSeries: boolean,
|
||||
hasImages: boolean,
|
||||
hasNews: boolean,
|
||||
hasWebsites: boolean,
|
||||
hasMagazine: boolean,
|
||||
@@ -290,11 +294,11 @@ function filterTabsByContext(
|
||||
return tabs
|
||||
}
|
||||
|
||||
if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasNews && !hasWebsites) {
|
||||
if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasNews && !hasWebsites) {
|
||||
return ['magazine']
|
||||
}
|
||||
|
||||
if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasNews && !hasMagazine) {
|
||||
if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasNews && !hasMagazine) {
|
||||
return ['websites']
|
||||
}
|
||||
|
||||
@@ -302,6 +306,7 @@ function filterTabsByContext(
|
||||
if (hasFilms) all.push('film')
|
||||
if (hasBooks) all.push('book')
|
||||
if (hasTVSeries) all.push('tvshow')
|
||||
if (hasImages) all.push('image')
|
||||
if (hasSongs) all.push('song')
|
||||
if (hasPodcasts) all.push('podcast')
|
||||
if (hasMagazine) all.push('magazine')
|
||||
@@ -883,6 +888,58 @@ export function useContentPanel() {
|
||||
return extractTVSeriesFromPatterns(text)
|
||||
}
|
||||
|
||||
/** Extract images from markdown image syntax and raw image URLs */
|
||||
function extractImages(text: string): ImageItem[] {
|
||||
const images: ImageItem[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
// Match markdown images: 
|
||||
const mdImgRe = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/gi
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = mdImgRe.exec(text)) !== null) {
|
||||
const alt = m[1].trim()
|
||||
const url = m[2].trim()
|
||||
if (seen.has(url)) continue
|
||||
seen.add(url)
|
||||
const domain = new URL(url).hostname.replace(/^www\./, '')
|
||||
images.push({
|
||||
id: `img-${seen.size}`,
|
||||
url,
|
||||
alt: alt || undefined,
|
||||
title: alt || undefined,
|
||||
source: domain,
|
||||
})
|
||||
}
|
||||
|
||||
// Match raw image URLs not already captured
|
||||
const urlRe = /(https?:\/\/[^\s)"'>]+\.(?:jpg|jpeg|png|gif|webp|svg|avif|bmp|tiff)(?:\?[^\s)"'>]*)?)/gi
|
||||
while ((m = urlRe.exec(text)) !== null) {
|
||||
const url = m[1].trim()
|
||||
if (seen.has(url)) continue
|
||||
seen.add(url)
|
||||
const domain = new URL(url).hostname.replace(/^www\./, '')
|
||||
images.push({
|
||||
id: `img-${seen.size}`,
|
||||
url,
|
||||
source: domain,
|
||||
})
|
||||
}
|
||||
|
||||
return images
|
||||
}
|
||||
|
||||
function isImageQuery(q: string): boolean {
|
||||
return /\b(image|images|photo|photos|picture|pictures|screenshot|screenshots|gallery|artwork|illustration|visual|infographic|diagram|chart)\b/i.test(q)
|
||||
}
|
||||
|
||||
function extractAllImages(text: string, userQuery: string): ImageItem[] {
|
||||
const images = extractImages(text)
|
||||
// Only surface as a tab if user asked about images or there are multiple images
|
||||
if (images.length === 0) return []
|
||||
if (isImageQuery(userQuery) || images.length >= 2) return images
|
||||
return []
|
||||
}
|
||||
|
||||
function updatePanelFromText(text: string, userQuery = '', webResults: WebSearchResult[] = []) {
|
||||
panelQuery.value = userQuery.trim()
|
||||
const songs = extractAllSongs(text)
|
||||
@@ -931,13 +988,16 @@ export function useContentPanel() {
|
||||
})
|
||||
}
|
||||
|
||||
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, hasNews, hasWebsites, hasMagazine)
|
||||
const images = extractAllImages(text, userQuery)
|
||||
|
||||
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, hasNews, hasWebsites, hasMagazine)
|
||||
availableTabs.value = tabs.length > 0 ? tabs : ['film']
|
||||
activeTab.value = tabs[0] ?? 'film'
|
||||
|
||||
const showFilms = tabs.includes('film')
|
||||
const showBooks = tabs.includes('book')
|
||||
const showTVSeries = tabs.includes('tvshow')
|
||||
const showImages = tabs.includes('image')
|
||||
const showSongs = tabs.includes('song')
|
||||
const showPodcasts = tabs.includes('podcast')
|
||||
const showNews = tabs.includes('news')
|
||||
@@ -947,6 +1007,7 @@ export function useContentPanel() {
|
||||
const visibleFilms = showFilms ? films : []
|
||||
const visibleBooks = showBooks ? books : []
|
||||
const visibleTVSeries = showTVSeries ? tvSeries : []
|
||||
const visibleImages = showImages ? images : []
|
||||
const visibleSongs = showSongs ? songs : []
|
||||
const visiblePodcasts = showPodcasts ? podcasts : []
|
||||
const visibleNews = showNews ? mergedNews : []
|
||||
@@ -956,6 +1017,7 @@ export function useContentPanel() {
|
||||
panelFilms.value = visibleFilms
|
||||
panelBooks.value = visibleBooks
|
||||
panelTVSeries.value = visibleTVSeries
|
||||
panelImages.value = visibleImages
|
||||
panelSongs.value = visibleSongs
|
||||
panelPodcasts.value = visiblePodcasts
|
||||
panelWebResults.value = visibleNews
|
||||
@@ -967,6 +1029,7 @@ export function useContentPanel() {
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
|
||||
@@ -984,6 +1047,8 @@ export function useContentPanel() {
|
||||
panelTitle.value = visibleTVSeries.length === 1 ? visibleTVSeries[0].title : `${visibleTVSeries.length} TV Series`
|
||||
} else if (primary === 'book' && visibleBooks.length > 0) {
|
||||
panelTitle.value = visibleBooks.length === 1 ? visibleBooks[0].title : `${visibleBooks.length} Books`
|
||||
} else if (primary === 'image' && visibleImages.length > 0) {
|
||||
panelTitle.value = `${visibleImages.length} Images`
|
||||
} else if (visibleFilms.length === 1) panelTitle.value = visibleFilms[0].title
|
||||
else if (visibleFilms.length > 1) panelTitle.value = `${visibleFilms.length} Films`
|
||||
else if (visibleBooks.length === 1) panelTitle.value = visibleBooks[0].title
|
||||
@@ -1032,11 +1097,13 @@ export function useContentPanel() {
|
||||
const websitesFromMd = hasLinkableContent ? fromMarkdown : []
|
||||
const websitesLinks = mergeNewsResults(websitesFromMd, boldDomains)
|
||||
const hasWebsites = websitesLinks.length > 0
|
||||
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, hasNews, hasWebsites, hasMagazine)
|
||||
const images = extractAllImages(text, userQuery)
|
||||
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, hasNews, hasWebsites, hasMagazine)
|
||||
return {
|
||||
films: tabs.includes('film') ? films : [],
|
||||
books: tabs.includes('book') ? books : [],
|
||||
tvSeries: tabs.includes('tvshow') ? tvSeries : [],
|
||||
images: tabs.includes('image') ? images : [],
|
||||
songs: tabs.includes('song') ? songs : [],
|
||||
podcasts: tabs.includes('podcast') ? podcasts : [],
|
||||
newsLinks: tabs.includes('news') ? newsLinks : [],
|
||||
@@ -1101,6 +1168,7 @@ export function useContentPanel() {
|
||||
selectedFilm.value = film
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
@@ -1114,6 +1182,7 @@ export function useContentPanel() {
|
||||
selectedBook.value = book
|
||||
selectedFilm.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
@@ -1128,6 +1197,7 @@ export function useContentPanel() {
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
}
|
||||
@@ -1141,6 +1211,7 @@ export function useContentPanel() {
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedSong.value = null
|
||||
selectedArticle.value = null
|
||||
}
|
||||
@@ -1154,6 +1225,7 @@ export function useContentPanel() {
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
panelOpen.value = true
|
||||
@@ -1167,6 +1239,7 @@ export function useContentPanel() {
|
||||
selectedTVSeries.value = series
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedImage.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
@@ -1176,11 +1249,26 @@ export function useContentPanel() {
|
||||
selectedTVSeries.value = null
|
||||
}
|
||||
|
||||
function openImageDetail(image: ImageItem) {
|
||||
selectedImage.value = image
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
}
|
||||
|
||||
function closeImageDetail() {
|
||||
selectedImage.value = null
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelOpen.value = false
|
||||
selectedFilm.value = null
|
||||
selectedBook.value = null
|
||||
selectedTVSeries.value = null
|
||||
selectedImage.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
selectedArticle.value = null
|
||||
@@ -1232,6 +1320,7 @@ export function useContentPanel() {
|
||||
panelFilms,
|
||||
panelBooks,
|
||||
panelTVSeries,
|
||||
panelImages,
|
||||
panelSongs,
|
||||
panelPodcasts,
|
||||
panelWebResults,
|
||||
@@ -1241,6 +1330,7 @@ export function useContentPanel() {
|
||||
selectedFilm,
|
||||
selectedBook,
|
||||
selectedTVSeries,
|
||||
selectedImage,
|
||||
selectedSong,
|
||||
selectedPodcast,
|
||||
selectedArticle,
|
||||
@@ -1259,6 +1349,8 @@ export function useContentPanel() {
|
||||
closeBookDetail,
|
||||
openTVSeriesDetail,
|
||||
closeTVSeriesDetail,
|
||||
openImageDetail,
|
||||
closeImageDetail,
|
||||
extractSongIds,
|
||||
resolveSongs,
|
||||
extractAllSongs,
|
||||
|
||||
Reference in New Issue
Block a user