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:
+13
-5
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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, '"')}"`)
|
||||
}
|
||||
if (tag === 'img' && el.getAttribute('src')) {
|
||||
const src = el.getAttribute('src') ?? ''
|
||||
if (/^https?:\/\//i.test(src)) attrs.push(`src="${src.replace(/"/g, '"')}"`)
|
||||
}
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>')}</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, '"')}"`)
|
||||
}
|
||||
if (tag === 'img' && el.getAttribute('src')) {
|
||||
const src = el.getAttribute('src') ?? ''
|
||||
if (/^https?:\/\//i.test(src)) attrs.push(`src="${src.replace(/"/g, '"')}"`)
|
||||
}
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>')}</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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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">
|
||||
|
||||
@@ -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.0–friendly 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)
|
||||
}
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+76
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user