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:
Dorian
2026-03-02 21:29:50 +00:00
parent 49ec6c09b6
commit 2d056f9498
36 changed files with 2616 additions and 303 deletions
+75 -89
View File
@@ -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>