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
+89 -20
View File
@@ -1,33 +1,87 @@
<template>
<div
class="glass-strong rounded-t-2xl flex items-center justify-between px-4 py-3 relative"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06); box-shadow: inset 0 1px 0 rgba(254, 253, 249, 1)'"
class="glass-strong rounded-t-2xl flex items-center justify-between px-4 py-3 relative z-20 border-t-0 border-x-0"
:class="isDark ? '!border-b-white/10' : '!border-b-black/8'"
>
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
:class="isDark ? 'bg-accent/20' : 'bg-accent/10'">
<span class="text-accent text-sm font-bold">AI</span>
</div>
<div class="min-w-0">
<h2 class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/96' : 'text-gray-900'">{{ title }}</h2>
<div class="flex items-center gap-1.5">
<p class="text-[10px] truncate font-mono"
:class="isDark ? 'text-white/40' : 'text-gray-400'">{{ conversationId }}</p>
<div class="min-w-0 flex-1 relative">
<button
class="w-full text-left group/btn"
:class="conversationList.length > 0 ? 'cursor-pointer' : ''"
@click="conversationList.length > 0 && (showChatList = !showChatList)"
>
<h2 class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/96' : 'text-gray-900'">{{ title }}</h2>
<div class="flex items-center gap-1.5">
<p class="text-[10px] truncate font-mono"
:class="isDark ? 'text-white/40' : 'text-gray-400'">{{ conversationId }}</p>
<span class="text-[10px]" :class="isDark ? 'text-white/20' : 'text-gray-300'">·</span>
<button
class="text-[10px] text-accent/70 hover:text-accent transition-colors truncate"
@click="showModelPicker = !showModelPicker"
<button
class="text-[10px] text-accent/70 hover:text-accent transition-colors truncate"
@click.stop="showModelPicker = !showModelPicker; showChatList = false"
>
{{ modelDisplayName }}
</button>
</div>
</button>
<Transition name="picker">
<div
v-if="showChatList && conversationList.length > 0"
class="contents"
>
{{ modelDisplayName }}
</button>
</div>
<div
class="fixed inset-0 z-40"
aria-hidden="true"
@click="showChatList = false"
/>
<div
class="absolute left-0 right-0 top-full z-50 mt-1 p-2 max-h-48 overflow-y-auto animate-fade-up-fast shadow-2xl rounded-xl"
:class="isDark ? 'bg-[#161618] border border-white/10' : 'bg-white border border-black/10'"
@click.stop
>
<p class="text-[10px] font-semibold uppercase tracking-wider mb-2 px-1"
:class="isDark ? 'text-white/40' : 'text-gray-400'">Saved chats</p>
<button
v-for="c in conversationList"
:key="c.id"
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all"
:class="c.id === activeConversationId
? 'nav-tab-active'
: isDark
? 'text-white/60 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
@click="selectChat(c.id)"
>
{{ c.title || 'Untitled' }}
</button>
</div>
</div>
</Transition>
</div>
</div>
<div class="flex items-center gap-1">
<button
class="p-2 rounded-lg transition-colors"
:class="isDark
? 'text-white/70 hover:text-white hover:bg-white/10'
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
:title="isDark ? 'Light mode' : 'Dark mode'"
aria-label="Toggle theme"
@click="toggleTheme"
>
<svg v-if="isDark" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
<button
class="p-2 rounded-lg transition-colors"
:class="isDark
@@ -74,7 +128,7 @@
<Transition name="picker">
<div
v-if="showModelPicker"
class="absolute left-0 right-0 top-full z-20 mx-3 mt-1 glass-card p-3 space-y-3 animate-fade-up-fast"
class="absolute left-0 right-0 top-full z-50 mx-3 mt-1 glass-card p-3 space-y-3 animate-fade-up-fast shadow-2xl"
>
<div v-for="provider in availableProviders" :key="provider.id">
<p class="text-[10px] font-semibold uppercase tracking-wider mb-1.5 px-1"
@@ -103,7 +157,8 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useAI } from '@/composables/useAI'
import { useTheme } from '@/composables/useTheme'
@@ -121,8 +176,22 @@ defineEmits<{
}>()
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
const { isDark } = useTheme()
const { isDark, toggleTheme } = useTheme()
const chatStore = useChatStore()
const showModelPicker = ref(false)
const showChatList = ref(false)
const conversationList = computed(() => chatStore.conversationList)
const activeConversationId = computed(() => chatStore.activeConversationId)
function selectChat(id: string) {
chatStore.setActiveConversation(id)
showChatList.value = false
}
watch(() => chatStore.conversationList.length, () => {
showChatList.value = false
})
const modelDisplayName = computed(() => {
for (const p of availableProviders.value) {
@@ -12,7 +12,7 @@
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words"
:class="isDark ? 'text-white/90' : 'text-gray-800'">{{ displayText }}</p>
<div v-if="inlineFilms.length > 0" class="mt-3 space-y-1">
<div v-if="inlineFilms.length > 0" class="mt-3 space-y-1" @click.stop>
<FilmCard
v-for="film in inlineFilms"
:key="film.id"
@@ -21,16 +21,32 @@
/>
</div>
<div v-if="inlineSongs.length > 0" class="mt-3 space-y-1" @click.stop>
<SongCard
v-for="song in inlineSongs"
:key="song.id"
:song="song"
@select="handleSongSelect"
/>
</div>
<div class="flex items-center gap-2 mt-1.5">
<span class="text-[10px] select-none"
:class="isDark ? 'text-white/30' : 'text-gray-400'">{{ formattedTime }}</span>
<button
v-if="inlineFilms.length > 1"
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
@click="openPanel"
@click.stop="openPanel"
>
View all {{ inlineFilms.length }} films
</button>
<button
v-else-if="inlineSongs.length > 1"
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
@click.stop="openPanel"
>
View all {{ inlineSongs.length }} songs
</button>
</div>
</div>
</div>
@@ -39,10 +55,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Message } from '@aiui/core/types/message'
import type { Film } from '@aiui/core/types/content'
import type { Film, Song } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel } from '@/composables/useContentPanel'
import FilmCard from '@/components/content/FilmCard.vue'
import SongCard from '@/components/content/SongCard.vue'
const props = defineProps<{
message: Message
@@ -50,7 +67,7 @@ const props = defineProps<{
}>()
const { isDark } = useTheme()
const { extractFilmIds, resolveFilms, stripFilmTags, updatePanelFromText, openFilmDetail } = useContentPanel()
const { extractAllFilms, extractAllSongs, stripContentTags, updatePanelFromText, openFilmDetail, openSongDetail, closeFilmDetail, closeSongDetail } = useContentPanel()
const isUser = computed(() => props.message.role === 'user')
@@ -62,15 +79,19 @@ const bubbleClasses = computed(() =>
const inlineFilms = computed(() => {
if (isUser.value) return []
const ids = extractFilmIds(props.message.content)
return resolveFilms(ids)
return extractAllFilms(props.message.content)
})
const hasContext = computed(() => !isUser.value && inlineFilms.value.length > 0)
const inlineSongs = computed(() => {
if (isUser.value) return []
return extractAllSongs(props.message.content)
})
const hasContext = computed(() => !isUser.value && (inlineFilms.value.length > 0 || inlineSongs.value.length > 0))
const displayText = computed(() => {
if (isUser.value) return props.message.content
return stripFilmTags(props.message.content)
return stripContentTags(props.message.content)
})
const formattedTime = computed(() => {
@@ -80,10 +101,19 @@ const formattedTime = computed(() => {
function handleFilmSelect(film: Film) {
updatePanelFromText(props.message.content)
closeSongDetail()
openFilmDetail(film)
}
function handleSongSelect(song: Song) {
updatePanelFromText(props.message.content)
closeFilmDetail()
openSongDetail(song)
}
function openPanel() {
closeFilmDetail()
closeSongDetail()
updatePanelFromText(props.message.content)
}
@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col h-full rounded-2xl overflow-hidden transition-all duration-300">
<div class="flex flex-col h-full rounded-2xl overflow-visible transition-all duration-300">
<ChatHeader
:title="title"
:conversation-id="displayId"
@@ -17,7 +17,7 @@
<div v-if="messages.length === 0" class="flex items-center justify-center h-full">
<div class="text-center space-y-3 animate-fade-up">
<div class="w-16 h-16 rounded-2xl glass flex items-center justify-center mx-auto">
<span class="text-2xl"></span>
<span class="text-2xl" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'"></span>
</div>
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
Start a conversation
@@ -120,7 +120,7 @@ watch(
const el = messageListRef.value
if (el) el.scrollTop = el.scrollHeight
})
if (content && !isStreaming.value) {
if (content) {
updatePanelFromText(content)
}
}