import { ref } from 'vue' import type { Film, Song, Podcast } from '@aiui/core/types/content' import type { WebSearchResult } from '@aiui/core/types/message' import { mockFilms } from '@/mocks/films' import { mockSongs } from '@/mocks/songs' import { mockPodcasts } from '@/mocks/podcasts' import { generatePosterFallback, generateSongCoverFallback } from '@/composables/useImageFallback' import { fetchRssFromUrls } from '@/composables/useRssFetch' export type ContentTab = 'film' | 'song' | 'podcast' | '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([]) const panelWebResults = ref([]) const panelRssArticles = ref([]) const panelWebsites = ref([]) const panelMagazineSections = ref([]) const panelMagazineHeroImage = ref(null) const panelSongs = ref([]) const panelPodcasts = ref([]) const selectedFilm = ref(null) const selectedSong = ref(null) const selectedPodcast = ref(null) const selectedArticle = ref(null) const panelTitle = ref('Recommended Films') const panelQuery = ref('') const contentType = ref<'film' | 'song' | 'podcast'>('film') const activeTab = ref('film') const availableTabs = ref([]) 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}([^*\n]+?)\*{0,2}(?:\s+(?:is|calls?|says?|cited)|\.|,)/i, /\bby\s+\*{0,2}([^*\n]+?)\*{0,2}(?:\s|$|\.|,)/i, /(?:source|—)\s*:?\s*\*{0,2}([^*\n]+?)\*{0,2}(?:\s|$|\.|,)/i, /\*\*([^*]+)\*\*(?:\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 > 2 && 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, ): 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() const blockContents = new Set() // 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() 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 (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, 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 && !hasNews && !hasWebsites) { return ['magazine'] } if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasNews && !hasMagazine) { return ['websites'] } const all: ContentTab[] = [] if (hasFilms) all.push('film') 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 /** 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() 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() 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() 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() 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() 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() 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() 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 updatePanelFromText(text: string, userQuery = '', webResults: WebSearchResult[] = []) { panelQuery.value = userQuery.trim() const songs = extractAllSongs(text) const films = extractAllFilms(text) const podcasts = extractAllPodcasts(text) 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, hasNews, hasWebsites, hasMagazine) availableTabs.value = tabs.length > 0 ? tabs : ['film'] activeTab.value = tabs[0] ?? 'film' const showFilms = tabs.includes('film') 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 visibleSongs = showSongs ? songs : [] const visiblePodcasts = showPodcasts ? podcasts : [] const visibleNews = showNews ? mergedNews : [] const visibleWebsites = showWebsites ? mergedWebsites : [] const visibleMagazineSections = showMagazine ? magazineSections : [] panelFilms.value = visibleFilms 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 selectedSong.value = null selectedPodcast.value = null if (visibleFilms.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 (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 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, hasNews, hasWebsites, hasMagazine) return { films: tabs.includes('film') ? films : [], 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 stripContentTags(text: string): string { return stripFilmTags(stripSongTags(stripPodcastTags(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 selectedSong.value = null selectedPodcast.value = null selectedArticle.value = null } function closeFilmDetail() { selectedFilm.value = null } function openSongDetail(song: Song) { selectedSong.value = song selectedFilm.value = null selectedPodcast.value = null selectedArticle.value = null } function closeSongDetail() { selectedSong.value = null } function openPodcastDetail(podcast: Podcast) { selectedPodcast.value = podcast selectedFilm.value = null selectedSong.value = null selectedArticle.value = null } function closePodcastDetail() { selectedPodcast.value = null } function openArticleDetail(article: WebSearchResult) { selectedArticle.value = article selectedFilm.value = null selectedSong.value = null selectedPodcast.value = null panelOpen.value = true } function closeArticleDetail() { selectedArticle.value = null } function closePanel() { panelOpen.value = false selectedFilm.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, panelSongs, panelPodcasts, panelWebResults, panelWebsites, panelMagazineSections, panelMagazineHeroImage, selectedFilm, selectedSong, selectedPodcast, selectedArticle, panelTitle, panelQuery, contentType, activeTab, availableTabs, setActiveTab, extractFilmIds, resolveFilms, extractAllFilms, 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, } }