Files
archy/packages/app/src/composables/useContentPanel.ts
T

1638 lines
61 KiB
TypeScript
Raw Normal View History

import { ref } from 'vue'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } 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' | 'tvshow' | 'image' | 'place' | 'news' | 'websites' | 'magazine' | 'code'
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
/** Heading group this section belongs to (for banner display) */
group?: string
}
const panelOpen = ref(false)
const panelFilms = ref<Film[]>([])
const panelBooks = ref<Book[]>([])
const panelTVSeries = ref<TVSeries[]>([])
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 panelImages = ref<ImageItem[]>([])
const panelPlaces = ref<Place[]>([])
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 selectedPlace = ref<Place | null>(null)
const selectedWebsite = ref<WebSearchResult | null>(null)
const selectedMagazineSection = ref<MagazineSection | null>(null)
const magazineSectionIndex = ref(0)
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>,
group?: string,
): void {
const t = title.trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/\*\*/g, '').replace(/^#+\s*/, '').replace(/\s+/g, ' ').slice(0, 150)
const c = content.trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/\*\*/g, '').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,
group,
})
}
/** Extract magazine sections comprehensively: ## headings, **camp** blocks, bullets, intro */
function cleanMagazineContent(raw: string): string {
return raw
.replace(/\[\[(?:podcast|film|song|book|tvshow|film_ext|song_ext):[^\]]*\]\]/g, '') // strip content tags
.replace(/^\s*\n---\s*\n?/g, '') // strip --- separators
.replace(/\n---\s*$/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function extractMagazineSections(text: string): MagazineSection[] {
const sections: MagazineSection[] = []
const seen = new Set<string>()
// Strip sources/references and "For deeper" sections at the end
let cleanedText = text
.replace(/\n+(?:Sources|References|Links):?\s*\n[\s\S]*$/i, '')
.replace(/\n+For deeper[^:]*:[\s\S]*$/im, '')
// 1. Find all ## or ### headings and extract content between them
const headingPositions: { title: string; start: number; contentStart: number }[] = []
const headingLineRe = /^#{2,3}\s+(.+)$/gm
let m: RegExpExecArray | null
while ((m = headingLineRe.exec(cleanedText)) !== null) {
const rawTitle = m[1].trim()
const title = rawTitle
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\uFE0F?\s*/gu, '')
.replace(/^[#*_\s-]+/, '')
.trim()
if (title.length > 1) {
headingPositions.push({ title, start: m.index, contentStart: m.index + m[0].length })
}
}
// For each heading, extract individual bullet points as separate sections
for (let i = 0; i < headingPositions.length; i++) {
const hp = headingPositions[i]
const nextStart = i + 1 < headingPositions.length ? headingPositions[i + 1].start : cleanedText.length
const rawContent = cleanMagazineContent(cleanedText.slice(hp.contentStart, nextStart))
if (rawContent.length < 20) continue
// Split bullets within this heading block
const bullets = rawContent
.split(/\n\s*[-•]\s+/)
.map(s => s.replace(/^[-•]\s*/, '').trim())
.filter(s => s.length > 10)
if (bullets.length >= 2) {
// Multiple bullets: each bullet becomes its own section under this heading group
for (const bullet of bullets) {
const cleaned = cleanMagazineContent(bullet)
if (cleaned.length < 15) continue
// Extract bold title from bullet if present: **Title**: content or **Title** — content
const boldMatch = /^\*\*([^*]+)\*\*\s*[:\u2014\u2013]\s*(.+)/s.exec(cleaned)
if (boldMatch) {
addSection(sections, boldMatch[1].trim(), boldMatch[2].trim(), seen, hp.title)
} else {
// No bold title — use first sentence as title, full text as content
const sentenceMatch = /^([^.!?]{10,80}[.!?])/.exec(cleaned)
const bulletTitle = sentenceMatch ? sentenceMatch[1] : cleaned.slice(0, 60)
addSection(sections, bulletTitle, cleaned, seen, hp.title)
}
}
} else {
// Single block content — add as one section
addSection(sections, hp.title, rawContent, 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|$)/gim
while ((m = campRe.exec(cleanedText)) !== null) {
const title = m[1].trim()
const content = cleanMagazineContent(m[2])
if (content.length > 15) addSection(sections, title, content, seen)
}
// 3. Intro paragraph before first heading or ---
const firstHeadingIdx = headingPositions.length > 0 ? headingPositions[0].start : -1
const introEnd = firstHeadingIdx > 0 ? firstHeadingIdx : cleanedText.indexOf('\n---\n')
if (introEnd > 0) {
const intro = cleanedText.slice(0, introEnd).trim()
.replace(/^[#*_\s-]+/gm, '').trim()
if (intro.length > 30 && !seen.has('Summary')) {
addSection(sections, 'Summary', intro, seen)
}
}
// 4. "This is being called..." / closing paragraph
const closingMatch = /(This is being called[^.]+\.[^"]*"[^"]+"[^.]*\.)/i.exec(cleanedText)
if (closingMatch && !seen.has('Key')) {
addSection(sections, 'Key takeaway', closingMatch[1].trim(), seen)
}
// Preserve document order: Summary first, then heading content in order, then key
const order = ['Summary', 'Key takeaway']
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 (/\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 (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse)\b/.test(q)) return 'place'
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,
hasTVSeries: boolean,
hasImages: boolean,
hasPlaces: 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 && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasWebsites) {
return ['magazine']
}
if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasMagazine) {
return ['websites']
}
const all: ContentTab[] = []
if (hasFilms) all.push('film')
if (hasBooks) all.push('book')
if (hasTVSeries) all.push('tvshow')
if (hasImages) all.push('image')
if (hasPlaces) all.push('place')
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
const TV_EXT_RE = /\[\[tv_ext:([^|]+)\|([^|]*)(?:\|(\d{4}))?\]\]/gi
const PLACE_EXT_RE = /\[\[place_ext:([^|]+)\|([^|]*)(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?\]\]/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 (with optional italic on 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, '').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
// Skip markdown links [text](url) — these are source references, not books
const afterMatch = text.substring(m.index + m[0].length, m.index + m[0].length + 200)
if (/^\s*\]\s*\(https?:/.test(afterMatch) || /\]\(https?:\/\//.test(m[0])) 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 the primary content (not just supplementary refs)
// When user explicitly asked about books, film/song tags are just cross-references
if (!isBookQuery(userQuery)) {
if (extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text)) return []
if (extractSongIds(text).length > 0 || /\[\[song_ext:/.test(text)) return []
}
if (isNewsLikeResponse(text)) return []
// Strip sources/references section at the end to avoid matching markdown links as books
const cleanText = text.replace(/\n---\n\s*(?:Sources|References|Links):?\s*\n[\s\S]*$/i, '')
return extractBooksFromPatterns(cleanText)
}
function extractExternalTVSeries(text: string): TVSeries[] {
const series: TVSeries[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
const re = new RegExp(TV_EXT_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const title = match[1].trim()
const creator = match[2].trim()
const year = match[3] ? parseInt(match[3], 10) : undefined
const key = title.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
series.push({
id: `ext-${key.replace(/\W/g, '-')}`,
title,
creator: creator || undefined,
year,
synopsis: extractDescriptionForTag(text, match.index, match[0].length),
genres: [],
sources: [],
})
}
return series
}
function isTVQuery(q: string): boolean {
return /\b(tv show|tv series|series|television|streaming|binge|watch|recommend.*show|best show|season)\b/i.test(q)
}
function isTVLikeResponse(text: string): boolean {
return /\b(season|episodes?|showrunner|streaming|renewed|cancelled|premiere|network|HBO|Netflix|AMC|FX|Apple TV|Disney\+)\b/i.test(text) &&
(text.match(/\bseason\b/gi)?.length ?? 0) >= 2
}
function extractTVSeriesFromPatterns(text: string): TVSeries[] {
const series: { title: string; desc: string; pos: number }[] = []
const seen = new Set<string>()
const patterns: RegExp[] = [
/"([^"]{2,60})"\s*[-–—]\s*(?:a |an )?(?:series|show|tv)/gi,
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*(?:a |an )?(?:\w+ )?(?:series|show|drama|comedy|thriller|animated)/gi,
/(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n]{2,60}?)\*{0,2}\s*\((\d{4})(?:[-]\d{0,4})?(?:,\s*\d+ seasons?)?\)/gm,
]
for (const re of patterns) {
let m: RegExpExecArray | null
const rx = new RegExp(re.source, re.flags)
while ((m = rx.exec(text)) !== null) {
const title = m[1].trim().replace(/^\*\*|\*\*$/g, '')
if (title.length < 2) continue
if (/\[\[(film|song|podcast|book|tv)(_ext)?:/.test(title)) continue
const key = title.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
const desc = extractDescriptionForTag(text, m.index, m[0].length)
series.push({ title, desc, pos: m.index })
}
}
return series
.sort((a, b) => a.pos - b.pos)
.map(({ title, desc }) => ({
id: `ext-${title.toLowerCase().replace(/\W/g, '-')}`,
title,
synopsis: desc,
genres: [],
sources: [],
}))
}
function convertFilmExtToTVSeries(text: string): TVSeries[] {
const series: TVSeries[] = []
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 creator = match[3].trim()
const key = title.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
series.push({
id: `ext-${key.replace(/\W/g, '-')}`,
title,
year,
synopsis: extractDescriptionForTag(text, match.index, match[0].length),
creator,
genres: [],
sources: [],
})
}
return series
}
function extractAllTVSeries(text: string, userQuery: string): TVSeries[] {
const external = extractExternalTVSeries(text)
if (external.length > 0) return external
if (!isTVQuery(userQuery) && !isTVLikeResponse(text)) return []
if (isNewsLikeResponse(text)) return []
// When user asked about TV but AI used film_ext tags, convert them
if (isTVQuery(userQuery) && /\[\[film_ext:/.test(text)) {
return convertFilmExtToTVSeries(text)
}
if (extractFilmIds(text).length > 0) return []
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: ![alt](url)
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 isPlaceQuery(q: string): boolean {
return /\b(restaurant|restaurants|place|places|food|eat|eating|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|where to eat|good food|best food|where should i eat|recommend.*eat|recommend.*restaurant|recommend.*place)\b/i.test(q)
}
function isPlaceLikeResponse(text: string): boolean {
return /\b(restaurant|cuisine|menu|reserv|dining|address|open|hours|price range|\$\$|\$\$\$|michelin|yelp|rating)\b/i.test(text) &&
(text.match(/\b(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill)\b/gi)?.length ?? 0) >= 2
}
/** Extract places from [[place_ext:Name|Cuisine|City|Rating|PriceLevel|Address]] tags */
function extractExternalPlaces(text: string): Place[] {
const places: Place[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
const re = new RegExp(PLACE_EXT_RE.source, 'gi')
while ((match = re.exec(text)) !== null) {
const name = match[1].trim()
const cuisine = match[2]?.trim() || undefined
const city = match[3]?.trim() || undefined
const rating = match[4] ? parseFloat(match[4]) : undefined
const priceLevel = match[5] ? parseInt(match[5], 10) : undefined
const address = match[6]?.trim() || undefined
const key = name.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
places.push({
id: `ext-place-${key.replace(/\W/g, '-')}`,
name,
cuisine,
city,
rating: rating && !isNaN(rating) ? rating : undefined,
priceLevel: priceLevel && priceLevel >= 1 && priceLevel <= 4 ? priceLevel : undefined,
address,
description: extractDescriptionForTag(text, match.index, match[0].length),
sources: [],
})
}
return places
}
/** Extract place-like patterns from AI response text */
function extractPlacesFromPatterns(text: string): Place[] {
const places: { name: string; cuisine?: string; city?: string; rating?: number; priceLevel?: number; desc: string; pos: number }[] = []
const seen = new Set<string>()
const patterns: RegExp[] = [
// **Name** — cuisine/category, details
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*(?:a |an )?(?:(\w[\w\s]{1,30}?)\s+)?(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|eatery|spot|joint)/gi,
// Numbered list: 1. **Name** (cuisine) or 1. Name — description
/(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n]{2,60}?)\*{0,2}\s*[-–—(]\s*(?:(\w[\w\s&]{1,30}?)\s+)?(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|cuisine|food|dining)/gim,
]
for (const re of patterns) {
let m: RegExpExecArray | null
const rx = new RegExp(re.source, re.flags)
while ((m = rx.exec(text)) !== null) {
const name = m[1].trim().replace(/^\*\*|\*\*$/g, '').replace(/^\[|\]$/g, '')
if (name.length < 2) continue
if (/\[\[(film|song|podcast|book|tv|place)(_ext)?:/.test(name)) continue
const key = name.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
const cuisine = m[2]?.trim()
const desc = extractDescriptionForTag(text, m.index, m[0].length)
// Try to extract rating from nearby text
const nearby = text.slice(m.index, m.index + 300)
const ratingMatch = /(\d\.?\d?)\s*(?:\/\s*5|stars?|★)/i.exec(nearby)
const rating = ratingMatch ? parseFloat(ratingMatch[1]) : undefined
const priceMatch = /(\${1,4})\b/.exec(nearby)
const priceLevel = priceMatch ? priceMatch[1].length : undefined
places.push({ name, cuisine, desc, rating, priceLevel, pos: m.index })
}
}
return places
.sort((a, b) => a.pos - b.pos)
.map(({ name, cuisine, desc, rating, priceLevel }) => ({
id: `ext-place-${name.toLowerCase().replace(/\W/g, '-')}`,
name,
cuisine,
rating,
priceLevel,
description: desc,
sources: [],
}))
}
function extractAllPlaces(text: string, userQuery: string): Place[] {
const external = extractExternalPlaces(text)
if (external.length > 0) return external
if (!isPlaceQuery(userQuery) && !isPlaceLikeResponse(text)) return []
if (isNewsLikeResponse(text)) return []
return extractPlacesFromPatterns(text)
}
function updatePanelFromText(text: string, userQuery = '', webResults: WebSearchResult[] = []) {
panelQuery.value = userQuery.trim()
const songs = extractAllSongs(text)
let films = extractAllFilms(text)
const podcasts = extractAllPodcasts(text)
const books = extractAllBooks(text, userQuery)
const tvSeries = extractAllTVSeries(text, userQuery)
// When film_ext tags were converted to TV series, only keep library films
if (tvSeries.length > 0 && isTVQuery(userQuery)) {
films = films.filter(f => !f.id.startsWith('ext-'))
}
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 images = extractAllImages(text, userQuery)
const places = extractAllPlaces(text, userQuery)
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.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 showPlaces = tabs.includes('place')
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 visibleTVSeries = showTVSeries ? tvSeries : []
const visibleImages = showImages ? images : []
const visiblePlaces = showPlaces ? places : []
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
panelTVSeries.value = visibleTVSeries
panelImages.value = visibleImages
panelPlaces.value = visiblePlaces
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
selectedTVSeries.value = null
selectedImage.value = null
selectedPlace.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 (visibleTVSeries.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'
// Title follows the primary (first) tab
const primary = tabs[0]
if (primary === 'place' && visiblePlaces.length > 0) {
panelTitle.value = visiblePlaces.length === 1 ? visiblePlaces[0].name : `${visiblePlaces.length} Places`
} else if (primary === 'tvshow' && visibleTVSeries.length > 0) {
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
else if (visibleBooks.length > 1) panelTitle.value = `${visibleBooks.length} Books`
else if (visibleTVSeries.length === 1) panelTitle.value = visibleTVSeries[0].title
else if (visibleTVSeries.length > 1) panelTitle.value = `${visibleTVSeries.length} TV Series`
else if (visibleSongs.length === 1) panelTitle.value = visibleSongs[0].title
else if (visibleSongs.length > 1) panelTitle.value = `${visibleSongs.length} Songs`
else if (visiblePlaces.length === 1) panelTitle.value = visiblePlaces[0].name
else if (visiblePlaces.length > 1) panelTitle.value = `${visiblePlaces.length} Places`
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[] = []) {
let films = extractAllFilms(text)
const songs = extractAllSongs(text)
const podcasts = extractAllPodcasts(text)
const books = extractAllBooks(text, userQuery)
const tvSeries = extractAllTVSeries(text, userQuery)
if (tvSeries.length > 0 && isTVQuery(userQuery)) {
films = films.filter(f => !f.id.startsWith('ext-'))
}
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 images = extractAllImages(text, userQuery)
const places = extractAllPlaces(text, userQuery)
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.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 : [],
places: tabs.includes('place') ? places : [],
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 stripTVTags(text: string): string {
return text
.replace(TV_EXT_RE, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function stripPlaceTags(text: string): string {
return text
.replace(PLACE_EXT_RE, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function stripContentTags(text: string): string {
return stripFilmTags(stripSongTags(stripPodcastTags(stripBookTags(stripTVTags(stripPlaceTags(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
selectedTVSeries.value = null
selectedImage.value = null
selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
selectedWebsite.value = null
selectedMagazineSection.value = null
}
function closeFilmDetail() {
selectedFilm.value = null
}
function openBookDetail(book: Book) {
selectedBook.value = book
selectedFilm.value = null
selectedTVSeries.value = null
selectedImage.value = null
selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
selectedWebsite.value = null
selectedMagazineSection.value = null
}
function closeBookDetail() {
selectedBook.value = null
}
function openSongDetail(song: Song) {
selectedSong.value = song
selectedFilm.value = null
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
selectedPlace.value = null
selectedPodcast.value = null
selectedArticle.value = null
selectedWebsite.value = null
selectedMagazineSection.value = null
}
function closeSongDetail() {
selectedSong.value = null
}
function openPodcastDetail(podcast: Podcast) {
selectedPodcast.value = podcast
selectedFilm.value = null
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
selectedPlace.value = null
selectedSong.value = null
selectedArticle.value = null
selectedWebsite.value = null
selectedMagazineSection.value = null
}
function closePodcastDetail() {
selectedPodcast.value = null
}
function openArticleDetail(article: WebSearchResult) {
selectedArticle.value = article
selectedFilm.value = null
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedWebsite.value = null
selectedMagazineSection.value = null
panelOpen.value = true
}
function closeArticleDetail() {
selectedArticle.value = null
}
function openWebsiteDetail(website: WebSearchResult) {
selectedWebsite.value = website
selectedFilm.value = null
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
panelOpen.value = true
}
function closeWebsiteDetail() {
selectedWebsite.value = null
}
function openMagazineSectionDetail(section: MagazineSection, index: number) {
selectedMagazineSection.value = section
magazineSectionIndex.value = index
selectedFilm.value = null
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
selectedWebsite.value = null
}
function closeMagazineSectionDetail() {
selectedMagazineSection.value = null
}
function navigateMagazineSection(direction: 'prev' | 'next') {
const sections = panelMagazineSections.value
if (!sections.length) return
let idx = magazineSectionIndex.value
idx += direction === 'next' ? 1 : -1
if (idx < 0) idx = sections.length - 1
if (idx >= sections.length) idx = 0
magazineSectionIndex.value = idx
selectedMagazineSection.value = sections[idx]
}
function openTVSeriesDetail(series: TVSeries) {
selectedTVSeries.value = series
selectedFilm.value = null
selectedBook.value = null
selectedImage.value = null
selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
selectedWebsite.value = null
selectedMagazineSection.value = null
}
function closeTVSeriesDetail() {
selectedTVSeries.value = null
}
function openImageDetail(image: ImageItem) {
selectedImage.value = image
selectedFilm.value = null
selectedBook.value = null
selectedTVSeries.value = null
selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
selectedWebsite.value = null
selectedMagazineSection.value = null
}
function closeImageDetail() {
selectedImage.value = null
}
function openPlaceDetail(place: Place) {
selectedPlace.value = place
selectedFilm.value = null
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
selectedWebsite.value = null
selectedMagazineSection.value = null
}
function closePlaceDetail() {
selectedPlace.value = null
}
function closePanel() {
panelOpen.value = false
selectedFilm.value = null
selectedBook.value = null
selectedTVSeries.value = null
selectedImage.value = null
selectedPlace.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
selectedWebsite.value = null
selectedMagazineSection.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
selectedWebsite.value = null
selectedMagazineSection.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
selectedWebsite.value = null
selectedMagazineSection.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,
panelTVSeries,
panelImages,
panelPlaces,
panelSongs,
panelPodcasts,
panelWebResults,
panelWebsites,
panelMagazineSections,
panelMagazineHeroImage,
selectedFilm,
selectedBook,
selectedTVSeries,
selectedImage,
selectedPlace,
selectedSong,
selectedPodcast,
selectedArticle,
selectedWebsite,
selectedMagazineSection,
magazineSectionIndex,
panelTitle,
panelQuery,
contentType,
activeTab,
availableTabs,
setActiveTab,
extractFilmIds,
resolveFilms,
extractAllFilms,
extractAllBooks,
extractAllTVSeries,
openBookDetail,
closeBookDetail,
openTVSeriesDetail,
closeTVSeriesDetail,
openImageDetail,
closeImageDetail,
openPlaceDetail,
closePlaceDetail,
extractSongIds,
resolveSongs,
extractAllSongs,
extractPodcastIds,
resolvePodcasts,
extractAllPodcasts,
getContextualInlineContent,
updatePanelFromText,
stripFilmTags,
stripSongTags,
stripPodcastTags,
stripContentTags,
stripMarkdownLinks,
openFilmDetail,
closeFilmDetail,
openSongDetail,
closeSongDetail,
openPodcastDetail,
closePodcastDetail,
openArticleDetail,
closeArticleDetail,
openWebsiteDetail,
closeWebsiteDetail,
openMagazineSectionDetail,
closeMagazineSectionDetail,
navigateMagazineSection,
closePanel,
showAllFilms,
showAllSongs,
showAllPodcasts,
}
}