diff --git a/packages/app/src/components/chat/ChatMessage.vue b/packages/app/src/components/chat/ChatMessage.vue index 1014680f..ddc39c1c 100644 --- a/packages/app/src/components/chat/ChatMessage.vue +++ b/packages/app/src/components/chat/ChatMessage.vue @@ -21,6 +21,15 @@ /> +
+ +
+
View all {{ inlineFilms.length }} films โ†’ + +
+ +
+ + + + + + + + + + + + +
@@ -50,21 +134,56 @@ diff --git a/packages/app/src/composables/useContentPanel.ts b/packages/app/src/composables/useContentPanel.ts index 2b943af2..847c15e6 100644 --- a/packages/app/src/composables/useContentPanel.ts +++ b/packages/app/src/composables/useContentPanel.ts @@ -1,13 +1,13 @@ import { ref } from 'vue' -import type { Film, Song, Podcast } from '@aiui/core/types/content' +import type { Film, Song, Podcast, Book } from '@aiui/core/types/content' import type { WebSearchResult } from '@aiui/core/types/message' import { mockFilms } from '@/mocks/films' import { mockSongs } from '@/mocks/songs' import { mockPodcasts } from '@/mocks/podcasts' -import { generatePosterFallback, generateSongCoverFallback } from '@/composables/useImageFallback' +import { generatePosterFallback, generateSongCoverFallback, generateBookCoverFallback } from '@/composables/useImageFallback' import { fetchRssFromUrls } from '@/composables/useRssFetch' -export type ContentTab = 'film' | 'song' | 'podcast' | 'news' | 'websites' | 'magazine' +export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'news' | 'websites' | 'magazine' export interface MagazineSection { title: string @@ -22,6 +22,7 @@ export interface MagazineSection { const panelOpen = ref(false) const panelFilms = ref([]) +const panelBooks = ref([]) const panelWebResults = ref([]) const panelRssArticles = ref([]) const panelWebsites = ref([]) @@ -30,6 +31,7 @@ const panelMagazineHeroImage = ref(null) const panelSongs = ref([]) const panelPodcasts = ref([]) const selectedFilm = ref(null) +const selectedBook = ref(null) const selectedSong = ref(null) const selectedPodcast = ref(null) const selectedArticle = ref(null) @@ -254,6 +256,7 @@ function preferredFirstTab(userQuery: string): ContentTab | null { if (/\b(film|movie|movies)\b/.test(q)) return 'film' if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song' 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 (isNewsQuery(q)) return 'news' if (isWebsitesQuery(q)) return 'websites' return null @@ -266,6 +269,7 @@ function filterTabsByContext( hasFilms: boolean, hasSongs: boolean, hasPodcasts: boolean, + hasBooks: boolean, hasNews: boolean, hasWebsites: boolean, hasMagazine: boolean, @@ -282,16 +286,17 @@ function filterTabsByContext( return tabs } - if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasNews && !hasWebsites) { + if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasNews && !hasWebsites) { return ['magazine'] } - if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasNews && !hasMagazine) { + if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasNews && !hasMagazine) { return ['websites'] } const all: ContentTab[] = [] if (hasFilms) all.push('film') + if (hasBooks) all.push('book') if (hasSongs) all.push('song') if (hasPodcasts) all.push('podcast') if (hasMagazine) all.push('magazine') @@ -335,6 +340,9 @@ function looksLikeSong(title: string, artist: string): boolean { const PODCAST_TAG_RE = /\[\[podcast:(p?\d+)\]\]/gi const PODCAST_EXT_RE = /\[\[podcast_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi +const BOOK_TAG_RE = /\[\[book:(b?\d+)\]\]/gi +const BOOK_EXT_RE = /\[\[book_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi + /** Reject obvious non-podcast phrases (documentation, mailing lists, etc.) */ function looksLikePodcast(title: string, host: string): boolean { const t = title.toLowerCase() @@ -658,11 +666,109 @@ export function useContentPanel() { return [...libraryPodcasts, ...externalPodcasts] } + function extractExternalBooks(text: string): Book[] { + const books: Book[] = [] + const seen = new Set() + let match: RegExpExecArray | null + const re = new RegExp(BOOK_EXT_RE.source, 'gi') + while ((match = re.exec(text)) !== null) { + const title = match[1].trim() + const author = match[2].trim() + const year = match[3] ? parseInt(match[3], 10) : undefined + const key = `${title.toLowerCase()}|${author.toLowerCase()}` + if (seen.has(key)) continue + seen.add(key) + books.push({ + id: `ext-${key.replace(/\W/g, '-')}`, + title, + author, + year, + coverUrl: undefined, + description: extractDescriptionForTag(text, match.index, match[0].length), + genres: [], + sources: [], + }) + } + return books + } + + /** Extract book-like patterns from AI response text: + * - "Title" by Author (quoted) + * - **Title** by Author (bold markdown) + * - Title โ€” Author (list format) + * Only matches when context suggests books (book query or book-like response) */ + function extractBooksFromPatterns(text: string): Book[] { + const books: { title: string; author: string; year?: number; desc: string; pos: number }[] = [] + const seen = new Set() + + const patterns: { re: RegExp; titleIdx: number; authorIdx: number }[] = [ + // "Title" by Author + { re: /["""]([^"""]{2,80})["""]\s+by\s+([A-Z][^,\n\.]{1,50}?)(?:\s*[,()\n\.]|$)/gi, titleIdx: 1, authorIdx: 2 }, + // **Title** by Author + { re: /\*\*([^*]{2,80})\*\*\s+by\s+([A-Z][^,\n\.]{1,50}?)(?:\s*[,()\n\.]|$)/g, titleIdx: 1, authorIdx: 2 }, + // - Title โ€” Author or Title by Author (in list) + { re: /(?:^|\n)\s*(?:\d+\.\s*|[-โ€ข]\s*)\*{0,2}([^*\n\-โ€“โ€”]{2,80}?)\*{0,2}\s+(?:by|โ€”|โ€“)\s+([A-Z][^,\n]{1,50}?)(?:\s*[\n(,.]|$)/gm, titleIdx: 1, authorIdx: 2 }, + ] + + for (const { re, titleIdx, authorIdx } of patterns) { + let m: RegExpExecArray | null + const rx = new RegExp(re.source, re.flags) + while ((m = rx.exec(text)) !== null) { + const title = m[titleIdx].trim().replace(/^\*\*|\*\*$/g, '') + const author = m[authorIdx].trim().replace(/^\*\*|\*\*$/g, '') + if (title.length < 2 || author.length < 2) continue + // Skip if it looks like a film/song/podcast tag + if (/\[\[(film|song|podcast|book)(_ext)?:/.test(title)) continue + // Skip numbers-only + if (/^\d{4}$/.test(title) || /^\d{4}$/.test(author)) continue + const key = `${title.toLowerCase()}|${author.toLowerCase()}` + if (seen.has(key)) continue + seen.add(key) + const desc = extractDescriptionForTag(text, m.index, m[0].length) + books.push({ title, author, desc, pos: m.index }) + } + } + + return books + .sort((a, b) => a.pos - b.pos) + .map(({ title, author, desc }) => ({ + id: `ext-${`${title}|${author}`.toLowerCase().replace(/\W/g, '-')}`, + title, + author, + description: desc, + coverUrl: undefined, + genres: [], + sources: [], + })) + } + + function isBookQuery(q: string): boolean { + return /\b(book|books|read|reading|novel|novels|author|nonfiction|non-fiction|recommend.*read|must.read|literature)\b/i.test(q) + } + + function isBookLikeResponse(text: string): boolean { + return /\b(novel|author|pages?|ISBN|published|bestsell|literary|fiction|nonfiction|book)\b/i.test(text) && + (text.match(/\bby\s+[A-Z]/g)?.length ?? 0) >= 2 + } + + function extractAllBooks(text: string, userQuery: string): Book[] { + const externalBooks = extractExternalBooks(text) + if (externalBooks.length > 0) return externalBooks + // Only do fallback pattern matching if the query or response looks book-related + if (!isBookQuery(userQuery) && !isBookLikeResponse(text)) return [] + // Skip if other content types are already tagged + if (extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text)) return [] + if (extractSongIds(text).length > 0 || /\[\[song_ext:/.test(text)) return [] + if (isNewsLikeResponse(text)) return [] + return extractBooksFromPatterns(text) + } + function updatePanelFromText(text: string, userQuery = '', webResults: WebSearchResult[] = []) { panelQuery.value = userQuery.trim() const songs = extractAllSongs(text) const films = extractAllFilms(text) const podcasts = extractAllPodcasts(text) + const books = extractAllBooks(text, userQuery) const fromMarkdown = extractMarkdownLinks(text) const boldDomains = extractBoldDomainLinks(text) @@ -700,11 +806,12 @@ export function useContentPanel() { }) } - const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, hasNews, hasWebsites, hasMagazine) + const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.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 showSongs = tabs.includes('song') const showPodcasts = tabs.includes('podcast') const showNews = tabs.includes('news') @@ -712,6 +819,7 @@ export function useContentPanel() { const showMagazine = tabs.includes('magazine') const visibleFilms = showFilms ? films : [] + const visibleBooks = showBooks ? books : [] const visibleSongs = showSongs ? songs : [] const visiblePodcasts = showPodcasts ? podcasts : [] const visibleNews = showNews ? mergedNews : [] @@ -719,6 +827,7 @@ export function useContentPanel() { const visibleMagazineSections = showMagazine ? magazineSections : [] panelFilms.value = visibleFilms + panelBooks.value = visibleBooks panelSongs.value = visibleSongs panelPodcasts.value = visiblePodcasts panelWebResults.value = visibleNews @@ -728,10 +837,12 @@ export function useContentPanel() { ? (extractMagazineHeroImage(text) ?? webResults[0]?.imgSrc ?? null) : null selectedFilm.value = null + selectedBook.value = null selectedSong.value = null selectedPodcast.value = null if (visibleFilms.length > 0) contentType.value = 'film' + else if (visibleBooks.length > 0) contentType.value = 'film' else if (visibleSongs.length > 0) contentType.value = 'song' else if (visiblePodcasts.length > 0) contentType.value = 'podcast' else if (visibleNews.length > 0) contentType.value = 'film' @@ -739,6 +850,8 @@ export function useContentPanel() { 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 + else if (visibleBooks.length > 1) panelTitle.value = `${visibleBooks.length} Books` else if (visibleSongs.length === 1) panelTitle.value = visibleSongs[0].title else if (visibleSongs.length > 1) panelTitle.value = `${visibleSongs.length} Songs` else if (visiblePodcasts.length === 1) panelTitle.value = visiblePodcasts[0].title @@ -766,6 +879,7 @@ export function useContentPanel() { const films = extractAllFilms(text) const songs = extractAllSongs(text) const podcasts = extractAllPodcasts(text) + const books = extractAllBooks(text, userQuery) const magazineSections = extractMagazineSections(text) const hasMagazine = magazineSections.length >= 1 && (isNewsQuery(userQuery) || isNewsLikeResponse(text) || /sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening/i.test(text)) const fromMarkdown = extractMarkdownLinks(text) @@ -776,9 +890,10 @@ 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, hasNews, hasWebsites, hasMagazine) + const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, hasNews, hasWebsites, hasMagazine) return { films: tabs.includes('film') ? films : [], + books: tabs.includes('book') ? books : [], songs: tabs.includes('song') ? songs : [], podcasts: tabs.includes('podcast') ? podcasts : [], newsLinks: tabs.includes('news') ? newsLinks : [], @@ -811,8 +926,16 @@ export function useContentPanel() { .trim() } + function stripBookTags(text: string): string { + return text + .replace(BOOK_TAG_RE, '') + .replace(BOOK_EXT_RE, '') + .replace(/\n{3,}/g, '\n\n') + .trim() + } + function stripContentTags(text: string): string { - return stripFilmTags(stripSongTags(stripPodcastTags(text))) + return stripFilmTags(stripSongTags(stripPodcastTags(stripBookTags(text)))) } /** Remove markdown links when surfacing as inline cards to avoid duplication */ @@ -826,6 +949,7 @@ export function useContentPanel() { function openFilmDetail(film: Film) { selectedFilm.value = film + selectedBook.value = null selectedSong.value = null selectedPodcast.value = null selectedArticle.value = null @@ -835,9 +959,22 @@ export function useContentPanel() { selectedFilm.value = null } + function openBookDetail(book: Book) { + selectedBook.value = book + selectedFilm.value = null + selectedSong.value = null + selectedPodcast.value = null + selectedArticle.value = null + } + + function closeBookDetail() { + selectedBook.value = null + } + function openSongDetail(song: Song) { selectedSong.value = song selectedFilm.value = null + selectedBook.value = null selectedPodcast.value = null selectedArticle.value = null } @@ -849,6 +986,7 @@ export function useContentPanel() { function openPodcastDetail(podcast: Podcast) { selectedPodcast.value = podcast selectedFilm.value = null + selectedBook.value = null selectedSong.value = null selectedArticle.value = null } @@ -860,6 +998,7 @@ export function useContentPanel() { function openArticleDetail(article: WebSearchResult) { selectedArticle.value = article selectedFilm.value = null + selectedBook.value = null selectedSong.value = null selectedPodcast.value = null panelOpen.value = true @@ -872,6 +1011,7 @@ export function useContentPanel() { function closePanel() { panelOpen.value = false selectedFilm.value = null + selectedBook.value = null selectedSong.value = null selectedPodcast.value = null selectedArticle.value = null @@ -921,6 +1061,7 @@ export function useContentPanel() { return { panelOpen, panelFilms, + panelBooks, panelSongs, panelPodcasts, panelWebResults, @@ -928,6 +1069,7 @@ export function useContentPanel() { panelMagazineSections, panelMagazineHeroImage, selectedFilm, + selectedBook, selectedSong, selectedPodcast, selectedArticle, @@ -940,6 +1082,9 @@ export function useContentPanel() { extractFilmIds, resolveFilms, extractAllFilms, + extractAllBooks, + openBookDetail, + closeBookDetail, extractSongIds, resolveSongs, extractAllSongs, diff --git a/packages/app/src/composables/useImageFallback.ts b/packages/app/src/composables/useImageFallback.ts index e3dac42b..adbc759d 100644 --- a/packages/app/src/composables/useImageFallback.ts +++ b/packages/app/src/composables/useImageFallback.ts @@ -214,6 +214,74 @@ export async function fetchPodcastCover( } /** Fetch album artwork from iTunes Search API (free, no key). Returns hi-res URL (600x600). */ +const bookCoverCache = new Map() +const SESSION_BOOK_KEY = 'aiui-book-cover-cache' + +function bookCacheKey(title: string, author: string): string { + return `${title.toLowerCase().trim()}|${author.toLowerCase().trim()}` +} + +function loadBookCache(): void { + try { + const raw = sessionStorage.getItem(SESSION_BOOK_KEY) + if (raw) { + const parsed = JSON.parse(raw) as Record + Object.entries(parsed).forEach(([k, v]) => bookCoverCache.set(k, v)) + } + } catch { /* ignore */ } +} + +function saveBookCache(): void { + try { + const entries = [...bookCoverCache.entries()].slice(-200) + sessionStorage.setItem(SESSION_BOOK_KEY, JSON.stringify(Object.fromEntries(entries))) + } catch { /* ignore */ } +} + +loadBookCache() + +export function generateBookCoverFallback(title: string, author?: string): string { + const hue = [...(title + (author ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360 + const svg = ` + + + + + + ${escapeXml(title.length > 22 ? title.slice(0, 20) + 'โ€ฆ' : title)} + + ${author ? `${escapeXml(author.length > 26 ? author.slice(0, 24) + 'โ€ฆ' : author)}` : ''} + ` + return `data:image/svg+xml,${encodeURIComponent(svg)}` +} + +/** Fetch book cover from Open Library Covers API (free, no key). */ +export async function fetchBookCover( + title: string, + author: string, +): Promise { + const key = bookCacheKey(title, author) + const cached = bookCoverCache.get(key) + if (cached) return cached + + try { + const q = `${title} ${author}`.trim() + const res = await fetch( + `https://openlibrary.org/search.json?q=${encodeURIComponent(q)}&limit=1&fields=cover_i`, + ) + if (!res.ok) return null + const data = (await res.json()) as { docs?: { cover_i?: number }[] } + const coverId = data.docs?.[0]?.cover_i + if (!coverId) return null + const url = `https://covers.openlibrary.org/b/id/${coverId}-L.jpg` + bookCoverCache.set(key, url) + saveBookCache() + return url + } catch { + return null + } +} + export async function fetchMusicCover( title: string, artist: string, diff --git a/packages/core/src/types/content.ts b/packages/core/src/types/content.ts index de021155..68ad3c3d 100644 --- a/packages/core/src/types/content.ts +++ b/packages/core/src/types/content.ts @@ -77,3 +77,24 @@ export interface PodcastRendererData { query?: string totalResults?: number } + +export interface Book { + id: string + title: string + author: string + year?: number + coverUrl?: string + description?: string + genres?: string[] + pages?: number + isbn?: string + rating?: number + sources?: BookSource[] +} + +export interface BookSource { + type: 'openlibrary' | 'gutenberg' | 'archive' | 'goodreads' | 'libgen' | 'local' + name: string + url: string + icon?: string +}