feat(chat): integrate podcast support into chat components
- Updated ChatMessage and ChatWindow components to handle podcast content alongside films and songs. - Enhanced useContentPanel to extract and manage podcasts, allowing for richer media interactions. - Added PodcastCard and PodcastGrid components for displaying podcast information. - Improved UI elements to accommodate podcast selection and detail viewing. - Updated styles for empty state icons and added new CSS for podcast-related elements. Made-with: Cursor
This commit is contained in:
@@ -8,6 +8,7 @@ const OPENROUTER_PATH = '/api/openrouter/api/v1/chat/completions'
|
||||
|
||||
import { mockFilms } from '@/mocks/films'
|
||||
import { mockSongs } from '@/mocks/songs'
|
||||
import { mockPodcasts } from '@/mocks/podcasts'
|
||||
|
||||
const filmContext = mockFilms.map((f) =>
|
||||
`- [${f.id}] "${f.title}" (${f.year}) dir. ${f.director} | ${f.genres.join(', ')} | ${f.rating}/10 | On: ${f.sources.map(s => s.type).join(', ')}`
|
||||
@@ -17,7 +18,11 @@ const songContext = mockSongs.map((s) =>
|
||||
`- [${s.id}] "${s.title}" by ${s.artist}${s.album ? ` (${s.album})` : ''}${s.year ? ` (${s.year})` : ''} | ${(s.genres ?? []).join(', ')} | On: ${(s.sources ?? []).map(x => x.type).join(', ')}`
|
||||
).join('\n')
|
||||
|
||||
const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the user's media library (films and songs).
|
||||
const podcastContext = mockPodcasts.map((p) =>
|
||||
`- [${p.id}] "${p.title}" by ${p.host ?? 'Unknown'}${p.year ? ` (${p.year})` : ''} | ${(p.genres ?? []).join(', ')} | On: ${p.sources.map(x => x.type).join(', ')}`
|
||||
).join('\n')
|
||||
|
||||
const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the user's media library (films, songs, and podcasts).
|
||||
|
||||
**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]].
|
||||
|
||||
@@ -26,6 +31,11 @@ const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the u
|
||||
- Other songs: [[song_ext:Title|Artist|Year]] (year optional), e.g. [[song_ext:Never Meant|American Football|1999]].
|
||||
Never list songs in plain text only—each recommendation must have a tag so the UI can show playable cards.
|
||||
|
||||
**Podcasts:** When recommending or discussing podcasts, use tags:
|
||||
- Library podcasts: [[podcast:ID]] where ID is the podcast's id (e.g. [[podcast:p1]]).
|
||||
- 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.
|
||||
|
||||
**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.
|
||||
@@ -34,7 +44,10 @@ The user's film library:
|
||||
${filmContext}
|
||||
|
||||
The user's song library:
|
||||
${songContext}`
|
||||
${songContext}
|
||||
|
||||
The user's podcast library:
|
||||
${podcastContext}`
|
||||
|
||||
const openrouterApiKey = import.meta.env.VITE_OPENROUTER_API_KEY ?? ''
|
||||
const hasOpenRouter = !!openrouterApiKey
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Film, Song } from '@aiui/core/types/content'
|
||||
import type { Film, Song, Podcast } from '@aiui/core/types/content'
|
||||
import { mockFilms } from '@/mocks/films'
|
||||
import { mockSongs } from '@/mocks/songs'
|
||||
import { mockPodcasts } from '@/mocks/podcasts'
|
||||
import { generatePosterFallback } from '@/composables/useImageFallback'
|
||||
|
||||
const panelOpen = ref(false)
|
||||
const panelFilms = ref<Film[]>([])
|
||||
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 panelTitle = ref('Recommended Films')
|
||||
const contentType = ref<'film' | 'song'>('film')
|
||||
const contentType = ref<'film' | 'song' | 'podcast'>('film')
|
||||
|
||||
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
|
||||
const PODCAST_TAG_RE = /\[\[podcast:(p?\d+)\]\]/gi
|
||||
const PODCAST_EXT_RE = /\[\[podcast_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
|
||||
|
||||
export function useContentPanel() {
|
||||
function normalizeFilmId(raw: string): string {
|
||||
@@ -198,14 +203,67 @@ export function useContentPanel() {
|
||||
return [...libMatches, ...fromPatterns]
|
||||
}
|
||||
|
||||
function normalizePodcastId(raw: string): string {
|
||||
return raw.startsWith('p') ? raw : `p${raw}`
|
||||
}
|
||||
|
||||
function extractPodcastIds(text: string): string[] {
|
||||
const ids: string[] = []
|
||||
let match: RegExpExecArray | null
|
||||
const re = new RegExp(PODCAST_TAG_RE.source, 'gi')
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const id = normalizePodcastId(match[1])
|
||||
if (!ids.includes(id)) ids.push(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function resolvePodcasts(ids: string[]): Podcast[] {
|
||||
return ids
|
||||
.map((id) => mockPodcasts.find((p) => p.id === id))
|
||||
.filter((p): p is Podcast => !!p)
|
||||
}
|
||||
|
||||
function extractExternalPodcasts(text: string): Podcast[] {
|
||||
const podcasts: Podcast[] = []
|
||||
const seen = new Set<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()
|
||||
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,
|
||||
sources: [],
|
||||
})
|
||||
}
|
||||
return podcasts
|
||||
}
|
||||
|
||||
function extractAllPodcasts(text: string): Podcast[] {
|
||||
const libraryPodcasts = resolvePodcasts(extractPodcastIds(text))
|
||||
const externalPodcasts = extractExternalPodcasts(text)
|
||||
return [...libraryPodcasts, ...externalPodcasts]
|
||||
}
|
||||
|
||||
function updatePanelFromText(text: string) {
|
||||
const songs = extractAllSongs(text)
|
||||
const films = extractAllFilms(text)
|
||||
const podcasts = extractAllPodcasts(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
|
||||
@@ -214,12 +272,25 @@ export function useContentPanel() {
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,13 +310,22 @@ export function useContentPanel() {
|
||||
.trim()
|
||||
}
|
||||
|
||||
function stripPodcastTags(text: string): string {
|
||||
return text
|
||||
.replace(PODCAST_TAG_RE, '')
|
||||
.replace(PODCAST_EXT_RE, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function stripContentTags(text: string): string {
|
||||
return stripFilmTags(stripSongTags(text))
|
||||
return stripFilmTags(stripSongTags(stripPodcastTags(text)))
|
||||
}
|
||||
|
||||
function openFilmDetail(film: Film) {
|
||||
selectedFilm.value = film
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
}
|
||||
|
||||
function closeFilmDetail() {
|
||||
@@ -255,44 +335,74 @@ export function useContentPanel() {
|
||||
function openSongDetail(song: Song) {
|
||||
selectedSong.value = song
|
||||
selectedFilm.value = null
|
||||
selectedPodcast.value = null
|
||||
}
|
||||
|
||||
function closeSongDetail() {
|
||||
selectedSong.value = null
|
||||
}
|
||||
|
||||
function openPodcastDetail(podcast: Podcast) {
|
||||
selectedPodcast.value = podcast
|
||||
selectedFilm.value = null
|
||||
selectedSong.value = null
|
||||
}
|
||||
|
||||
function closePodcastDetail() {
|
||||
selectedPodcast.value = null
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelOpen.value = false
|
||||
selectedFilm.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
}
|
||||
|
||||
function showAllFilms() {
|
||||
panelFilms.value = [...mockFilms]
|
||||
panelSongs.value = []
|
||||
panelPodcasts.value = []
|
||||
panelTitle.value = 'Your Film Library'
|
||||
contentType.value = 'film'
|
||||
panelOpen.value = true
|
||||
selectedFilm.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
}
|
||||
|
||||
function showAllSongs() {
|
||||
panelFilms.value = []
|
||||
panelSongs.value = [...mockSongs]
|
||||
panelPodcasts.value = []
|
||||
panelTitle.value = 'Your Song Library'
|
||||
contentType.value = 'song'
|
||||
panelOpen.value = true
|
||||
selectedFilm.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
}
|
||||
|
||||
function showAllPodcasts() {
|
||||
panelFilms.value = []
|
||||
panelSongs.value = []
|
||||
panelPodcasts.value = [...mockPodcasts]
|
||||
panelTitle.value = 'Your Podcast Library'
|
||||
contentType.value = 'podcast'
|
||||
panelOpen.value = true
|
||||
selectedFilm.value = null
|
||||
selectedSong.value = null
|
||||
selectedPodcast.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
panelOpen,
|
||||
panelFilms,
|
||||
panelSongs,
|
||||
panelPodcasts,
|
||||
selectedFilm,
|
||||
selectedSong,
|
||||
selectedPodcast,
|
||||
panelTitle,
|
||||
contentType,
|
||||
extractFilmIds,
|
||||
@@ -301,16 +411,23 @@ export function useContentPanel() {
|
||||
extractSongIds,
|
||||
resolveSongs,
|
||||
extractAllSongs,
|
||||
extractPodcastIds,
|
||||
resolvePodcasts,
|
||||
extractAllPodcasts,
|
||||
updatePanelFromText,
|
||||
stripFilmTags,
|
||||
stripSongTags,
|
||||
stripPodcastTags,
|
||||
stripContentTags,
|
||||
openFilmDetail,
|
||||
closeFilmDetail,
|
||||
openSongDetail,
|
||||
closeSongDetail,
|
||||
openPodcastDetail,
|
||||
closePodcastDetail,
|
||||
closePanel,
|
||||
showAllFilms,
|
||||
showAllSongs,
|
||||
showAllPodcasts,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,19 @@ 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
|
||||
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>` : ''}
|
||||
</svg>`
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
export function generateSongCoverFallback(title: string, artist?: string): string {
|
||||
const hue = [...(title + (artist ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
|
||||
Reference in New Issue
Block a user