feat(chat): enhance film integration and UI improvements

- Updated ChatMessage component to display inline film cards and added functionality for selecting films.
- Improved ChatWindow to handle film-related content and update the panel with selected films.
- Refactored useAI to streamline film recommendation prompts and context.
- Enhanced ChatPage layout for better film library access and user experience.
- Updated service worker revision for PWA improvements.

Made-with: Cursor
This commit is contained in:
Dorian
2026-03-02 16:48:17 +00:00
parent ece4b8256f
commit 464069b2f2
13 changed files with 658 additions and 112 deletions
+9 -15
View File
@@ -6,26 +6,20 @@ type Provider = 'claude' | 'openrouter' | 'mock'
const CLAUDE_PATH = '/api/claude/v1/messages'
const OPENROUTER_PATH = '/api/openrouter/api/v1/chat/completions'
import { mockFilms } from '@/mocks/films'
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.
When recommending or discussing films, reference films from the user's library using the tag format [[film:ID]] where ID matches a film in their collection. Always include the tag so the UI can render rich cards. You can recommend multiple films. Write a brief description of why each film is worth watching alongside the tag.
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.
The user's film library:
${generateFilmContext()}
${filmContext}
If a user asks about a film NOT in their library, still discuss it but mention it's not currently in their collection.`
function generateFilmContext(): string {
const { mockFilms } = await_import_films()
return mockFilms.map((f) =>
`- [${f.id}] "${f.title}" (${f.year}) dir. ${f.director} | ${f.genres.join(', ')} | ${f.rating}/10 | Available on: ${f.sources.map(s => s.type).join(', ')}`
).join('\n')
}
function await_import_films() {
// Synchronous access — the mock is bundled
return require('@/mocks/films') as typeof import('@/mocks/films')
}
If a user asks about a film NOT in their library, discuss it normally but note it's not in their collection.`
const openrouterApiKey = import.meta.env.VITE_OPENROUTER_API_KEY ?? ''
const hasOpenRouter = !!openrouterApiKey
@@ -0,0 +1,81 @@
import { ref, computed } from 'vue'
import type { Film } from '@aiui/core/types/content'
import { mockFilms } from '@/mocks/films'
const panelOpen = ref(false)
const panelFilms = ref<Film[]>([])
const selectedFilm = ref<Film | null>(null)
const panelTitle = ref('Recommended Films')
const FILM_TAG_RE = /\[\[film:(f\d+)\]\]/g
export function useContentPanel() {
function extractFilmIds(text: string): string[] {
const ids: string[] = []
let match: RegExpExecArray | null
const re = new RegExp(FILM_TAG_RE.source, 'g')
while ((match = re.exec(text)) !== null) {
if (!ids.includes(match[1])) ids.push(match[1])
}
return ids
}
function resolveFilms(ids: string[]): Film[] {
return ids
.map((id) => mockFilms.find((f) => f.id === id))
.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 stripFilmTags(text: string): string {
return text.replace(FILM_TAG_RE, '').replace(/\n{3,}/g, '\n\n').trim()
}
function openFilmDetail(film: Film) {
selectedFilm.value = film
}
function closeFilmDetail() {
selectedFilm.value = null
}
function closePanel() {
panelOpen.value = false
selectedFilm.value = null
}
function showAllFilms() {
panelFilms.value = [...mockFilms]
panelTitle.value = 'Your Film Library'
panelOpen.value = true
selectedFilm.value = null
}
return {
panelOpen,
panelFilms,
selectedFilm,
panelTitle,
extractFilmIds,
resolveFilms,
updatePanelFromText,
stripFilmTags,
openFilmDetail,
closeFilmDetail,
closePanel,
showAllFilms,
}
}
@@ -0,0 +1,25 @@
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">
<rect width="342" height="513" fill="hsl(${hue}, 30%, 15%)"/>
<rect x="1" y="1" width="340" height="511" rx="8" fill="none" stroke="hsl(${hue}, 40%, 25%)" stroke-width="2"/>
<text x="171" y="230" text-anchor="middle" fill="hsl(${hue}, 50%, 70%)" font-family="system-ui" font-size="20" font-weight="600">
${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)}`
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
export function handleImgError(e: Event, title: string, year?: number) {
const img = e.target as HTMLImageElement
if (!img.dataset.fallback) {
img.dataset.fallback = '1'
img.src = generatePosterFallback(title, year)
}
}