feat(chat): enhance chat functionality with web search and article integration
- Updated ChatMessage and ChatWindow components to support inline web search results and articles. - Integrated new web search and RSS plugins into the chat system for real-time information retrieval. - Enhanced useContentPanel to manage web search results alongside existing media types. - Added ArticleOverlay component for displaying selected articles from search results. - Improved UI elements and styles for better user interaction with web search features. Made-with: Cursor
This commit is contained in:
@@ -1,104 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
ref="headerRef"
|
||||
class="path-glass-card rounded-t-2xl rounded-b-none flex items-center justify-between px-4 py-3 relative z-[60] shrink-0 border-t-0 border-x-0"
|
||||
:class="isDark ? '!border-b-white/10' : '!border-b-black/8'"
|
||||
class="flex flex-col gap-0 p-3 relative z-[60] shrink-0"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div class="w-8 h-8 rounded-xl path-glass-icon flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<span class="text-base" :class="isDark ? 'text-[#fafafa]' : 'text-gray-800'">✦</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 relative">
|
||||
<button
|
||||
ref="chatListTriggerRef"
|
||||
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
|
||||
ref="modelPickerTriggerRef"
|
||||
class="text-[10px] text-accent/70 hover:text-accent transition-colors truncate"
|
||||
@click.stop="showModelPicker = !showModelPicker; showChatList = false"
|
||||
>
|
||||
{{ modelDisplayName }}
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div v-if="showChatList && conversationList.length > 0" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showChatList = false" />
|
||||
<Transition name="picker">
|
||||
<div
|
||||
v-if="showChatList && conversationList.length > 0"
|
||||
class="fixed z-[9999] mt-1 p-2 max-h-48 overflow-y-auto animate-fade-up-fast shadow-2xl rounded-xl min-w-[200px]"
|
||||
:class="isDark ? 'bg-[#161618] border border-white/10' : 'bg-white border border-black/10'"
|
||||
:style="chatListDropdownStyle"
|
||||
@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>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<button
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
ref="modelPickerTriggerRef"
|
||||
class="w-8 h-8 rounded-xl path-glass-icon flex items-center justify-center shrink-0 transition-colors cursor-pointer"
|
||||
: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"
|
||||
? 'text-[#fafafa] hover:text-white'
|
||||
: 'text-gray-800 hover:text-gray-900'"
|
||||
:title="`AI model: ${modelDisplayName}`"
|
||||
aria-label="Select AI model"
|
||||
@click="showModelPicker = !showModelPicker; showChatList = false"
|
||||
>
|
||||
<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>
|
||||
<span class="text-base">✦</span>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<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="side === 'right' ? 'Move panel to left' : 'Move panel to right'"
|
||||
aria-label="Switch panel side"
|
||||
@click="$emit('switchSide')"
|
||||
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors"
|
||||
:class="webSearchEnabled
|
||||
? 'text-accent'
|
||||
: isDark
|
||||
? 'text-white/70 hover:text-white'
|
||||
: 'text-gray-500 hover:text-gray-900'"
|
||||
:title="webSearchEnabled ? 'Web search on' : 'Web search off'"
|
||||
aria-label="Toggle web search"
|
||||
@click="chatStore.webSearchEnabled = !chatStore.webSearchEnabled"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-if="side === 'right'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center 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'"
|
||||
? 'text-white/70 hover:text-white'
|
||||
: 'text-gray-500 hover:text-gray-900'"
|
||||
aria-label="New conversation"
|
||||
@click="$emit('newChat')"
|
||||
>
|
||||
@@ -109,10 +48,10 @@
|
||||
|
||||
<button
|
||||
v-if="showClose"
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center 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'"
|
||||
? 'text-white/70 hover:text-white'
|
||||
: 'text-gray-500 hover:text-gray-900'"
|
||||
aria-label="Close"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
@@ -120,8 +59,54 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
ref="chatListTriggerRef"
|
||||
class="w-full text-left pt-3 pb-1 min-w-0"
|
||||
: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 mt-0.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>
|
||||
<span class="text-[10px] truncate"
|
||||
:class="isDark ? 'text-white/50' : 'text-gray-500'">{{ modelDisplayName }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="showChatList && conversationList.length > 0" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showChatList = false" />
|
||||
<Transition name="picker">
|
||||
<div
|
||||
v-if="showChatList && conversationList.length > 0"
|
||||
class="fixed z-[9999] path-glass-card p-3 max-h-48 overflow-y-auto animate-fade-up-fast shadow-2xl min-w-[200px]"
|
||||
:style="chatListDropdownStyle"
|
||||
@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>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="showModelPicker" class="fixed inset-0 z-[9998]" aria-hidden="true" @click="showModelPicker = false" />
|
||||
<Transition name="picker">
|
||||
@@ -178,8 +163,9 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
const { isDark } = useTheme()
|
||||
const chatStore = useChatStore()
|
||||
const webSearchEnabled = computed(() => chatStore.webSearchEnabled)
|
||||
const showModelPicker = ref(false)
|
||||
const showChatList = ref(false)
|
||||
const headerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -39,6 +39,24 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="inlineNewsLinks.length > 0" class="mt-3 space-y-1" @click.stop>
|
||||
<NewsCard
|
||||
v-for="(link, i) in inlineNewsLinks"
|
||||
:key="i"
|
||||
:article="link"
|
||||
@select-article="handleArticleSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="inlineWebsitesLinks.length > 0" class="mt-3 space-y-1" @click.stop>
|
||||
<NewsCard
|
||||
v-for="(link, i) in inlineWebsitesLinks"
|
||||
:key="`web-${i}`"
|
||||
:article="link"
|
||||
@select-article="handleWebsiteSelect"
|
||||
/>
|
||||
</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>
|
||||
@@ -63,6 +81,20 @@
|
||||
>
|
||||
View all {{ inlinePodcasts.length }} podcasts →
|
||||
</button>
|
||||
<button
|
||||
v-else-if="inlineNewsLinks.length > 1"
|
||||
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
|
||||
@click.stop="openPanel"
|
||||
>
|
||||
View all {{ inlineNewsLinks.length }} articles →
|
||||
</button>
|
||||
<button
|
||||
v-else-if="inlineWebsitesLinks.length > 1"
|
||||
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
|
||||
@click.stop="openPanel"
|
||||
>
|
||||
View all {{ inlineWebsitesLinks.length }} websites →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,50 +102,61 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Message } from '@aiui/core/types/message'
|
||||
import type { Message, WebSearchResult } from '@aiui/core/types/message'
|
||||
import type { Film, Song, Podcast } from '@aiui/core/types/content'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useContentPanel } from '@/composables/useContentPanel'
|
||||
import { useArticleOverlayStore } from '@/stores/articleOverlay'
|
||||
import FilmCard from '@/components/content/FilmCard.vue'
|
||||
import SongCard from '@/components/content/SongCard.vue'
|
||||
import PodcastCard from '@/components/content/PodcastCard.vue'
|
||||
import NewsCard from '@/components/content/NewsCard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
message: Message
|
||||
index: number
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
message: Message
|
||||
index: number
|
||||
triggeringQuery?: string
|
||||
}>(),
|
||||
{ triggeringQuery: '' }
|
||||
)
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const { extractAllFilms, extractAllSongs, extractAllPodcasts, stripContentTags, updatePanelFromText, openFilmDetail, openSongDetail, openPodcastDetail, closeFilmDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
|
||||
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, openFilmDetail, openSongDetail, openPodcastDetail, openArticleDetail, closeFilmDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
|
||||
const overlayStore = useArticleOverlayStore()
|
||||
|
||||
const isUser = computed(() => props.message.role === 'user')
|
||||
|
||||
const inlineContent = computed(() => {
|
||||
if (isUser.value) return { films: [] as Film[], songs: [] as Song[], podcasts: [] as Podcast[], newsLinks: [], websitesLinks: [] }
|
||||
return getContextualInlineContent(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
|
||||
})
|
||||
|
||||
const bubbleClasses = computed(() =>
|
||||
isUser.value
|
||||
? 'path-glass-bubble-user rounded-br-md rounded-2xl'
|
||||
: 'path-glass-bubble rounded-bl-md rounded-2xl'
|
||||
)
|
||||
|
||||
const inlineFilms = computed(() => {
|
||||
if (isUser.value) return []
|
||||
return extractAllFilms(props.message.content)
|
||||
})
|
||||
const inlineFilms = computed(() => inlineContent.value.films)
|
||||
const inlineSongs = computed(() => inlineContent.value.songs)
|
||||
const inlinePodcasts = computed(() => inlineContent.value.podcasts)
|
||||
const inlineNewsLinks = computed(() => inlineContent.value.newsLinks ?? [])
|
||||
const inlineWebsitesLinks = computed(() => inlineContent.value.websitesLinks ?? [])
|
||||
|
||||
const inlineSongs = computed(() => {
|
||||
if (isUser.value) return []
|
||||
return extractAllSongs(props.message.content)
|
||||
})
|
||||
|
||||
const inlinePodcasts = computed(() => {
|
||||
if (isUser.value) return []
|
||||
return extractAllPodcasts(props.message.content)
|
||||
})
|
||||
|
||||
const hasContext = computed(() => !isUser.value && (inlineFilms.value.length > 0 || inlineSongs.value.length > 0 || inlinePodcasts.value.length > 0))
|
||||
const hasContext = computed(() => !isUser.value && (
|
||||
inlineFilms.value.length > 0 ||
|
||||
inlineSongs.value.length > 0 ||
|
||||
inlinePodcasts.value.length > 0 ||
|
||||
inlineNewsLinks.value.length > 0 ||
|
||||
inlineWebsitesLinks.value.length > 0
|
||||
))
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (isUser.value) return props.message.content
|
||||
return stripContentTags(props.message.content)
|
||||
let text = stripContentTags(props.message.content)
|
||||
if (inlineNewsLinks.value.length > 0 || inlineWebsitesLinks.value.length > 0) text = stripMarkdownLinks(text)
|
||||
return text
|
||||
})
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
@@ -121,22 +164,26 @@ const formattedTime = computed(() => {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
})
|
||||
|
||||
function openPanelWithContext() {
|
||||
updatePanelFromText(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
|
||||
}
|
||||
|
||||
function handleFilmSelect(film: Film) {
|
||||
updatePanelFromText(props.message.content)
|
||||
openPanelWithContext()
|
||||
closeSongDetail()
|
||||
closePodcastDetail()
|
||||
openFilmDetail(film)
|
||||
}
|
||||
|
||||
function handleSongSelect(song: Song) {
|
||||
updatePanelFromText(props.message.content)
|
||||
openPanelWithContext()
|
||||
closeFilmDetail()
|
||||
closePodcastDetail()
|
||||
openSongDetail(song)
|
||||
}
|
||||
|
||||
function handlePodcastSelect(podcast: Podcast) {
|
||||
updatePanelFromText(props.message.content)
|
||||
openPanelWithContext()
|
||||
closeFilmDetail()
|
||||
closeSongDetail()
|
||||
openPodcastDetail(podcast)
|
||||
@@ -146,7 +193,16 @@ function openPanel() {
|
||||
closeFilmDetail()
|
||||
closeSongDetail()
|
||||
closePodcastDetail()
|
||||
updatePanelFromText(props.message.content)
|
||||
openPanelWithContext()
|
||||
}
|
||||
|
||||
function handleArticleSelect(article: WebSearchResult) {
|
||||
openPanelWithContext()
|
||||
openArticleDetail(article)
|
||||
}
|
||||
|
||||
function handleWebsiteSelect(article: WebSearchResult) {
|
||||
overlayStore.open(article.url, article.title, undefined, article.imgSrc)
|
||||
}
|
||||
|
||||
function handleBubbleClick() {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:index="i"
|
||||
:triggering-query="getTriggeringQuery(messages, i)"
|
||||
/>
|
||||
|
||||
<StreamingDots v-if="isStreaming && lastMessageEmpty" />
|
||||
@@ -95,6 +96,14 @@ const lastMessageEmpty = computed(() => {
|
||||
return msgs[msgs.length - 1].content === ''
|
||||
})
|
||||
|
||||
function getTriggeringQuery(msgs: typeof messages.value, idx: number): string {
|
||||
if (msgs[idx]?.role !== 'assistant') return ''
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
if (msgs[i]?.role === 'user') return msgs[i].content ?? ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function handleNewChat() {
|
||||
chatStore.createConversation()
|
||||
}
|
||||
@@ -114,15 +123,23 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => messages.value[messages.value.length - 1]?.content,
|
||||
(content) => {
|
||||
() => {
|
||||
const msgs = messages.value
|
||||
const last = msgs[msgs.length - 1]
|
||||
return last ? { content: last.content, webResults: last.webResults } : null
|
||||
},
|
||||
(val) => {
|
||||
nextTick(() => {
|
||||
const el = messageListRef.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
if (content) {
|
||||
updatePanelFromText(content)
|
||||
if (val?.content) {
|
||||
const msgs = messages.value
|
||||
const lastMsg = msgs[msgs.length - 1]
|
||||
const lastUser = [...msgs].reverse().find((m) => m.role === 'user')
|
||||
updatePanelFromText(val.content, lastUser?.content ?? '', lastMsg?.webResults ?? [])
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user