feat(chat): enhance chat functionality with song integration and UI improvements
- Updated ChatHeader and ChatMessage components to support song selection and display. - Enhanced useContentPanel to handle both film and song content, allowing for richer interactions. - Improved FilmCard and added SongCard components for better media representation. - Refactored environment variables for better configuration management. - Updated .gitignore to include development chat history. Made-with: Cursor
This commit is contained in:
@@ -7,19 +7,29 @@ const CLAUDE_PATH = '/api/claude/v1/messages'
|
||||
const OPENROUTER_PATH = '/api/openrouter/api/v1/chat/completions'
|
||||
|
||||
import { mockFilms } from '@/mocks/films'
|
||||
import { mockSongs } from '@/mocks/songs'
|
||||
|
||||
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(', ')}`
|
||||
).join('\n')
|
||||
|
||||
const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the user's media library.
|
||||
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')
|
||||
|
||||
When recommending or discussing films, reference films from the user's library using the tag format [[film:ID]] where ID is the film's id from their collection. Always include these tags so the UI can render rich film cards. You may recommend multiple films. Write a brief reason why each film is worth watching alongside its tag.
|
||||
const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the user's media library (films and songs).
|
||||
|
||||
**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 from the user's library, use [[song:ID]] where ID is the song's id. For songs NOT in the library, use [[song_ext:Title|Artist|Year]] (year optional), e.g. [[song_ext:Never Meant|American Football|1999]].
|
||||
|
||||
Always include these tags so the UI can render rich cards. You may recommend multiple items. Write a brief reason why each is worth checking out.
|
||||
|
||||
The user's film library:
|
||||
${filmContext}
|
||||
|
||||
If a user asks about a film NOT in their library, discuss it normally but note it's not in their collection.`
|
||||
The user's song library:
|
||||
${songContext}`
|
||||
|
||||
const openrouterApiKey = import.meta.env.VITE_OPENROUTER_API_KEY ?? ''
|
||||
const hasOpenRouter = !!openrouterApiKey
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Film } from '@aiui/core/types/content'
|
||||
import { ref } from 'vue'
|
||||
import type { Film, Song } from '@aiui/core/types/content'
|
||||
import { mockFilms } from '@/mocks/films'
|
||||
import { mockSongs } from '@/mocks/songs'
|
||||
import { generatePosterFallback } from '@/composables/useImageFallback'
|
||||
|
||||
const panelOpen = ref(false)
|
||||
const panelFilms = ref<Film[]>([])
|
||||
const panelSongs = ref<Song[]>([])
|
||||
const selectedFilm = ref<Film | null>(null)
|
||||
const selectedSong = ref<Song | null>(null)
|
||||
const panelTitle = ref('Recommended Films')
|
||||
const contentType = ref<'film' | 'song'>('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
|
||||
|
||||
export function useContentPanel() {
|
||||
function normalizeFilmId(raw: string): string {
|
||||
@@ -31,56 +39,223 @@ export function useContentPanel() {
|
||||
.filter((f): f is Film => !!f)
|
||||
}
|
||||
|
||||
function updatePanelFromText(text: string) {
|
||||
const ids = extractFilmIds(text)
|
||||
if (ids.length > 0) {
|
||||
const films = resolveFilms(ids)
|
||||
if (films.length > 0) {
|
||||
panelFilms.value = films
|
||||
panelOpen.value = true
|
||||
panelTitle.value = films.length === 1
|
||||
? films[0].title
|
||||
: `${films.length} Recommended Films`
|
||||
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: generatePosterFallback(title, year),
|
||||
synopsis: '',
|
||||
genres: [],
|
||||
rating: 0,
|
||||
runtime: 0,
|
||||
director,
|
||||
cast: [],
|
||||
sources: [],
|
||||
})
|
||||
}
|
||||
return films
|
||||
}
|
||||
|
||||
function extractAllFilms(text: string): Film[] {
|
||||
const libraryFilms = resolveFilms(extractFilmIds(text))
|
||||
const externalFilms = extractExternalFilms(text)
|
||||
return [...libraryFilms, ...externalFilms]
|
||||
}
|
||||
|
||||
function normalizeSongId(raw: string): string {
|
||||
return raw.startsWith('s') ? raw : `s${raw}`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
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,
|
||||
sources: [],
|
||||
})
|
||||
}
|
||||
return songs
|
||||
}
|
||||
|
||||
/** Infer songs from plain text when no tags present (e.g. old chats) */
|
||||
function extractSongsFromPlainText(text: string): Song[] {
|
||||
const lower = text.toLowerCase()
|
||||
const found: Song[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const song of mockSongs) {
|
||||
const key = song.id
|
||||
if (seen.has(key)) continue
|
||||
if (lower.includes(song.title.toLowerCase()) && lower.includes(song.artist.toLowerCase())) {
|
||||
seen.add(key)
|
||||
found.push(song)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function extractAllSongs(text: string): Song[] {
|
||||
const librarySongs = resolveSongs(extractSongIds(text))
|
||||
const externalSongs = extractExternalSongs(text)
|
||||
if (librarySongs.length > 0 || externalSongs.length > 0) {
|
||||
return [...librarySongs, ...externalSongs]
|
||||
}
|
||||
return extractSongsFromPlainText(text)
|
||||
}
|
||||
|
||||
function updatePanelFromText(text: string) {
|
||||
const songs = extractAllSongs(text)
|
||||
const films = extractAllFilms(text)
|
||||
|
||||
if (songs.length > 0) {
|
||||
panelSongs.value = songs
|
||||
panelFilms.value = []
|
||||
selectedFilm.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 = []
|
||||
selectedSong.value = null
|
||||
contentType.value = 'film'
|
||||
panelTitle.value = films.length === 1
|
||||
? films[0].title
|
||||
: `${films.length} Recommended Films`
|
||||
panelOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function stripFilmTags(text: string): string {
|
||||
return text.replace(FILM_TAG_RE, '').replace(/\n{3,}/g, '\n\n').trim()
|
||||
return text
|
||||
.replace(FILM_TAG_RE, '')
|
||||
.replace(FILM_EXT_RE, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function stripSongTags(text: string): string {
|
||||
return text
|
||||
.replace(SONG_TAG_RE, '')
|
||||
.replace(SONG_EXT_RE, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function stripContentTags(text: string): string {
|
||||
return stripFilmTags(stripSongTags(text))
|
||||
}
|
||||
|
||||
function openFilmDetail(film: Film) {
|
||||
selectedFilm.value = film
|
||||
selectedSong.value = null
|
||||
}
|
||||
|
||||
function closeFilmDetail() {
|
||||
selectedFilm.value = null
|
||||
}
|
||||
|
||||
function openSongDetail(song: Song) {
|
||||
selectedSong.value = song
|
||||
selectedFilm.value = null
|
||||
}
|
||||
|
||||
function closeSongDetail() {
|
||||
selectedSong.value = null
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelOpen.value = false
|
||||
selectedFilm.value = null
|
||||
selectedSong.value = null
|
||||
}
|
||||
|
||||
function showAllFilms() {
|
||||
panelFilms.value = [...mockFilms]
|
||||
panelSongs.value = []
|
||||
panelTitle.value = 'Your Film Library'
|
||||
contentType.value = 'film'
|
||||
panelOpen.value = true
|
||||
selectedFilm.value = null
|
||||
selectedSong.value = null
|
||||
}
|
||||
|
||||
function showAllSongs() {
|
||||
panelFilms.value = []
|
||||
panelSongs.value = [...mockSongs]
|
||||
panelTitle.value = 'Your Song Library'
|
||||
contentType.value = 'song'
|
||||
panelOpen.value = true
|
||||
selectedFilm.value = null
|
||||
selectedSong.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
panelOpen,
|
||||
panelFilms,
|
||||
panelSongs,
|
||||
selectedFilm,
|
||||
selectedSong,
|
||||
panelTitle,
|
||||
contentType,
|
||||
extractFilmIds,
|
||||
resolveFilms,
|
||||
extractAllFilms,
|
||||
extractSongIds,
|
||||
resolveSongs,
|
||||
extractAllSongs,
|
||||
updatePanelFromText,
|
||||
stripFilmTags,
|
||||
stripSongTags,
|
||||
stripContentTags,
|
||||
openFilmDetail,
|
||||
closeFilmDetail,
|
||||
openSongDetail,
|
||||
closeSongDetail,
|
||||
closePanel,
|
||||
showAllFilms,
|
||||
showAllSongs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,69 @@
|
||||
type TmdbResult = { posterUrl: string | null; backdropUrl: string | null }
|
||||
const memoryCache = new Map<string, TmdbResult>()
|
||||
const SESSION_KEY = 'aiui-poster-cache'
|
||||
const failedUrls = new Set<string>()
|
||||
|
||||
const musicCoverCache = new Map<string, string>()
|
||||
const SESSION_MUSIC_KEY = 'aiui-music-cover-cache'
|
||||
|
||||
function musicCacheKey(artist: string, title: string): string {
|
||||
return `${artist.toLowerCase().trim()}|${title.toLowerCase().trim()}`
|
||||
}
|
||||
|
||||
function loadMusicCache(): void {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_MUSIC_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Record<string, string>
|
||||
Object.entries(parsed).forEach(([k, v]) => musicCoverCache.set(k, v))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function saveMusicCache(): void {
|
||||
try {
|
||||
const entries = [...musicCoverCache.entries()].slice(-300)
|
||||
sessionStorage.setItem(SESSION_MUSIC_KEY, JSON.stringify(Object.fromEntries(entries)))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
loadMusicCache()
|
||||
|
||||
function cacheKey(t: string, y?: number): string {
|
||||
return `${t.toLowerCase().trim()}|${y ?? ''}`
|
||||
}
|
||||
|
||||
function loadSessionCache(): void {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Record<string, TmdbResult>
|
||||
Object.entries(parsed).forEach(([k, v]) => memoryCache.set(k, v))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function saveSessionCache(): void {
|
||||
try {
|
||||
const entries = [...memoryCache.entries()].slice(-200)
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify(Object.fromEntries(entries)))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
loadSessionCache()
|
||||
|
||||
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">
|
||||
<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"/>
|
||||
<path d="M100 50 v100 M70 70 q30-20 60 0 M70 130 q30 20 60 0" fill="none" stroke="hsl(${hue}, 50%, 60%)" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="100" y="115" text-anchor="middle" fill="hsl(${hue}, 50%, 70%)" font-family="system-ui" font-size="14" font-weight="600">${escapeXml(title.length > 12 ? title.slice(0, 10) + '…' : title)}</text>
|
||||
${artist ? `<text x="100" y="135" text-anchor="middle" fill="hsl(${hue}, 30%, 50%)" font-family="system-ui" font-size="10">${escapeXml(artist.length > 14 ? artist.slice(0, 12) + '…' : artist)}</text>` : ''}
|
||||
</svg>`
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
export function generatePosterFallback(title: string, year?: number): string {
|
||||
const hue = [...title].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 342 513">
|
||||
@@ -7,7 +73,6 @@ export function generatePosterFallback(title: string, year?: number): string {
|
||||
${escapeXml(title.length > 20 ? title.slice(0, 18) + '…' : title)}
|
||||
</text>
|
||||
${year ? `<text x="171" y="260" text-anchor="middle" fill="hsl(${hue}, 30%, 50%)" font-family="system-ui" font-size="14">${year}</text>` : ''}
|
||||
<text x="171" y="290" text-anchor="middle" fill="hsl(${hue}, 20%, 35%)" font-size="40">🎬</text>
|
||||
</svg>`
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
}
|
||||
@@ -16,10 +81,96 @@ function escapeXml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export function handleImgError(e: Event, title: string, year?: number) {
|
||||
export async function fetchTmdbPoster(
|
||||
title: string,
|
||||
year?: number,
|
||||
): Promise<TmdbResult> {
|
||||
const key = cacheKey(title, year)
|
||||
const cached = memoryCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const empty: TmdbResult = { posterUrl: null, backdropUrl: null }
|
||||
try {
|
||||
const params = new URLSearchParams({ q: title.trim() })
|
||||
if (year && year > 0) params.set('y', String(year))
|
||||
const res = await fetch(`/api/tmdb/search?${params}`)
|
||||
if (!res.ok) return empty
|
||||
const data = (await res.json()) as { posterUrl?: string | null; backdropUrl?: string | null }
|
||||
const result: TmdbResult = {
|
||||
posterUrl: data.posterUrl ?? null,
|
||||
backdropUrl: data.backdropUrl ?? null,
|
||||
}
|
||||
if (result.posterUrl || result.backdropUrl) {
|
||||
memoryCache.set(key, result)
|
||||
saveSessionCache()
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return empty
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleImgError(
|
||||
e: Event,
|
||||
title: string,
|
||||
year?: number,
|
||||
): Promise<void> {
|
||||
const img = e.target as HTMLImageElement
|
||||
if (!img.dataset.fallback) {
|
||||
img.dataset.fallback = '1'
|
||||
img.src = generatePosterFallback(title, year)
|
||||
if (img.dataset.fallback === 'done') return
|
||||
|
||||
const originalSrc = img.src
|
||||
failedUrls.add(originalSrc)
|
||||
|
||||
const key = cacheKey(title, year)
|
||||
const cached = memoryCache.get(key)
|
||||
if (cached?.posterUrl && cached.posterUrl !== originalSrc) {
|
||||
img.dataset.fallback = 'tmdb'
|
||||
img.src = cached.posterUrl
|
||||
return
|
||||
}
|
||||
|
||||
if (img.dataset.fallback !== 'tmdb') {
|
||||
const { posterUrl } = await fetchTmdbPoster(title, year)
|
||||
if (posterUrl && posterUrl !== originalSrc) {
|
||||
img.dataset.fallback = 'tmdb'
|
||||
img.src = posterUrl
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
img.dataset.fallback = 'done'
|
||||
img.src = generatePosterFallback(title, year)
|
||||
}
|
||||
|
||||
export function isUrlFailed(url: string | undefined): boolean {
|
||||
return !!url && failedUrls.has(url)
|
||||
}
|
||||
|
||||
/** Fetch album artwork from iTunes Search API (free, no key). Returns hi-res URL (600x600). */
|
||||
export async function fetchMusicCover(
|
||||
title: string,
|
||||
artist: string,
|
||||
album?: string,
|
||||
): Promise<string | null> {
|
||||
const key = musicCacheKey(artist, title)
|
||||
const cached = musicCoverCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
try {
|
||||
const term = `${artist} ${title}`.trim().replace(/\s+/g, '+')
|
||||
const res = await fetch(
|
||||
`https://itunes.apple.com/search?term=${encodeURIComponent(term)}&media=music&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')
|
||||
musicCoverCache.set(key, hiRes)
|
||||
saveMusicCache()
|
||||
return hiRes
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user