feat(app): add design system viewer, nostr feed, stop generation, and content refactor
- Design system browser with grid/detail views for tokens and components - Nostr feed tab with note/article/zap filtering and relay status - Stop generation button to abort AI streaming mid-response - Paste & extract content without sending to AI - Refactor useContentPanel into contentExtraction.ts and contentFiltering.ts - Banner fallback composable for 3-stage image loading - Wikipedia and Google Books as fallback image sources - Loading skeletons with variant-specific shapes - Mobile UX: auto-switch to content, back button, detail flow - Project grid with breadcrumb nav and inline creation - Filesystem Vite plugin for local project browsing - Magazine text cleanup and song grid polish Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e8fc54cade
commit
00bdc055ba
@@ -0,0 +1,967 @@
|
||||
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
|
||||
import type { WebSearchResult } from '@aiui/core/types/message'
|
||||
import type { MagazineSection } from './contentFiltering'
|
||||
import { mockFilms } from '@/mocks/films'
|
||||
import { mockSongs } from '@/mocks/songs'
|
||||
import { mockPodcasts } from '@/mocks/podcasts'
|
||||
import { isNewsLikeResponse, isMusicQuery, isTVQuery, isBookQuery, isBookLikeResponse, isImageQuery, isPlaceQuery, isPlaceLikeResponse } from './contentFiltering'
|
||||
|
||||
// ─── Tag regexes ──────────────────────────────────────────────────
|
||||
|
||||
export const FILM_TAG_RE = /\[\[film:(f?\d+)\]\]/gi
|
||||
export const FILM_EXT_RE = /\[\[film_ext:([^|]+)\|(\d{4})\|([^\]]+)\]\]/gi
|
||||
export const SONG_TAG_RE = /\[\[song:(s?\d+)\]\]/gi
|
||||
export const SONG_EXT_RE = /\[\[song_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
|
||||
export const PODCAST_TAG_RE = /\[\[podcast:(p?\d+)\]\]/gi
|
||||
export const PODCAST_EXT_RE = /\[\[podcast_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
|
||||
export const BOOK_TAG_RE = /\[\[book:(b?\d+)\]\]/gi
|
||||
export const BOOK_EXT_RE = /\[\[book_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
|
||||
export const TV_EXT_RE = /\[\[tv_ext:([^|]+)\|([^|]*)(?:\|(\d{4}))?\]\]/gi
|
||||
export const PLACE_EXT_RE = /\[\[place_ext:([^|]+)\|([^|]*)(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?(?:\|([^|]*))?\]\]/gi
|
||||
export const MARKDOWN_LINK_RE = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g
|
||||
const SAFE_URL_SCHEME = /^https?:\/\//i
|
||||
|
||||
// ─── Utility helpers ──────────────────────────────────────────────
|
||||
|
||||
function extractUrlFromText(text: string): string | undefined {
|
||||
const mdLink = /\[([^\]]*)\]\((https?:\/\/[^)]+)\)/.exec(text)
|
||||
const raw = mdLink ? mdLink[2] : (/(https?:\/\/[^\s)\]\"'<>]+)/.exec(text)?.[1])
|
||||
if (!raw?.trim()) return undefined
|
||||
try {
|
||||
const u = new URL(raw.trim())
|
||||
if (!/^https?:$/i.test(u.protocol)) return undefined
|
||||
return u.href
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function extractAuthorFromText(text: string): string | undefined {
|
||||
const patterns = [
|
||||
/(?:analyst|according to)\s+\*{0,2}([A-Z][^*\n]+?)\*{0,2}(?:\s+(?:is|calls?|says?|cited)|\.|,)/,
|
||||
/\bby\s+\*{0,2}([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)+)\*{0,2}/,
|
||||
/(?:source|—)\s*:?\s*\*{0,2}([A-Z][^*\n]+?)\*{0,2}(?:\s|$|\.|,)/,
|
||||
/\*\*([A-Z][^*]+)\*\*(?:\s+(?:is|calls?|says?|cited|predicts?))/,
|
||||
]
|
||||
for (const re of patterns) {
|
||||
const m = re.exec(text)
|
||||
if (m) {
|
||||
const name = m[1].trim().slice(0, 60)
|
||||
if (name.length > 3 && name.length < 50) return name
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function extractFirstImageFromText(text: string): string | undefined {
|
||||
const mdImg = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(text)
|
||||
const raw = mdImg ? mdImg[1] : (/(https?:\/\/[^\s)\]\"'<>]+\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s)\]]*)?)/i.exec(text)?.[1])
|
||||
if (!raw?.trim()) return undefined
|
||||
try {
|
||||
const u = new URL(raw.trim())
|
||||
if (!/^https?:$/i.test(u.protocol)) return undefined
|
||||
return u.href
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
line = line.replace(text.slice(matchIndex, matchIndex + matchLength), '')
|
||||
line = line.replace(/\[\[(?:film|song|podcast)(?:_ext)?:[^\]]*\]\]/g, '')
|
||||
line = line.replace(/^\s*[-*•]\s*/, '').replace(/^\s*\d+\.\s*/, '')
|
||||
line = line.replace(/\*\*[^*]+\*\*\s*[-–—:]\s*/, '').replace(/\*\*[^*]+\*\*\s*/, '')
|
||||
line = line.replace(/\*\*/g, '').replace(/\*/g, '')
|
||||
line = line.replace(/\(\d{4}\)\s*/g, '')
|
||||
line = line.replace(/^[\s\-–—:,]+/, '').replace(/[\s\-–—:,]+$/, '')
|
||||
|
||||
const result = line.trim().slice(0, 300)
|
||||
return result.length >= 10 ? result : ''
|
||||
}
|
||||
|
||||
export function normUrl(u: string): string {
|
||||
return u.toLowerCase().trim().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
// ─── Magazine extraction ──────────────────────────────────────────
|
||||
|
||||
const MAGAZINE_CONTENT_MAX = 2000
|
||||
|
||||
/** Clean markdown artifacts from magazine text */
|
||||
function cleanMagazineText(text: string): string {
|
||||
return text
|
||||
.replace(/\[([^\]]*)\]\([^)]+\)/g, '$1')
|
||||
.replace(/https?:\/\/\S+/g, '')
|
||||
.replace(/\uFE0F/g, '')
|
||||
.replace(/(?:^|(?<=\s))[\p{Emoji_Presentation}\p{Extended_Pictographic}]+\s*/gu, '') // standalone emojis only
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/\*([^*\n]+)\*/g, '$1') // *italic* → italic
|
||||
.replace(/---+/g, '')
|
||||
.replace(/^#+\s*/gm, '')
|
||||
.replace(/\|/g, ', ') // pipes → comma-space
|
||||
.replace(/,\s*,+/g, ',') // collapse multiple commas
|
||||
.replace(/(^|\n)\s*,\s*/g, '$1') // trim leading commas per line
|
||||
.replace(/\s*,\s*($|\n)/g, '$1') // trim trailing commas per line
|
||||
}
|
||||
|
||||
function addSection(
|
||||
sections: MagazineSection[],
|
||||
title: string,
|
||||
content: string,
|
||||
seen: Set<string>,
|
||||
group?: string,
|
||||
): void {
|
||||
let t = cleanMagazineText(title).replace(/\s+/g, ' ').trim().slice(0, 150)
|
||||
let c = cleanMagazineText(content).replace(/\n{3,}/g, '\n\n').trim().slice(0, MAGAZINE_CONTENT_MAX)
|
||||
if (t.length < 2 || c.length < 15) return
|
||||
const tLower = t.toLowerCase()
|
||||
const cLower = c.toLowerCase()
|
||||
if (tLower.length >= 10 && cLower.startsWith(tLower.slice(0, Math.min(tLower.length, 40)))) {
|
||||
c = c.slice(t.length).replace(/^[\s.,:;—–\-]+/, '').trim()
|
||||
if (c.length < 15) return
|
||||
}
|
||||
const key = `${t.slice(0, 50)}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
const imgMatch = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(content)?.[1]
|
||||
const imageUrl = imgMatch ? (() => {
|
||||
try {
|
||||
const u = new URL(imgMatch.trim())
|
||||
return /^https?:$/i.test(u.protocol) ? u.href : undefined
|
||||
} catch { return undefined }
|
||||
})() : undefined
|
||||
sections.push({
|
||||
title: t,
|
||||
content: c,
|
||||
url: extractUrlFromText(content),
|
||||
author: extractAuthorFromText(content),
|
||||
imageUrl,
|
||||
group,
|
||||
})
|
||||
}
|
||||
|
||||
function cleanMagazineContent(raw: string): string {
|
||||
return raw
|
||||
.replace(/\[\[(?:podcast|film|song|book|tvshow|film_ext|song_ext):[^\]]*\]\]/g, '')
|
||||
.replace(/^\s*\n---\s*\n?/g, '')
|
||||
.replace(/\n---\s*$/g, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function extractMagazineSections(text: string): MagazineSection[] {
|
||||
const sections: MagazineSection[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
let cleanedText = text
|
||||
.replace(/\n+(?:Sources|References|Links):?\s*\n[\s\S]*$/i, '')
|
||||
.replace(/\n+For deeper[^:]*:[\s\S]*$/im, '')
|
||||
|
||||
const headingPositions: { title: string; start: number; contentStart: number }[] = []
|
||||
const headingLineRe = /^#{2,3}\s+(.+)$/gm
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = headingLineRe.exec(cleanedText)) !== null) {
|
||||
const rawTitle = m[1].trim()
|
||||
const title = rawTitle
|
||||
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\uFE0F?\s*/gu, '')
|
||||
.replace(/^[#*_\s-]+/, '')
|
||||
.trim()
|
||||
if (title.length > 1) {
|
||||
headingPositions.push({ title, start: m.index, contentStart: m.index + m[0].length })
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < headingPositions.length; i++) {
|
||||
const hp = headingPositions[i]
|
||||
const nextStart = i + 1 < headingPositions.length ? headingPositions[i + 1].start : cleanedText.length
|
||||
const rawContent = cleanMagazineContent(cleanedText.slice(hp.contentStart, nextStart))
|
||||
if (rawContent.length < 20) continue
|
||||
|
||||
const bullets = rawContent
|
||||
.split(/\n\s*[-•]\s+/)
|
||||
.map(s => s.replace(/^[-•]\s*/, '').trim())
|
||||
.filter(s => s.length > 10)
|
||||
|
||||
if (bullets.length >= 2) {
|
||||
for (const bullet of bullets) {
|
||||
const cleaned = cleanMagazineContent(bullet)
|
||||
if (cleaned.length < 15) continue
|
||||
const boldMatch = /^\*\*([^*]+)\*\*\s*[:\u2014\u2013–]\s*(.+)/s.exec(cleaned)
|
||||
if (boldMatch) {
|
||||
addSection(sections, boldMatch[1].trim(), boldMatch[2].trim(), seen, hp.title)
|
||||
} else {
|
||||
const sentenceMatch = /^([^.!?]{10,80}[.!?])/.exec(cleaned)
|
||||
const bulletTitle = sentenceMatch ? sentenceMatch[1] : cleaned.slice(0, 60)
|
||||
addSection(sections, bulletTitle, cleaned, seen, hp.title)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addSection(sections, hp.title, rawContent, seen)
|
||||
}
|
||||
}
|
||||
|
||||
const campRe = /\*\*([^*]+(?:camp| side| view)[^*]*)\*\*[🟠🔴🟢]?\s*\n([\s\S]+?)(?=\n\*\*[^*]+(?:camp| side)[^*]*\*\*|\n#{2,3}\s|\n\nThis is|\n\nFor deeper|$)/gim
|
||||
while ((m = campRe.exec(cleanedText)) !== null) {
|
||||
const title = m[1].trim()
|
||||
const content = cleanMagazineContent(m[2])
|
||||
if (content.length > 15) addSection(sections, title, content, seen)
|
||||
}
|
||||
|
||||
const firstHeadingIdx = headingPositions.length > 0 ? headingPositions[0].start : -1
|
||||
const introEnd = firstHeadingIdx > 0 ? firstHeadingIdx : cleanedText.indexOf('\n---\n')
|
||||
if (introEnd > 0) {
|
||||
const intro = cleanedText.slice(0, introEnd).trim()
|
||||
.replace(/^[#*_\s-]+/gm, '').trim()
|
||||
if (intro.length > 30 && !seen.has('Summary')) {
|
||||
addSection(sections, 'Summary', intro, seen)
|
||||
}
|
||||
}
|
||||
|
||||
const closingMatch = /(This is being called[^.]+\.[^"]*"[^"]+"[^.]*\.)/i.exec(cleanedText)
|
||||
if (closingMatch && !seen.has('Key')) {
|
||||
addSection(sections, 'Key takeaway', closingMatch[1].trim(), seen)
|
||||
}
|
||||
|
||||
const order = ['Summary', 'Key takeaway']
|
||||
sections.sort((a, b) => {
|
||||
const ai = order.indexOf(a.title)
|
||||
const bi = order.indexOf(b.title)
|
||||
if (ai >= 0 && bi >= 0) return ai - bi
|
||||
if (ai >= 0) return -1
|
||||
if (bi >= 0) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
export function extractMagazineHeroImage(text: string): string | undefined {
|
||||
return extractFirstImageFromText(text)
|
||||
}
|
||||
|
||||
// ─── Links extraction ─────────────────────────────────────────────
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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()]
|
||||
}
|
||||
|
||||
// ─── Film extraction ──────────────────────────────────────────────
|
||||
|
||||
function normalizeFilmId(raw: string): string {
|
||||
return raw.startsWith('f') ? raw : `f${raw}`
|
||||
}
|
||||
|
||||
export 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 extractExternalFilms(text: string): Film[] {
|
||||
const films: Film[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
const re = new RegExp(FILM_EXT_RE.source, 'gi')
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const title = match[1].trim()
|
||||
const year = parseInt(match[2], 10)
|
||||
const director = match[3].trim()
|
||||
const key = `${title.toLowerCase()}|${year}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
films.push({
|
||||
id: `ext-${key.replace(/\W/g, '-')}`,
|
||||
title,
|
||||
year,
|
||||
posterUrl: '',
|
||||
synopsis: extractDescriptionForTag(text, match.index, match[0].length),
|
||||
genres: [],
|
||||
rating: 0,
|
||||
runtime: 0,
|
||||
director,
|
||||
cast: [],
|
||||
sources: [],
|
||||
})
|
||||
}
|
||||
return films
|
||||
}
|
||||
|
||||
export function extractAllFilms(text: string): Film[] {
|
||||
const libraryFilms = resolveFilms(extractFilmIds(text))
|
||||
const externalFilms = extractExternalFilms(text)
|
||||
return [...libraryFilms, ...externalFilms]
|
||||
}
|
||||
|
||||
// ─── Song extraction ──────────────────────────────────────────────
|
||||
|
||||
function normalizeSongId(raw: string): string {
|
||||
return raw.startsWith('s') ? raw : `s${raw}`
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
/** 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',
|
||||
'series', 'season', 'episode', 'animated', 'anime', 'netflix', 'amazon',
|
||||
'streaming', 'hbo', 'hulu', 'disney', 'showtime', 'cancelled', 'renewed',
|
||||
'viewership', 'rotten tomatoes', 'imdb',
|
||||
'published', 'author', 'edition', 'chapter', 'novel', 'nonfiction',
|
||||
'product', 'brand', 'company', 'startup', 'pricing',
|
||||
'meaning', 'definition', 'synonym', 'refers to', 'describes',
|
||||
'example', 'similar to', 'also known as', 'originates from',
|
||||
'a word', 'a term', 'conveys', 'evokes', 'suggests',
|
||||
]
|
||||
for (const phrase of bad) {
|
||||
if (t.includes(phrase) || a.includes(phrase)) return false
|
||||
}
|
||||
if (t.length > 55 || a.length > 40) return false
|
||||
if (/\b(the act of|a type of|when something|which means|referring to|something that)\b/i.test(a)) return false
|
||||
if (/^(this|these|it|that|here|there|when|where|what|how|why|if|but|and|or|the |a |an )\b/i.test(t)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function extractExternalSongs(text: string): Song[] {
|
||||
const songs: Song[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
const re = new RegExp(SONG_EXT_RE.source, 'gi')
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const title = match[1].trim()
|
||||
const artist = match[2].trim()
|
||||
if (!looksLikeSong(title, artist)) continue
|
||||
const year = match[3] ? parseInt(match[3], 10) : undefined
|
||||
const key = `${title.toLowerCase()}|${artist.toLowerCase()}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
songs.push({
|
||||
id: `ext-${key.replace(/\W/g, '-')}`,
|
||||
title,
|
||||
artist,
|
||||
year,
|
||||
coverUrl: undefined,
|
||||
sources: [],
|
||||
})
|
||||
}
|
||||
return songs
|
||||
}
|
||||
|
||||
function extractSongsFromLibraryMatch(text: string): Song[] {
|
||||
const lower = text.toLowerCase()
|
||||
const found: { song: Song; pos: number }[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const song of mockSongs) {
|
||||
const key = song.id
|
||||
if (seen.has(key)) continue
|
||||
const title = song.title.toLowerCase()
|
||||
const artist = song.artist.toLowerCase()
|
||||
if (!lower.includes(title)) continue
|
||||
const artistRe = new RegExp('\\b' + artist.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i')
|
||||
if (!artistRe.test(lower)) continue
|
||||
const titlePos = lower.indexOf(title)
|
||||
const artistMatch = lower.match(artistRe)
|
||||
const artistPos = artistMatch?.index ?? -1
|
||||
if (artistPos < 0) continue
|
||||
const dist = Math.abs(titlePos - artistPos)
|
||||
if (dist > 120) continue
|
||||
seen.add(key)
|
||||
found.push({ song, pos: Math.min(titlePos, artistPos) })
|
||||
}
|
||||
return found.sort((a, b) => a.pos - b.pos).map((f) => f.song)
|
||||
}
|
||||
|
||||
function extractSongsFromPatterns(text: string): Song[] {
|
||||
const songs: { title: string; artist: string; pos: number }[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const patterns: { re: RegExp; titleIdx: number; artistIdx: number }[] = [
|
||||
{ re: /"([^"]{2,80})"\s+by\s+([A-Za-z0-9][^,\n\.]{1,50}?)(?:\s*[,\n\.]|$)/gi, titleIdx: 1, artistIdx: 2 },
|
||||
{ re: /\*\*([^*]{2,80})\*\*\s+by\s+([A-Za-z0-9][^,\n\.]{1,50}?)(?:\s*[,\n\.]|$)/g, titleIdx: 1, artistIdx: 2 },
|
||||
{ re: /(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)?([^\n\-–—]{2,60}?)\s*[-–—]\s*([A-Za-z0-9][^,\n]{1,50}?)(?:\s*[,\n\.]|$)/gm, titleIdx: 1, artistIdx: 2 },
|
||||
{ re: /([A-Za-z0-9][^\-–—\n]{2,60}?)\s+[-–—]\s+([A-Za-z0-9][^,\n]{1,50}?)(?=\s*[,\n\.]|$)/g, titleIdx: 1, artistIdx: 2 },
|
||||
]
|
||||
|
||||
for (const { re, titleIdx, artistIdx } of patterns) {
|
||||
let m: RegExpExecArray | null
|
||||
const rx = new RegExp(re.source, re.flags)
|
||||
while ((m = rx.exec(text)) !== null) {
|
||||
const title = m[titleIdx].trim()
|
||||
const artist = m[artistIdx].trim()
|
||||
if (title.length < 2 || artist.length < 2) continue
|
||||
if (!looksLikeSong(title, artist)) continue
|
||||
if (/^\d{4}$/.test(title) || /^\d{4}$/.test(artist)) continue
|
||||
if (/\[\[(film|song)(_ext)?:/.test(title) || /\[\[(film|song)(_ext)?:/.test(artist)) continue
|
||||
if (/\*\*\[\[/.test(title) || title.includes(']]**')) continue
|
||||
const key = `${title.toLowerCase()}|${artist.toLowerCase()}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
songs.push({ title, artist, pos: m.index })
|
||||
}
|
||||
}
|
||||
|
||||
return songs
|
||||
.sort((a, b) => a.pos - b.pos)
|
||||
.map(({ title, artist }) => ({
|
||||
id: `ext-${`${title}|${artist}`.toLowerCase().replace(/\W/g, '-')}`,
|
||||
title,
|
||||
artist,
|
||||
coverUrl: undefined,
|
||||
sources: [],
|
||||
}))
|
||||
}
|
||||
|
||||
export function extractAllSongs(text: string, userQuery = ''): 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 (/\[\[tv_ext:/.test(text) || /\[\[book_ext:/.test(text)) return []
|
||||
if (isNewsLikeResponse(text)) return []
|
||||
const q = userQuery.toLowerCase()
|
||||
if (q && !isMusicQuery(q)) 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]
|
||||
}
|
||||
|
||||
// ─── Podcast extraction ───────────────────────────────────────────
|
||||
|
||||
function normalizePodcastId(raw: string): string {
|
||||
return raw.startsWith('p') ? raw : `p${raw}`
|
||||
}
|
||||
|
||||
export 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 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
|
||||
}
|
||||
|
||||
function extractExternalPodcasts(text: string): Podcast[] {
|
||||
const podcasts: Podcast[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
const re = new RegExp(PODCAST_EXT_RE.source, 'gi')
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const title = match[1].trim()
|
||||
const host = match[2].trim()
|
||||
if (!looksLikePodcast(title, host)) continue
|
||||
const year = match[3] ? parseInt(match[3], 10) : undefined
|
||||
const key = `${title.toLowerCase()}|${host.toLowerCase()}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
podcasts.push({
|
||||
id: `ext-${key.replace(/\W/g, '-')}`,
|
||||
title,
|
||||
host,
|
||||
year,
|
||||
coverUrl: undefined,
|
||||
sources: [],
|
||||
})
|
||||
}
|
||||
return podcasts
|
||||
}
|
||||
|
||||
export function extractAllPodcasts(text: string): Podcast[] {
|
||||
const libraryPodcasts = resolvePodcasts(extractPodcastIds(text))
|
||||
const externalPodcasts = extractExternalPodcasts(text)
|
||||
return [...libraryPodcasts, ...externalPodcasts]
|
||||
}
|
||||
|
||||
// ─── Book extraction ──────────────────────────────────────────────
|
||||
|
||||
function extractExternalBooks(text: string): Book[] {
|
||||
const books: Book[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
const re = new RegExp(BOOK_EXT_RE.source, 'gi')
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const title = match[1].trim()
|
||||
const author = match[2].trim()
|
||||
const year = match[3] ? parseInt(match[3], 10) : undefined
|
||||
const key = `${title.toLowerCase()}|${author.toLowerCase()}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
books.push({
|
||||
id: `ext-${key.replace(/\W/g, '-')}`,
|
||||
title,
|
||||
author,
|
||||
year,
|
||||
coverUrl: undefined,
|
||||
description: extractDescriptionForTag(text, match.index, match[0].length),
|
||||
genres: [],
|
||||
sources: [],
|
||||
})
|
||||
}
|
||||
return books
|
||||
}
|
||||
|
||||
function extractBooksFromPatterns(text: string): Book[] {
|
||||
const books: { title: string; author: string; year?: number; desc: string; pos: number }[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const patterns: { re: RegExp; titleIdx: number; authorIdx: number }[] = [
|
||||
{ re: /["""]([^"""]{2,80})["""]\s+by\s+([A-Z][^,\n\.]{1,50}?)(?:\s*[,()\n\.]|$)/gi, titleIdx: 1, authorIdx: 2 },
|
||||
{ re: /\*\*([^*]{2,80})\*\*\s+(?:by|—|–)\s+\*?([A-Z][^*\n]{1,50}?)\*?(?:\s*[,()*\n]|$)/g, titleIdx: 1, authorIdx: 2 },
|
||||
{ re: /(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n\-–—]{2,80}?)\*{0,2}\s+(?:by|—|–)\s+\*?([A-Z][^*\n]{1,50}?)\*?(?:\s*[,()*\n]|$)/gm, titleIdx: 1, authorIdx: 2 },
|
||||
]
|
||||
|
||||
for (const { re, titleIdx, authorIdx } of patterns) {
|
||||
let m: RegExpExecArray | null
|
||||
const rx = new RegExp(re.source, re.flags)
|
||||
while ((m = rx.exec(text)) !== null) {
|
||||
const title = m[titleIdx].trim().replace(/^\*\*|\*\*$/g, '').replace(/^\[|\]$/g, '')
|
||||
const author = m[authorIdx].trim().replace(/^\*\*|\*\*$/g, '')
|
||||
if (title.length < 2 || author.length < 2) continue
|
||||
if (/\[\[(film|song|podcast|book)(_ext)?:/.test(title)) continue
|
||||
if (/^\d{4}$/.test(title) || /^\d{4}$/.test(author)) continue
|
||||
const afterMatch = text.substring(m.index + m[0].length, m.index + m[0].length + 200)
|
||||
if (/^\s*\]\s*\(https?:/.test(afterMatch) || /\]\(https?:\/\//.test(m[0])) continue
|
||||
const key = `${title.toLowerCase()}|${author.toLowerCase()}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
const desc = extractDescriptionForTag(text, m.index, m[0].length)
|
||||
books.push({ title, author, desc, pos: m.index })
|
||||
}
|
||||
}
|
||||
|
||||
return books
|
||||
.sort((a, b) => a.pos - b.pos)
|
||||
.map(({ title, author, desc }) => ({
|
||||
id: `ext-${`${title}|${author}`.toLowerCase().replace(/\W/g, '-')}`,
|
||||
title,
|
||||
author,
|
||||
description: desc,
|
||||
coverUrl: undefined,
|
||||
genres: [],
|
||||
sources: [],
|
||||
}))
|
||||
}
|
||||
|
||||
export function extractAllBooks(text: string, userQuery: string): Book[] {
|
||||
const externalBooks = extractExternalBooks(text)
|
||||
if (externalBooks.length > 0) return externalBooks
|
||||
if (!isBookQuery(userQuery) && !isBookLikeResponse(text)) return []
|
||||
if (!isBookQuery(userQuery)) {
|
||||
if (extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text)) return []
|
||||
if (extractSongIds(text).length > 0 || /\[\[song_ext:/.test(text)) return []
|
||||
}
|
||||
if (isNewsLikeResponse(text)) return []
|
||||
const cleanText = text.replace(/\n---\n\s*(?:Sources|References|Links):?\s*\n[\s\S]*$/i, '')
|
||||
return extractBooksFromPatterns(cleanText)
|
||||
}
|
||||
|
||||
// ─── TV Series extraction ─────────────────────────────────────────
|
||||
|
||||
function extractExternalTVSeries(text: string): TVSeries[] {
|
||||
const series: TVSeries[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
const re = new RegExp(TV_EXT_RE.source, 'gi')
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const title = match[1].trim()
|
||||
const creator = match[2].trim()
|
||||
const year = match[3] ? parseInt(match[3], 10) : undefined
|
||||
const key = title.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
series.push({
|
||||
id: `ext-${key.replace(/\W/g, '-')}`,
|
||||
title,
|
||||
creator: creator || undefined,
|
||||
year,
|
||||
synopsis: extractDescriptionForTag(text, match.index, match[0].length),
|
||||
genres: [],
|
||||
sources: [],
|
||||
})
|
||||
}
|
||||
return series
|
||||
}
|
||||
|
||||
function isTVLikeResponse(text: string): boolean {
|
||||
return /\b(season|episodes?|showrunner|streaming|renewed|cancelled|premiere|network|HBO|Netflix|AMC|FX|Apple TV|Disney\+)\b/i.test(text) &&
|
||||
(text.match(/\bseason\b/gi)?.length ?? 0) >= 2
|
||||
}
|
||||
|
||||
function extractTVSeriesFromPatterns(text: string): TVSeries[] {
|
||||
const series: { title: string; desc: string; pos: number }[] = []
|
||||
const seen = new Set<string>()
|
||||
const patterns: RegExp[] = [
|
||||
/"([^"]{2,60})"\s*[-–—]\s*(?:a |an )?(?:series|show|tv)/gi,
|
||||
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*(?:a |an )?(?:\w+ )?(?:series|show|drama|comedy|thriller|animated)/gi,
|
||||
/(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n]{2,60}?)\*{0,2}\s*\((\d{4})(?:[-–]\d{0,4})?(?:,\s*\d+ seasons?)?\)/gm,
|
||||
]
|
||||
for (const re of patterns) {
|
||||
let m: RegExpExecArray | null
|
||||
const rx = new RegExp(re.source, re.flags)
|
||||
while ((m = rx.exec(text)) !== null) {
|
||||
const title = m[1].trim().replace(/^\*\*|\*\*$/g, '')
|
||||
if (title.length < 2) continue
|
||||
if (/\[\[(film|song|podcast|book|tv)(_ext)?:/.test(title)) continue
|
||||
const key = title.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
const desc = extractDescriptionForTag(text, m.index, m[0].length)
|
||||
series.push({ title, desc, pos: m.index })
|
||||
}
|
||||
}
|
||||
return series
|
||||
.sort((a, b) => a.pos - b.pos)
|
||||
.map(({ title, desc }) => ({
|
||||
id: `ext-${title.toLowerCase().replace(/\W/g, '-')}`,
|
||||
title,
|
||||
synopsis: desc,
|
||||
genres: [],
|
||||
sources: [],
|
||||
}))
|
||||
}
|
||||
|
||||
function convertFilmExtToTVSeries(text: string): TVSeries[] {
|
||||
const series: TVSeries[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
const re = new RegExp(FILM_EXT_RE.source, 'gi')
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const title = match[1].trim()
|
||||
const year = parseInt(match[2], 10)
|
||||
const creator = match[3].trim()
|
||||
const key = title.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
series.push({
|
||||
id: `ext-${key.replace(/\W/g, '-')}`,
|
||||
title,
|
||||
year,
|
||||
synopsis: extractDescriptionForTag(text, match.index, match[0].length),
|
||||
creator,
|
||||
genres: [],
|
||||
sources: [],
|
||||
})
|
||||
}
|
||||
return series
|
||||
}
|
||||
|
||||
export function extractAllTVSeries(text: string, userQuery: string): TVSeries[] {
|
||||
const external = extractExternalTVSeries(text)
|
||||
if (external.length > 0) return external
|
||||
if (!isTVQuery(userQuery) && !isTVLikeResponse(text)) return []
|
||||
if (isNewsLikeResponse(text)) return []
|
||||
if (isTVQuery(userQuery) && /\[\[film_ext:/.test(text)) {
|
||||
return convertFilmExtToTVSeries(text)
|
||||
}
|
||||
if (extractFilmIds(text).length > 0) return []
|
||||
return extractTVSeriesFromPatterns(text)
|
||||
}
|
||||
|
||||
// ─── Image extraction ─────────────────────────────────────────────
|
||||
|
||||
function extractImages(text: string): ImageItem[] {
|
||||
const images: ImageItem[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const mdImgRe = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/gi
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = mdImgRe.exec(text)) !== null) {
|
||||
const alt = m[1].trim()
|
||||
const url = m[2].trim()
|
||||
if (seen.has(url)) continue
|
||||
seen.add(url)
|
||||
const domain = new URL(url).hostname.replace(/^www\./, '')
|
||||
images.push({
|
||||
id: `img-${seen.size}`,
|
||||
url,
|
||||
alt: alt || undefined,
|
||||
title: alt || undefined,
|
||||
source: domain,
|
||||
})
|
||||
}
|
||||
|
||||
const urlRe = /(https?:\/\/[^\s)"'>]+\.(?:jpg|jpeg|png|gif|webp|svg|avif|bmp|tiff)(?:\?[^\s)"'>]*)?)/gi
|
||||
while ((m = urlRe.exec(text)) !== null) {
|
||||
const url = m[1].trim()
|
||||
if (seen.has(url)) continue
|
||||
seen.add(url)
|
||||
const domain = new URL(url).hostname.replace(/^www\./, '')
|
||||
images.push({
|
||||
id: `img-${seen.size}`,
|
||||
url,
|
||||
source: domain,
|
||||
})
|
||||
}
|
||||
|
||||
return images
|
||||
}
|
||||
|
||||
export function extractAllImages(text: string, userQuery: string): ImageItem[] {
|
||||
const images = extractImages(text)
|
||||
if (images.length === 0) return []
|
||||
if (isImageQuery(userQuery) || images.length >= 2) return images
|
||||
return []
|
||||
}
|
||||
|
||||
// ─── Place extraction ─────────────────────────────────────────────
|
||||
|
||||
function extractExternalPlaces(text: string): Place[] {
|
||||
const places: Place[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
const re = new RegExp(PLACE_EXT_RE.source, 'gi')
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const name = match[1].trim()
|
||||
const cuisine = match[2]?.trim() || undefined
|
||||
const city = match[3]?.trim() || undefined
|
||||
const rating = match[4] ? parseFloat(match[4]) : undefined
|
||||
const priceLevel = match[5] ? parseInt(match[5], 10) : undefined
|
||||
const address = match[6]?.trim() || undefined
|
||||
const key = name.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
places.push({
|
||||
id: `ext-place-${key.replace(/\W/g, '-')}`,
|
||||
name,
|
||||
cuisine,
|
||||
city,
|
||||
rating: rating && !isNaN(rating) ? rating : undefined,
|
||||
priceLevel: priceLevel && priceLevel >= 1 && priceLevel <= 4 ? priceLevel : undefined,
|
||||
address,
|
||||
description: extractDescriptionForTag(text, match.index, match[0].length),
|
||||
sources: [],
|
||||
})
|
||||
}
|
||||
return places
|
||||
}
|
||||
|
||||
function extractPlacesFromPatterns(text: string): Place[] {
|
||||
const places: { name: string; cuisine?: string; city?: string; rating?: number; priceLevel?: number; desc: string; pos: number }[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const patterns: RegExp[] = [
|
||||
/\*\*([^*]{2,60})\*\*\s*[-–—:]\s*(?:a |an )?(?:(\w[\w\s]{1,30}?)\s+)?(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|eatery|spot|joint)/gi,
|
||||
/(?:^|\n)\s*(?:\d+\.\s*|[-•]\s*)\*{0,2}([^*\n]{2,60}?)\*{0,2}\s*[-–—(]\s*(?:(\w[\w\s&]{1,30}?)\s+)?(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill|cuisine|food|dining)/gim,
|
||||
]
|
||||
|
||||
for (const re of patterns) {
|
||||
let m: RegExpExecArray | null
|
||||
const rx = new RegExp(re.source, re.flags)
|
||||
while ((m = rx.exec(text)) !== null) {
|
||||
const name = m[1].trim().replace(/^\*\*|\*\*$/g, '').replace(/^\[|\]$/g, '')
|
||||
if (name.length < 2) continue
|
||||
if (/\[\[(film|song|podcast|book|tv|place)(_ext)?:/.test(name)) continue
|
||||
const key = name.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
const cuisine = m[2]?.trim()
|
||||
const desc = extractDescriptionForTag(text, m.index, m[0].length)
|
||||
const nearby = text.slice(m.index, m.index + 300)
|
||||
const ratingMatch = /(\d\.?\d?)\s*(?:\/\s*5|stars?|★)/i.exec(nearby)
|
||||
const rating = ratingMatch ? parseFloat(ratingMatch[1]) : undefined
|
||||
const priceMatch = /(\${1,4})\b/.exec(nearby)
|
||||
const priceLevel = priceMatch ? priceMatch[1].length : undefined
|
||||
places.push({ name, cuisine, desc, rating, priceLevel, pos: m.index })
|
||||
}
|
||||
}
|
||||
|
||||
return places
|
||||
.sort((a, b) => a.pos - b.pos)
|
||||
.map(({ name, cuisine, desc, rating, priceLevel }) => ({
|
||||
id: `ext-place-${name.toLowerCase().replace(/\W/g, '-')}`,
|
||||
name,
|
||||
cuisine,
|
||||
rating,
|
||||
priceLevel,
|
||||
description: desc,
|
||||
sources: [],
|
||||
}))
|
||||
}
|
||||
|
||||
export function extractAllPlaces(text: string, userQuery: string): Place[] {
|
||||
const external = extractExternalPlaces(text)
|
||||
if (external.length > 0) return external
|
||||
if (!isPlaceQuery(userQuery) && !isPlaceLikeResponse(text)) return []
|
||||
if (isNewsLikeResponse(text)) return []
|
||||
return extractPlacesFromPatterns(text)
|
||||
}
|
||||
|
||||
// ─── Tag stripping ────────────────────────────────────────────────
|
||||
|
||||
export function stripFilmTags(text: string): string {
|
||||
return text
|
||||
.replace(FILM_TAG_RE, '')
|
||||
.replace(FILM_EXT_RE, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function stripSongTags(text: string): string {
|
||||
return text
|
||||
.replace(SONG_TAG_RE, '')
|
||||
.replace(SONG_EXT_RE, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function stripPodcastTags(text: string): string {
|
||||
return text
|
||||
.replace(PODCAST_TAG_RE, '')
|
||||
.replace(PODCAST_EXT_RE, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function stripBookTags(text: string): string {
|
||||
return text
|
||||
.replace(BOOK_TAG_RE, '')
|
||||
.replace(BOOK_EXT_RE, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function stripTVTags(text: string): string {
|
||||
return text
|
||||
.replace(TV_EXT_RE, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function stripPlaceTags(text: string): string {
|
||||
return text
|
||||
.replace(PLACE_EXT_RE, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function stripContentTags(text: string): string {
|
||||
return stripFilmTags(stripSongTags(stripPodcastTags(stripBookTags(stripTVTags(stripPlaceTags(text))))))
|
||||
}
|
||||
|
||||
export 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()
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'place' | 'news' | 'websites' | 'magazine' | 'code' | 'design-system' | 'nostr'
|
||||
|
||||
export interface MagazineSection {
|
||||
title: string
|
||||
content: string
|
||||
imageUrl?: string
|
||||
author?: string
|
||||
url?: string
|
||||
group?: string
|
||||
}
|
||||
|
||||
// ─── Query classifiers ───────────────────────────────────────────
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
export function isMusicQuery(q: string): boolean {
|
||||
if (!q) return false
|
||||
return /\b(song|songs|music|track|tracks|playlist|album|albums|listen|listening|sing|singing|singer|band|bands|artist|artists|rapper|rap|hip hop|r&b|rock|jazz|classical|edm|electronic|pop music|concert|vinyl|soundtrack|anthem|beat|beats|melody|melodies|tune|tunes|lyric|lyrics|acoustic|remix|dj)\b/i.test(q) ||
|
||||
/recommend.*(song|music|track|listen)/i.test(q) ||
|
||||
/play\s+(me\s+)?(some|a)\b/i.test(q)
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
export function isBookQuery(q: string): boolean {
|
||||
return /\b(book|books|read|reading|novel|novels|author|nonfiction|non-fiction|recommend.*read|must.read|literature)\b/i.test(q)
|
||||
}
|
||||
|
||||
export function isBookLikeResponse(text: string): boolean {
|
||||
return /\b(novel|author|pages?|ISBN|published|bestsell|literary|fiction|nonfiction|book)\b/i.test(text) &&
|
||||
(text.match(/\bby\s+[A-Z]/g)?.length ?? 0) >= 2
|
||||
}
|
||||
|
||||
export function isTVQuery(q: string): boolean {
|
||||
return /\b(tv show|tv series|series|television|streaming|binge|watch|recommend.*show|best show|season)\b/i.test(q)
|
||||
}
|
||||
|
||||
export function isImageQuery(q: string): boolean {
|
||||
return /\b(image|images|photo|photos|picture|pictures|screenshot|screenshots|gallery|artwork|illustration|visual|infographic|diagram|chart)\b/i.test(q)
|
||||
}
|
||||
|
||||
export function isPlaceQuery(q: string): boolean {
|
||||
return /\b(restaurant|restaurants|place|places|food|eat|eating|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse|where to eat|good food|best food|where should i eat|recommend.*eat|recommend.*restaurant|recommend.*place)\b/i.test(q)
|
||||
}
|
||||
|
||||
export function isPlaceLikeResponse(text: string): boolean {
|
||||
return /\b(restaurant|cuisine|menu|reserv|dining|address|open|hours|price range|\$\$|\$\$\$|michelin|yelp|rating)\b/i.test(text) &&
|
||||
(text.match(/\b(?:restaurant|cafe|bar|bistro|pub|pizzeria|bakery|deli|steakhouse|grill)\b/gi)?.length ?? 0) >= 2
|
||||
}
|
||||
|
||||
// ─── Tab filtering ────────────────────────────────────────────────
|
||||
|
||||
export 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 || ''
|
||||
}
|
||||
|
||||
export function preferredFirstTab(userQuery: string): ContentTab | null {
|
||||
const q = userQuery.toLowerCase().trim()
|
||||
if (/\b(film|movie|movies)\b/.test(q)) return 'film'
|
||||
if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song'
|
||||
if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
|
||||
if (/\b(book|books|read|reading|novel|author|nonfiction|non-fiction)\b/.test(q)) return 'book'
|
||||
if (/\b(tv show|tv series|series|television|streaming|binge|watch)\b/.test(q)) return 'tvshow'
|
||||
if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image'
|
||||
if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|cafes|bar|bars|pub|pubs|brunch|lunch|dinner|bistro|pizzeria|sushi|ramen|tacos|burger|bakery|deli|steakhouse)\b/.test(q)) return 'place'
|
||||
if (isNewsQuery(q)) return 'news'
|
||||
if (isWebsitesQuery(q)) return 'websites'
|
||||
return null
|
||||
}
|
||||
|
||||
export function filterTabsByContext(
|
||||
userQuery: string,
|
||||
hasFilms: boolean,
|
||||
hasSongs: boolean,
|
||||
hasPodcasts: boolean,
|
||||
hasBooks: boolean,
|
||||
hasTVSeries: boolean,
|
||||
hasImages: boolean,
|
||||
hasPlaces: boolean,
|
||||
hasNews: boolean,
|
||||
hasWebsites: boolean,
|
||||
hasMagazine: boolean,
|
||||
): ContentTab[] {
|
||||
const q = userQuery.toLowerCase().trim()
|
||||
const preferred = preferredFirstTab(userQuery)
|
||||
|
||||
if (isNewsQuery(q)) {
|
||||
const tabs: ContentTab[] = []
|
||||
if (hasMagazine) tabs.push('magazine')
|
||||
if (hasNews) tabs.push('news')
|
||||
if (hasWebsites) tabs.push('websites')
|
||||
if (hasPodcasts) tabs.push('podcast')
|
||||
return tabs
|
||||
}
|
||||
|
||||
if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasWebsites) {
|
||||
return ['magazine']
|
||||
}
|
||||
|
||||
if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasBooks && !hasTVSeries && !hasImages && !hasPlaces && !hasNews && !hasMagazine) {
|
||||
return ['websites']
|
||||
}
|
||||
|
||||
const all: ContentTab[] = []
|
||||
if (hasFilms) all.push('film')
|
||||
if (hasBooks) all.push('book')
|
||||
if (hasTVSeries) all.push('tvshow')
|
||||
if (hasImages) all.push('image')
|
||||
if (hasPlaces) all.push('place')
|
||||
if (hasSongs) all.push('song')
|
||||
if (hasPodcasts) all.push('podcast')
|
||||
if (hasMagazine) all.push('magazine')
|
||||
if (hasNews) all.push('news')
|
||||
if (hasWebsites) all.push('websites')
|
||||
|
||||
if (preferred && all.includes(preferred)) {
|
||||
const rest = all.filter((t) => t !== preferred)
|
||||
return [preferred, ...rest]
|
||||
}
|
||||
return all
|
||||
}
|
||||
@@ -114,6 +114,7 @@ interface ChatMessage {
|
||||
async function streamMock(
|
||||
messages: ChatMessage[],
|
||||
onToken: (text: string) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const lastUser = messages.filter((m) => m.role === 'user').pop()
|
||||
const text = lastUser
|
||||
@@ -121,6 +122,7 @@ async function streamMock(
|
||||
: 'Hello! I am AIUI running in mock mode.'
|
||||
|
||||
for (const char of text) {
|
||||
if (signal?.aborted) return
|
||||
onToken(char)
|
||||
await new Promise((r) => setTimeout(r, 12))
|
||||
}
|
||||
@@ -132,6 +134,7 @@ async function streamClaude(
|
||||
onError: (err: string) => void,
|
||||
systemPrompt: string,
|
||||
webSearch: boolean,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const res = await fetch(CLAUDE_PATH, {
|
||||
method: 'POST',
|
||||
@@ -143,6 +146,7 @@ async function streamClaude(
|
||||
stream: true,
|
||||
webSearch,
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -158,7 +162,7 @@ async function streamClaude(
|
||||
} else if (parsed.type === 'error') {
|
||||
onError(parsed.error?.message ?? 'Claude stream error')
|
||||
}
|
||||
}, onError)
|
||||
}, onError, signal)
|
||||
}
|
||||
|
||||
async function streamOpenRouter(
|
||||
@@ -166,6 +170,7 @@ async function streamOpenRouter(
|
||||
onToken: (text: string) => void,
|
||||
onError: (err: string) => void,
|
||||
systemPrompt: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const orMessages = [
|
||||
{ role: 'system' as const, content: systemPrompt },
|
||||
@@ -184,6 +189,7 @@ async function streamOpenRouter(
|
||||
messages: orMessages,
|
||||
stream: true,
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -197,13 +203,14 @@ async function streamOpenRouter(
|
||||
const parsed = JSON.parse(data)
|
||||
const delta = parsed.choices?.[0]?.delta?.content
|
||||
if (delta) onToken(delta)
|
||||
}, onError)
|
||||
}, onError, signal)
|
||||
}
|
||||
|
||||
async function readSSE(
|
||||
res: Response,
|
||||
onData: (data: string) => void,
|
||||
onError: (err: string) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
@@ -214,25 +221,33 @@ async function readSSE(
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
reader.cancel()
|
||||
return
|
||||
}
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
||||
const payload = trimmed.slice(6)
|
||||
if (payload === '[DONE]') return
|
||||
try {
|
||||
onData(payload)
|
||||
} catch {
|
||||
// skip malformed chunks
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
||||
const payload = trimmed.slice(6)
|
||||
if (payload === '[DONE]') return
|
||||
try {
|
||||
onData(payload)
|
||||
} catch {
|
||||
// skip malformed chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.cancel().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,11 +262,23 @@ function formatWebSearchContext(results: { title: string; url: string; content?:
|
||||
- You MAY add [[podcast_ext:...]] or [[film_ext:...]] tags for "to learn more" recommendations after your answer.\n\n${lines.join('\n')}`
|
||||
}
|
||||
|
||||
let currentAbort: AbortController | null = null
|
||||
|
||||
export function useAI() {
|
||||
const chatStore = useChatStore()
|
||||
|
||||
function stopGeneration() {
|
||||
if (currentAbort) {
|
||||
currentAbort.abort()
|
||||
currentAbort = null
|
||||
}
|
||||
chatStore.isStreaming = false
|
||||
}
|
||||
|
||||
async function sendMessage(userText: string) {
|
||||
const provider = activeProvider.value
|
||||
currentAbort = new AbortController()
|
||||
const signal = currentAbort.signal
|
||||
|
||||
let convId = chatStore.activeConversationId
|
||||
if (!convId) {
|
||||
@@ -294,23 +321,26 @@ export function useAI() {
|
||||
|
||||
try {
|
||||
if (provider === 'claude') {
|
||||
await streamClaude(history, onToken, onError, systemPrompt, chatStore.webSearchEnabled)
|
||||
await streamClaude(history, onToken, onError, systemPrompt, chatStore.webSearchEnabled, signal)
|
||||
} else if (provider === 'openrouter') {
|
||||
await streamOpenRouter(history, onToken, onError, systemPrompt)
|
||||
await streamOpenRouter(history, onToken, onError, systemPrompt, signal)
|
||||
} else {
|
||||
await streamMock(history, onToken)
|
||||
await streamMock(history, onToken, signal)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
console.error(`[AIUI] Connection error:`, err)
|
||||
chatStore.appendToLastMessage(cid, `\n\n⚠ Connection error: ${msg}`)
|
||||
} finally {
|
||||
currentAbort = null
|
||||
chatStore.isStreaming = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
activeProvider,
|
||||
activeModel,
|
||||
availableProviders,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ref, computed, type ComputedRef } from 'vue'
|
||||
|
||||
export interface BannerFallbackOptions {
|
||||
/** Primary image URL candidates, tried in order */
|
||||
primaryUrls: () => (string | undefined | null)[]
|
||||
/** Async fetch to try when all primary URLs fail */
|
||||
apiFetch: () => Promise<{ posterUrl: string | null; backdropUrl: string | null }>
|
||||
/** Title for gradient generation */
|
||||
title: () => string
|
||||
/** Optional seed override for gradient hue */
|
||||
gradientSeed?: () => string
|
||||
}
|
||||
|
||||
export interface BannerFallbackReturn {
|
||||
bannerSrc: ComputedRef<string | null>
|
||||
fallbackGradient: ComputedRef<string>
|
||||
onBannerError: () => void
|
||||
}
|
||||
|
||||
export function useBannerFallback(options: BannerFallbackOptions): BannerFallbackReturn {
|
||||
const primaryIndex = ref(0)
|
||||
const stage = ref<'primary' | 'api' | 'done'>('primary')
|
||||
const apiUrl = ref<string | null>(null)
|
||||
let apiFetching = false
|
||||
|
||||
const bannerSrc = computed<string | null>(() => {
|
||||
if (stage.value === 'done') return null
|
||||
|
||||
if (stage.value === 'primary') {
|
||||
const urls = options.primaryUrls()
|
||||
// Find first non-null URL starting from primaryIndex
|
||||
for (let i = primaryIndex.value; i < urls.length; i++) {
|
||||
if (urls[i]) return urls[i]!
|
||||
}
|
||||
// No primary URLs available — skip to API immediately
|
||||
return null
|
||||
}
|
||||
|
||||
if (stage.value === 'api' && apiUrl.value) return apiUrl.value
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const fallbackGradient = computed(() => {
|
||||
const seed = options.gradientSeed ? options.gradientSeed() : options.title()
|
||||
const hue = [...seed].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
|
||||
return `linear-gradient(135deg, hsl(${hue}, 25%, 12%) 0%, hsl(${(hue + 40) % 360}, 20%, 8%) 100%)`
|
||||
})
|
||||
|
||||
async function onBannerError() {
|
||||
if (stage.value === 'primary') {
|
||||
const urls = options.primaryUrls()
|
||||
// Advance to next primary URL
|
||||
let nextIdx = primaryIndex.value + 1
|
||||
while (nextIdx < urls.length && !urls[nextIdx]) nextIdx++
|
||||
|
||||
if (nextIdx < urls.length) {
|
||||
primaryIndex.value = nextIdx
|
||||
return
|
||||
}
|
||||
|
||||
// All primaries exhausted — try API
|
||||
if (!apiFetching) {
|
||||
apiFetching = true
|
||||
try {
|
||||
const result = await options.apiFetch()
|
||||
const url = result.backdropUrl ?? result.posterUrl
|
||||
if (url) {
|
||||
apiUrl.value = url
|
||||
stage.value = 'api'
|
||||
return
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
stage.value = 'done'
|
||||
return
|
||||
}
|
||||
|
||||
if (stage.value === 'api') {
|
||||
stage.value = 'done'
|
||||
}
|
||||
}
|
||||
|
||||
// If no primary URLs at all, trigger API fetch on first render
|
||||
const urls = options.primaryUrls()
|
||||
const hasAnyPrimary = urls.some(u => !!u)
|
||||
if (!hasAnyPrimary && !apiFetching) {
|
||||
apiFetching = true
|
||||
options.apiFetch().then(result => {
|
||||
const url = result.backdropUrl ?? result.posterUrl
|
||||
if (url) {
|
||||
apiUrl.value = url
|
||||
stage.value = 'api'
|
||||
} else {
|
||||
stage.value = 'done'
|
||||
}
|
||||
}).catch(() => {
|
||||
stage.value = 'done'
|
||||
})
|
||||
}
|
||||
|
||||
return { bannerSrc, fallbackGradient, onBannerError }
|
||||
}
|
||||
@@ -183,6 +183,43 @@ export function useCodeContext() {
|
||||
return `// ${name}\n`
|
||||
}
|
||||
|
||||
async function createProject(name: string): Promise<void> {
|
||||
const safeName = name.trim().replace(/[^a-zA-Z0-9_\-. ]/g, '')
|
||||
if (!safeName) return
|
||||
|
||||
const projectPath = `${PROJECTS_ROOT}/${safeName}`
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/fs/mkdir', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: projectPath }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
console.warn('[AIUI code] Failed to create directory:', data.error ?? res.status)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[AIUI code] Could not create directory:', err)
|
||||
}
|
||||
|
||||
const newProject: ProjectInfo = {
|
||||
name: safeName,
|
||||
path: projectPath,
|
||||
isGit: false,
|
||||
language: 'Unknown',
|
||||
}
|
||||
|
||||
projectList.value = [newProject, ...projectList.value]
|
||||
selectProject(newProject)
|
||||
}
|
||||
|
||||
function clearActiveFile(): void {
|
||||
activeFile.value = null
|
||||
activeFileContent.value = ''
|
||||
activeFileLanguage.value = 'plaintext'
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
codeMode,
|
||||
@@ -202,5 +239,7 @@ export function useCodeContext() {
|
||||
openFile,
|
||||
loadProjects,
|
||||
detectLanguage,
|
||||
createProject,
|
||||
clearActiveFile,
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -207,6 +207,15 @@ export async function handleImgError(
|
||||
}
|
||||
}
|
||||
|
||||
if (img.dataset.fallback !== 'wiki') {
|
||||
const wiki = await fetchWikipediaImage(title, 'film')
|
||||
if (wiki) {
|
||||
img.dataset.fallback = 'wiki'
|
||||
img.src = wiki
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
img.dataset.fallback = 'done'
|
||||
img.src = generatePosterFallback(title, year)
|
||||
}
|
||||
@@ -364,3 +373,132 @@ export async function fetchMusicCover(
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wikipedia image source (free, no key)
|
||||
// ---------------------------------------------------------------------------
|
||||
const wikiImageCache = new Map<string, string | null>()
|
||||
|
||||
/** Fetch an image from Wikipedia REST API. Free, no API key needed. */
|
||||
export async function fetchWikipediaImage(
|
||||
title: string,
|
||||
disambiguator?: string,
|
||||
): Promise<string | null> {
|
||||
const key = `${title.toLowerCase().trim()}|${(disambiguator ?? '').toLowerCase()}`
|
||||
if (wikiImageCache.has(key)) return wikiImageCache.get(key) ?? null
|
||||
|
||||
const tryTitle = async (t: string): Promise<string | null> => {
|
||||
try {
|
||||
const encoded = encodeURIComponent(t.trim().replace(/\s+/g, '_'))
|
||||
const res = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encoded}`)
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as {
|
||||
thumbnail?: { source?: string }
|
||||
originalimage?: { source?: string }
|
||||
}
|
||||
return data.originalimage?.source ?? data.thumbnail?.source ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Try exact title first
|
||||
let url = await tryTitle(title)
|
||||
|
||||
// Try with disambiguator suffix if no result
|
||||
if (!url && disambiguator) {
|
||||
url = await tryTitle(`${title} (${disambiguator})`)
|
||||
}
|
||||
|
||||
wikiImageCache.set(key, url)
|
||||
return url
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Google Books image source (free, no key)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fetch book cover from Google Books API. Free, no API key needed. */
|
||||
export async function fetchGoogleBooksImage(
|
||||
title: string,
|
||||
author?: string,
|
||||
): Promise<string | null> {
|
||||
const key = bookCacheKey(title, author ?? '')
|
||||
const gKey = `gbooks:${key}`
|
||||
if (wikiImageCache.has(gKey)) return wikiImageCache.get(gKey) ?? null
|
||||
|
||||
try {
|
||||
const q = author ? `intitle:${title}+inauthor:${author}` : `intitle:${title}`
|
||||
const res = await fetch(
|
||||
`https://www.googleapis.com/books/v1/volumes?q=${encodeURIComponent(q)}&maxResults=1`,
|
||||
)
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as {
|
||||
items?: { volumeInfo?: { imageLinks?: { thumbnail?: string; smallThumbnail?: string } } }[]
|
||||
}
|
||||
const links = data.items?.[0]?.volumeInfo?.imageLinks
|
||||
let url = links?.thumbnail ?? links?.smallThumbnail ?? null
|
||||
// Google Books returns http URLs and small sizes — upgrade
|
||||
if (url) {
|
||||
url = url.replace(/^http:/, 'https:').replace(/&edge=curl/g, '')
|
||||
// Request larger zoom
|
||||
if (!url.includes('zoom=')) url += '&zoom=2'
|
||||
}
|
||||
wikiImageCache.set(gKey, url)
|
||||
return url
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chained fetchers — try multiple sources in order
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Film image: TMDB → Wikipedia */
|
||||
export async function fetchFilmImage(
|
||||
title: string,
|
||||
year?: number,
|
||||
): Promise<{ posterUrl: string | null; backdropUrl: string | null }> {
|
||||
// Try TMDB first
|
||||
const tmdb = await fetchTmdbPoster(title, year)
|
||||
if (tmdb.posterUrl || tmdb.backdropUrl) return tmdb
|
||||
|
||||
// Fall back to Wikipedia
|
||||
const wiki = await fetchWikipediaImage(title, 'film')
|
||||
if (wiki) return { posterUrl: wiki, backdropUrl: null }
|
||||
|
||||
return { posterUrl: null, backdropUrl: null }
|
||||
}
|
||||
|
||||
/** TV series image: TMDB → Wikipedia */
|
||||
export async function fetchTVImage(
|
||||
title: string,
|
||||
year?: number,
|
||||
): Promise<{ posterUrl: string | null; backdropUrl: string | null }> {
|
||||
const tmdb = await fetchTmdbTVPoster(title, year)
|
||||
if (tmdb.posterUrl || tmdb.backdropUrl) return tmdb
|
||||
|
||||
const wiki = await fetchWikipediaImage(title, 'TV series')
|
||||
if (wiki) return { posterUrl: wiki, backdropUrl: null }
|
||||
|
||||
return { posterUrl: null, backdropUrl: null }
|
||||
}
|
||||
|
||||
/** Book image: Open Library → Google Books → Wikipedia */
|
||||
export async function fetchBookImage(
|
||||
title: string,
|
||||
author?: string,
|
||||
): Promise<string | null> {
|
||||
// Try Open Library first
|
||||
const ol = await fetchBookCover(title, author ?? '')
|
||||
if (ol) return ol
|
||||
|
||||
// Try Google Books
|
||||
const gb = await fetchGoogleBooksImage(title, author)
|
||||
if (gb) return gb
|
||||
|
||||
// Try Wikipedia
|
||||
const wiki = await fetchWikipediaImage(title, 'novel')
|
||||
return wiki
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user