Files
archy/packages/app/src/composables/useContentPanel.ts
T
DorianandClaude Opus 4.6 99e380e1b2 feat(content): add Books content type and ContentPanel tab system
Wire up ContentPanel with tab navigation for all existing content types
(films, songs, podcasts, news, websites, magazine). Add complete Books
pipeline: type definition, extraction (tags + pattern matching), cover
fetching from Open Library, BookCard/BookGrid/BookDetail components,
inline chat cards, and prompt index badges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:27:00 +00:00

1115 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ref } from 'vue'
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, generateBookCoverFallback } from '@/composables/useImageFallback'
import { fetchRssFromUrls } from '@/composables/useRssFetch'
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'news' | 'websites' | 'magazine'
export interface MagazineSection {
title: string
content: string
/** Optional image URL (from markdown or parsed) */
imageUrl?: string
/** Optional author (e.g. "Henrik Zeberg") */
author?: string
/** Optional link to open in iframe */
url?: string
}
const panelOpen = ref(false)
const panelFilms = ref<Film[]>([])
const panelBooks = ref<Book[]>([])
const panelWebResults = ref<WebSearchResult[]>([])
const panelRssArticles = ref<WebSearchResult[]>([])
const panelWebsites = ref<WebSearchResult[]>([])
const panelMagazineSections = ref<MagazineSection[]>([])
const panelMagazineHeroImage = ref<string | null>(null)
const panelSongs = ref<Song[]>([])
const panelPodcasts = ref<Podcast[]>([])
const selectedFilm = ref<Film | null>(null)
const selectedBook = ref<Book | null>(null)
const selectedSong = ref<Song | null>(null)
const selectedPodcast = ref<Podcast | null>(null)
const selectedArticle = ref<WebSearchResult | null>(null)
const panelTitle = ref('Recommended Films')
const panelQuery = ref('')
const contentType = ref<'film' | 'song' | 'podcast'>('film')
const activeTab = ref<ContentTab>('film')
const availableTabs = ref<ContentTab[]>([])
function isNewsQuery(q: string): boolean {
const lower = q.toLowerCase().trim()
if (!lower) return false
return /\b(news|latest|recent|current|what'?s happening|updates? about)\b/.test(lower) ||
/what'?s the latest|latest \w+ news/.test(lower) ||
/what are people saying|what'?s the word|what do people think/.test(lower)
}
function isNewsLikeResponse(text: string): boolean {
const lower = text.toLowerCase()
return /for instant .* news|check these sources|for (the )?latest (bitcoin )?news|direct sources/i.test(lower) ||
/(have )?access to (live )?web search|want me to (go back and )?search/i.test(lower)
}
function isWebsitesQuery(q: string): boolean {
const lower = q.toLowerCase().trim()
return /\b(website|websites|where to check|best places? to check|places? to (look|check|find)|check online|resources?|sources? to (check|read|visit))\b/.test(lower) ||
/where (can i|should i) (check|look|find)/.test(lower)
}
function isWebsitesLikeResponse(text: string): boolean {
const lower = text.toLowerCase()
return /best places? to check|check online yourself|places? to check online|websites? to (visit|check|read)/i.test(lower)
}
function extractUrlFromText(text: string): string | undefined {
const mdLink = /\[([^\]]*)\]\((https?:\/\/[^)]+)\)/.exec(text)
const raw = mdLink ? mdLink[2] : (/(https?:\/\/[^\s)\]\"'<>]+)/.exec(text)?.[1])
if (!raw?.trim()) return undefined
try {
const u = new URL(raw.trim())
if (!/^https?:$/i.test(u.protocol)) return undefined
return u.href
} catch {
return undefined
}
}
function extractAuthorFromText(text: string): string | undefined {
const patterns = [
/(?:analyst|according to)\s+\*{0,2}([A-Z][^*\n]+?)\*{0,2}(?:\s+(?:is|calls?|says?|cited)|\.|,)/,
/\bby\s+\*{0,2}([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)+)\*{0,2}/,
/(?:source|—)\s*:?\s*\*{0,2}([A-Z][^*\n]+?)\*{0,2}(?:\s|$|\.|,)/,
/\*\*([A-Z][^*]+)\*\*(?:\s+(?:is|calls?|says?|cited|predicts?))/,
]
for (const re of patterns) {
const m = re.exec(text)
if (m) {
const name = m[1].trim().slice(0, 60)
if (name.length > 3 && name.length < 50) return name
}
}
return undefined
}
function extractFirstImageFromText(text: string): string | undefined {
const mdImg = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(text)
const raw = mdImg ? mdImg[1] : (/(https?:\/\/[^\s)\]\"'<>]+\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s)\]]*)?)/i.exec(text)?.[1])
if (!raw?.trim()) return undefined
try {
const u = new URL(raw.trim())
if (!/^https?:$/i.test(u.protocol)) return undefined
return u.href
} catch {
return undefined
}
}
const MAGAZINE_CONTENT_MAX = 2000
function addSection(
sections: MagazineSection[],
title: string,
content: string,
seen: Set<string>,
): void {
const t = title.trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/^#+\s*/, '').replace(/\s+/g, ' ').slice(0, 150)
const c = content.trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/\n{2,}/g, '\n\n').slice(0, MAGAZINE_CONTENT_MAX)
if (t.length < 2 || c.length < 15) return
const key = `${t.slice(0, 50)}`
if (seen.has(key)) return
seen.add(key)
const imgMatch = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(content)?.[1]
const imageUrl = imgMatch ? (() => {
try {
const u = new URL(imgMatch.trim())
return /^https?:$/i.test(u.protocol) ? u.href : undefined
} catch { return undefined }
})() : undefined
sections.push({
title: t,
content: c,
url: extractUrlFromText(content),
author: extractAuthorFromText(content),
imageUrl,
})
}
/** Extract magazine sections comprehensively: ## headings, **camp** blocks, bullets, intro */
function extractMagazineSections(text: string): MagazineSection[] {
const sections: MagazineSection[] = []
const seen = new Set<string>()
const blockContents = new Set<string>() // avoid duplicate bullets already in ## blocks
// 1. ## or ### Heading blocks: full content until next heading or **Section**
const headingRe = /^#{2,3}\s*[^\n]*?(?:⚡|🔥|📌|✨|📺|⭐|🔄|🎬|🎭|🎵|🎧|📖|💡|🔍|🌍|💭|🧠|🔬|📊|🏆|📝)?\s*(.+?)\n\n([\s\S]+?)(?=\n#{2,3}\s|\n\*\*[^*]+\*\*[🟠🔴🟢]?|\n\nFor deeper|\n\nThis is being|\n\n---|\n\n\*\*TL;DR|\z)/gim
let m: RegExpExecArray | null
while ((m = headingRe.exec(text)) !== null) {
const title = m[1].trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').split(':')[0].trim()
const content = m[2].trim()
if (content.length > 20) {
blockContents.add(content)
addSection(sections, title, content, seen)
}
}
// 2. **Pro/ Anti camp** blocks with their bullets
const campRe = /\*\*([^*]+(?:camp| side| view)[^*]*)\*\*[🟠🔴🟢]?\s*\n([\s\S]+?)(?=\n\*\*[^*]+(?:camp| side)[^*]*\*\*|\n#{2,3}\s|\n\nThis is|\n\nFor deeper|\z)/gim
while ((m = campRe.exec(text)) !== null) {
const title = m[1].trim()
const content = m[2].trim()
if (content.length > 15) addSection(sections, title, content, seen)
}
// 3. Bullets: - **Title**: Content (skip if already in a ## or ### block)
const bulletRe = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*[:\u2014\u2013]\s*(.+?)(?=\n\s*[-•]\s*\*\*|\n\s*[-•]\s+\w|\n\n#{2,3}\s|\n\*\*[^*]+\*\*[🟠🔴]?|\z)/gms
while ((m = bulletRe.exec(text)) !== null) {
const raw = m[2].trim().replace(/\n+/g, ' ')
if (raw.length < 15) continue
const inBlock = [...blockContents].some((b) => b.includes(raw.slice(0, 100)))
if (!inBlock) addSection(sections, m[1].trim(), raw, seen)
}
// 4. - **Name** (Role) description — attributed quotes
const attrRe = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*\(([^)]+)\)\s+([^-\n].+?)(?=\n\s*[-•]|\n\*\*|\n#{2,3}\s|\z)/gms
while ((m = attrRe.exec(text)) !== null) {
const content = m[3].trim().replace(/\n+/g, ' ')
if (content.length > 20) addSection(sections, `${m[1].trim()} (${m[2].trim()})`, content, seen)
}
// 5. Intro paragraph before first ## / ### or ---
const introMatch = /^([\s\S]+?)(?=\n#{2,3}\s|\n---\s*\n|\n-\s+\*\*[^*]+\*\*\s*[:\u2014])/m.exec(text)
if (introMatch) {
const intro = introMatch[1].trim().replace(/^[#*_\s-]+/gm, '').trim()
if (intro.length > 60 && !seen.has('Summary')) {
addSection(sections, 'Summary', intro, seen)
}
}
// 6. "This is being called..." / closing paragraph
const closingMatch = /(This is being called[^.]+\.[^"]*"[^"]+"[^.]*\.)/i.exec(text)
if (closingMatch && !seen.has('Key')) {
addSection(sections, 'Key takeaway', closingMatch[1].trim(), seen)
}
// 7. "For deeper analysis" / podcast or further reading block
const deeperMatch = /(?:For deeper analysis[^:]*:[\s\S]+?)(?=\n\z)/im.exec(text)
if (deeperMatch) {
const block = deeperMatch[1].trim()
if (block.length > 30 && !seen.has('Further')) {
addSection(sections, 'Further reading', block, seen)
}
}
// Preserve document order: Summary first, then ## blocks order, then camps, then key/further
const order = ['Summary', 'Key takeaway', 'Further reading']
sections.sort((a, b) => {
const ai = order.indexOf(a.title)
const bi = order.indexOf(b.title)
if (ai >= 0 && bi >= 0) return ai - bi
if (ai >= 0) return -1
if (bi >= 0) return 1
return 0
})
return sections
}
/** Extract first image from text for magazine hero */
function extractMagazineHeroImage(text: string): string | undefined {
return extractFirstImageFromText(text)
}
/** Extract **Name** (domain) pattern, e.g. **Bitcoin Mailing List** (gnusha.org) */
function extractBoldDomainLinks(text: string): WebSearchResult[] {
const results: WebSearchResult[] = []
const seen = new Set<string>()
const re = /\*\*([^*]+)\*\*\s*\(([a-zA-Z0-9][-a-zA-Z0-9.]*\.[a-zA-Z]{2,})\)/g
let match: RegExpExecArray | null
while ((match = re.exec(text)) !== null) {
const title = match[1].trim().slice(0, 500)
const domain = match[2].trim()
if (title.length < 2) continue
const url = /^https?:\/\//i.test(domain) ? domain : `https://${domain}`
const norm = normUrl(url)
if (seen.has(norm)) continue
seen.add(norm)
results.push({ title, url, content: undefined })
}
return results
}
/** Infer which tab to show first from user prompt keywords */
/** Extract a short contextual phrase from the user query for display (e.g. "BIP 110" from "what is BIP 110") */
function extractQueryContext(q: string): string {
const stop = /\b(what|is|are|the|a|an|latest|recent|current|news|about|for|how|why|when|where|can|could|should|would|tell|me|please|best|good)\b/gi
const cleaned = q.replace(stop, ' ').replace(/\s+/g, ' ').trim().slice(0, 60)
return cleaned || ''
}
function preferredFirstTab(userQuery: string): ContentTab | null {
const q = userQuery.toLowerCase().trim()
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
}
/** Filter which content types to show based on query + response context (no presets).
* First tab defaults to what the user asked about when detectable. */
function filterTabsByContext(
userQuery: string,
hasFilms: boolean,
hasSongs: boolean,
hasPodcasts: boolean,
hasBooks: boolean,
hasNews: boolean,
hasWebsites: boolean,
hasMagazine: boolean,
): ContentTab[] {
const q = userQuery.toLowerCase().trim()
const preferred = preferredFirstTab(userQuery)
if (isNewsQuery(q)) {
const tabs: ContentTab[] = []
if (hasMagazine) tabs.push('magazine')
if (hasNews) tabs.push('news')
if (hasWebsites) tabs.push('websites')
if (hasPodcasts) tabs.push('podcast')
return tabs
}
if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasNews && !hasWebsites) {
return ['magazine']
}
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')
if (hasNews) all.push('news')
if (hasWebsites) all.push('websites')
if (preferred && all.includes(preferred)) {
const rest = all.filter((t) => t !== preferred)
return [preferred, ...rest]
}
return all
}
const FILM_TAG_RE = /\[\[film:(f?\d+)\]\]/gi
const FILM_EXT_RE = /\[\[film_ext:([^|]+)\|(\d{4})\|([^\]]+)\]\]/gi
const SONG_TAG_RE = /\[\[song:(s?\d+)\]\]/gi
const SONG_EXT_RE = /\[\[song_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
/** Reject obvious non-song phrases (news bullets, factual descriptions, etc.) */
function looksLikeSong(title: string, artist: string): boolean {
const t = title.toLowerCase()
const a = artist.toLowerCase()
const bad = [
'latest news', 'protocol updates', 'community debates', 'real-time information',
'training cutoff', 'bip discussion', 'beyond my training', 'what people are saying',
'want me to go', 'search for what', 'look up things', 'direct answer',
'for deeper coverage', 'for instant', 'check these sources',
'bip 110', 'bip discussions', 'web search',
'developer mailing list', 'mailing list reactions', 'technical opinions',
'community sentiment', 'twitter', 'reddit', 'github', 'stackexchange',
'bitcoin bips', 'bitcoin mailing', 'canonical source', 'formal dev',
'what i\'d suggest', 'for bip', 'sources to',
]
for (const phrase of bad) {
if (t.includes(phrase) || a.includes(phrase)) return false
}
if (t.length > 55 || a.length > 40) return false
return true
}
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()
const h = host.toLowerCase()
const bad = [
'bitcoin mailing list', 'mailing list', 'developer mailing list', 'gnusha.org',
'canonical source', 'formal dev', 'github', 'stackexchange', 'reddit', 'twitter',
'latest news', 'protocol updates', 'web search', 'training cutoff',
'documentation', 'bip discussion', 'bip 110', 'bitcoin bips',
]
for (const phrase of bad) {
if (t.includes(phrase) || h.includes(phrase)) return false
}
if (t.length > 80 || h.length > 50) return false
return true
}
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g
const SAFE_URL_SCHEME = /^https?:\/\//i
function extractMarkdownLinks(text: string): WebSearchResult[] {
const results: WebSearchResult[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
const re = new RegExp(MARKDOWN_LINK_RE.source, 'g')
while ((match = re.exec(text)) !== null) {
const title = match[1].trim().slice(0, 500)
const rawUrl = match[2].trim()
if (title.length < 2 || rawUrl.length < 10 || !SAFE_URL_SCHEME.test(rawUrl)) continue
try {
new URL(rawUrl)
} catch {
continue
}
const norm = rawUrl.toLowerCase().replace(/\/$/, '')
if (seen.has(norm)) continue
seen.add(norm)
results.push({ title, url: rawUrl, content: undefined })
}
return results
}
function normUrl(u: string): string {
return u.toLowerCase().trim().replace(/\/$/, '')
}
function mergeNewsResults(web: WebSearchResult[], fromText: WebSearchResult[]): WebSearchResult[] {
const byUrl = new Map<string, WebSearchResult>()
for (const r of web) {
byUrl.set(normUrl(r.url), r)
}
for (const r of fromText) {
const k = normUrl(r.url)
if (!byUrl.has(k)) byUrl.set(k, r)
}
return [...byUrl.values()]
}
export function useContentPanel() {
function normalizeFilmId(raw: string): string {
return raw.startsWith('f') ? raw : `f${raw}`
}
function extractFilmIds(text: string): string[] {
const ids: string[] = []
let match: RegExpExecArray | null
const re = new RegExp(FILM_TAG_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const id = normalizeFilmId(match[1])
if (!ids.includes(id)) ids.push(id)
}
return ids
}
function resolveFilms(ids: string[]): Film[] {
return ids
.map((id) => mockFilms.find((f) => f.id === id))
.filter((f): f is Film => !!f)
}
function extractDescriptionForTag(text: string, matchIndex: number, matchLength: number): string {
const prevNewline = text.lastIndexOf('\n', matchIndex - 1)
const lineStart = prevNewline === -1 ? 0 : prevNewline + 1
const nextNewline = text.indexOf('\n', matchIndex + matchLength)
const lineEnd = nextNewline === -1 ? text.length : nextNewline
let line = text.slice(lineStart, lineEnd)
// Remove the tag itself
line = line.replace(text.slice(matchIndex, matchIndex + matchLength), '')
// Remove other content tags on the same line
line = line.replace(/\[\[(?:film|song|podcast)(?:_ext)?:[^\]]*\]\]/g, '')
// Remove leading bullet/list markers
line = line.replace(/^\s*[-*•]\s*/, '').replace(/^\s*\d+\.\s*/, '')
// Remove **Bold Title** followed by separator
line = line.replace(/\*\*[^*]+\*\*\s*[-–—:]\s*/, '').replace(/\*\*[^*]+\*\*\s*/, '')
// Remove stray markdown bold/italic
line = line.replace(/\*\*/g, '').replace(/\*/g, '')
// Remove parenthetical (Year) duplicating tag data
line = line.replace(/\(\d{4}\)\s*/g, '')
// Clean separators at edges
line = line.replace(/^[\s\-–—:,]+/, '').replace(/[\s\-–—:,]+$/, '')
const result = line.trim().slice(0, 300)
return result.length >= 10 ? result : ''
}
function extractExternalFilms(text: string): Film[] {
const films: Film[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
const re = new RegExp(FILM_EXT_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const title = match[1].trim()
const year = parseInt(match[2], 10)
const director = match[3].trim()
const key = `${title.toLowerCase()}|${year}`
if (seen.has(key)) continue
seen.add(key)
films.push({
id: `ext-${key.replace(/\W/g, '-')}`,
title,
year,
posterUrl: generatePosterFallback(title, year),
synopsis: extractDescriptionForTag(text, match.index, match[0].length),
genres: [],
rating: 0,
runtime: 0,
director,
cast: [],
sources: [],
})
}
return films
}
function extractAllFilms(text: string): Film[] {
const libraryFilms = resolveFilms(extractFilmIds(text))
const externalFilms = extractExternalFilms(text)
return [...libraryFilms, ...externalFilms]
}
function normalizeSongId(raw: string): string {
return raw.startsWith('s') ? raw : `s${raw}`
}
function extractSongIds(text: string): string[] {
const ids: string[] = []
let match: RegExpExecArray | null
const re = new RegExp(SONG_TAG_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const id = normalizeSongId(match[1])
if (!ids.includes(id)) ids.push(id)
}
return ids
}
function resolveSongs(ids: string[]): Song[] {
return ids
.map((id) => mockSongs.find((s) => s.id === id))
.filter((s): s is Song => !!s)
}
function extractExternalSongs(text: string): Song[] {
const songs: Song[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
const re = new RegExp(SONG_EXT_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const title = match[1].trim()
const artist = match[2].trim()
if (!looksLikeSong(title, artist)) continue
const year = match[3] ? parseInt(match[3], 10) : undefined
const key = `${title.toLowerCase()}|${artist.toLowerCase()}`
if (seen.has(key)) continue
seen.add(key)
songs.push({
id: `ext-${key.replace(/\W/g, '-')}`,
title,
artist,
year,
coverUrl: generateSongCoverFallback(title, artist),
sources: [],
})
}
return songs
}
/** Infer songs from plain text when no tags present. Requires title+artist within 100 chars, whole-word artist. */
function extractSongsFromLibraryMatch(text: string): Song[] {
const lower = text.toLowerCase()
const found: { song: Song; pos: number }[] = []
const seen = new Set<string>()
for (const song of mockSongs) {
const key = song.id
if (seen.has(key)) continue
const title = song.title.toLowerCase()
const artist = song.artist.toLowerCase()
if (!lower.includes(title)) continue
const artistRe = new RegExp('\\b' + artist.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i')
if (!artistRe.test(lower)) continue
const titlePos = lower.indexOf(title)
const artistMatch = lower.match(artistRe)
const artistPos = artistMatch?.index ?? -1
if (artistPos < 0) continue
const dist = Math.abs(titlePos - artistPos)
if (dist > 120) continue
seen.add(key)
found.push({ song, pos: Math.min(titlePos, artistPos) })
}
return found.sort((a, b) => a.pos - b.pos).map((f) => f.song)
}
/** Extract song-like patterns: "Title" by Artist, Title Artist, 1. Title - Artist */
function extractSongsFromPatterns(text: string): Song[] {
const songs: { title: string; artist: string; pos: number }[] = []
const seen = new Set<string>()
const patterns: { re: RegExp; titleIdx: number; artistIdx: number }[] = [
{ re: /"([^"]{2,80})"\s+by\s+([A-Za-z0-9][^,\n\.]{1,50}?)(?:\s*[,\n\.]|$)/gi, titleIdx: 1, artistIdx: 2 },
{ re: /\*\*([^*]{2,80})\*\*\s+by\s+([A-Za-z0-9][^,\n\.]{1,50}?)(?:\s*[,\n\.]|$)/g, titleIdx: 1, artistIdx: 2 },
{ re: /(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)?([^\n\-–—]{2,60}?)\s*[-–—]\s*([A-Za-z0-9][^,\n]{1,50}?)(?:\s*[,\n\.]|$)/gm, titleIdx: 1, artistIdx: 2 },
{ re: /([A-Za-z0-9][^\-–—\n]{2,60}?)\s+[-–—]\s+([A-Za-z0-9][^,\n]{1,50}?)(?=\s*[,\n\.]|$)/g, titleIdx: 1, artistIdx: 2 },
]
for (const { re, titleIdx, artistIdx } 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()
const artist = m[artistIdx].trim()
if (title.length < 2 || artist.length < 2) continue
if (!looksLikeSong(title, artist)) continue
if (/^\d{4}$/.test(title) || /^\d{4}$/.test(artist)) continue
if (/\[\[(film|song)(_ext)?:/.test(title) || /\[\[(film|song)(_ext)?:/.test(artist)) continue
if (/\*\*\[\[/.test(title) || title.includes(']]**')) continue
const key = `${title.toLowerCase()}|${artist.toLowerCase()}`
if (seen.has(key)) continue
seen.add(key)
songs.push({ title, artist, pos: m.index })
}
}
return songs
.sort((a, b) => a.pos - b.pos)
.map(({ title, artist }) => ({
id: `ext-${`${title}|${artist}`.toLowerCase().replace(/\W/g, '-')}`,
title,
artist,
coverUrl: generateSongCoverFallback(title, artist),
sources: [],
}))
}
function extractAllSongs(text: string): Song[] {
const librarySongs = resolveSongs(extractSongIds(text))
const externalSongs = extractExternalSongs(text)
if (librarySongs.length > 0 || externalSongs.length > 0) {
return [...librarySongs, ...externalSongs]
}
if (extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text)) return []
if (extractPodcastIds(text).length > 0 || /\[\[podcast_ext:/.test(text)) return []
if (isNewsLikeResponse(text)) return []
const libMatches = extractSongsFromLibraryMatch(text)
const patternMatches = extractSongsFromPatterns(text)
const libKeys = new Set(libMatches.map((s) => `${s.title.toLowerCase()}|${s.artist.toLowerCase()}`))
const fromPatterns = patternMatches.filter(
(p) => !libKeys.has(`${p.title.toLowerCase()}|${p.artist.toLowerCase()}`)
)
return [...libMatches, ...fromPatterns]
}
function normalizePodcastId(raw: string): string {
return raw.startsWith('p') ? raw : `p${raw}`
}
function extractPodcastIds(text: string): string[] {
const ids: string[] = []
let match: RegExpExecArray | null
const re = new RegExp(PODCAST_TAG_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const id = normalizePodcastId(match[1])
if (!ids.includes(id)) ids.push(id)
}
return ids
}
function resolvePodcasts(ids: string[]): Podcast[] {
return ids
.map((id) => mockPodcasts.find((p) => p.id === id))
.filter((p): p is Podcast => !!p)
}
function extractExternalPodcasts(text: string): Podcast[] {
const podcasts: Podcast[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
const re = new RegExp(PODCAST_EXT_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const title = match[1].trim()
const host = match[2].trim()
if (!looksLikePodcast(title, host)) continue
const year = match[3] ? parseInt(match[3], 10) : undefined
const key = `${title.toLowerCase()}|${host.toLowerCase()}`
if (seen.has(key)) continue
seen.add(key)
podcasts.push({
id: `ext-${key.replace(/\W/g, '-')}`,
title,
host,
year,
coverUrl: undefined,
sources: [],
})
}
return podcasts
}
function extractAllPodcasts(text: string): Podcast[] {
const libraryPodcasts = resolvePodcasts(extractPodcastIds(text))
const externalPodcasts = extractExternalPodcasts(text)
return [...libraryPodcasts, ...externalPodcasts]
}
function extractExternalBooks(text: string): Book[] {
const books: Book[] = []
const seen = new Set<string>()
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<string>()
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)
panelRssArticles.value = [] // clear; will repopulate when RSS fetch completes
// Websites = plain links from response (markdown + bold domains).
const hasLinkableContent = fromMarkdown.length > 0 || boldDomains.length > 0
const websitesFromMarkdown = hasLinkableContent ? fromMarkdown : []
const mergedWebsites = mergeNewsResults(websitesFromMarkdown, boldDomains)
const hasWebsites = mergedWebsites.length > 0
// Magazine = bullet-style sections (- **Title**: Content)
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))
// News = actual articles (web search + RSS from website domains). Plain links → Websites.
const newsContext = isNewsQuery(userQuery) || isNewsLikeResponse(text)
const hasNews = (webResults.length > 0 || mergedWebsites.length > 0) && newsContext
const mergedNews = hasNews ? mergeNewsResults(webResults, panelRssArticles.value) : []
// Fetch RSS from website URLs only when news context — avoid surfacing RSS from docs/resource links
if (mergedWebsites.length > 0 && newsContext) {
const urls = mergedWebsites.map((w) => w.url)
fetchRssFromUrls(urls).then((articles) => {
if (articles.length === 0) return
panelRssArticles.value = articles
const combined = mergeNewsResults(panelWebResults.value, articles)
panelWebResults.value = combined
if (!availableTabs.value.includes('news')) {
availableTabs.value = ['news', ...availableTabs.value]
activeTab.value = 'news'
}
const ctx = extractQueryContext(panelQuery.value)
panelTitle.value = ctx ? `${ctx}${combined.length} articles` : `${combined.length} Articles`
})
}
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')
const showWebsites = tabs.includes('websites')
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 : []
const visibleWebsites = showWebsites ? mergedWebsites : []
const visibleMagazineSections = showMagazine ? magazineSections : []
panelFilms.value = visibleFilms
panelBooks.value = visibleBooks
panelSongs.value = visibleSongs
panelPodcasts.value = visiblePodcasts
panelWebResults.value = visibleNews
panelWebsites.value = visibleWebsites
panelMagazineSections.value = visibleMagazineSections
panelMagazineHeroImage.value = showMagazine
? (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'
else contentType.value = 'film'
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
else if (visiblePodcasts.length > 1) panelTitle.value = `${visiblePodcasts.length} Podcasts`
else if (visibleNews.length > 0) {
const ctx = extractQueryContext(userQuery)
panelTitle.value = ctx ? `${ctx}${visibleNews.length} articles` : `${visibleNews.length} Articles`
}
else if (visibleMagazineSections.length > 0) {
const ctx = extractQueryContext(userQuery)
panelTitle.value = ctx ? `${ctx} — Brief` : 'Market Brief'
}
else if (visibleWebsites.length > 0) panelTitle.value = `${visibleWebsites.length} Websites`
else panelTitle.value = 'Content'
panelOpen.value = tabs.length > 0
}
function setActiveTab(tab: ContentTab) {
if (availableTabs.value.includes(tab)) activeTab.value = tab
}
/** Contextual films/songs/podcasts/news/websites/magazine for inline cards (respects query+response, no presets) */
function getContextualInlineContent(text: string, userQuery: string, webResults: WebSearchResult[] = []) {
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)
const boldDomains = extractBoldDomainLinks(text)
const hasNews = webResults.length > 0 && (isNewsQuery(userQuery) || isNewsLikeResponse(text))
const newsLinks = hasNews ? webResults : []
const hasLinkableContent = fromMarkdown.length > 0 || boldDomains.length > 0
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, 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 : [],
websitesLinks: tabs.includes('websites') ? websitesLinks : [],
magazineSections: tabs.includes('magazine') ? magazineSections : [],
}
}
function stripFilmTags(text: string): string {
return text
.replace(FILM_TAG_RE, '')
.replace(FILM_EXT_RE, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function stripSongTags(text: string): string {
return text
.replace(SONG_TAG_RE, '')
.replace(SONG_EXT_RE, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function stripPodcastTags(text: string): string {
return text
.replace(PODCAST_TAG_RE, '')
.replace(PODCAST_EXT_RE, '')
.replace(/\n{3,}/g, '\n\n')
.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(stripBookTags(text))))
}
/** Remove markdown links when surfacing as inline cards to avoid duplication */
function stripMarkdownLinks(text: string): string {
return text
.replace(/^[\s]*[-*]\s*\[[^\]]+\]\(https?:\/\/[^)\s]+\)\s*$/gm, '')
.replace(/\s*\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, (_, title) => ` ${title}`)
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function openFilmDetail(film: Film) {
selectedFilm.value = film
selectedBook.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
}
function closeFilmDetail() {
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
}
function closeSongDetail() {
selectedSong.value = null
}
function openPodcastDetail(podcast: Podcast) {
selectedPodcast.value = podcast
selectedFilm.value = null
selectedBook.value = null
selectedSong.value = null
selectedArticle.value = null
}
function closePodcastDetail() {
selectedPodcast.value = null
}
function openArticleDetail(article: WebSearchResult) {
selectedArticle.value = article
selectedFilm.value = null
selectedBook.value = null
selectedSong.value = null
selectedPodcast.value = null
panelOpen.value = true
}
function closeArticleDetail() {
selectedArticle.value = null
}
function closePanel() {
panelOpen.value = false
selectedFilm.value = null
selectedBook.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
activeTab.value = 'film'
availableTabs.value = []
}
function showAllFilms() {
panelFilms.value = [...mockFilms]
panelSongs.value = []
panelPodcasts.value = []
panelTitle.value = 'Your Film Library'
contentType.value = 'film'
panelOpen.value = true
selectedFilm.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
}
function showAllSongs() {
panelFilms.value = []
panelSongs.value = [...mockSongs]
panelPodcasts.value = []
panelTitle.value = 'Your Song Library'
contentType.value = 'song'
panelOpen.value = true
selectedFilm.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
}
function showAllPodcasts() {
panelFilms.value = []
panelSongs.value = []
panelPodcasts.value = [...mockPodcasts]
panelTitle.value = 'Your Podcast Library'
contentType.value = 'podcast'
panelOpen.value = true
selectedFilm.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
}
return {
panelOpen,
panelFilms,
panelBooks,
panelSongs,
panelPodcasts,
panelWebResults,
panelWebsites,
panelMagazineSections,
panelMagazineHeroImage,
selectedFilm,
selectedBook,
selectedSong,
selectedPodcast,
selectedArticle,
panelTitle,
panelQuery,
contentType,
activeTab,
availableTabs,
setActiveTab,
extractFilmIds,
resolveFilms,
extractAllFilms,
extractAllBooks,
openBookDetail,
closeBookDetail,
extractSongIds,
resolveSongs,
extractAllSongs,
extractPodcastIds,
resolvePodcasts,
extractAllPodcasts,
getContextualInlineContent,
updatePanelFromText,
stripFilmTags,
stripSongTags,
stripPodcastTags,
stripContentTags,
stripMarkdownLinks,
openFilmDetail,
closeFilmDetail,
openSongDetail,
closeSongDetail,
openPodcastDetail,
closePodcastDetail,
openArticleDetail,
closeArticleDetail,
closePanel,
showAllFilms,
showAllSongs,
showAllPodcasts,
}
}