feat(app): integrate Jamendo API and enhance UI components

- Added Jamendo API client ID to the environment configuration for music search.
- Updated pnpm lock file to include new dependencies for enhanced functionality.
- Integrated Plyr library for improved media playback experience.
- Refactored ChatHeader, ChatInput, and ChatMessage components to utilize new styles and improve user interaction.
- Enhanced CSS styles for path-glass elements to align with the new design system.

Made-with: Cursor
This commit is contained in:
Dorian
2026-03-02 19:37:00 +00:00
parent 80aeefa4bf
commit 7a0e42bf63
25 changed files with 1165 additions and 111 deletions
+7 -2
View File
@@ -21,9 +21,14 @@ const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the u
**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]].
**Songs:** When recommending or discussing songs, ALWAYS use tags for every song you mention:
- Library songs: [[song:ID]] where ID is the song's id (e.g. [[song:s1]]).
- 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.
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.
**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.
The user's film library:
${filmContext}
@@ -118,20 +118,68 @@ export function useContentPanel() {
return songs
}
/** Infer songs from plain text when no tags present (e.g. old chats) */
function extractSongsFromPlainText(text: string): Song[] {
/** Infer songs from plain text when no tags present. Requires title+artist within 100 chars, whole-word artist. */
function extractSongsFromLibraryMatch(text: string): Song[] {
const lower = text.toLowerCase()
const found: Song[] = []
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
if (lower.includes(song.title.toLowerCase()) && lower.includes(song.artist.toLowerCase())) {
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)
}
/** Extract song-like patterns: "Title" by Artist, Title Artist, 1. Title - Artist */
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 (/^\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)
found.push(song)
songs.push({ title, artist, pos: m.index })
}
}
return found
return songs
.sort((a, b) => a.pos - b.pos)
.map(({ title, artist }) => ({
id: `ext-${`${title}|${artist}`.toLowerCase().replace(/\W/g, '-')}`,
title,
artist,
sources: [],
}))
}
function extractAllSongs(text: string): Song[] {
@@ -140,7 +188,14 @@ export function useContentPanel() {
if (librarySongs.length > 0 || externalSongs.length > 0) {
return [...librarySongs, ...externalSongs]
}
return extractSongsFromPlainText(text)
if (extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text)) 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]
}
function updatePanelFromText(text: string) {
+185
View File
@@ -0,0 +1,185 @@
import { ref, computed } from 'vue'
import type { Song } from '@aiui/core/types/content'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
interface MusicSearchResult {
source: string
type: 'stream' | 'embed'
url: string
title?: string
artist?: string
}
const currentSong = ref<Song | null>(null)
const playableSource = ref<MusicSearchResult | null>(null)
const isPlaying = ref(false)
const isLoading = ref(false)
const error = ref<string | null>(null)
const currentTime = ref(0)
const duration = ref(0)
let plyrInstance: Plyr | null = null
let containerEl: HTMLDivElement | null = null
export function usePlayer() {
const hasTrack = computed(() => !!currentSong.value)
const progress = computed(() => {
if (duration.value <= 0) return 0
return (currentTime.value / duration.value) * 100
})
async function searchMusic(query: string, title?: string, artist?: string): Promise<MusicSearchResult | null> {
try {
const params = new URLSearchParams({ q: query })
if (title) params.set('title', title)
if (artist) params.set('artist', artist)
const res = await fetch(`/api/music/search?${params}`)
const data = (await res.json()) as MusicSearchResult & { error?: string }
if (data.error || !data.url) {
error.value = data.error ?? 'No playable source found'
return null
}
return data as MusicSearchResult
} catch (e) {
error.value = 'Network error. Is the dev server running?'
return null
}
}
function setContainer(el: HTMLDivElement | null) {
containerEl = el
if (el && playableSource.value) {
initPlayer(playableSource.value)
}
}
function initPlayer(result: MusicSearchResult) {
if (!containerEl) return
destroyPlayer()
if (result.type === 'stream') {
const audio = document.createElement('audio')
audio.src = result.url
audio.crossOrigin = 'anonymous'
containerEl.innerHTML = ''
containerEl.appendChild(audio)
plyrInstance = new Plyr(audio, {
controls: [],
autoplay: true,
muted: false,
})
} else {
const iframe = document.createElement('iframe')
iframe.src = result.url
iframe.style.width = '100%'
iframe.style.height = '100%'
iframe.style.border = 'none'
containerEl.innerHTML = ''
containerEl.appendChild(iframe)
plyrInstance = null
duration.value = 0
currentTime.value = 0
return
}
plyrInstance!.on('ready', () => {
plyrInstance!.play().catch(() => { /* autoplay blocked */ })
})
plyrInstance!.on('timeupdate', () => {
currentTime.value = plyrInstance!.currentTime ?? 0
})
plyrInstance!.on('loadedmetadata', () => {
duration.value = plyrInstance!.duration ?? 0
})
plyrInstance!.on('ended', () => {
isPlaying.value = false
})
plyrInstance!.on('playing', () => {
isPlaying.value = true
})
plyrInstance!.on('pause', () => {
isPlaying.value = false
})
}
function destroyPlayer() {
if (plyrInstance) {
plyrInstance.destroy()
plyrInstance = null
}
if (containerEl) {
containerEl.innerHTML = ''
}
}
async function play(song: Song) {
error.value = null
if (currentSong.value?.id === song.id && playableSource.value) {
plyrInstance?.play()
isPlaying.value = true
return
}
isLoading.value = true
const query = `${song.title} ${song.artist}`.trim()
const result = await searchMusic(query, song.title, song.artist)
isLoading.value = false
currentSong.value = song
if (!result) {
if (!error.value) error.value = 'No playable source. Tries: Internet Archive, Jamendo, Odysee. Add JAMENDO_CLIENT_ID for more.'
return
}
error.value = null
playableSource.value = result
if (containerEl) {
initPlayer(result)
}
}
function pause() {
plyrInstance?.pause()
isPlaying.value = false
}
function toggle() {
if (plyrInstance) {
if (isPlaying.value) pause()
else plyrInstance.play()
}
}
function seek(percent: number) {
if (!plyrInstance || !duration.value) return
const time = (percent / 100) * duration.value
plyrInstance.currentTime = time
currentTime.value = time
}
function clear() {
pause()
destroyPlayer()
currentSong.value = null
playableSource.value = null
currentTime.value = 0
duration.value = 0
error.value = null
}
return {
currentSong,
playableSource,
isPlaying,
isLoading,
error,
currentTime,
duration,
hasTrack,
progress,
play,
pause,
toggle,
seek,
clear,
setContainer,
}
}