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:
Dorian
2026-03-02 18:16:04 +00:00
parent 6f79f0860d
commit 80aeefa4bf
27 changed files with 1751 additions and 143 deletions
+188 -13
View File
@@ -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,
}
}