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,5 +1,6 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { searchWeb } from '@/composables/useWebSearch'
|
||||
|
||||
type Provider = 'claude' | 'openrouter' | 'mock'
|
||||
|
||||
@@ -24,6 +25,8 @@ const podcastContext = mockPodcasts.map((p) =>
|
||||
|
||||
const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the user's media library (films, songs, and podcasts).
|
||||
|
||||
**News/Factual queries:** When the user asks for "news", "latest", "recent", or current information, lead with a direct answer summarizing the news/facts. You MAY add "For deeper coverage:" with [[podcast_ext:...]] tags only. Do NOT use [[song_ext:...]] or [[film_ext:...]] for news queries—podcasts are the appropriate follow-up. Never substitute an answer with only recommendations.
|
||||
|
||||
**Films:** When recommending or discussing films from the user's library, use [[film:ID]] where ID is the film's id. For films NOT in the library, use [[film_ext:Title|Year|Director]], e.g. [[film_ext:Brokeback Mountain|2005|Ang Lee]].
|
||||
|
||||
**Songs:** When recommending or discussing songs, ALWAYS use tags for every song you mention:
|
||||
@@ -36,6 +39,8 @@ Never list songs in plain text only—each recommendation must have a tag so the
|
||||
- Other podcasts: [[podcast_ext:Title|Host|Year]] (year optional), e.g. [[podcast_ext:What Bitcoin Did|Peter McCormack|2018]].
|
||||
Prioritize Podcasting 2.0–friendly platforms: Fountain.fm, Podcast Index, Castopod, Odysee, Rumble, YouTube, Podverse.
|
||||
|
||||
**Websites / "Best places to check":** When listing resources, places to check online, or websites for the user to visit, use markdown links: [Name](https://full-url). For simple domains use **Name** (domain.com), e.g. **Bitcoin Mailing List** (gnusha.org).
|
||||
|
||||
**Music discovery:** For genre-based requests (e.g. "best math rock"), pick from the user's library when relevant, or use [[song_ext:...]] for others. Prioritize indie-friendly platforms: Wavlake, Bandcamp, Internet Archive, SoundCloud, Odysee, Jamendo.
|
||||
|
||||
Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out.
|
||||
@@ -124,15 +129,18 @@ async function streamClaude(
|
||||
messages: ChatMessage[],
|
||||
onToken: (text: string) => void,
|
||||
onError: (err: string) => void,
|
||||
systemPrompt: string,
|
||||
webSearch: boolean,
|
||||
): Promise<void> {
|
||||
const res = await fetch(CLAUDE_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: activeModel.value,
|
||||
system: SYSTEM_PROMPT,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
stream: true,
|
||||
webSearch,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -156,6 +164,7 @@ async function streamOpenRouter(
|
||||
messages: ChatMessage[],
|
||||
onToken: (text: string) => void,
|
||||
onError: (err: string) => void,
|
||||
systemPrompt: string,
|
||||
): Promise<void> {
|
||||
if (!openrouterApiKey) {
|
||||
onError('Missing VITE_OPENROUTER_API_KEY in .env.local')
|
||||
@@ -163,7 +172,7 @@ async function streamOpenRouter(
|
||||
}
|
||||
|
||||
const orMessages = [
|
||||
{ role: 'system' as const, content: SYSTEM_PROMPT },
|
||||
{ role: 'system' as const, content: systemPrompt },
|
||||
...messages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })),
|
||||
]
|
||||
|
||||
@@ -232,6 +241,17 @@ async function readSSE(
|
||||
}
|
||||
}
|
||||
|
||||
function formatWebSearchContext(results: { title: string; url: string; content?: string }[]): string {
|
||||
if (results.length === 0) return ''
|
||||
const lines = results.map((r, i) => {
|
||||
const snippet = r.content ? ` — ${r.content.slice(0, 200)}${r.content.length > 200 ? '…' : ''}` : ''
|
||||
return `${i + 1}. [${r.title}](${r.url})${snippet}`
|
||||
})
|
||||
return `\n\n**Web search results (PRIORITIZE these):**
|
||||
- Answer the user's question using these results. Cite sources.
|
||||
- You MAY add [[podcast_ext:...]] or [[film_ext:...]] tags for "to learn more" recommendations after your answer.\n\n${lines.join('\n')}`
|
||||
}
|
||||
|
||||
export function useAI() {
|
||||
const chatStore = useChatStore()
|
||||
|
||||
@@ -250,6 +270,23 @@ export function useAI() {
|
||||
|
||||
chatStore.isStreaming = true
|
||||
|
||||
let systemPrompt = SYSTEM_PROMPT
|
||||
if (chatStore.webSearchEnabled) {
|
||||
systemPrompt += `
|
||||
|
||||
**Web search:** You have access to WebSearch and WebFetch tools. Use them to look up current information, news, and facts when the user asks. You can search the web and fetch page content. Web search is enabled for this session—do not tell the user it is unavailable.`
|
||||
}
|
||||
if (chatStore.webSearchEnabled && userText.trim()) {
|
||||
const results = await searchWeb(userText)
|
||||
if (results.length > 0) {
|
||||
systemPrompt += formatWebSearchContext(results)
|
||||
chatStore.setMessageWebResults(cid, assistantMsg.id, results)
|
||||
console.log('[AIUI] Injected', results.length, 'web search results into context')
|
||||
} else {
|
||||
console.warn('[AIUI] Web search enabled but 0 results — check browser console for [AIUI web-search] logs')
|
||||
}
|
||||
}
|
||||
|
||||
const history: ChatMessage[] = chatStore.messages
|
||||
.filter((m) => m.id !== assistantMsg.id)
|
||||
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }))
|
||||
@@ -262,9 +299,9 @@ export function useAI() {
|
||||
|
||||
try {
|
||||
if (provider === 'claude') {
|
||||
await streamClaude(history, onToken, onError)
|
||||
await streamClaude(history, onToken, onError, systemPrompt, chatStore.webSearchEnabled)
|
||||
} else if (provider === 'openrouter') {
|
||||
await streamOpenRouter(history, onToken, onError)
|
||||
await streamOpenRouter(history, onToken, onError, systemPrompt)
|
||||
} else {
|
||||
await streamMock(history, onToken)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,11 +5,17 @@ const failedUrls = new Set<string>()
|
||||
|
||||
const musicCoverCache = new Map<string, string>()
|
||||
const SESSION_MUSIC_KEY = 'aiui-music-cover-cache'
|
||||
const podcastCoverCache = new Map<string, string>()
|
||||
const SESSION_PODCAST_KEY = 'aiui-podcast-cover-cache'
|
||||
|
||||
function musicCacheKey(artist: string, title: string): string {
|
||||
return `${artist.toLowerCase().trim()}|${title.toLowerCase().trim()}`
|
||||
}
|
||||
|
||||
function podcastCacheKey(title: string, host?: string): string {
|
||||
return `${title.toLowerCase().trim()}|${(host ?? '').toLowerCase().trim()}`
|
||||
}
|
||||
|
||||
function loadMusicCache(): void {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_MUSIC_KEY)
|
||||
@@ -52,15 +58,35 @@ function saveSessionCache(): void {
|
||||
|
||||
loadSessionCache()
|
||||
|
||||
export function generatePodcastCoverFallback(title: string, host?: string): string {
|
||||
const hue = [...(title + (host ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
|
||||
function loadPodcastCache(): void {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_PODCAST_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Record<string, string>
|
||||
Object.entries(parsed).forEach(([k, v]) => podcastCoverCache.set(k, v))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function savePodcastCache(): void {
|
||||
try {
|
||||
const entries = [...podcastCoverCache.entries()].slice(-200)
|
||||
sessionStorage.setItem(SESSION_PODCAST_KEY, JSON.stringify(Object.fromEntries(entries)))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
loadPodcastCache()
|
||||
|
||||
const MICROPHONE_PATH = 'M45.04,95.45v24.11c0,1.83-1.49,3.32-3.32,3.32c-1.83,0-3.32-1.49-3.32-3.32V95.45c-10.16-0.81-19.32-5.3-26.14-12.12C4.69,75.77,0,65.34,0,53.87c0-1.83,1.49-3.32,3.32-3.32s3.32,1.49,3.32,3.32c0,9.64,3.95,18.41,10.31,24.77c6.36,6.36,15.13,10.31,24.77,10.31h0c9.64,0,18.41-3.95,24.77-10.31c6.36-6.36,10.31-15.13,10.31-24.77c0-1.83,1.49-3.32,3.32-3.32s3.32,1.49,3.32,3.32c0,11.48-4.69,21.91-12.25,29.47C64.36,90.16,55.2,94.64,45.04,95.45z M41.94,0c6.38,0,12.18,2.61,16.38,6.81c4.2,4.2,6.81,10,6.81,16.38v30c0,6.38-2.61,12.18-6.81,16.38c-4.2,4.2-10,6.81-16.38,6.81s-12.18-2.61-16.38-6.81c-4.2-4.2-6.81-10-6.81-16.38v-30c0-6.38,2.61-12.18,6.81-16.38C29.76,2.61,35.56,0,41.94,0z M53.62,11.51c-3-3-7.14-4.86-11.68-4.86c-4.55,0-8.68,1.86-11.68,4.86c-3,3-4.86,7.14-4.86,11.68v30c0,4.55,1.86,8.68,4.86,11.68c3,3,7.14,4.86,11.68,4.86c4.55,0,8.68-1.86,11.68-4.86c3-3,4.86-7.14,4.86-11.68v-30C58.49,18.64,56.62,14.51,53.62,11.51z'
|
||||
|
||||
export function generatePodcastCoverFallback(_title: string, _host?: string): string {
|
||||
const hue = 220
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<rect width="200" height="200" fill="hsl(${hue}, 30%, 14%)"/>
|
||||
<rect x="2" y="2" width="196" height="196" rx="8" fill="none" stroke="hsl(${hue}, 40%, 25%)" stroke-width="2"/>
|
||||
<circle cx="100" cy="80" r="25" fill="none" stroke="hsl(${hue}, 50%, 60%)" stroke-width="4"/>
|
||||
<path d="M100 105 v25 M85 130 h30 M100 155 v15 M80 170 h40" fill="none" stroke="hsl(${hue}, 50%, 60%)" stroke-width="3" stroke-linecap="round"/>
|
||||
<text x="100" y="115" text-anchor="middle" fill="hsl(${hue}, 50%, 70%)" font-family="system-ui" font-size="12" font-weight="600">${escapeXml(title.length > 14 ? title.slice(0, 12) + '…' : title)}</text>
|
||||
${host ? `<text x="100" y="132" text-anchor="middle" fill="hsl(${hue}, 30%, 50%)" font-family="system-ui" font-size="9">${escapeXml(host.length > 16 ? host.slice(0, 14) + '…' : host)}</text>` : ''}
|
||||
<rect width="200" height="200" fill="hsl(${hue}, 25%, 12%)"/>
|
||||
<rect x="2" y="2" width="196" height="196" rx="8" fill="none" stroke="hsl(${hue}, 30%, 22%)" stroke-width="2"/>
|
||||
<g fill="hsl(${hue}, 40%, 55%)" transform="translate(38 8) scale(1.5)">
|
||||
<path d="${MICROPHONE_PATH}"/>
|
||||
</g>
|
||||
</svg>`
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
}
|
||||
@@ -159,6 +185,34 @@ export function isUrlFailed(url: string | undefined): boolean {
|
||||
return !!url && failedUrls.has(url)
|
||||
}
|
||||
|
||||
/** Fetch podcast artwork from iTunes Search API (free, no key). Returns hi-res URL (600x600). */
|
||||
export async function fetchPodcastCover(
|
||||
title: string,
|
||||
host?: string,
|
||||
): Promise<string | null> {
|
||||
const key = podcastCacheKey(title, host)
|
||||
const cached = podcastCoverCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
try {
|
||||
const term = host ? `${title} ${host}` : title
|
||||
const res = await fetch(
|
||||
`https://itunes.apple.com/search?term=${encodeURIComponent(term.trim())}&media=podcast&limit=3`,
|
||||
)
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as { results?: { artworkUrl100?: string }[] }
|
||||
const first = data.results?.[0]
|
||||
const url = first?.artworkUrl100
|
||||
if (!url) return null
|
||||
const hiRes = url.replace(/100x100/g, '600x600')
|
||||
podcastCoverCache.set(key, hiRes)
|
||||
savePodcastCache()
|
||||
return hiRes
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch album artwork from iTunes Search API (free, no key). Returns hi-res URL (600x600). */
|
||||
export async function fetchMusicCover(
|
||||
title: string,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { WebSearchResult } from '@aiui/core/types/message'
|
||||
|
||||
export async function fetchRssFromUrls(urls: string[]): Promise<WebSearchResult[]> {
|
||||
const safe = urls.filter((u) => typeof u === 'string' && /^https?:\/\//i.test(u.trim())).slice(0, 8)
|
||||
if (safe.length === 0) return []
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
safe.forEach((u) => params.append('url', u))
|
||||
const res = await fetch(`/api/rss-articles?${params}`, { signal: AbortSignal.timeout(15000) })
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as { articles?: Array<{ title?: string; url?: string; content?: string; imgSrc?: string }> }
|
||||
const articles = data.articles ?? []
|
||||
return articles
|
||||
.filter((a) => a.title && a.url)
|
||||
.map((a) => ({
|
||||
title: a.title ?? '',
|
||||
url: a.url ?? '',
|
||||
content: a.content,
|
||||
imgSrc: a.imgSrc,
|
||||
}))
|
||||
} catch (err) {
|
||||
console.warn('[AIUI rss]', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface WebSearchResult {
|
||||
title: string
|
||||
url: string
|
||||
content?: string
|
||||
imgSrc?: string
|
||||
}
|
||||
|
||||
export async function searchWeb(query: string): Promise<WebSearchResult[]> {
|
||||
if (!query.trim()) return []
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query.trim() })
|
||||
const res = await fetch(`/api/web-search?${params}`, { signal: AbortSignal.timeout(10000) })
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
console.warn('[AIUI web-search]', res.status, body)
|
||||
return []
|
||||
}
|
||||
const data = (await res.json()) as { results?: Array<WebSearchResult & { imgSrc?: string }>; error?: string }
|
||||
if (data.error) {
|
||||
console.warn('[AIUI web-search]', data.error)
|
||||
return []
|
||||
}
|
||||
const results = data.results ?? []
|
||||
console.log('[AIUI web-search]', query.slice(0, 50), '→', results.length, 'results')
|
||||
return results
|
||||
} catch (err) {
|
||||
console.warn('[AIUI web-search] failed:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user