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
+13 -5
View File
@@ -5,11 +5,14 @@
# Get your key at: https://openrouter.ai/keys
VITE_OPENROUTER_API_KEY=sk-or-your-key-here
# Anthropic Claude — use ONE of these:
# Option 1: OAuth token from Claude Code CLI (run: claude auth login)
VITE_ANTHROPIC_TOKEN=sk-ant-oat01-your-token-here
# Option 2: API key from console (https://console.anthropic.com/settings/keys)
VITE_ANTHROPIC_API_KEY=sk-ant-your-key-here
# Anthropic Claude — for live web search (Claude invokes search mid-response):
# Option 1: OAuth token from Max subscription (run: claude setup-token, save output)
# → No extra cost; uses your existing Max subscription.
ANTHROPIC_TOKEN=sk-ant-oat01-your-token-here
# Option 2: API key from https://console.anthropic.com/settings/keys
ANTHROPIC_API_KEY=sk-ant-your-key-here
#
# Without either: proxy uses CLI with built-in WebSearch + pre-fetched context.
# TMDB API (free, fetches posters on-demand when images fail)
# Get your key at: https://www.themoviedb.org/settings/api
@@ -19,6 +22,11 @@ TMDB_API_KEY=your-tmdb-key-here
# Get your client_id at: https://devportal.jamendo.com/
JAMENDO_CLIENT_ID=your-jamendo-client-id
# SearXNG instance for web search (optional)
# Uses public instances by default; falls back to DuckDuckGo when they fail.
# For reliable dev: host your own (https://docs.searxng.org/) or rely on DDG fallback.
# SEARXNG_URL=https://your-searxng.instance
# Development flags
VITE_DEV_MODE=true
VITE_MOCK_MEDIA_SOURCES=true
+2
View File
@@ -26,7 +26,9 @@
"devDependencies": {
"@tailwindcss/vite": "latest",
"@vitejs/plugin-vue": "latest",
"duck-duck-scrape": "^2.2.7",
"eslint": "latest",
"rss-parser": "^3.13.0",
"tailwindcss": "latest",
"tsx": "^4.21.0",
"typescript": "~5.8.0",
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 83.44 122.88">
<path fill="currentColor" d="M45.04,95.45v24.11c0,1.83-1.49,3.32-3.32,3.32c-1.83,0-3.32-1.49-3.32-3.32V95.45c-10.16-0.81-19.32-5.3-26.14-12.12C4.69,75.77,0,65.34,0,53.87c0-1.83,1.49-3.32,3.32-3.32s3.32,1.49,3.32,3.32c0,9.64,3.95,18.41,10.31,24.77c6.36,6.36,15.13,10.31,24.77,10.31h0c9.64,0,18.41-3.95,24.77-10.31c6.36-6.36,10.31-15.13,10.31-24.77c0-1.83,1.49-3.32,3.32-3.32s3.32,1.49,3.32,3.32c0,11.48-4.69,21.91-12.25,29.47C64.36,90.16,55.2,94.64,45.04,95.45z M41.94,0c6.38,0,12.18,2.61,16.38,6.81c4.2,4.2,6.81,10,6.81,16.38v30c0,6.38-2.61,12.18-6.81,16.38c-4.2,4.2-10,6.81-16.38,6.81s-12.18-2.61-16.38-6.81c-4.2-4.2-6.81-10-6.81-16.38v-30c0-6.38,2.61-12.18,6.81-16.38C29.76,2.61,35.56,0,41.94,0z M53.62,11.51c-3-3-7.14-4.86-11.68-4.86c-4.55,0-8.68,1.86-11.68,4.86c-3,3-4.86,7.14-4.86,11.68v30c0,4.55,1.86,8.68,4.86,11.68c3,3,7.14,4.86,11.68,4.86c4.55,0,8.68-1.86,11.68-4.86c3-3,4.86-7.14,4.86-11.68v-30C58.49,18.64,56.62,14.51,53.62,11.51z"/>
</svg>

After

Width:  |  Height:  |  Size: 1022 B

+234 -5
View File
@@ -1,9 +1,206 @@
import { spawn } from 'child_process'
import { createServer } from 'http'
import { resolve } from 'path'
import { readFileSync, existsSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
// Load .env.local from workspace root (monorepo) or cwd
function loadEnv() {
for (const base of [resolve(__dirname, '../../..'), process.cwd()]) {
const path = resolve(base, '.env.local')
if (existsSync(path)) {
try {
const buf = readFileSync(path, 'utf8')
for (const line of buf.split('\n')) {
const m = line.match(/^([^#=]+)=(.*)$/)
if (m) {
const key = m[1].trim()
const val = m[2].trim().replace(/^["']|["']$/g, '')
if (!process.env[key]) process.env[key] = val
}
}
break
} catch {
/* ignore */
}
}
}
}
loadEnv()
const PORT = 3141
const CLAUDE_BIN = resolve(process.env.HOME ?? '', '.local/bin/claude')
const APP_URL = process.env.APP_URL ?? 'http://localhost:5173'
/** API key (sk-ant-api03-...) or OAuth token (sk-ant-oat...) from Max subscription */
function getAnthropicCredential(): string | undefined {
const fromEnv = process.env.ANTHROPIC_API_KEY
?? process.env.VITE_ANTHROPIC_API_KEY
?? process.env.ANTHROPIC_TOKEN
?? process.env.VITE_ANTHROPIC_TOKEN
if (fromEnv) return fromEnv
const home = process.env.HOME ?? ''
const settingsPath = resolve(home, '.claude/settings.json')
if (home && existsSync(settingsPath)) {
try {
const json = JSON.parse(readFileSync(settingsPath, 'utf8'))
const env = json?.env
if (env && typeof env === 'object') {
const t = env.ANTHROPIC_TOKEN ?? env.VITE_ANTHROPIC_TOKEN ?? env.ANTHROPIC_API_KEY ?? env.VITE_ANTHROPIC_API_KEY
if (typeof t === 'string') return t
}
} catch { /* ignore */ }
}
return undefined
}
const ANTHROPIC_CREDENTIAL = getAnthropicCredential()
const isOAuthToken = (s: string) => /^sk-ant-oat/.test(s)
const SEARCH_WEB_TOOL = {
name: 'search_web',
description: 'Search the web for current information. Use this when the user asks for news, recent events, facts you are unsure about, or any information that may have changed. Perform one search per distinct topic. Returns titles, URLs, and snippets.',
input_schema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query (e.g. "Bitcoin price March 2025", "latest news AI regulation")',
},
},
required: ['query'],
},
}
function mapModelToApi(model: string): string {
if (model?.includes('opus')) return 'claude-opus-4-20250514'
if (model?.includes('haiku')) return 'claude-3-5-haiku-20241022'
return 'claude-sonnet-4-20250514'
}
async function runSearchWeb(query: string): Promise<string> {
const url = `${APP_URL.replace(/\/$/, '')}/api/web-search?${new URLSearchParams({ q: query })}`
try {
const res = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10000),
})
if (!res.ok) return `Search failed: ${res.status}`
const data = (await res.json()) as { results?: { title?: string; url?: string; content?: string }[] }
const results = data.results ?? []
if (results.length === 0) return 'No results found.'
return results
.map((r, i) => `${i + 1}. [${r.title ?? 'Unknown'}](${r.url ?? ''})${r.content ? `${r.content.slice(0, 150)}${r.content.length > 150 ? '…' : ''}` : ''}`)
.join('\n')
} catch (err) {
return `Search error: ${err instanceof Error ? err.message : String(err)}`
}
}
async function streamViaAnthropicApi(
model: string,
system: string | undefined,
messages: { role: string; content: string }[],
res: import('http').ServerResponse,
): Promise<void> {
const apiModel = mapModelToApi(model)
const apiMessages = messages.map((m) => ({
role: m.role === 'assistant' ? 'assistant' : 'user',
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
}))
let clientDisconnected = false
res.on('close', () => { clientDisconnected = true })
const sendDelta = (text: string) => {
if (!clientDisconnected) {
res.write(`data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text } })}\n\n`)
}
}
const sendError = (msg: string) => {
if (!clientDisconnected) {
res.write(`data: ${JSON.stringify({ type: 'error', error: { message: msg } })}\n\n`)
}
}
let turnMessages = [...apiMessages]
const maxToolRounds = 5
let rounds = 0
while (rounds < maxToolRounds) {
rounds++
const body: Record<string, unknown> = {
model: apiModel,
max_tokens: 4096,
system,
messages: turnMessages,
tools: [SEARCH_WEB_TOOL],
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
}
if (isOAuthToken(ANTHROPIC_CREDENTIAL!)) {
headers['Authorization'] = `Bearer ${ANTHROPIC_CREDENTIAL}`
headers['anthropic-beta'] = 'oauth-2025-04-20'
} else {
headers['x-api-key'] = ANTHROPIC_CREDENTIAL!
}
const apiRes = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(120000),
})
if (!apiRes.ok) {
const errBody = await apiRes.text()
sendError(`Anthropic API ${apiRes.status}: ${errBody.slice(0, 200)}`)
break
}
const data = (await apiRes.json()) as {
content?: { type: string; text?: string; id?: string; name?: string; input?: { query?: string } }[]
stop_reason?: string
}
const content = data.content ?? []
const toolUses = content.filter((b) => b.type === 'tool_use')
const textBlocks = content.filter((b) => b.type === 'text')
if (data.stop_reason === 'tool_use' && toolUses.length > 0) {
const toolResults: { type: string; tool_use_id: string; content: string }[] = []
for (const tu of toolUses) {
if (tu.name === 'search_web' && tu.id && tu.input?.query) {
console.log('[proxy] tool search_web:', tu.input.query)
const result = await runSearchWeb(tu.input.query)
toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: result })
}
}
turnMessages = [
...turnMessages,
{ role: 'assistant' as const, content },
{ role: 'user' as const, content: toolResults },
]
continue
}
for (const block of textBlocks) {
if (block.text) sendDelta(block.text)
}
break
}
if (!clientDisconnected) {
res.write('data: [DONE]\n\n')
res.end()
}
}
const server = createServer((req, res) => {
if (req.method === 'OPTIONS') {
@@ -26,7 +223,26 @@ const server = createServer((req, res) => {
req.on('data', (chunk) => { body += chunk })
req.on('end', () => {
try {
const { model, messages, system } = JSON.parse(body)
const payload = JSON.parse(body)
const { model, messages, system, webSearch } = payload
const useTools = webSearch === true && !!ANTHROPIC_CREDENTIAL
if (useTools) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no',
})
streamViaAnthropicApi(model, system, messages ?? [], res)
return
}
if (webSearch === true && !ANTHROPIC_CREDENTIAL) {
console.log('[proxy] webSearch: using CLI built-in WebSearch + pre-fetched context')
}
const modelFlag = model?.includes('opus') ? 'opus'
: model?.includes('haiku') ? 'haiku'
@@ -50,13 +266,21 @@ const server = createServer((req, res) => {
const args = ['-p', '--model', modelFlag]
if (systemPrompt) args.push('--system-prompt', systemPrompt)
if (webSearch === true) {
args.push('--allowed-tools', 'WebSearch', 'WebFetch')
args.push('--permission-mode', 'dontAsk')
}
args.push('--', lastUserMsg)
console.log(`[proxy] → claude -p --model ${modelFlag} "${lastUserMsg.slice(0, 60)}..."`)
console.log(`[proxy] → claude -p --model ${modelFlag}${webSearch ? ' [WebSearch]' : ''} "${lastUserMsg.slice(0, 60)}..."`)
const procEnv = { ...process.env, NO_COLOR: '1', TERM: 'dumb' }
if (webSearch === true) {
delete procEnv.DISALLOWED_TOOLS
}
const proc = spawn(CLAUDE_BIN, args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
env: procEnv,
detached: false,
})
@@ -135,5 +359,10 @@ const server = createServer((req, res) => {
server.listen(PORT, () => {
console.log(`\n Claude proxy → http://localhost:${PORT}`)
console.log(` Binary: ${CLAUDE_BIN}`)
console.log(` Using your Max subscription\n`)
if (ANTHROPIC_CREDENTIAL) {
const mode = isOAuthToken(ANTHROPIC_CREDENTIAL) ? 'OAuth (Max)' : 'API key'
console.log(` Tool use (search_web): enabled (${mode})\n`)
} else {
console.log(` Tool use: add ANTHROPIC_TOKEN (Max) or ANTHROPIC_API_KEY to .env.local\n`)
}
})
+2
View File
@@ -1,6 +1,7 @@
<template>
<div class="h-dvh flex flex-col" :class="currentTheme">
<RouterView />
<ArticleOverlay />
</div>
</template>
@@ -8,6 +9,7 @@
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useTheme } from '@/composables/useTheme'
import ArticleOverlay from '@/components/content/ArticleOverlay.vue'
const { currentTheme, initTheme } = useTheme()
+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>
@@ -0,0 +1,131 @@
<template>
<div class="article-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden aspect-[16/7] shrink-0">
<img
v-if="article.imgSrc && isSafeImgSrc(article.imgSrc)"
:src="article.imgSrc"
:alt="article.title"
class="absolute inset-0 w-full h-full object-cover object-center block"
/>
<div
v-else
class="absolute inset-0"
:style="{ background: fallbackGradient }"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-3 left-3 p-2 rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10 text-white/80"
@click="$emit('back')"
>
<svg 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="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ article.title }}</h2>
<p v-if="articleDomain" class="text-xs text-white/60 mt-1">{{ articleDomain }}</p>
</div>
</div>
<div class="p-4 space-y-4">
<article
v-if="article.content"
class="[&_p]:mb-3 [&_ul]:list-disc [&_ol]:list-decimal [&_li]:ml-4 [&_a]:underline [&_a]:underline-offset-2 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:italic"
:class="isDark ? 'text-white/90' : 'text-gray-900'"
>
<div v-html="sanitizedContent" />
</article>
<div v-else class="py-4">
<p class="text-sm" :class="isDark ? 'text-white/50' : 'text-gray-500'">
Full article content is not available. Open the link below to read on the source site.
</p>
</div>
<a
v-if="article.url"
:href="article.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/10 hover:bg-white/15 text-white/90'
: 'bg-black/5 hover:bg-black/10 text-gray-800'"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Read full article
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
const props = defineProps<{ article: WebSearchResult }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const articleDomain = computed(() => {
try {
return new URL(props.article.url).hostname.replace(/^www\./, '')
} catch {
return ''
}
})
const fallbackGradient = computed(() => {
const hue = [...props.article.title].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
return `linear-gradient(135deg, hsl(${hue}, 25%, 12%) 0%, hsl(${(hue + 40) % 360}, 20%, 8%) 100%)`
})
function isSafeImgSrc(src: string): boolean {
try {
const u = new URL(src)
return /^https?:$/i.test(u.protocol)
} catch {
return false
}
}
/** Allow safe HTML tags; strip scripts and dangerous attributes */
function sanitizeHtml(html: string): string {
const div = document.createElement('div')
div.innerHTML = html
const allowed = new Set(['p', 'br', 'a', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'span', 'div'])
const walk = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ''
if (node.nodeType !== Node.ELEMENT_NODE) return ''
const el = node as Element
const tag = el.tagName.toLowerCase()
if (tag === 'script' || tag === 'style' || tag === 'iframe' || tag === 'object' || tag === 'embed') return ''
if (!allowed.has(tag)) return [...node.childNodes].map(walk).join('')
const attrs: string[] = []
if (tag === 'a' && el.getAttribute('href')) {
const href = el.getAttribute('href') ?? ''
if (/^https?:\/\//i.test(href) && !/javascript:/i.test(href)) attrs.push(`href="${href.replace(/"/g, '&quot;')}"`)
}
if (tag === 'img' && el.getAttribute('src')) {
const src = el.getAttribute('src') ?? ''
if (/^https?:\/\//i.test(src)) attrs.push(`src="${src.replace(/"/g, '&quot;')}"`)
}
const inner = [...node.childNodes].map(walk).join('')
return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>${inner}</${tag}>`
}
return [...div.childNodes].map(walk).join('')
}
const sanitizedContent = computed(() => {
const c = props.article.content
if (!c) return ''
if (/<[a-z][\s\S]*>/i.test(c)) return sanitizeHtml(c)
return `<p class="whitespace-pre-wrap">${c.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>`
})
</script>
@@ -0,0 +1,233 @@
<template>
<Teleport to="body">
<Transition name="app-launcher">
<div
v-if="store.isOpen"
class="fixed inset-0 z-[2400] flex items-center justify-center p-6 md:p-10"
@click.self="store.close()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md" />
<div
class="article-overlay-panel relative z-10 flex flex-col overflow-hidden rounded-2xl shadow-2xl path-glass-card"
:class="panelClasses"
>
<div class="flex items-center gap-3 px-4 py-3 shrink-0"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
<button
v-if="!store.content"
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors transition-transform duration-300 disabled:opacity-70 disabled:cursor-not-allowed"
:class="isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-black/5 text-gray-600'"
aria-label="Refresh page"
title="Refresh"
:disabled="isRefreshing"
@click="refreshIframe"
>
<svg
class="w-5 h-5"
:class="{ 'animate-spin': isRefreshing }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<span class="flex-1 truncate text-sm font-medium min-w-0"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ store.title || 'Article' }}
</span>
<a
v-if="store.url"
:href="store.url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors shrink-0"
:class="isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-black/5 text-gray-600'"
aria-label="Open in new tab"
title="Open in new tab"
@click.stop
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<button
type="button"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-colors shrink-0"
:class="isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-black/5 text-gray-600'"
aria-label="Close"
@click="store.close()"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="relative flex-1 min-h-0 bg-black/20 overflow-hidden">
<!-- When we have RSS/article content, render it; otherwise load URL in iframe -->
<div
v-if="store.content"
class="absolute inset-0 overflow-y-auto p-4 md:p-6 text-sm leading-relaxed"
:class="isDark ? 'text-white/90' : 'text-gray-900'"
>
<article
class="[&_p]:mb-3 [&_ul]:list-disc [&_ol]:list-decimal [&_li]:ml-4 [&_a]:underline [&_a]:underline-offset-2 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:italic"
>
<img
v-if="store.imgSrc && isSafeImgSrc(store.imgSrc)"
:src="store.imgSrc"
:alt="store.title"
class="w-full rounded-lg object-cover max-h-48 mb-4"
/>
<div v-html="sanitizedContent" />
</article>
<a
:href="store.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 mt-4 text-sm"
:class="isDark ? 'text-white/70 hover:text-white' : 'text-gray-500 hover:text-gray-800'"
>
Read full article
<svg 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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
<iframe
v-else-if="store.url"
ref="iframeRef"
:key="iframeRefreshKey"
:src="store.url"
class="absolute inset-0 w-full h-full border-0"
style="-ms-overflow-style: none; scrollbar-width: none;"
title="Article content"
@load="onIframeLoad"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useArticleOverlayStore } from '@/stores/articleOverlay'
import { useTheme } from '@/composables/useTheme'
const store = useArticleOverlayStore()
const { isDark } = useTheme()
/** Allow safe HTML tags; strip scripts and dangerous attributes */
function sanitizeHtml(html: string): string {
const div = document.createElement('div')
div.innerHTML = html
const allowed = new Set(['p', 'br', 'a', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'span', 'div'])
const walk = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ''
if (node.nodeType !== Node.ELEMENT_NODE) return ''
const el = node as Element
const tag = el.tagName.toLowerCase()
if (tag === 'script' || tag === 'style' || tag === 'iframe' || tag === 'object' || tag === 'embed') return ''
if (!allowed.has(tag)) return [...node.childNodes].map(walk).join('')
const attrs: string[] = []
if (tag === 'a' && el.getAttribute('href')) {
const href = el.getAttribute('href') ?? ''
if (/^https?:\/\//i.test(href) && !/javascript:/i.test(href)) attrs.push(`href="${href.replace(/"/g, '&quot;')}"`)
}
if (tag === 'img' && el.getAttribute('src')) {
const src = el.getAttribute('src') ?? ''
if (/^https?:\/\//i.test(src)) attrs.push(`src="${src.replace(/"/g, '&quot;')}"`)
}
const inner = [...node.childNodes].map(walk).join('')
return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>${inner}</${tag}>`
}
return [...div.childNodes].map(walk).join('')
}
const sanitizedContent = computed(() => {
const c = store.content
if (!c) return ''
if (/<[a-z][\s\S]*>/i.test(c)) return sanitizeHtml(c)
return `<p class="whitespace-pre-wrap">${c.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>`
})
function isSafeImgSrc(src: string): boolean {
try {
const u = new URL(src)
return /^https?:$/i.test(u.protocol)
} catch {
return false
}
}
const iframeRef = ref<HTMLIFrameElement | null>(null)
const iframeRefreshKey = ref(0)
const isRefreshing = ref(false)
function refreshIframe() {
isRefreshing.value = true
iframeRefreshKey.value++
}
function onIframeLoad() {
isRefreshing.value = false
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && store.isOpen) {
store.close()
e.preventDefault()
e.stopPropagation()
}
}
watch(
() => store.isOpen,
(open) => {
if (!open) isRefreshing.value = false
}
)
onMounted(() => {
window.addEventListener('keydown', onKeyDown, true)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeyDown, true)
})
const panelClasses = [
'w-full max-w-[calc(100vw-3rem)] h-[80vh] max-h-[calc(100vh-5rem)]',
'md:max-w-[calc(100vw-5rem)]',
]
</script>
<style scoped>
iframe::-webkit-scrollbar {
display: none;
}
.app-launcher-enter-active,
.app-launcher-leave-active {
transition: opacity 0.25s ease;
}
.app-launcher-enter-active .article-overlay-panel,
.app-launcher-leave-active .article-overlay-panel {
transition: transform 0.25s ease, opacity 0.25s ease;
}
.app-launcher-enter-from,
.app-launcher-leave-to {
opacity: 0;
}
.app-launcher-enter-from .article-overlay-panel,
.app-launcher-leave-to .article-overlay-panel {
transform: scale(0.96);
opacity: 0;
}
</style>
@@ -1,7 +1,7 @@
<template>
<div class="relative flex-1 flex flex-col min-h-0 overflow-hidden">
<div
v-if="contextType === 'film' || contextType === 'song' || contextType === 'podcast'"
v-if="contextType === 'film' || contextType === 'song' || contextType === 'podcast' || contextType === 'websites'"
class="flex-1 flex flex-col min-h-0"
>
<div
@@ -75,7 +75,7 @@ import LoadingFilmGrid from './LoadingFilmGrid.vue'
const props = withDefaults(
defineProps<{
contextType?: 'film' | 'song' | 'podcast' | 'generic'
contextType?: 'film' | 'song' | 'podcast' | 'websites' | 'generic'
}>(),
{ contextType: 'film' }
)
@@ -86,6 +86,7 @@ const contextLabel = computed(() => {
if (props.contextType === 'film') return 'Film recommendations'
if (props.contextType === 'song') return 'Song recommendations'
if (props.contextType === 'podcast') return 'Podcast recommendations'
if (props.contextType === 'websites') return 'Websites'
return 'Content'
})
</script>
@@ -1,22 +1,22 @@
<template>
<div class="film-detail h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
<div class="relative w-full overflow-hidden">
<div class="relative w-full overflow-hidden aspect-[16/7] shrink-0">
<img
v-if="bannerSrc"
:src="bannerSrc"
:alt="film.title"
class="w-full aspect-[16/7] object-cover block"
class="absolute inset-0 w-full h-full object-cover object-center block"
@error="onBannerError"
/>
<div
v-else
class="w-full aspect-[16/7]"
class="absolute inset-0"
:style="{ background: fallbackGradient }"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<button
class="absolute top-3 left-3 p-2 rounded-lg backdrop-blur-md transition-colors bg-black/30 text-white/80 hover:bg-black/50"
class="absolute top-3 left-3 p-2 rounded-lg path-glass-icon z-10 transition-colors hover:bg-white/10 text-white/80"
@click="$emit('back')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -43,12 +43,12 @@
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-3">
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="film in filteredFilms"
:key="film.id"
class="group flex flex-col items-stretch text-left w-full"
class="group flex flex-col items-stretch text-left w-full path-glass-bubble rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105"
@click="$emit('selectFilm', film)"
>
<div class="poster-card flex-1 min-h-0">
@@ -83,10 +83,6 @@
</div>
</div>
</div>
<p class="text-xs font-semibold mt-2 truncate px-0.5"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ film.title }}
</p>
</button>
</div>
@@ -1,68 +1,12 @@
<template>
<div class="flex w-full animate-pulse flex-col space-y-3">
<div
class="aspect-[2/3] rounded-xl overflow-hidden relative"
:class="isDark ? 'text-white/20' : 'text-gray-400'"
>
<svg
viewBox="0 0 200 300"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="w-full h-full object-cover"
>
<defs>
<clipPath :id="clipId">
<rect width="200" height="300" rx="12" fill="white"/>
</clipPath>
</defs>
<g :clip-path="`url(#${clipId})`">
<rect width="200" height="300" fill="currentColor" fill-opacity="0.4"/>
<g fill="currentColor" fill-opacity="0.5">
<rect x="12" y="16" width="24" height="36" rx="3"/>
<rect x="52" y="16" width="24" height="36" rx="3"/>
<rect x="92" y="16" width="24" height="36" rx="3"/>
<rect x="132" y="16" width="24" height="36" rx="3"/>
<rect x="12" y="64" width="24" height="36" rx="3"/>
<rect x="52" y="64" width="24" height="36" rx="3"/>
<rect x="92" y="64" width="24" height="36" rx="3"/>
<rect x="132" y="64" width="24" height="36" rx="3"/>
</g>
</g>
</svg>
</div>
<div v-if="hasText" class="space-y-2">
<div
class="h-5 w-24 rounded-full"
:class="isDark ? 'bg-white/20' : 'bg-gray-300'"
/>
<div class="flex items-center gap-2">
<div
class="h-4 w-16 rounded-full"
:class="isDark ? 'bg-white/15' : 'bg-gray-200'"
/>
<div
class="size-1.5 rounded-full shrink-0"
:class="isDark ? 'bg-white/15' : 'bg-gray-300'"
/>
<div
class="h-4 w-14 rounded-full"
:class="isDark ? 'bg-white/15' : 'bg-gray-200'"
/>
</div>
</div>
</div>
<div
class="aspect-[2/3] rounded-xl animate-pulse"
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
/>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
withDefaults(
defineProps<{
hasText?: boolean
}>(),
{ hasText: true }
)
const { isDark } = useTheme()
const clipId = `loading-poster-${Math.random().toString(36).slice(2, 10)}`
</script>
@@ -4,7 +4,6 @@
<LoadingFilmCard
v-for="i in count"
:key="i"
:has-text="hasText"
/>
</div>
</div>
@@ -16,8 +15,7 @@ import LoadingFilmCard from './LoadingFilmCard.vue'
withDefaults(
defineProps<{
count?: number
hasText?: boolean
}>(),
{ count: 12, hasText: true }
{ count: 12 }
)
</script>
@@ -0,0 +1,289 @@
<template>
<div class="h-full flex flex-col">
<!-- Masthead: AI Brief branding -->
<header
class="shrink-0 px-4 py-2 flex items-center justify-between gap-2"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<p class="text-[10px] uppercase tracking-[0.2em] font-semibold shrink-0"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
AI Brief
</p>
<div class="flex-1" />
<div class="shrink-0">
<slot name="header-actions" />
</div>
</header>
<div class="flex-1 overflow-y-auto custom-scrollbar pb-16">
<!-- Headline banner: edge-to-edge, tech style -->
<div
v-if="headlineText"
class="relative w-full pt-4 overflow-hidden"
:class="isDark ? 'bg-white/[0.02]' : 'bg-black/[0.02]'"
>
<!-- Tech lines decoration -->
<div class="absolute inset-0 pointer-events-none overflow-hidden">
<div
class="absolute left-0 top-0 bottom-0 w-px"
:class="isDark ? 'bg-gradient-to-b from-transparent via-white/20 to-transparent' : 'bg-gradient-to-b from-transparent via-black/10 to-transparent'"
/>
<div
class="absolute left-12 top-0 bottom-0 w-px"
:class="isDark ? 'bg-white/5' : 'bg-black/5'"
/>
<div
class="absolute right-0 top-0 bottom-0 w-px"
:class="isDark ? 'bg-gradient-to-b from-transparent via-white/20 to-transparent' : 'bg-gradient-to-b from-transparent via-black/10 to-transparent'"
/>
<div
class="absolute left-0 right-0 bottom-0 h-px"
:class="isDark ? 'bg-gradient-to-r from-transparent via-white/15 to-transparent' : 'bg-gradient-to-r from-transparent via-black/10 to-transparent'"
/>
<!-- Scan line accent -->
<div
class="absolute left-0 right-0 top-1/2 h-px"
:class="isDark ? 'bg-white/5' : 'bg-black/5'"
/>
</div>
<div class="relative flex items-start gap-3 px-4 py-5 sm:py-6">
<!-- News icon -->
<div
class="shrink-0 w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center"
:class="isDark ? 'bg-white/10' : 'bg-black/5'"
>
<svg class="w-5 h-5 sm:w-6 sm:h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
<path d="M18 14h-8" />
<path d="M15 18h-5" />
<path d="M10 6h8v4h-8V6Z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<span
class="text-[9px] uppercase tracking-[0.25em] font-bold block mb-1"
:class="isDark ? 'text-white/40' : 'text-gray-500'"
>
What you asked
</span>
<h1
class="text-xl sm:text-2xl font-bold leading-snug"
:class="isDark ? 'text-white/95' : 'text-gray-900'"
>
{{ headlineText }}
</h1>
</div>
</div>
</div>
<!-- News homepage body -->
<div
class="space-y-4 px-4 pt-5"
>
<!-- Lead story: first section -->
<article
v-if="sections[0]"
class="path-glass-bubble rounded-2xl p-4 sm:p-5 border-l relative"
:class="isDark ? 'border-white/10' : 'border-black/5'"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<span
class="text-[9px] uppercase tracking-widest font-bold block mb-1.5"
:class="isDark ? 'text-white/40' : 'text-gray-500'"
>
Lead
</span>
<h2 class="text-base font-bold mb-2"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ sections[0].title }}
</h2>
<p v-if="sections[0].author"
class="text-[10px] mb-2"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ sections[0].author }}
</p>
<p class="text-sm leading-relaxed"
:class="isDark ? 'text-white/80' : 'text-gray-700'"
v-html="formatContent(sections[0].content)"
/>
</div>
<button
v-if="sections[0].url"
class="shrink-0 p-2 rounded-lg transition-colors"
:class="isDark ? 'text-white/50 hover:bg-white/10 hover:text-white/70' : 'text-gray-500 hover:bg-black/5 hover:text-gray-700'"
title="Open in new window"
aria-label="Open link"
@click.stop="openLink(sections[0].url!)"
>
<svg 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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</div>
</article>
<!-- Secondary stories grid -->
<div class="grid sm:grid-cols-2 gap-4">
<article
v-for="(section, i) in sections.slice(1)"
:key="i"
class="path-glass-bubble rounded-2xl p-3 sm:p-4 relative"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<h3 class="text-sm font-bold mb-1"
:class="isDark ? 'text-white/85' : 'text-gray-800'">
{{ section.title }}
</h3>
<p v-if="section.author"
class="text-[10px] mb-2"
:class="isDark ? 'text-white/45' : 'text-gray-500'">
{{ section.author }}
</p>
<p class="text-xs leading-relaxed"
:class="isDark ? 'text-white/75' : 'text-gray-600'"
v-html="formatContent(section.content)"
/>
</div>
<button
v-if="section.url"
class="shrink-0 p-1.5 rounded-lg transition-colors"
:class="isDark ? 'text-white/40 hover:bg-white/10 hover:text-white/60' : 'text-gray-400 hover:bg-black/5 hover:text-gray-600'"
title="Open in new window"
aria-label="Open link"
@click.stop="openLink(section.url!)"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</div>
</article>
</div>
</div>
<div v-if="sections.length === 0" class="flex items-center justify-center py-12 px-4 pt-5">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No sections to display
</p>
</div>
<!-- Images below content: equally sized containers -->
<div class="px-4 pt-6 grid grid-cols-2 gap-4">
<div
class="path-glass-bubble rounded-2xl overflow-hidden aspect-[4/3]"
>
<div class="relative w-full h-full overflow-hidden">
<img
:src="heroImageDisplay"
alt=""
class="absolute inset-0 w-full h-full object-cover"
loading="lazy"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent pointer-events-none" />
</div>
</div>
<div
class="path-glass-bubble rounded-2xl overflow-hidden aspect-[4/3]"
>
<div class="relative w-full h-full overflow-hidden">
<img
:src="memeImageUrl"
alt=""
class="absolute inset-0 w-full h-full object-cover"
loading="lazy"
/>
<div class="absolute bottom-0 left-0 right-0 p-2 text-center"
:class="isDark ? 'bg-black/70' : 'bg-black/50'">
<p class="text-[11px] font-bold"
:class="isDark ? 'text-white/95' : 'text-white'">
{{ memeCaption }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useArticleOverlayStore } from '@/stores/articleOverlay'
import type { MagazineSection } from '@/composables/useContentPanel'
const props = withDefaults(defineProps<{
sections: MagazineSection[]
/** Hero image URL (from response or web results) */
heroImageUrl?: string | null
title?: string
/** User's prompt for headline banner */
query?: string
}>(), {
heroImageUrl: null,
title: 'Brief',
query: '',
})
const { isDark } = useTheme()
const overlayStore = useArticleOverlayStore()
function isSafeImgUrl(u: string | undefined | null): u is string {
return !!u && typeof u === 'string' && /^https?:\/\//i.test(u.trim())
}
/** Hero image: use extracted/web result, or picsum fallback seeded by query */
const heroImageDisplay = computed(() => {
if (props.heroImageUrl && isSafeImgUrl(props.heroImageUrl)) return props.heroImageUrl
const seed = (props.query || 'magazine').toLowerCase().replace(/\s+/g, '-').slice(0, 30) || 'brief'
return `https://picsum.photos/seed/${seed}/800/450`
})
/** Meme image: contextual meme templates (imgflip) */
const memeImageUrl = computed(() => {
const text = props.sections.map((s) => s.title + ' ' + s.content).join(' ').toLowerCase()
const q = (props.query || '').toLowerCase()
const combined = text + ' ' + q
if (/\bbearish|bear|fear|dump|crash|extreme fear\b/.test(combined)) return 'https://i.imgflip.com/wxica.jpg' // This is Fine
if (/\bbull|rally|moon|pump|buy the dip\b/.test(combined)) return 'https://i.imgflip.com/1bhk.jpg' // Success Kid
if (/\bbitcoin|btc\b/.test(combined)) return 'https://i.imgflip.com/30b1gx.jpg' // Drake
if (/\bmacro|fed|rate|inflation\b/.test(combined)) return 'https://i.imgflip.com/1ur9b0.jpg' // Distracted Boyfriend
return 'https://i.imgflip.com/1bij.jpg' // One does not simply
})
/** Meme caption: short contextual phrase */
const memeCaption = computed(() => {
const text = props.sections.map((s) => s.title + ' ' + s.content).join(' ').toLowerCase()
if (/\bbearish|fear|extreme fear\b/.test(text)) return 'Me checking my portfolio'
if (/\bbull|rally|moon\b/.test(text)) return 'Buying the dip'
if (/\bmacro|fed|inflation\b/.test(text)) return 'The economy rn'
if (/\bbitcoin|btc\b/.test(text)) return 'Bitcoin holders'
return 'Markets be like'
})
function openLink(url: string) {
overlayStore.open(url, '', undefined, undefined)
}
const headlineText = computed(() => {
const q = (props.query ?? '').trim()
if (!q) return props.title
return q.length > 100 ? q.slice(0, 97) + '…' : q
})
/** Render content with **bold** preserved (sanitized). */
function formatContent(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
}
</script>
@@ -0,0 +1,89 @@
<template>
<button
class="flex gap-3 p-2 rounded-xl transition-all duration-200 text-left w-full group overflow-hidden"
:class="isDark
? 'hover:bg-white/5 active:bg-white/10'
: 'hover:bg-black/[0.03] active:bg-black/5'"
@click="$emit('select-article', article)"
>
<div class="cover-card-sm shrink-0 w-12 h-12 rounded-lg overflow-hidden">
<img
v-if="imgSrc"
:src="imgSrc"
:alt="article.title"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
@error="imgFailed = true"
/>
<div
v-else
class="w-full h-full rounded-[6px] bg-cover bg-center flex items-center justify-center"
:style="{ backgroundImage: faviconUrl ? `url(${faviconUrl})` : undefined }"
>
<span v-if="!faviconUrl"
class="text-lg opacity-40"
:class="isDark ? 'text-white' : 'text-gray-600'">📰</span>
</div>
</div>
<div class="min-w-0 flex-1 py-0.5">
<p class="text-sm font-semibold truncate"
:class="isDark ? 'text-white/90' : 'text-gray-900'">{{ article.title }}</p>
<p v-if="article.content"
class="text-[11px] mt-0.5 line-clamp-2"
:class="isDark ? 'text-white/40' : 'text-gray-500'">
{{ article.content }}
</p>
<p class="text-[10px] mt-1 truncate"
:class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ formatDomain(article.url) }}
</p>
</div>
<svg class="w-4 h-4 shrink-0 self-center opacity-50"
:class="isDark ? 'text-white/50' : 'text-gray-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
const props = defineProps<{ article: WebSearchResult }>()
defineEmits<{ 'select-article': [article: WebSearchResult] }>()
const { isDark } = useTheme()
const imgFailed = ref(false)
function isSafeImgUrl(u: string | undefined): u is string {
return !!u && typeof u === 'string' && /^https?:\/\//i.test(u.trim())
}
const imgSrc = computed(() => {
if (imgFailed.value) return null
const u = props.article.imgSrc
return isSafeImgUrl(u) ? u : null
})
const faviconUrl = computed(() => {
if (imgSrc.value) return null
try {
const u = new URL(props.article.url)
if (!/^https?:\/\//i.test(props.article.url)) return null
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(u.hostname)}&sz=64`
} catch {
return null
}
})
function formatDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '')
} catch {
return url
}
}
</script>
@@ -0,0 +1,214 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3" :style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ title }}
</h3>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[10px] font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredArticles.length }} {{ variant === 'websites' ? 'websites' : 'articles' }}
</span>
<slot name="header-actions" />
</div>
</div>
<input
v-model="search"
type="text"
:placeholder="variant === 'websites' ? 'Search websites...' : 'Search articles...'"
class="w-full px-3 py-2 rounded-lg text-xs outline-none transition-colors"
:class="isDark
? 'bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10'
: 'bg-black/3 text-gray-800 placeholder:text-gray-400 focus:bg-black/5'"
/>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="(article, i) in filteredArticles"
:key="i"
class="group flex flex-col items-stretch text-left w-full path-glass-bubble rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105"
@click="openArticle(article)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-[4/3] flex flex-col w-full overflow-hidden rounded-[10px]">
<!-- Top: image or icon area (edge-to-edge with title bar below) -->
<div class="flex-1 min-h-0 relative">
<img
v-if="isSafeImgUrl(article.imgSrc) && !failedImgs.has(article.url)"
:src="article.imgSrc"
:alt="article.title"
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="onImgError(article.url)"
/>
<div
v-else
class="absolute inset-0 flex items-center justify-center"
:class="isDark ? 'bg-white/5' : 'bg-black/[0.04]'"
>
<!-- Websites: glassmorphic circle -->
<div
v-if="variant === 'websites'"
class="w-14 h-14 rounded-full flex items-center justify-center path-glass-icon shrink-0"
>
<img
v-if="faviconUrl(article.url)"
:src="faviconUrl(article.url)!"
:alt="formatDomain(article.url)"
class="w-7 h-7 object-contain"
/>
<span v-else
class="text-xl opacity-70"
:class="isDark ? 'text-white' : 'text-gray-600'">🌐</span>
</div>
<!-- News: icon -->
<template v-else>
<img
v-if="faviconUrl(article.url)"
:src="faviconUrl(article.url)!"
:alt="formatDomain(article.url)"
class="w-8 h-8 object-contain opacity-70"
/>
<span v-else
class="text-2xl opacity-40"
:class="isDark ? 'text-white' : 'text-gray-600'">📰</span>
</template>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent pointer-events-none" />
</div>
<div
class="shrink-0 p-2 backdrop-blur-md rounded-b-[10px]"
:class="[
isDark ? 'bg-black shadow-[inset_0_1px_0_rgba(255,255,255,0.12)]' : 'bg-white shadow-[inset_0_1px_0_rgba(0,0,0,0.06)]'
]"
>
<p class="text-[11px] font-semibold leading-tight line-clamp-2"
:class="isDark ? 'text-white/95' : 'text-gray-900'">
{{ article.title }}
</p>
<p v-if="article.content"
class="text-[9px] line-clamp-1 mt-0.5"
:class="isDark ? 'text-white/70' : 'text-gray-600'">
{{ article.content }}
</p>
<p class="text-[8px] truncate mt-0.5"
:class="isDark ? 'text-white/50' : 'text-gray-500'">
{{ formatDomain(article.url) }}
</p>
</div>
</div>
</div>
</button>
</div>
<div v-if="filteredArticles.length === 0" class="flex items-center justify-center py-12">
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ variant === 'websites' ? 'No websites match your search' : 'No articles match your search' }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
import { useContentPanel } from '@/composables/useContentPanel'
import { useArticleOverlayStore } from '@/stores/articleOverlay'
const props = withDefaults(defineProps<{
articles: WebSearchResult[]
title?: string
/** User query for contextual sorting (most relevant first) */
query?: string
/** 'news' = icon in upper area; 'websites' = icon in glassmorphic circle centered */
variant?: 'news' | 'websites'
}>(), {
title: 'News & Articles',
query: '',
variant: 'news',
})
const { isDark } = useTheme()
const { openArticleDetail } = useContentPanel()
const overlayStore = useArticleOverlayStore()
const search = ref('')
const failedImgs = ref<Set<string>>(new Set())
function isSafeImgUrl(u: string | undefined): u is string {
if (!u || typeof u !== 'string') return false
return /^https?:\/\//i.test(u.trim())
}
function onImgError(url: string) {
failedImgs.value = new Set([...failedImgs.value, url])
}
function formatDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '')
} catch {
return url
}
}
function faviconUrl(url: string): string | null {
try {
if (!/^https?:\/\//i.test(url)) return null
const u = new URL(url)
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(u.hostname)}&sz=64`
} catch {
return null
}
}
function openArticle(article: WebSearchResult) {
if (props.variant === 'websites') {
overlayStore.open(article.url, article.title, undefined, article.imgSrc)
} else {
openArticleDetail(article)
}
}
function relevanceScore(article: WebSearchResult, query: string): number {
if (!query.trim()) return 0
const q = query.toLowerCase()
const terms = q.split(/\s+/).filter((t) => t.length > 1)
if (terms.length === 0) return 0
const title = article.title.toLowerCase()
const content = (article.content ?? '').toLowerCase()
const url = article.url.toLowerCase()
let score = 0
for (const term of terms) {
if (title.includes(term)) score += 3
if (content.includes(term)) score += 2
if (url.includes(term)) score += 1
}
return score
}
const filteredArticles = computed(() => {
let list = props.articles
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(
(a) =>
a.title.toLowerCase().includes(q) ||
(a.content ?? '').toLowerCase().includes(q) ||
a.url.toLowerCase().includes(q),
)
}
if (props.query.trim()) {
return [...list].sort((a, b) => relevanceScore(b, props.query) - relevanceScore(a, props.query))
}
return list
})
</script>
@@ -49,20 +49,28 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import type { Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePodcastCoverFallback } from '@/composables/useImageFallback'
import { generatePodcastCoverFallback, fetchPodcastCover } from '@/composables/useImageFallback'
const props = defineProps<{ podcast: Podcast }>()
defineEmits<{ select: [podcast: Podcast] }>()
const { isDark } = useTheme()
const coverFailed = ref(false)
const fetchedCover = ref<string | null>(null)
const coverSrc = computed(() => {
if (coverFailed.value) return null
return props.podcast.coverUrl || null
return props.podcast.coverUrl || fetchedCover.value || null
})
onMounted(() => {
if (props.podcast.coverUrl) return
fetchPodcastCover(props.podcast.title, props.podcast.host).then((url) => {
if (url) fetchedCover.value = url
})
})
const fallbackCover = computed(() =>
@@ -112,20 +112,28 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import type { Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePodcastCoverFallback } from '@/composables/useImageFallback'
import { generatePodcastCoverFallback, fetchPodcastCover } from '@/composables/useImageFallback'
const props = defineProps<{ podcast: Podcast }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const coverFailed = ref(false)
const fetchedCover = ref<string | null>(null)
const coverSrc = computed(() => {
if (coverFailed.value) return null
return props.podcast.coverUrl || null
return props.podcast.coverUrl || fetchedCover.value || null
})
onMounted(() => {
if (props.podcast.coverUrl) return
fetchPodcastCover(props.podcast.title, props.podcast.host).then((url) => {
if (url) fetchedCover.value = url
})
})
const fallbackCover = computed(() =>
@@ -43,12 +43,12 @@
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-3">
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="podcast in filteredPodcasts"
:key="podcast.id"
class="group flex flex-col items-stretch text-left w-full"
class="group flex flex-col items-stretch text-left w-full path-glass-bubble rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105"
@click="$emit('selectPodcast', podcast)"
>
<div class="cover-card flex-1 min-h-0 relative">
@@ -86,10 +86,6 @@
</div>
</div>
</div>
<p class="text-xs font-semibold mt-2 truncate px-0.5"
:class="isDark ? 'text-white/90' : 'text-gray-900'">
{{ podcast.title }}
</p>
</button>
</div>
@@ -103,10 +99,10 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, reactive, onMounted, watch } from 'vue'
import type { Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePodcastCoverFallback } from '@/composables/useImageFallback'
import { generatePodcastCoverFallback, fetchPodcastCover } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
podcasts: Podcast[]
@@ -121,12 +117,25 @@ const { isDark } = useTheme()
const search = ref('')
const activeGenre = ref<string | null>(null)
const failedCovers = ref<Set<string>>(new Set())
const fetchedCovers = reactive<Map<string, string>>(new Map())
function coverSrc(podcast: Podcast): string | null {
if (failedCovers.value.has(podcast.id)) return null
return podcast.coverUrl || null
return podcast.coverUrl || fetchedCovers.get(podcast.id) || null
}
function fetchCoversFor(podcasts: Podcast[]) {
for (const podcast of podcasts) {
if (podcast.coverUrl || fetchedCovers.has(podcast.id)) continue
fetchPodcastCover(podcast.title, podcast.host).then((url) => {
if (url) fetchedCovers.set(podcast.id, url)
})
}
}
onMounted(() => fetchCoversFor(props.podcasts))
watch(() => props.podcasts, (p) => fetchCoversFor(p), { immediate: false })
function fallbackFor(podcast: Podcast): string {
return generatePodcastCoverFallback(podcast.title, podcast.host)
}
@@ -43,19 +43,19 @@
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-3">
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<button
v-for="song in filteredSongs"
:key="song.id"
class="group flex flex-col items-stretch text-left w-full"
class="group flex flex-col items-stretch text-left w-full path-glass-bubble rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105"
@click="$emit('selectSong', song)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]">
<button
class="absolute inset-0 flex items-center justify-center z-10 backdrop-blur-sm bg-black/30 opacity-0 group-hover:opacity-100 transition-all duration-200"
title="Play"
aria-label="Play"
@click.stop="onPlayClick(song)"
>
<span class="w-16 h-16 rounded-full flex items-center justify-center path-glass-icon">
+41 -4
View File
@@ -1,5 +1,6 @@
import { ref, computed } from 'vue'
import { useChatStore } from '@/stores/chat'
import { searchWeb } from '@/composables/useWebSearch'
type Provider = 'claude' | 'openrouter' | 'mock'
@@ -24,6 +25,8 @@ const podcastContext = mockPodcasts.map((p) =>
const SYSTEM_PROMPT = `You are AIUI, a helpful AI assistant with access to the user's media library (films, songs, and podcasts).
**News/Factual queries:** When the user asks for "news", "latest", "recent", or current information, lead with a direct answer summarizing the news/facts. You MAY add "For deeper coverage:" with [[podcast_ext:...]] tags only. Do NOT use [[song_ext:...]] or [[film_ext:...]] for news queries—podcasts are the appropriate follow-up. Never substitute an answer with only recommendations.
**Films:** When recommending or discussing films from the user's library, use [[film:ID]] where ID is the film's id. For films NOT in the library, use [[film_ext:Title|Year|Director]], e.g. [[film_ext:Brokeback Mountain|2005|Ang Lee]].
**Songs:** When recommending or discussing songs, ALWAYS use tags for every song you mention:
@@ -36,6 +39,8 @@ Never list songs in plain text only—each recommendation must have a tag so the
- Other podcasts: [[podcast_ext:Title|Host|Year]] (year optional), e.g. [[podcast_ext:What Bitcoin Did|Peter McCormack|2018]].
Prioritize Podcasting 2.0friendly platforms: Fountain.fm, Podcast Index, Castopod, Odysee, Rumble, YouTube, Podverse.
**Websites / "Best places to check":** When listing resources, places to check online, or websites for the user to visit, use markdown links: [Name](https://full-url). For simple domains use **Name** (domain.com), e.g. **Bitcoin Mailing List** (gnusha.org).
**Music discovery:** For genre-based requests (e.g. "best math rock"), pick from the user's library when relevant, or use [[song_ext:...]] for others. Prioritize indie-friendly platforms: Wavlake, Bandcamp, Internet Archive, SoundCloud, Odysee, Jamendo.
Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out.
@@ -124,15 +129,18 @@ async function streamClaude(
messages: ChatMessage[],
onToken: (text: string) => void,
onError: (err: string) => void,
systemPrompt: string,
webSearch: boolean,
): Promise<void> {
const res = await fetch(CLAUDE_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: activeModel.value,
system: SYSTEM_PROMPT,
system: systemPrompt,
messages,
stream: true,
webSearch,
}),
})
@@ -156,6 +164,7 @@ async function streamOpenRouter(
messages: ChatMessage[],
onToken: (text: string) => void,
onError: (err: string) => void,
systemPrompt: string,
): Promise<void> {
if (!openrouterApiKey) {
onError('Missing VITE_OPENROUTER_API_KEY in .env.local')
@@ -163,7 +172,7 @@ async function streamOpenRouter(
}
const orMessages = [
{ role: 'system' as const, content: SYSTEM_PROMPT },
{ role: 'system' as const, content: systemPrompt },
...messages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })),
]
@@ -232,6 +241,17 @@ async function readSSE(
}
}
function formatWebSearchContext(results: { title: string; url: string; content?: string }[]): string {
if (results.length === 0) return ''
const lines = results.map((r, i) => {
const snippet = r.content ? `${r.content.slice(0, 200)}${r.content.length > 200 ? '…' : ''}` : ''
return `${i + 1}. [${r.title}](${r.url})${snippet}`
})
return `\n\n**Web search results (PRIORITIZE these):**
- Answer the user's question using these results. Cite sources.
- You MAY add [[podcast_ext:...]] or [[film_ext:...]] tags for "to learn more" recommendations after your answer.\n\n${lines.join('\n')}`
}
export function useAI() {
const chatStore = useChatStore()
@@ -250,6 +270,23 @@ export function useAI() {
chatStore.isStreaming = true
let systemPrompt = SYSTEM_PROMPT
if (chatStore.webSearchEnabled) {
systemPrompt += `
**Web search:** You have access to WebSearch and WebFetch tools. Use them to look up current information, news, and facts when the user asks. You can search the web and fetch page content. Web search is enabled for this session—do not tell the user it is unavailable.`
}
if (chatStore.webSearchEnabled && userText.trim()) {
const results = await searchWeb(userText)
if (results.length > 0) {
systemPrompt += formatWebSearchContext(results)
chatStore.setMessageWebResults(cid, assistantMsg.id, results)
console.log('[AIUI] Injected', results.length, 'web search results into context')
} else {
console.warn('[AIUI] Web search enabled but 0 results — check browser console for [AIUI web-search] logs')
}
}
const history: ChatMessage[] = chatStore.messages
.filter((m) => m.id !== assistantMsg.id)
.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }))
@@ -262,9 +299,9 @@ export function useAI() {
try {
if (provider === 'claude') {
await streamClaude(history, onToken, onError)
await streamClaude(history, onToken, onError, systemPrompt, chatStore.webSearchEnabled)
} else if (provider === 'openrouter') {
await streamOpenRouter(history, onToken, onError)
await streamOpenRouter(history, onToken, onError, systemPrompt)
} else {
await streamMock(history, onToken)
}
+424 -35
View File
@@ -1,26 +1,277 @@
import { ref } from 'vue'
import type { Film, Song, Podcast } from '@aiui/core/types/content'
import type { WebSearchResult } from '@aiui/core/types/message'
import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs'
import { mockPodcasts } from '@/mocks/podcasts'
import { generatePosterFallback } from '@/composables/useImageFallback'
import { generatePosterFallback, generateSongCoverFallback } from '@/composables/useImageFallback'
import { fetchRssFromUrls } from '@/composables/useRssFetch'
export type ContentTab = 'film' | 'song' | 'podcast' | 'news' | 'websites' | 'magazine'
export interface MagazineSection {
title: string
content: string
/** Optional image URL (from markdown or parsed) */
imageUrl?: string
/** Optional author (e.g. "Henrik Zeberg") */
author?: string
/** Optional link to open in iframe */
url?: string
}
const panelOpen = ref(false)
const panelFilms = ref<Film[]>([])
const panelWebResults = ref<WebSearchResult[]>([])
const panelRssArticles = ref<WebSearchResult[]>([])
const panelWebsites = ref<WebSearchResult[]>([])
const panelMagazineSections = ref<MagazineSection[]>([])
const panelMagazineHeroImage = ref<string | null>(null)
const panelSongs = ref<Song[]>([])
const panelPodcasts = ref<Podcast[]>([])
const selectedFilm = ref<Film | null>(null)
const selectedSong = ref<Song | null>(null)
const selectedPodcast = ref<Podcast | null>(null)
const selectedArticle = ref<WebSearchResult | null>(null)
const panelTitle = ref('Recommended Films')
const panelQuery = ref('')
const contentType = ref<'film' | 'song' | 'podcast'>('film')
const activeTab = ref<ContentTab>('film')
const availableTabs = ref<ContentTab[]>([])
function isNewsQuery(q: string): boolean {
const lower = q.toLowerCase().trim()
if (!lower) return false
return /\b(news|latest|recent|current|what'?s happening|updates? about)\b/.test(lower) ||
/what'?s the latest|latest \w+ news/.test(lower) ||
/what are people saying|what'?s the word|what do people think/.test(lower)
}
function isNewsLikeResponse(text: string): boolean {
const lower = text.toLowerCase()
return /for instant .* news|check these sources|for (the )?latest (bitcoin )?news|direct sources/i.test(lower) ||
/(have )?access to (live )?web search|want me to (go back and )?search/i.test(lower)
}
function isWebsitesQuery(q: string): boolean {
const lower = q.toLowerCase().trim()
return /\b(website|websites|where to check|best places? to check|places? to (look|check|find)|check online|resources?|sources? to (check|read|visit))\b/.test(lower) ||
/where (can i|should i) (check|look|find)/.test(lower)
}
function isWebsitesLikeResponse(text: string): boolean {
const lower = text.toLowerCase()
return /best places? to check|check online yourself|places? to check online|websites? to (visit|check|read)/i.test(lower)
}
function extractUrlFromText(text: string): string | undefined {
const mdLink = /\[([^\]]*)\]\((https?:\/\/[^)]+)\)/.exec(text)
if (mdLink) return mdLink[2]
const bare = /(https?:\/\/[^\s)\]\"'<>]+)/.exec(text)
return bare ? bare[1] : undefined
}
function extractAuthorFromText(text: string): string | undefined {
const patterns = [
/(?:analyst|according to)\s+\*{0,2}([^*\n]+?)\*{0,2}(?:\s+(?:is|calls?|says?|cited)|\.|,)/i,
/\bby\s+\*{0,2}([^*\n]+?)\*{0,2}(?:\s|$|\.|,)/i,
/(?:source|—)\s*:?\s*\*{0,2}([^*\n]+?)\*{0,2}(?:\s|$|\.|,)/i,
/\*\*([^*]+)\*\*(?:\s+(?:is|calls?|says?|cited|predicts?))/,
]
for (const re of patterns) {
const m = re.exec(text)
if (m) {
const name = m[1].trim().slice(0, 60)
if (name.length > 2 && name.length < 50) return name
}
}
return undefined
}
function extractFirstImageFromText(text: string): string | undefined {
const mdImg = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(text)
if (mdImg) return mdImg[1]
const ext = /(https?:\/\/[^\s)\]\"'<>]+\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s)\]]*)?)/i.exec(text)
return ext ? ext[1] : undefined
}
/** Extract magazine-style bullets: - **Title**: Content or - **Title** — Content */
function extractMagazineSections(text: string): MagazineSection[] {
const sections: MagazineSection[] = []
const re = /^\s*[-]\s*\*\*([^*]+)\*\*\s*[:\u2014\u2013]\s*(.+?)(?=\n\s*[-]\s*\*\*|\n\s*[-]\s*[^*]|\n\n##\s|$)/gms
let match: RegExpExecArray | null
while ((match = re.exec(text)) !== null) {
const title = match[1].trim().slice(0, 120)
const raw = match[2].trim().replace(/\n+/g, ' ')
const content = raw.slice(0, 800)
if (title.length > 1 && content.length > 10) {
const url = extractUrlFromText(raw)
const author = extractAuthorFromText(raw)
const imageUrl = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/.exec(raw)?.[1]
sections.push({ title, content, url, author, imageUrl })
}
}
return sections
}
/** Extract first image from text for magazine hero */
function extractMagazineHeroImage(text: string): string | undefined {
return extractFirstImageFromText(text)
}
/** Extract **Name** (domain) pattern, e.g. **Bitcoin Mailing List** (gnusha.org) */
function extractBoldDomainLinks(text: string): WebSearchResult[] {
const results: WebSearchResult[] = []
const seen = new Set<string>()
const re = /\*\*([^*]+)\*\*\s*\(([a-zA-Z0-9][-a-zA-Z0-9.]*\.[a-zA-Z]{2,})\)/g
let match: RegExpExecArray | null
while ((match = re.exec(text)) !== null) {
const title = match[1].trim().slice(0, 500)
const domain = match[2].trim()
if (title.length < 2) continue
const url = /^https?:\/\//i.test(domain) ? domain : `https://${domain}`
const norm = normUrl(url)
if (seen.has(norm)) continue
seen.add(norm)
results.push({ title, url, content: undefined })
}
return results
}
/** Infer which tab to show first from user prompt keywords */
/** Extract a short contextual phrase from the user query for display (e.g. "BIP 110" from "what is BIP 110") */
function extractQueryContext(q: string): string {
const stop = /\b(what|is|are|the|a|an|latest|recent|current|news|about|for|how|why|when|where|can|could|should|would|tell|me|please|best|good)\b/gi
const cleaned = q.replace(stop, ' ').replace(/\s+/g, ' ').trim().slice(0, 60)
return cleaned || ''
}
function preferredFirstTab(userQuery: string): ContentTab | null {
const q = userQuery.toLowerCase().trim()
if (/\b(film|movie|movies)\b/.test(q)) return 'film'
if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song'
if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
if (isNewsQuery(q)) return 'news'
if (isWebsitesQuery(q)) return 'websites'
return null
}
/** Filter which content types to show based on query + response context (no presets).
* First tab defaults to what the user asked about when detectable. */
function filterTabsByContext(
userQuery: string,
hasFilms: boolean,
hasSongs: boolean,
hasPodcasts: boolean,
hasNews: boolean,
hasWebsites: boolean,
hasMagazine: boolean,
): ContentTab[] {
const q = userQuery.toLowerCase().trim()
const preferred = preferredFirstTab(userQuery)
if (isNewsQuery(q)) {
const tabs: ContentTab[] = []
if (hasMagazine) tabs.push('magazine')
if (hasNews) tabs.push('news')
if (hasWebsites) tabs.push('websites')
if (hasPodcasts) tabs.push('podcast')
return tabs
}
if (hasMagazine && !hasFilms && !hasSongs && !hasPodcasts && !hasNews && !hasWebsites) {
return ['magazine']
}
if (hasWebsites && !hasFilms && !hasSongs && !hasPodcasts && !hasNews && !hasMagazine) {
return ['websites']
}
const all: ContentTab[] = []
if (hasFilms) all.push('film')
if (hasSongs) all.push('song')
if (hasPodcasts) all.push('podcast')
if (hasMagazine) all.push('magazine')
if (hasNews) all.push('news')
if (hasWebsites) all.push('websites')
if (preferred && all.includes(preferred)) {
const rest = all.filter((t) => t !== preferred)
return [preferred, ...rest]
}
return all
}
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
/** Reject obvious non-song phrases (news bullets, factual descriptions, etc.) */
function looksLikeSong(title: string, artist: string): boolean {
const t = title.toLowerCase()
const a = artist.toLowerCase()
const bad = [
'latest news', 'protocol updates', 'community debates', 'real-time information',
'training cutoff', 'bip discussion', 'beyond my training', 'what people are saying',
'want me to go', 'search for what', 'look up things', 'direct answer',
'for deeper coverage', 'for instant', 'check these sources',
'bip 110', 'bip discussions', 'web search',
'developer mailing list', 'mailing list reactions', 'technical opinions',
'community sentiment', 'twitter', 'reddit', 'github', 'stackexchange',
'bitcoin bips', 'bitcoin mailing', 'canonical source', 'formal dev',
'what i\'d suggest', 'for bip', 'sources to',
]
for (const phrase of bad) {
if (t.includes(phrase) || a.includes(phrase)) return false
}
if (t.length > 55 || a.length > 40) return false
return true
}
const PODCAST_TAG_RE = /\[\[podcast:(p?\d+)\]\]/gi
const PODCAST_EXT_RE = /\[\[podcast_ext:([^|]+)\|([^|]+)(?:\|(\d{4}))?\]\]/gi
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g
const SAFE_URL_SCHEME = /^https?:\/\//i
function extractMarkdownLinks(text: string): WebSearchResult[] {
const results: WebSearchResult[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
const re = new RegExp(MARKDOWN_LINK_RE.source, 'g')
while ((match = re.exec(text)) !== null) {
const title = match[1].trim().slice(0, 500)
const rawUrl = match[2].trim()
if (title.length < 2 || rawUrl.length < 10 || !SAFE_URL_SCHEME.test(rawUrl)) continue
try {
new URL(rawUrl)
} catch {
continue
}
const norm = rawUrl.toLowerCase().replace(/\/$/, '')
if (seen.has(norm)) continue
seen.add(norm)
results.push({ title, url: rawUrl, content: undefined })
}
return results
}
function normUrl(u: string): string {
return u.toLowerCase().trim().replace(/\/$/, '')
}
function mergeNewsResults(web: WebSearchResult[], fromText: WebSearchResult[]): WebSearchResult[] {
const byUrl = new Map<string, WebSearchResult>()
for (const r of web) {
byUrl.set(normUrl(r.url), r)
}
for (const r of fromText) {
const k = normUrl(r.url)
if (!byUrl.has(k)) byUrl.set(k, r)
}
return [...byUrl.values()]
}
export function useContentPanel() {
function normalizeFilmId(raw: string): string {
@@ -108,6 +359,7 @@ export function useContentPanel() {
while ((match = re.exec(text)) !== null) {
const title = match[1].trim()
const artist = match[2].trim()
if (!looksLikeSong(title, artist)) continue
const year = match[3] ? parseInt(match[3], 10) : undefined
const key = `${title.toLowerCase()}|${artist.toLowerCase()}`
if (seen.has(key)) continue
@@ -117,6 +369,7 @@ export function useContentPanel() {
title,
artist,
year,
coverUrl: generateSongCoverFallback(title, artist),
sources: [],
})
}
@@ -167,6 +420,7 @@ export function useContentPanel() {
const title = m[titleIdx].trim()
const artist = m[artistIdx].trim()
if (title.length < 2 || artist.length < 2) continue
if (!looksLikeSong(title, artist)) continue
if (/^\d{4}$/.test(title) || /^\d{4}$/.test(artist)) continue
if (/\[\[(film|song)(_ext)?:/.test(title) || /\[\[(film|song)(_ext)?:/.test(artist)) continue
if (/\*\*\[\[/.test(title) || title.includes(']]**')) continue
@@ -183,6 +437,7 @@ export function useContentPanel() {
id: `ext-${`${title}|${artist}`.toLowerCase().replace(/\W/g, '-')}`,
title,
artist,
coverUrl: generateSongCoverFallback(title, artist),
sources: [],
}))
}
@@ -194,6 +449,8 @@ export function useContentPanel() {
return [...librarySongs, ...externalSongs]
}
if (extractFilmIds(text).length > 0 || /\[\[film_ext:/.test(text)) return []
if (extractPodcastIds(text).length > 0 || /\[\[podcast_ext:/.test(text)) return []
if (isNewsLikeResponse(text)) return []
const libMatches = extractSongsFromLibraryMatch(text)
const patternMatches = extractSongsFromPatterns(text)
const libKeys = new Set(libMatches.map((s) => `${s.title.toLowerCase()}|${s.artist.toLowerCase()}`))
@@ -241,6 +498,7 @@ export function useContentPanel() {
title,
host,
year,
coverUrl: undefined,
sources: [],
})
}
@@ -253,44 +511,132 @@ export function useContentPanel() {
return [...libraryPodcasts, ...externalPodcasts]
}
function updatePanelFromText(text: string) {
function updatePanelFromText(text: string, userQuery = '', webResults: WebSearchResult[] = []) {
panelQuery.value = userQuery.trim()
const songs = extractAllSongs(text)
const films = extractAllFilms(text)
const podcasts = extractAllPodcasts(text)
const fromMarkdown = extractMarkdownLinks(text)
const boldDomains = extractBoldDomainLinks(text)
if (songs.length > 0) {
panelSongs.value = songs
panelFilms.value = []
panelPodcasts.value = []
selectedFilm.value = null
selectedPodcast.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 = []
panelPodcasts.value = []
selectedSong.value = null
selectedPodcast.value = null
contentType.value = 'film'
panelTitle.value = films.length === 1
? films[0].title
: `${films.length} Recommended Films`
panelOpen.value = true
} else if (podcasts.length > 0) {
panelPodcasts.value = podcasts
panelFilms.value = []
panelSongs.value = []
selectedFilm.value = null
selectedSong.value = null
contentType.value = 'podcast'
panelTitle.value = podcasts.length === 1
? podcasts[0].title
: `${podcasts.length} Recommended Podcasts`
panelOpen.value = true
panelRssArticles.value = [] // clear; will repopulate when RSS fetch completes
// Websites = plain links from response (markdown + bold domains).
const hasLinkableContent = fromMarkdown.length > 0 || boldDomains.length > 0
const websitesFromMarkdown = hasLinkableContent ? fromMarkdown : []
const mergedWebsites = mergeNewsResults(websitesFromMarkdown, boldDomains)
const hasWebsites = mergedWebsites.length > 0
// Magazine = bullet-style sections (- **Title**: Content)
const magazineSections = extractMagazineSections(text)
const hasMagazine = magazineSections.length >= 2 && (isNewsQuery(userQuery) || isNewsLikeResponse(text) || /sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening/i.test(text))
// News = actual articles (web search + RSS from website domains). Plain links → Websites.
const newsContext = isNewsQuery(userQuery) || isNewsLikeResponse(text)
const hasNews = (webResults.length > 0 || mergedWebsites.length > 0) && newsContext
const mergedNews = hasNews ? mergeNewsResults(webResults, panelRssArticles.value) : []
// Fetch RSS from website URLs to surface actual articles in News
if (mergedWebsites.length > 0) {
const urls = mergedWebsites.map((w) => w.url)
fetchRssFromUrls(urls).then((articles) => {
if (articles.length === 0) return
panelRssArticles.value = articles
const combined = mergeNewsResults(panelWebResults.value, articles)
panelWebResults.value = combined
if (!availableTabs.value.includes('news')) {
availableTabs.value = ['news', ...availableTabs.value]
activeTab.value = 'news'
}
const ctx = extractQueryContext(panelQuery.value)
panelTitle.value = ctx ? `${ctx}${combined.length} articles` : `${combined.length} Articles`
})
}
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, hasNews, hasWebsites, hasMagazine)
availableTabs.value = tabs.length > 0 ? tabs : ['film']
activeTab.value = tabs[0] ?? 'film'
const showFilms = tabs.includes('film')
const showSongs = tabs.includes('song')
const showPodcasts = tabs.includes('podcast')
const showNews = tabs.includes('news')
const showWebsites = tabs.includes('websites')
const showMagazine = tabs.includes('magazine')
const visibleFilms = showFilms ? films : []
const visibleSongs = showSongs ? songs : []
const visiblePodcasts = showPodcasts ? podcasts : []
const visibleNews = showNews ? mergedNews : []
const visibleWebsites = showWebsites ? mergedWebsites : []
const visibleMagazineSections = showMagazine ? magazineSections : []
panelFilms.value = visibleFilms
panelSongs.value = visibleSongs
panelPodcasts.value = visiblePodcasts
panelWebResults.value = visibleNews
panelWebsites.value = visibleWebsites
panelMagazineSections.value = visibleMagazineSections
panelMagazineHeroImage.value = showMagazine
? (extractMagazineHeroImage(text) ?? webResults[0]?.imgSrc ?? null)
: null
selectedFilm.value = null
selectedSong.value = null
selectedPodcast.value = null
if (visibleFilms.length > 0) contentType.value = 'film'
else if (visibleSongs.length > 0) contentType.value = 'song'
else if (visiblePodcasts.length > 0) contentType.value = 'podcast'
else if (visibleNews.length > 0) contentType.value = 'film'
else contentType.value = 'film'
if (visibleFilms.length === 1) panelTitle.value = visibleFilms[0].title
else if (visibleFilms.length > 1) panelTitle.value = `${visibleFilms.length} Films`
else if (visibleSongs.length === 1) panelTitle.value = visibleSongs[0].title
else if (visibleSongs.length > 1) panelTitle.value = `${visibleSongs.length} Songs`
else if (visiblePodcasts.length === 1) panelTitle.value = visiblePodcasts[0].title
else if (visiblePodcasts.length > 1) panelTitle.value = `${visiblePodcasts.length} Podcasts`
else if (visibleNews.length > 0) {
const ctx = extractQueryContext(userQuery)
panelTitle.value = ctx ? `${ctx}${visibleNews.length} articles` : `${visibleNews.length} Articles`
}
else if (visibleMagazineSections.length > 0) {
const ctx = extractQueryContext(userQuery)
panelTitle.value = ctx ? `${ctx} — Brief` : 'Market Brief'
}
else if (visibleWebsites.length > 0) panelTitle.value = `${visibleWebsites.length} Websites`
else panelTitle.value = 'Content'
panelOpen.value = tabs.length > 0
}
function setActiveTab(tab: ContentTab) {
if (availableTabs.value.includes(tab)) activeTab.value = tab
}
/** Contextual films/songs/podcasts/news/websites/magazine for inline cards (respects query+response, no presets) */
function getContextualInlineContent(text: string, userQuery: string, webResults: WebSearchResult[] = []) {
const films = extractAllFilms(text)
const songs = extractAllSongs(text)
const podcasts = extractAllPodcasts(text)
const magazineSections = extractMagazineSections(text)
const hasMagazine = magazineSections.length >= 2 && (isNewsQuery(userQuery) || isNewsLikeResponse(text) || /sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening/i.test(text))
const fromMarkdown = extractMarkdownLinks(text)
const boldDomains = extractBoldDomainLinks(text)
const hasNews = webResults.length > 0 && (isNewsQuery(userQuery) || isNewsLikeResponse(text))
const newsLinks = hasNews ? webResults : []
const hasLinkableContent = fromMarkdown.length > 0 || boldDomains.length > 0
const websitesFromMd = hasLinkableContent ? fromMarkdown : []
const websitesLinks = mergeNewsResults(websitesFromMd, boldDomains)
const hasWebsites = websitesLinks.length > 0
const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, hasNews, hasWebsites, hasMagazine)
return {
films: tabs.includes('film') ? films : [],
songs: tabs.includes('song') ? songs : [],
podcasts: tabs.includes('podcast') ? podcasts : [],
newsLinks: tabs.includes('news') ? newsLinks : [],
websitesLinks: tabs.includes('websites') ? websitesLinks : [],
magazineSections: tabs.includes('magazine') ? magazineSections : [],
}
}
@@ -322,10 +668,20 @@ export function useContentPanel() {
return stripFilmTags(stripSongTags(stripPodcastTags(text)))
}
/** Remove markdown links when surfacing as inline cards to avoid duplication */
function stripMarkdownLinks(text: string): string {
return text
.replace(/^[\s]*[-*]\s*\[[^\]]+\]\(https?:\/\/[^)\s]+\)\s*$/gm, '')
.replace(/\s*\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, (_, title) => ` ${title}`)
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function openFilmDetail(film: Film) {
selectedFilm.value = film
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
}
function closeFilmDetail() {
@@ -336,6 +692,7 @@ export function useContentPanel() {
selectedSong.value = song
selectedFilm.value = null
selectedPodcast.value = null
selectedArticle.value = null
}
function closeSongDetail() {
@@ -346,17 +703,33 @@ export function useContentPanel() {
selectedPodcast.value = podcast
selectedFilm.value = null
selectedSong.value = null
selectedArticle.value = null
}
function closePodcastDetail() {
selectedPodcast.value = null
}
function openArticleDetail(article: WebSearchResult) {
selectedArticle.value = article
selectedFilm.value = null
selectedSong.value = null
selectedPodcast.value = null
panelOpen.value = true
}
function closeArticleDetail() {
selectedArticle.value = null
}
function closePanel() {
panelOpen.value = false
selectedFilm.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
activeTab.value = 'film'
availableTabs.value = []
}
function showAllFilms() {
@@ -369,6 +742,7 @@ export function useContentPanel() {
selectedFilm.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
}
function showAllSongs() {
@@ -381,6 +755,7 @@ export function useContentPanel() {
selectedFilm.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
}
function showAllPodcasts() {
@@ -393,6 +768,7 @@ export function useContentPanel() {
selectedFilm.value = null
selectedSong.value = null
selectedPodcast.value = null
selectedArticle.value = null
}
return {
@@ -400,11 +776,20 @@ export function useContentPanel() {
panelFilms,
panelSongs,
panelPodcasts,
panelWebResults,
panelWebsites,
panelMagazineSections,
panelMagazineHeroImage,
selectedFilm,
selectedSong,
selectedPodcast,
selectedArticle,
panelTitle,
panelQuery,
contentType,
activeTab,
availableTabs,
setActiveTab,
extractFilmIds,
resolveFilms,
extractAllFilms,
@@ -414,17 +799,21 @@ export function useContentPanel() {
extractPodcastIds,
resolvePodcasts,
extractAllPodcasts,
getContextualInlineContent,
updatePanelFromText,
stripFilmTags,
stripSongTags,
stripPodcastTags,
stripContentTags,
stripMarkdownLinks,
openFilmDetail,
closeFilmDetail,
openSongDetail,
closeSongDetail,
openPodcastDetail,
closePodcastDetail,
openArticleDetail,
closeArticleDetail,
closePanel,
showAllFilms,
showAllSongs,
@@ -5,11 +5,17 @@ const failedUrls = new Set<string>()
const musicCoverCache = new Map<string, string>()
const SESSION_MUSIC_KEY = 'aiui-music-cover-cache'
const podcastCoverCache = new Map<string, string>()
const SESSION_PODCAST_KEY = 'aiui-podcast-cover-cache'
function musicCacheKey(artist: string, title: string): string {
return `${artist.toLowerCase().trim()}|${title.toLowerCase().trim()}`
}
function podcastCacheKey(title: string, host?: string): string {
return `${title.toLowerCase().trim()}|${(host ?? '').toLowerCase().trim()}`
}
function loadMusicCache(): void {
try {
const raw = sessionStorage.getItem(SESSION_MUSIC_KEY)
@@ -52,15 +58,35 @@ function saveSessionCache(): void {
loadSessionCache()
export function generatePodcastCoverFallback(title: string, host?: string): string {
const hue = [...(title + (host ?? ''))].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
function loadPodcastCache(): void {
try {
const raw = sessionStorage.getItem(SESSION_PODCAST_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Record<string, string>
Object.entries(parsed).forEach(([k, v]) => podcastCoverCache.set(k, v))
}
} catch { /* ignore */ }
}
function savePodcastCache(): void {
try {
const entries = [...podcastCoverCache.entries()].slice(-200)
sessionStorage.setItem(SESSION_PODCAST_KEY, JSON.stringify(Object.fromEntries(entries)))
} catch { /* ignore */ }
}
loadPodcastCache()
const MICROPHONE_PATH = 'M45.04,95.45v24.11c0,1.83-1.49,3.32-3.32,3.32c-1.83,0-3.32-1.49-3.32-3.32V95.45c-10.16-0.81-19.32-5.3-26.14-12.12C4.69,75.77,0,65.34,0,53.87c0-1.83,1.49-3.32,3.32-3.32s3.32,1.49,3.32,3.32c0,9.64,3.95,18.41,10.31,24.77c6.36,6.36,15.13,10.31,24.77,10.31h0c9.64,0,18.41-3.95,24.77-10.31c6.36-6.36,10.31-15.13,10.31-24.77c0-1.83,1.49-3.32,3.32-3.32s3.32,1.49,3.32,3.32c0,11.48-4.69,21.91-12.25,29.47C64.36,90.16,55.2,94.64,45.04,95.45z M41.94,0c6.38,0,12.18,2.61,16.38,6.81c4.2,4.2,6.81,10,6.81,16.38v30c0,6.38-2.61,12.18-6.81,16.38c-4.2,4.2-10,6.81-16.38,6.81s-12.18-2.61-16.38-6.81c-4.2-4.2-6.81-10-6.81-16.38v-30c0-6.38,2.61-12.18,6.81-16.38C29.76,2.61,35.56,0,41.94,0z M53.62,11.51c-3-3-7.14-4.86-11.68-4.86c-4.55,0-8.68,1.86-11.68,4.86c-3,3-4.86,7.14-4.86,11.68v30c0,4.55,1.86,8.68,4.86,11.68c3,3,7.14,4.86,11.68,4.86c4.55,0,8.68-1.86,11.68-4.86c3-3,4.86-7.14,4.86-11.68v-30C58.49,18.64,56.62,14.51,53.62,11.51z'
export function generatePodcastCoverFallback(_title: string, _host?: string): string {
const hue = 220
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<rect width="200" height="200" fill="hsl(${hue}, 30%, 14%)"/>
<rect x="2" y="2" width="196" height="196" rx="8" fill="none" stroke="hsl(${hue}, 40%, 25%)" stroke-width="2"/>
<circle cx="100" cy="80" r="25" fill="none" stroke="hsl(${hue}, 50%, 60%)" stroke-width="4"/>
<path d="M100 105 v25 M85 130 h30 M100 155 v15 M80 170 h40" fill="none" stroke="hsl(${hue}, 50%, 60%)" stroke-width="3" stroke-linecap="round"/>
<text x="100" y="115" text-anchor="middle" fill="hsl(${hue}, 50%, 70%)" font-family="system-ui" font-size="12" font-weight="600">${escapeXml(title.length > 14 ? title.slice(0, 12) + '…' : title)}</text>
${host ? `<text x="100" y="132" text-anchor="middle" fill="hsl(${hue}, 30%, 50%)" font-family="system-ui" font-size="9">${escapeXml(host.length > 16 ? host.slice(0, 14) + '…' : host)}</text>` : ''}
<rect width="200" height="200" fill="hsl(${hue}, 25%, 12%)"/>
<rect x="2" y="2" width="196" height="196" rx="8" fill="none" stroke="hsl(${hue}, 30%, 22%)" stroke-width="2"/>
<g fill="hsl(${hue}, 40%, 55%)" transform="translate(38 8) scale(1.5)">
<path d="${MICROPHONE_PATH}"/>
</g>
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
@@ -159,6 +185,34 @@ export function isUrlFailed(url: string | undefined): boolean {
return !!url && failedUrls.has(url)
}
/** Fetch podcast artwork from iTunes Search API (free, no key). Returns hi-res URL (600x600). */
export async function fetchPodcastCover(
title: string,
host?: string,
): Promise<string | null> {
const key = podcastCacheKey(title, host)
const cached = podcastCoverCache.get(key)
if (cached) return cached
try {
const term = host ? `${title} ${host}` : title
const res = await fetch(
`https://itunes.apple.com/search?term=${encodeURIComponent(term.trim())}&media=podcast&limit=3`,
)
if (!res.ok) return null
const data = (await res.json()) as { results?: { artworkUrl100?: string }[] }
const first = data.results?.[0]
const url = first?.artworkUrl100
if (!url) return null
const hiRes = url.replace(/100x100/g, '600x600')
podcastCoverCache.set(key, hiRes)
savePodcastCache()
return hiRes
} catch {
return null
}
}
/** Fetch album artwork from iTunes Search API (free, no key). Returns hi-res URL (600x600). */
export async function fetchMusicCover(
title: string,
@@ -0,0 +1,26 @@
import type { WebSearchResult } from '@aiui/core/types/message'
export async function fetchRssFromUrls(urls: string[]): Promise<WebSearchResult[]> {
const safe = urls.filter((u) => typeof u === 'string' && /^https?:\/\//i.test(u.trim())).slice(0, 8)
if (safe.length === 0) return []
try {
const params = new URLSearchParams()
safe.forEach((u) => params.append('url', u))
const res = await fetch(`/api/rss-articles?${params}`, { signal: AbortSignal.timeout(15000) })
if (!res.ok) return []
const data = (await res.json()) as { articles?: Array<{ title?: string; url?: string; content?: string; imgSrc?: string }> }
const articles = data.articles ?? []
return articles
.filter((a) => a.title && a.url)
.map((a) => ({
title: a.title ?? '',
url: a.url ?? '',
content: a.content,
imgSrc: a.imgSrc,
}))
} catch (err) {
console.warn('[AIUI rss]', err)
return []
}
}
@@ -0,0 +1,30 @@
export interface WebSearchResult {
title: string
url: string
content?: string
imgSrc?: string
}
export async function searchWeb(query: string): Promise<WebSearchResult[]> {
if (!query.trim()) return []
try {
const params = new URLSearchParams({ q: query.trim() })
const res = await fetch(`/api/web-search?${params}`, { signal: AbortSignal.timeout(10000) })
if (!res.ok) {
const body = await res.text().catch(() => '')
console.warn('[AIUI web-search]', res.status, body)
return []
}
const data = (await res.json()) as { results?: Array<WebSearchResult & { imgSrc?: string }>; error?: string }
if (data.error) {
console.warn('[AIUI web-search]', data.error)
return []
}
const results = data.results ?? []
console.log('[AIUI web-search]', query.slice(0, 50), '→', results.length, 'results')
return results
} catch (err) {
console.warn('[AIUI web-search] failed:', err)
return []
}
}
+131 -19
View File
@@ -17,12 +17,12 @@
class="flex-1 min-w-0 path-glass-card overflow-hidden flex flex-col relative"
:class="[
panelSide === 'left' ? 'order-last' : 'order-first',
(selectedFilm || selectedSong || selectedPodcast) && 'detail-active'
(selectedFilm || selectedSong || selectedPodcast || selectedArticle) && 'detail-active'
]"
>
<button
v-if="(selectedFilm || selectedSong || selectedPodcast) && (panelOpen || chatStore.isStreaming)"
class="absolute top-3 right-3 z-10 p-2 rounded-lg transition-colors"
v-if="(selectedFilm || selectedSong || selectedPodcast || selectedArticle) && (panelOpen || chatStore.isStreaming)"
class="absolute top-3 right-3 z-10 p-2 rounded-lg path-glass-icon transition-colors"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
@@ -51,15 +51,44 @@
:podcast="selectedPodcast"
@back="closePodcastDetail"
/>
<FilmGrid
v-else-if="contentType === 'film'"
:films="panelFilms"
:title="panelTitle"
@select-film="openFilmDetail"
>
<ArticleDetail
v-else-if="selectedArticle"
:article="selectedArticle"
@back="closeArticleDetail"
/>
<template v-else>
<div
v-if="availableTabs.length > 1"
class="shrink-0 flex items-center justify-between gap-2 px-4 py-2"
:style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex flex-wrap gap-1.5 flex-1 min-w-0 justify-center">
<button
v-for="tab in availableTabs"
:key="tab"
class="text-[10px] px-2 py-1 rounded-md transition-all duration-150"
:class="activeTab === tab
? 'nav-tab-active'
: isDark
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
@click="setActiveTab(tab)"
>
{{ tab === 'film' ? 'Films' : tab === 'song' ? 'Songs' : tab === 'podcast' ? 'Podcasts' : tab === 'magazine' ? 'Magazine' : tab === 'news' ? 'News' : 'Websites' }}
</button>
</div>
</div>
<FilmGrid
v-if="activeTab === 'film'"
:films="panelFilms"
:title="panelTitle"
@select-film="openFilmDetail"
>
<template #header-actions>
<button
class="p-2 rounded-lg transition-colors -mr-1"
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
@@ -67,21 +96,21 @@
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</FilmGrid>
<SongGrid
v-else-if="contentType === 'song'"
v-else-if="activeTab === 'song'"
:songs="panelSongs"
:title="panelTitle"
@select-song="openSongDetail"
>
<template #header-actions>
<button
class="p-2 rounded-lg transition-colors -mr-1"
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
@@ -89,21 +118,88 @@
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</SongGrid>
<MagazineGrid
v-else-if="activeTab === 'magazine'"
:sections="panelMagazineSections"
:hero-image-url="panelMagazineHeroImage"
:title="panelTitle"
:query="panelQuery"
>
<template #header-actions>
<button
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Clear content"
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</MagazineGrid>
<NewsGrid
v-else-if="activeTab === 'news'"
:articles="panelWebResults"
:title="panelTitle"
:query="panelQuery"
>
<template #header-actions>
<button
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Clear content"
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</NewsGrid>
<NewsGrid
v-else-if="activeTab === 'websites'"
:articles="panelWebsites"
:title="panelTitle"
variant="websites"
>
<template #header-actions>
<button
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
title="Clear content"
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</NewsGrid>
<PodcastGrid
v-else
v-else-if="activeTab === 'podcast'"
:podcasts="panelPodcasts"
:title="panelTitle"
@select-podcast="openPodcastDetail"
>
<template #header-actions>
<button
class="p-2 rounded-lg transition-colors -mr-1"
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
@@ -111,12 +207,13 @@
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</PodcastGrid>
</template>
</div>
<ContextLoader
@@ -126,7 +223,7 @@
>
<template #header-actions>
<button
class="p-2 rounded-lg transition-colors -mr-1"
class="flex items-center justify-center p-2 rounded-lg transition-colors -mr-1"
:class="isDark
? 'text-white/70 hover:bg-white/10'
: 'text-gray-500 hover:bg-black/5 hover:text-gray-800'"
@@ -134,7 +231,7 @@
aria-label="Clear content"
@click="closePanel"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -186,11 +283,14 @@ import { useAI } from '@/composables/useAI'
import { useContentPanel } from '@/composables/useContentPanel'
import ChatWindow from '@/components/chat/ChatWindow.vue'
import FilmGrid from '@/components/content/FilmGrid.vue'
import ArticleDetail from '@/components/content/ArticleDetail.vue'
import FilmDetail from '@/components/content/FilmDetail.vue'
import SongGrid from '@/components/content/SongGrid.vue'
import SongDetail from '@/components/content/SongDetail.vue'
import PodcastGrid from '@/components/content/PodcastGrid.vue'
import PodcastDetail from '@/components/content/PodcastDetail.vue'
import MagazineGrid from '@/components/content/MagazineGrid.vue'
import NewsGrid from '@/components/content/NewsGrid.vue'
import ContextLoader from '@/components/content/ContextLoader.vue'
import PlayerBar from '@/components/player/PlayerBar.vue'
import { usePlayer } from '@/composables/usePlayer'
@@ -206,17 +306,28 @@ const {
panelFilms,
panelSongs,
panelPodcasts,
panelWebResults,
panelWebsites,
panelMagazineSections,
panelMagazineHeroImage,
panelTitle,
panelQuery,
contentType,
activeTab,
availableTabs,
setActiveTab,
selectedFilm,
selectedSong,
selectedPodcast,
selectedArticle,
openFilmDetail,
closeFilmDetail,
openSongDetail,
closeSongDetail,
openPodcastDetail,
closePodcastDetail,
openArticleDetail,
closeArticleDetail,
closePanel,
} = useContentPanel()
@@ -229,6 +340,7 @@ const loaderContextType = computed(() => {
const q = (lastUser?.content ?? '').toLowerCase()
if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song'
if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast'
if (/\b(website|websites|where to check|best places|check online|resources?|sources?)\b/.test(q)) return 'websites'
return contentType.value
})
</script>
+37
View File
@@ -0,0 +1,37 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
const SAFE_URL_SCHEME = /^https?:\/\//i
export const useArticleOverlayStore = defineStore('articleOverlay', () => {
const isOpen = ref(false)
const url = ref<string | null>(null)
const title = ref('')
const content = ref<string | null>(null)
const imgSrc = ref<string | null>(null)
function open(articleUrl: string, articleTitle = '', articleContent?: string, articleImgSrc?: string) {
const trimmed = String(articleUrl ?? '').trim()
if (!SAFE_URL_SCHEME.test(trimmed)) return
try {
new URL(trimmed)
} catch {
return
}
url.value = trimmed
title.value = String(articleTitle ?? 'Article').slice(0, 200)
content.value = typeof articleContent === 'string' && articleContent.trim().length > 0 ? articleContent.trim() : null
imgSrc.value = typeof articleImgSrc === 'string' && articleImgSrc.trim().length > 0 ? articleImgSrc.trim() : null
isOpen.value = true
}
function close() {
isOpen.value = false
url.value = null
title.value = ''
content.value = null
imgSrc.value = null
}
return { isOpen, url, title, content, imgSrc, open, close }
})
+16 -1
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import type { Message, Conversation } from '@aiui/core/types/message'
import type { Message, Conversation, WebSearchResult } from '@aiui/core/types/message'
const isDev = import.meta.env.DEV
let saveTimer: ReturnType<typeof setTimeout> | null = null
@@ -52,6 +52,8 @@ export const useChatStore = defineStore('chat', () => {
const savedSide = localStorage.getItem('aiui-panel-side') as 'left' | 'right' | null
const panelSide = ref<'left' | 'right'>(savedSide ?? 'right')
const webSearchEnabled = ref(localStorage.getItem('aiui-web-search') !== 'false')
// Load chats from server on startup — _loaded gate prevents the watcher
// from overwriting the file with empty data before the load completes
if (isDev) {
@@ -75,6 +77,10 @@ export const useChatStore = defineStore('chat', () => {
localStorage.setItem('aiui-panel-side', val)
})
watch(webSearchEnabled, (val) => {
localStorage.setItem('aiui-web-search', String(val))
})
watch(
[conversations, activeConversationId],
([conv, active]) => {
@@ -134,6 +140,13 @@ export const useChatStore = defineStore('chat', () => {
last.content += text
}
function setMessageWebResults(conversationId: string, messageId: string, results: WebSearchResult[]) {
const conv = conversations.value.get(conversationId)
if (!conv) return
const msg = conv.messages.find((m) => m.id === messageId)
if (msg) msg.webResults = results
}
function switchSide() {
panelSide.value = panelSide.value === 'right' ? 'left' : 'right'
}
@@ -161,9 +174,11 @@ export const useChatStore = defineStore('chat', () => {
isStreaming,
loaded,
panelSide,
webSearchEnabled,
createConversation,
addMessage,
appendToLastMessage,
setMessageWebResults,
switchSide,
setActiveConversation,
deleteConversation,
+29 -13
View File
@@ -328,7 +328,7 @@ body {
position: absolute;
inset: 0;
border-radius: inherit;
padding: 2px;
padding: 1px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
@@ -407,7 +407,7 @@ body {
.light .nav-tab-active {
background: linear-gradient(135deg, #fefdf9 0%, #faf9f6 100%) !important;
color: #0a0a0a !important;
border: 1px solid #e8e8e8;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 6px 20px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(254, 253, 249, 1) !important;
@@ -510,34 +510,50 @@ input:focus-visible {
color: transparent;
}
/* ===== SCROLLBAR — Archy custom gradient scrollbar ===== */
/* ===== SCROLLBAR — slim, elegant ===== */
.custom-scrollbar {
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 10px;
width: 5px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
background: transparent;
margin: 8px 0;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.3) 0%, rgba(255, 255, 255, 0.1) 100%);
background: rgba(255, 255, 255, 0.15);
border-radius: 10px;
border: 2px solid rgba(0, 0, 0, 0.2);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.2) 100%);
background: rgba(255, 255, 255, 0.25);
}
.light .custom-scrollbar::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.04);
.custom-scrollbar::-webkit-scrollbar-thumb:active {
background: rgba(255, 255, 255, 0.35);
}
.light .custom-scrollbar {
scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
}
.light .custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.08) 100%);
border: 2px solid rgba(255, 255, 255, 0.5);
background: rgba(0, 0, 0, 0.12);
}
.light .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
.light .custom-scrollbar::-webkit-scrollbar-thumb:active {
background: rgba(0, 0, 0, 0.28);
}
.scrollbar-hide {
+141
View File
@@ -0,0 +1,141 @@
import type { Plugin } from 'vite'
import type { Connect } from 'vite'
import Parser from 'rss-parser'
export interface RssArticle {
title: string
url: string
content?: string
imgSrc?: string
}
const RSS_PATHS = ['/feed', '/rss', '/atom.xml', '/feed.xml', '/?feed=rss', '/rss.xml', '/.rss', '/feeds/posts/default']
function extractImgFromItem(item: Record<string, unknown>): string | undefined {
const enc = item.enclosure as { url?: string; type?: string } | undefined
if (enc?.url && /^https?:\/\//i.test(enc.url)) {
const t = (enc.type ?? '').toLowerCase()
if (t.startsWith('image/') || t === '') return enc.url
}
const thumb = item['media:thumbnail'] as { $?: { url?: string }; url?: string } | undefined
if (thumb?.$?.url) return thumb.$.url
if (typeof thumb?.url === 'string') return thumb.url
const arr = thumb as unknown[]
if (Array.isArray(arr) && arr[0]?.$?.url) return (arr[0] as { $: { url: string } }).$.url
const media = item['media:content'] as { $?: { url?: string }; url?: string } | undefined
if (media?.$?.url) return media.$.url
if (typeof media?.url === 'string') return media.url
const group = item['media:group'] as { 'media:content'?: Array<{ $?: { url?: string } }> } | undefined
if (group?.['media:content']?.[0]?.$?.url) return group['media:content'][0].$.url
const itunes = item['itunes:image'] as { href?: string } | undefined
if (itunes?.href) return itunes.href
const content = item.content ?? item.summary
const html = typeof content === 'string' ? content : ''
const imgMatch = html.match(/<img[^>]+src=["']([^"']+)["']/i)
if (imgMatch?.[1]) return imgMatch[1]
return undefined
}
async function tryParseFeed(parser: Parser, feedUrl: string): Promise<RssArticle[] | null> {
try {
const feed = await parser.parseURL(feedUrl)
if (!feed?.items?.length) return null
return feed.items.slice(0, 10).map((item) => {
const rawContent = item.contentSnippet ?? item.content ?? item.summary
const contentStr = typeof rawContent === 'string' ? rawContent : ''
const imgSrc = extractImgFromItem(item as Record<string, unknown>)
return {
title: (item.title ?? '').trim() || 'Untitled',
url: (item.link ?? item.guid ?? '').trim() || feedUrl,
content: contentStr.trim().slice(0, 15000),
imgSrc: imgSrc && /^https?:\/\//i.test(imgSrc) ? imgSrc : undefined,
}
})
} catch {
return null
}
}
function discoverFeedUrl(siteUrl: string): string[] {
try {
const u = new URL(siteUrl)
const base = `${u.protocol}//${u.host}`
return RSS_PATHS.map((path) => base + path)
} catch {
return []
}
}
async function fetchRssFromUrls(urls: string[]): Promise<RssArticle[]> {
const parser = new Parser({
timeout: 5000,
headers: { 'User-Agent': 'AIUI/1.0 (RSS reader)' },
})
const seen = new Set<string>()
const articles: RssArticle[] = []
for (const url of urls.slice(0, 5)) {
const candidates = discoverFeedUrl(url)
for (const feedUrl of candidates) {
const items = await tryParseFeed(parser, feedUrl)
if (!items?.length) continue
for (const a of items) {
const k = a.url.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
articles.push(a)
}
break // found a feed for this site, move to next URL
}
}
return articles.slice(0, 15)
}
function createRssMiddleware() {
return async (req: Connect.IncomingMessage, res: any, next: () => void) => {
if (req.method !== 'GET') return next()
const requestUrl = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
if (!requestUrl.pathname.startsWith('/api/rss-articles')) return next()
const urls = requestUrl.searchParams.getAll('url').map((u) => u.trim()).filter(Boolean)
if (urls.length === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Missing urls (array or comma-separated)' }))
return
}
const safe = urls.filter((u) => /^https?:\/\//i.test(u.trim())).slice(0, 8)
if (safe.length === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'No valid https URLs' }))
return
}
try {
const articles = await fetchRssFromUrls(safe)
res.setHeader('Content-Type', 'application/json')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Cache-Control', 'public, max-age=300')
res.end(JSON.stringify({ articles }))
} catch (err) {
console.error('[rss]', err)
res.writeHead(502, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: String(err) }))
}
}
}
export function rssPlugin(): Plugin {
return {
name: 'aiui-rss',
configureServer(server) {
server.middlewares.use(createRssMiddleware())
},
configurePreviewServer(server) {
server.middlewares.use(createRssMiddleware())
},
}
}
+135
View File
@@ -0,0 +1,135 @@
import type { Plugin } from 'vite'
import type { Connect } from 'vite'
import { loadEnv } from 'vite'
import { search as searchDuckDuckGo } from 'duck-duck-scrape'
export interface WebSearchResult {
title: string
url: string
content?: string
imgSrc?: string
engine?: string
}
const FALLBACK_INSTANCES = [
'https://searx.tiekoetter.com',
'https://search.bus-hit.me',
'https://paulgo.io',
]
async function fetchFromSearXNG(
searchUrl: string,
q: string,
): Promise<{ results?: { title?: string; url?: string; content?: string; img_src?: string; thumbnail?: string; engine?: string }[] } | null> {
try {
const searchRes = await fetch(searchUrl, {
headers: { Accept: 'application/json', 'User-Agent': 'AIUI/1.0' },
signal: AbortSignal.timeout(6000),
})
if (!searchRes.ok) return null
return (await searchRes.json()) as { results?: { title?: string; url?: string; content?: string; img_src?: string; thumbnail?: string; engine?: string }[] }
} catch {
return null
}
}
function createWebSearchMiddleware(searxUrl: string | undefined) {
return async (req: Connect.IncomingMessage, res: any, next: () => void) => {
if (req.method !== 'GET') return next()
const url = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
const q = url.searchParams.get('q')?.trim()
if (!q) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Missing q (query)' }))
return
}
const instances = searxUrl ? [searxUrl] : FALLBACK_INSTANCES
let data: { results?: { title?: string; url?: string; content?: string; img_src?: string; thumbnail?: string; engine?: string }[] } | null = null
let lastError = ''
for (const baseUrl of instances) {
const normalized = baseUrl.replace(/\/$/, '')
const searchUrl = `${normalized}/search?${new URLSearchParams({ q, format: 'json', pageno: '1' })}`
console.log('[web-search]', q.slice(0, 40), '→', normalized)
data = await fetchFromSearXNG(searchUrl, q)
if (data?.results && data.results.length > 0) {
console.log('[web-search]', data.results.length, 'results from', normalized)
break
}
lastError = data ? 'no results' : 'request failed'
console.warn('[web-search]', normalized, lastError, '— trying next')
}
if (!data) {
console.warn('[web-search] SearXNG failed, trying DuckDuckGo fallback')
try {
const ddg = await searchDuckDuckGo(q)
if (ddg?.results?.length) {
const results: WebSearchResult[] = ddg.results
.filter((r: { title?: string; url?: string }) => r.title && r.url)
.slice(0, 6)
.map((r: { title: string; url: string; description?: string; icon?: string }) => ({
title: r.title,
url: r.url,
content: r.description ?? undefined,
imgSrc: r.icon ?? undefined,
}))
console.log('[web-search] DuckDuckGo fallback:', results.length, 'results')
res.setHeader('Content-Type', 'application/json')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Cache-Control', 'public, max-age=300')
res.end(JSON.stringify({ results }))
return
}
} catch (ddgErr) {
console.warn('[web-search] DuckDuckGo fallback failed:', ddgErr)
}
console.error('[web-search] All search backends failed:', lastError)
res.writeHead(502, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Web search unavailable. Try SEARXNG_URL in .env.local.' }))
return
}
try {
const results: WebSearchResult[] = (data.results ?? [])
.filter((r) => r.title && r.url)
.slice(0, 6)
.map((r) => ({
title: r.title ?? '',
url: r.url ?? '',
content: r.content ?? undefined,
imgSrc: r.img_src || r.thumbnail || undefined,
engine: r.engine ?? undefined,
}))
res.setHeader('Content-Type', 'application/json')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Cache-Control', 'public, max-age=300')
res.end(JSON.stringify({ results }))
} catch (err) {
console.error('[web-search]', err)
res.writeHead(502, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: String(err) }))
}
}
}
export function webSearchPlugin(): Plugin {
let searxUrl: string | undefined
return {
name: 'aiui-web-search',
configResolved(config) {
const env = loadEnv(config.mode, process.cwd(), '')
searxUrl = env.SEARXNG_URL ?? env.VITE_SEARXNG_URL
},
configureServer(server) {
server.middlewares.use('/api/web-search', createWebSearchMiddleware(searxUrl))
},
configurePreviewServer(server) {
server.middlewares.use('/api/web-search', createWebSearchMiddleware(searxUrl))
},
}
}
+12
View File
@@ -6,6 +6,8 @@ import { resolve } from 'path'
import { tmdbPlugin } from './vite-tmdb'
import { devChatsPlugin } from './vite-dev-chats'
import { musicSearchPlugin } from './vite-music-search'
import { webSearchPlugin } from './vite-web-search'
import { rssPlugin } from './vite-rss'
export default defineConfig({
plugins: [
@@ -14,6 +16,8 @@ export default defineConfig({
tmdbPlugin(),
devChatsPlugin(),
musicSearchPlugin(),
webSearchPlugin(),
rssPlugin(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icon.svg', 'apple-touch-icon-180x180.png'],
@@ -56,6 +60,14 @@ export default defineConfig({
urlPattern: /^https:\/\/openrouter\.ai\/.*/i,
handler: 'NetworkOnly',
},
{
urlPattern: /\/api\/web-search\?.*/i,
handler: 'NetworkOnly',
},
{
urlPattern: /\/api\/rss-articles\?.*/i,
handler: 'NetworkOnly',
},
],
},
devOptions: {
+10
View File
@@ -1,5 +1,13 @@
import type { ContentBlock } from './content'
export interface WebSearchResult {
title: string
url: string
content?: string
/** Image/thumbnail URL from search engine (e.g. SearXNG img_src) */
imgSrc?: string
}
export interface Message {
id: string
role: 'user' | 'assistant' | 'system'
@@ -11,6 +19,8 @@ export interface Message {
replyTo?: string
reactions?: Reaction[]
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error'
/** Web search results (articles, links) attached when query used web search */
webResults?: WebSearchResult[]
}
export interface Reaction {
+76
View File
@@ -39,9 +39,15 @@ importers:
'@vitejs/plugin-vue':
specifier: latest
version: 6.0.4(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.8.3))
duck-duck-scrape:
specifier: ^2.2.7
version: 2.2.7
eslint:
specifier: latest
version: 10.0.2(jiti@2.6.1)
rss-parser:
specifier: ^3.13.0
version: 3.13.0
tailwindcss:
specifier: latest
version: 4.2.1
@@ -1436,6 +1442,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
duck-duck-scrape@2.2.7:
resolution: {integrity: sha512-BEcglwnfx5puJl90KQfX+Q2q5vCguqyMpZcSRPBWk8OY55qWwV93+E+7DbIkrGDW4qkqPfUvtOUdi0lXz6lEMQ==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -1452,6 +1461,9 @@ packages:
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
engines: {node: '>=10.13.0'}
entities@2.2.0:
resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
@@ -1693,6 +1705,13 @@ packages:
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
html-entities@2.6.0:
resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
@@ -2035,6 +2054,11 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
needle@3.3.1:
resolution: {integrity: sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==}
engines: {node: '>= 4.4.x'}
hasBin: true
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@@ -2216,6 +2240,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
rss-parser@3.13.0:
resolution: {integrity: sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==}
safe-array-concat@1.1.3:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
@@ -2231,6 +2258,13 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sax@1.5.0:
resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==}
engines: {node: '>=11.0.0'}
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
@@ -2721,6 +2755,14 @@ packages:
workbox-window@7.4.0:
resolution: {integrity: sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==}
xml2js@0.5.0:
resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==}
engines: {node: '>=4.0.0'}
xmlbuilder@11.0.1:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -4130,6 +4172,11 @@ snapshots:
detect-libc@2.1.2: {}
duck-duck-scrape@2.2.7:
dependencies:
html-entities: 2.6.0
needle: 3.3.1
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -4147,6 +4194,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.0
entities@2.2.0: {}
entities@7.0.1: {}
es-abstract@1.24.1:
@@ -4482,6 +4531,12 @@ snapshots:
hookable@5.5.3: {}
html-entities@2.6.0: {}
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
idb@7.1.1: {}
ignore@5.3.2: {}
@@ -4775,6 +4830,11 @@ snapshots:
natural-compare@1.4.0: {}
needle@3.3.1:
dependencies:
iconv-lite: 0.6.3
sax: 1.5.0
node-releases@2.0.27: {}
object-inspect@1.13.4: {}
@@ -4983,6 +5043,11 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.59.0
fsevents: 2.3.3
rss-parser@3.13.0:
dependencies:
entities: 2.2.0
xml2js: 0.5.0
safe-array-concat@1.1.3:
dependencies:
call-bind: 1.0.8
@@ -5004,6 +5069,10 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
safer-buffer@2.1.2: {}
sax@1.5.0: {}
scule@1.3.0: {}
semver@6.3.1: {}
@@ -5593,6 +5662,13 @@ snapshots:
'@types/trusted-types': 2.0.7
workbox-core: 7.4.0
xml2js@0.5.0:
dependencies:
sax: 1.5.0
xmlbuilder: 11.0.1
xmlbuilder@11.0.1: {}
yallist@3.1.1: {}
yaml@2.8.2: {}