feat(chat): enhance chat functionality with web search and article integration

- Updated ChatMessage and ChatWindow components to support inline web search results and articles.
- Integrated new web search and RSS plugins into the chat system for real-time information retrieval.
- Enhanced useContentPanel to manage web search results alongside existing media types.
- Added ArticleOverlay component for displaying selected articles from search results.
- Improved UI elements and styles for better user interaction with web search features.

Made-with: Cursor
This commit is contained in:
Dorian
2026-03-02 21:29:50 +00:00
parent 49ec6c09b6
commit 2d056f9498
36 changed files with 2616 additions and 303 deletions
+424 -35
View File
@@ -1,26 +1,277 @@
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 } from '@/composables/useImageFallback'
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<Film[]>([])
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 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)
if (mdLink) return mdLink[2]
const bare = /(https?:\/\/[^\s)\]\"'<>]+)/.exec(text)
return bare ? bare[1] : 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)
if (mdImg) return mdImg[1]
const ext = /(https?:\/\/[^\s)\]\"'<>]+\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s)\]]*)?)/i.exec(text)
return ext ? ext[1] : undefined
}
/** Extract magazine-style bullets: - **Title**: Content or - **Title** — Content */
function extractMagazineSections(text: string): MagazineSection[] {
const sections: MagazineSection[] = []
const re = /^\s*[-]\s*\*\*([^*]+)\*\*\s*[:\u2014\u2013]\s*(.+?)(?=\n\s*[-]\s*\*\*|\n\s*[-]\s*[^*]|\n\n##\s|$)/gms
let match: RegExpExecArray | null
while ((match = re.exec(text)) !== null) {
const title = match[1].trim().slice(0, 120)
const raw = match[2].trim().replace(/\n+/g, ' ')
const content = raw.slice(0, 800)
if (title.length > 1 && content.length > 10) {
const url = extractUrlFromText(raw)
const author = extractAuthorFromText(raw)
const imageUrl = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(raw)?.[1]
sections.push({ title, content, url, author, imageUrl })
}
}
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 (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
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 {
@@ -108,6 +359,7 @@ export function useContentPanel() {
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
@@ -117,6 +369,7 @@ export function useContentPanel() {
title,
artist,
year,
coverUrl: generateSongCoverFallback(title, artist),
sources: [],
})
}
@@ -167,6 +420,7 @@ export function useContentPanel() {
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
@@ -183,6 +437,7 @@ export function useContentPanel() {
id: `ext-${`${title}|${artist}`.toLowerCase().replace(/\W/g, '-')}`,
title,
artist,
coverUrl: generateSongCoverFallback(title, artist),
sources: [],
}))
}
@@ -194,6 +449,8 @@ export function useContentPanel() {
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()}`))
@@ -241,6 +498,7 @@ export function useContentPanel() {
title,
host,
year,
coverUrl: undefined,
sources: [],
})
}
@@ -253,44 +511,132 @@ export function useContentPanel() {
return [...libraryPodcasts, ...externalPodcasts]
}
function updatePanelFromText(text: string) {
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)
if (songs.length > 0) {
panelSongs.value = songs
panelFilms.value = []
panelPodcasts.value = []
selectedFilm.value = null
selectedPodcast.value = null
contentType.value = 'song'
panelTitle.value = songs.length === 1
? songs[0].title
: `${songs.length} Recommended Songs`
panelOpen.value = true
} else if (films.length > 0) {
panelFilms.value = films
panelSongs.value = []
panelPodcasts.value = []
selectedSong.value = null
selectedPodcast.value = null
contentType.value = 'film'
panelTitle.value = films.length === 1
? films[0].title
: `${films.length} Recommended Films`
panelOpen.value = true
} else if (podcasts.length > 0) {
panelPodcasts.value = podcasts
panelFilms.value = []
panelSongs.value = []
selectedFilm.value = null
selectedSong.value = null
contentType.value = 'podcast'
panelTitle.value = podcasts.length === 1
? podcasts[0].title
: `${podcasts.length} Recommended Podcasts`
panelOpen.value = true
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 >= 2 && (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 to surface actual articles in News
if (mergedWebsites.length > 0) {
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 >= 2 && (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 : [],
}
}
@@ -322,10 +668,20 @@ export function useContentPanel() {
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() {
@@ -336,6 +692,7 @@ export function useContentPanel() {
selectedSong.value = song
selectedFilm.value = null
selectedPodcast.value = null
selectedArticle.value = null
}
function closeSongDetail() {
@@ -346,17 +703,33 @@ export function useContentPanel() {
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() {
@@ -369,6 +742,7 @@ export function useContentPanel() {
selectedFilm.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
}
function showAllSongs() {
@@ -381,6 +755,7 @@ export function useContentPanel() {
selectedFilm.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
}
function showAllPodcasts() {
@@ -393,6 +768,7 @@ export function useContentPanel() {
selectedFilm.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
}
return {
@@ -400,11 +776,20 @@ export function useContentPanel() {
panelFilms,
panelSongs,
panelPodcasts,
panelWebResults,
panelWebsites,
panelMagazineSections,
panelMagazineHeroImage,
selectedFilm,
selectedSong,
selectedPodcast,
selectedArticle,
panelTitle,
panelQuery,
contentType,
activeTab,
availableTabs,
setActiveTab,
extractFilmIds,
resolveFilms,
extractAllFilms,
@@ -414,17 +799,21 @@ export function useContentPanel() {
extractPodcastIds,
resolvePodcasts,
extractAllPodcasts,
getContextualInlineContent,
updatePanelFromText,
stripFilmTags,
stripSongTags,
stripPodcastTags,
stripContentTags,
stripMarkdownLinks,
openFilmDetail,
closeFilmDetail,
openSongDetail,
closeSongDetail,
openPodcastDetail,
closePodcastDetail,
openArticleDetail,
closeArticleDetail,
closePanel,
showAllFilms,
showAllSongs,