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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user