feat(app): redesign magazine grid, improve article views and proxy
- Redesign MagazineGrid with editorial New Yorker-inspired layout - Simplify ArticleDetail and ArticleOverlay components - Enhance claude-proxy with improved content extraction - Add HTML utility for content processing - Update NewsCard styling and chat message handling - Clean up worktree references Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
b71c88f03b
commit
c82b4ef54f
Submodule .claude/worktrees/hungry-sinoussi deleted from 5414060225
Submodule .claude/worktrees/unruffled-carson deleted from 5414060225
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
"clean": "turbo clean"
|
"clean": "turbo clean"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"turbo": "latest",
|
"turbo": "^2.8.12",
|
||||||
"typescript": "~5.8.0"
|
"typescript": "~5.8.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.30.3",
|
"packageManager": "pnpm@10.30.3",
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ define(['./workbox-cf23aef7'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.h2t4rjv3m6"
|
"revision": "0.ndi22oikhk8"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ test.describe('Content surfaces', () => {
|
|||||||
])
|
])
|
||||||
await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 })
|
await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 })
|
||||||
await page.getByRole('button', { name: /View all \d+ films/i }).click()
|
await page.getByRole('button', { name: /View all \d+ films/i }).click()
|
||||||
await expect(page.locator('main').getByRole('button', { name: 'Films' })).toBeVisible({ timeout: 5000 })
|
await expect(page.locator('main').getByText('Blade Runner 2049').first()).toBeVisible({ timeout: 5000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('films surface: clicking assistant bubble opens panel', async ({ page }) => {
|
test('films surface: clicking assistant bubble opens panel', async ({ page }) => {
|
||||||
@@ -33,7 +33,7 @@ test.describe('Content surfaces', () => {
|
|||||||
])
|
])
|
||||||
await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 })
|
await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 })
|
||||||
await page.locator('.path-glass-bubble').filter({ hasText: /Blade Runner|Arrival|Dune/ }).first().click()
|
await page.locator('.path-glass-bubble').filter({ hasText: /Blade Runner|Arrival|Dune/ }).first().click()
|
||||||
await expect(page.locator('main').getByRole('button', { name: 'Films' })).toBeVisible({ timeout: 5000 })
|
await expect(page.locator('main').getByText('Blade Runner 2049').first()).toBeVisible({ timeout: 5000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('magazine surface: BIP brief shows sections', async ({ page }) => {
|
test('magazine surface: BIP brief shows sections', async ({ page }) => {
|
||||||
@@ -41,7 +41,7 @@ test.describe('Content surfaces', () => {
|
|||||||
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
|
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
|
||||||
page.goto('/'),
|
page.goto('/'),
|
||||||
])
|
])
|
||||||
await page.locator('aside').getByRole('button', { name: /Film recommendations/ }).click()
|
await page.locator('aside button').filter({ has: page.locator('h2') }).first().click()
|
||||||
await page.getByRole('button', { name: 'BIP 110 brief' }).click()
|
await page.getByRole('button', { name: 'BIP 110 brief' }).click()
|
||||||
await expect(page.getByText(/BIP 110|Pro camp|Summary/i).first()).toBeVisible({ timeout: 8000 })
|
await expect(page.getByText(/BIP 110|Pro camp|Summary/i).first()).toBeVisible({ timeout: 8000 })
|
||||||
await page.getByRole('button', { name: 'View brief' }).click()
|
await page.getByRole('button', { name: 'View brief' }).click()
|
||||||
@@ -53,11 +53,11 @@ test.describe('Content surfaces', () => {
|
|||||||
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
|
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
|
||||||
page.goto('/'),
|
page.goto('/'),
|
||||||
])
|
])
|
||||||
await page.locator('aside').getByRole('button', { name: /Film recommendations/ }).click()
|
await page.locator('aside button').filter({ has: page.locator('h2') }).first().click()
|
||||||
await page.getByRole('button', { name: 'Music recommendations' }).click()
|
await page.getByRole('button', { name: 'Music recommendations' }).click()
|
||||||
await expect(page.getByText('Never Meant').first()).toBeVisible({ timeout: 8000 })
|
await expect(page.getByText('Never Meant').first()).toBeVisible({ timeout: 8000 })
|
||||||
await page.getByRole('button', { name: /View all \d+ songs/i }).click()
|
await page.getByRole('button', { name: /View all \d+ songs/i }).click()
|
||||||
await expect(page.locator('main').getByRole('button', { name: 'Songs' })).toBeVisible({ timeout: 5000 })
|
await expect(page.locator('main').getByText('Never Meant').first()).toBeVisible({ timeout: 5000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('podcasts surface: podcasts conversation shows podcast cards', async ({ page }) => {
|
test('podcasts surface: podcasts conversation shows podcast cards', async ({ page }) => {
|
||||||
@@ -65,11 +65,11 @@ test.describe('Content surfaces', () => {
|
|||||||
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
|
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
|
||||||
page.goto('/'),
|
page.goto('/'),
|
||||||
])
|
])
|
||||||
await page.locator('aside').getByRole('button', { name: /Film recommendations/ }).click()
|
await page.locator('aside button').filter({ has: page.locator('h2') }).first().click()
|
||||||
await page.getByRole('button', { name: 'Bitcoin podcasts' }).click()
|
await page.getByRole('button', { name: 'Bitcoin podcasts' }).click()
|
||||||
await expect(page.getByText('What Bitcoin Did').first()).toBeVisible({ timeout: 8000 })
|
await expect(page.getByText('What Bitcoin Did').first()).toBeVisible({ timeout: 8000 })
|
||||||
await page.getByRole('button', { name: /View all \d+ podcasts/i }).click()
|
await page.getByRole('button', { name: /View all \d+ podcasts/i }).click()
|
||||||
await expect(page.locator('main').getByRole('button', { name: 'Podcasts' })).toBeVisible({ timeout: 5000 })
|
await expect(page.locator('main').getByText('What Bitcoin Did').first()).toBeVisible({ timeout: 5000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('websites surface: websites tab shows link cards', async ({ page }) => {
|
test('websites surface: websites tab shows link cards', async ({ page }) => {
|
||||||
@@ -77,11 +77,11 @@ test.describe('Content surfaces', () => {
|
|||||||
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
|
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
|
||||||
page.goto('/'),
|
page.goto('/'),
|
||||||
])
|
])
|
||||||
await page.locator('aside').getByRole('button', { name: /Film recommendations/ }).click()
|
await page.locator('aside button').filter({ has: page.locator('h2') }).first().click()
|
||||||
await page.getByRole('button', { name: 'Bitcoin resources' }).click()
|
await page.getByRole('button', { name: 'Bitcoin resources' }).click()
|
||||||
await expect(page.getByText('Bitcoin Magazine').first()).toBeVisible({ timeout: 8000 })
|
await expect(page.getByText('Bitcoin Magazine').first()).toBeVisible({ timeout: 8000 })
|
||||||
await page.getByRole('button', { name: /View all \d+ websites/i }).click()
|
await page.getByRole('button', { name: /View all \d+ websites/i }).click()
|
||||||
await expect(page.locator('main').getByRole('button', { name: 'Websites' })).toBeVisible({ timeout: 5000 })
|
await expect(page.locator('main').getByText('Bitcoin Magazine').first()).toBeVisible({ timeout: 5000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('news surface: news conversation shows articles', async ({ page }) => {
|
test('news surface: news conversation shows articles', async ({ page }) => {
|
||||||
@@ -89,10 +89,10 @@ test.describe('Content surfaces', () => {
|
|||||||
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
|
page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }),
|
||||||
page.goto('/'),
|
page.goto('/'),
|
||||||
])
|
])
|
||||||
await page.locator('aside').getByRole('button', { name: /Film recommendations/ }).click()
|
await page.locator('aside button').filter({ has: page.locator('h2') }).first().click()
|
||||||
await page.getByRole('button', { name: 'Latest Bitcoin news' }).click()
|
await page.getByRole('button', { name: 'Latest Bitcoin news' }).click()
|
||||||
await expect(page.getByText(/Bitcoin hits|ETF inflows/i).first()).toBeVisible({ timeout: 8000 })
|
await expect(page.getByText(/Bitcoin hits|ETF inflows/i).first()).toBeVisible({ timeout: 8000 })
|
||||||
await page.getByRole('button', { name: /View all \d+ articles/i }).click()
|
await page.getByRole('button', { name: /View all \d+ articles/i }).click()
|
||||||
await expect(page.locator('main').getByRole('button', { name: 'News' })).toBeVisible({ timeout: 5000 })
|
await expect(page.locator('main').getByText(/Bitcoin hits|ETF inflows/i).first()).toBeVisible({ timeout: 5000 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+11
-11
@@ -6,7 +6,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx server/claude-proxy.ts & vite",
|
"dev": "bash scripts/dev.sh",
|
||||||
"dev:vite": "vite",
|
"dev:vite": "vite",
|
||||||
"dev:proxy": "tsx server/claude-proxy.ts",
|
"dev:proxy": "tsx server/claude-proxy.ts",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
@@ -20,24 +20,24 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aiui/core": "workspace:*",
|
"@aiui/core": "workspace:*",
|
||||||
"pinia": "latest",
|
"pinia": "^3.0.4",
|
||||||
"plyr": "^3.8.4",
|
"plyr": "^3.8.4",
|
||||||
"vue": "latest",
|
"vue": "^3.5.29",
|
||||||
"vue-router": "latest"
|
"vue-router": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "latest",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@vitejs/plugin-vue": "latest",
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
"duck-duck-scrape": "^2.2.7",
|
"duck-duck-scrape": "^2.2.7",
|
||||||
"eslint": "latest",
|
"eslint": "^10.0.2",
|
||||||
"rss-parser": "^3.13.0",
|
"rss-parser": "^3.13.0",
|
||||||
"tailwindcss": "latest",
|
"tailwindcss": "^4.2.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "~5.8.0",
|
"typescript": "~5.8.0",
|
||||||
"vite": "latest",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"vitest": "latest",
|
"vitest": "^4.0.18",
|
||||||
"vue-tsc": "latest"
|
"vue-tsc": "^3.2.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start Claude proxy and Vite dev server together.
|
||||||
|
# Both are killed when either exits or when this script receives SIGINT/SIGTERM.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
# Kill all child processes
|
||||||
|
kill 0 2>/dev/null || true
|
||||||
|
wait 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
# Start Claude proxy in background
|
||||||
|
npx tsx "$APP_DIR/server/claude-proxy.ts" &
|
||||||
|
PROXY_PID=$!
|
||||||
|
|
||||||
|
# Give proxy a moment to bind port
|
||||||
|
sleep 0.3
|
||||||
|
|
||||||
|
# Start Vite dev server in foreground
|
||||||
|
npx vite --host &
|
||||||
|
VITE_PID=$!
|
||||||
|
|
||||||
|
# Wait for either to exit — then cleanup kills both
|
||||||
|
wait -n $PROXY_PID $VITE_PID 2>/dev/null || true
|
||||||
@@ -57,6 +57,7 @@ function getAnthropicCredential(): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ANTHROPIC_CREDENTIAL = getAnthropicCredential()
|
const ANTHROPIC_CREDENTIAL = getAnthropicCredential()
|
||||||
|
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? process.env.VITE_OPENROUTER_API_KEY ?? ''
|
||||||
const isOAuthToken = (s: string) => /^sk-ant-oat/.test(s)
|
const isOAuthToken = (s: string) => /^sk-ant-oat/.test(s)
|
||||||
|
|
||||||
const SEARCH_WEB_TOOL = {
|
const SEARCH_WEB_TOOL = {
|
||||||
@@ -202,6 +203,80 @@ async function streamViaAnthropicApi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function streamOpenRouterProxy(
|
||||||
|
reqBody: string,
|
||||||
|
res: import('http').ServerResponse,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!OPENROUTER_API_KEY) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||||
|
res.end(JSON.stringify({ error: 'OPENROUTER_API_KEY not configured on server' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
})
|
||||||
|
|
||||||
|
let clientDisconnected = false
|
||||||
|
res.on('close', () => { clientDisconnected = true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
|
||||||
|
'HTTP-Referer': APP_URL,
|
||||||
|
'X-Title': 'AIUI',
|
||||||
|
},
|
||||||
|
body: reqBody,
|
||||||
|
signal: AbortSignal.timeout(120000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!apiRes.ok) {
|
||||||
|
const errBody = await apiRes.text()
|
||||||
|
if (!clientDisconnected) {
|
||||||
|
res.write(`data: ${JSON.stringify({ error: `OpenRouter API ${apiRes.status}: ${errBody.slice(0, 200)}` })}\n\n`)
|
||||||
|
res.write('data: [DONE]\n\n')
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = apiRes.body?.getReader()
|
||||||
|
if (!reader) {
|
||||||
|
if (!clientDisconnected) {
|
||||||
|
res.write('data: [DONE]\n\n')
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done || clientDisconnected) break
|
||||||
|
const chunk = decoder.decode(value, { stream: true })
|
||||||
|
res.write(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientDisconnected) {
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[proxy] OpenRouter error:', err)
|
||||||
|
if (!clientDisconnected) {
|
||||||
|
res.write(`data: ${JSON.stringify({ error: `OpenRouter proxy error: ${err instanceof Error ? err.message : String(err)}` })}\n\n`)
|
||||||
|
res.write('data: [DONE]\n\n')
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
const server = createServer((req, res) => {
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
res.writeHead(204, {
|
res.writeHead(204, {
|
||||||
@@ -213,7 +288,7 @@ const server = createServer((req, res) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method !== 'POST' || req.url !== '/v1/messages') {
|
if (req.method !== 'POST' || (req.url !== '/v1/messages' && req.url !== '/v1/openrouter')) {
|
||||||
res.writeHead(404, { 'Content-Type': 'application/json' })
|
res.writeHead(404, { 'Content-Type': 'application/json' })
|
||||||
res.end(JSON.stringify({ error: 'Not found' }))
|
res.end(JSON.stringify({ error: 'Not found' }))
|
||||||
return
|
return
|
||||||
@@ -222,6 +297,11 @@ const server = createServer((req, res) => {
|
|||||||
let body = ''
|
let body = ''
|
||||||
req.on('data', (chunk) => { body += chunk })
|
req.on('data', (chunk) => { body += chunk })
|
||||||
req.on('end', () => {
|
req.on('end', () => {
|
||||||
|
if (req.url === '/v1/openrouter') {
|
||||||
|
console.log('[proxy] → OpenRouter proxy')
|
||||||
|
streamOpenRouterProxy(body, res)
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(body)
|
const payload = JSON.parse(body)
|
||||||
const { model, messages, system, webSearch } = payload
|
const { model, messages, system, webSearch } = payload
|
||||||
@@ -361,8 +441,9 @@ server.listen(PORT, () => {
|
|||||||
console.log(` Binary: ${CLAUDE_BIN}`)
|
console.log(` Binary: ${CLAUDE_BIN}`)
|
||||||
if (ANTHROPIC_CREDENTIAL) {
|
if (ANTHROPIC_CREDENTIAL) {
|
||||||
const mode = isOAuthToken(ANTHROPIC_CREDENTIAL) ? 'OAuth (Max)' : 'API key'
|
const mode = isOAuthToken(ANTHROPIC_CREDENTIAL) ? 'OAuth (Max)' : 'API key'
|
||||||
console.log(` Tool use (search_web): enabled (${mode})\n`)
|
console.log(` Tool use (search_web): enabled (${mode})`)
|
||||||
} else {
|
} else {
|
||||||
console.log(` Tool use: add ANTHROPIC_TOKEN (Max) or ANTHROPIC_API_KEY to .env.local\n`)
|
console.log(` Tool use: add ANTHROPIC_TOKEN (Max) or ANTHROPIC_API_KEY to .env.local`)
|
||||||
}
|
}
|
||||||
|
console.log(` OpenRouter proxy: ${OPENROUTER_API_KEY ? 'enabled' : 'add OPENROUTER_API_KEY to .env.local'}\n`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ import { computed } from 'vue'
|
|||||||
import type { Message, WebSearchResult } 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 type { Film, Song, Podcast } from '@aiui/core/types/content'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
import { useContentPanel } from '@/composables/useContentPanel'
|
import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel'
|
||||||
import { useArticleOverlayStore } from '@/stores/articleOverlay'
|
import { useArticleOverlayStore } from '@/stores/articleOverlay'
|
||||||
import FilmCard from '@/components/content/FilmCard.vue'
|
import FilmCard from '@/components/content/FilmCard.vue'
|
||||||
import SongCard from '@/components/content/SongCard.vue'
|
import SongCard from '@/components/content/SongCard.vue'
|
||||||
@@ -135,7 +135,7 @@ const overlayStore = useArticleOverlayStore()
|
|||||||
const isUser = computed(() => props.message.role === 'user')
|
const isUser = computed(() => props.message.role === 'user')
|
||||||
|
|
||||||
const inlineContent = computed(() => {
|
const inlineContent = computed(() => {
|
||||||
if (isUser.value) return { films: [] as Film[], songs: [] as Song[], podcasts: [] as Podcast[], newsLinks: [], websitesLinks: [] }
|
if (isUser.value) return { films: [] as Film[], songs: [] as Song[], podcasts: [] as Podcast[], newsLinks: [] as WebSearchResult[], websitesLinks: [] as WebSearchResult[], magazineSections: [] as MagazineSection[] }
|
||||||
return getContextualInlineContent(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
|
return getContextualInlineContent(props.message.content, props.triggeringQuery, props.message.webResults ?? [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { WebSearchResult } from '@aiui/core/types/message'
|
import type { WebSearchResult } from '@aiui/core/types/message'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
import { isSafeImgSrc, sanitizeHtml, escapeHtml } from '@/utils/html'
|
||||||
|
|
||||||
const props = defineProps<{ article: WebSearchResult }>()
|
const props = defineProps<{ article: WebSearchResult }>()
|
||||||
defineEmits<{ back: [] }>()
|
defineEmits<{ back: [] }>()
|
||||||
@@ -90,46 +91,10 @@ const fallbackGradient = computed(() => {
|
|||||||
return `linear-gradient(135deg, hsl(${hue}, 25%, 12%) 0%, hsl(${(hue + 40) % 360}, 20%, 8%) 100%)`
|
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 sanitizedContent = computed(() => {
|
||||||
const c = props.article.content
|
const c = props.article.content
|
||||||
if (!c) return ''
|
if (!c) return ''
|
||||||
if (/<[a-z][\s\S]*>/i.test(c)) return sanitizeHtml(c)
|
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>`
|
return `<p class="whitespace-pre-wrap">${escapeHtml(c)}</p>`
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
<div v-html="sanitizedContent" />
|
<div v-html="sanitizedContent" />
|
||||||
</article>
|
</article>
|
||||||
<a
|
<a
|
||||||
:href="store.url"
|
:href="store.url ?? undefined"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="inline-flex items-center gap-1.5 mt-4 text-sm"
|
class="inline-flex items-center gap-1.5 mt-4 text-sm"
|
||||||
@@ -121,52 +121,18 @@
|
|||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useArticleOverlayStore } from '@/stores/articleOverlay'
|
import { useArticleOverlayStore } from '@/stores/articleOverlay'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
import { isSafeImgSrc, sanitizeHtml, escapeHtml } from '@/utils/html'
|
||||||
|
|
||||||
const store = useArticleOverlayStore()
|
const store = useArticleOverlayStore()
|
||||||
const { isDark } = useTheme()
|
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 sanitizedContent = computed(() => {
|
||||||
const c = store.content
|
const c = store.content
|
||||||
if (!c) return ''
|
if (!c) return ''
|
||||||
if (/<[a-z][\s\S]*>/i.test(c)) return sanitizeHtml(c)
|
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>`
|
return `<p class="whitespace-pre-wrap">${escapeHtml(c)}</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 iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||||
const iframeRefreshKey = ref(0)
|
const iframeRefreshKey = ref(0)
|
||||||
const isRefreshing = ref(false)
|
const isRefreshing = ref(false)
|
||||||
|
|||||||
@@ -1,217 +1,160 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col">
|
<div class="magazine-newyorker h-full flex flex-col"
|
||||||
<!-- Masthead: AI Brief branding -->
|
:class="isDark ? 'magazine-dark' : 'magazine-light'">
|
||||||
|
<!-- Masthead: editorial, minimal -->
|
||||||
<header
|
<header
|
||||||
class="shrink-0 px-4 py-2 flex items-center justify-between gap-2"
|
class="shrink-0 px-4 py-3 flex items-center justify-between border-b"
|
||||||
:style="isDark
|
:class="isDark ? 'border-white/10' : 'border-black/8'"
|
||||||
? '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"
|
<h1 class="font-serif text-lg font-semibold tracking-tight"
|
||||||
:class="isDark ? 'text-white/50' : 'text-gray-500'">
|
:class="isDark ? 'text-white/95' : 'text-gray-900'">
|
||||||
AI Brief
|
AI Brief
|
||||||
</p>
|
</h1>
|
||||||
<div class="flex-1" />
|
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<slot name="header-actions" />
|
<slot name="header-actions" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto custom-scrollbar pb-16">
|
<div class="flex-1 overflow-y-auto custom-scrollbar pb-16">
|
||||||
<!-- Headline banner: edge-to-edge, tech style -->
|
<!-- Query context (subtle, editorial) -->
|
||||||
<div
|
<div v-if="headlineText" class="px-4 sm:px-6 pt-4 pb-2">
|
||||||
v-if="headlineText"
|
<p class="text-[10px] uppercase tracking-widest"
|
||||||
class="relative w-full pt-4 overflow-hidden"
|
:class="isDark ? 'text-white/40' : 'text-gray-500'">
|
||||||
:class="isDark ? 'bg-white/[0.02]' : 'bg-black/[0.02]'"
|
In response to
|
||||||
>
|
</p>
|
||||||
<!-- Tech lines decoration -->
|
<p class="font-serif text-sm mt-0.5" :class="isDark ? 'text-white/70' : 'text-gray-600'">
|
||||||
<div class="absolute inset-0 pointer-events-none overflow-hidden">
|
{{ headlineText }}
|
||||||
<div
|
</p>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<!-- News homepage body -->
|
<!-- Lead story: hero + headline (New Yorker featured style) -->
|
||||||
<div
|
<article v-if="sections[0]" class="border-b" :class="isDark ? 'border-white/10' : 'border-black/8'">
|
||||||
class="space-y-4 px-4 pt-5"
|
<!-- Hero image above lead -->
|
||||||
>
|
<div class="relative w-full aspect-[16/9] overflow-hidden">
|
||||||
<!-- Lead story: first section -->
|
<img
|
||||||
<article
|
:src="heroImageDisplay"
|
||||||
v-if="sections[0]"
|
alt=""
|
||||||
class="path-glass-bubble rounded-2xl p-4 sm:p-5 border-l relative"
|
class="absolute inset-0 w-full h-full object-cover"
|
||||||
:class="isDark ? 'border-white/10' : 'border-black/5'"
|
loading="eager"
|
||||||
>
|
/>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="absolute bottom-0 left-0 right-0 p-4 sm:p-6">
|
||||||
<span
|
<span
|
||||||
class="text-[9px] uppercase tracking-widest font-bold block mb-1.5"
|
class="text-[10px] uppercase tracking-[0.2em] font-medium block mb-1"
|
||||||
:class="isDark ? 'text-white/40' : 'text-gray-500'"
|
:class="isDark ? 'text-white/60' : 'text-white/80'"
|
||||||
>
|
|
||||||
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">
|
The Lead
|
||||||
<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" />
|
</span>
|
||||||
</svg>
|
<h2 class="font-serif text-xl sm:text-2xl md:text-3xl font-semibold leading-tight text-white drop-shadow-sm">
|
||||||
</button>
|
{{ sections[0].title }}
|
||||||
|
</h2>
|
||||||
|
<p v-if="sections[0].author"
|
||||||
|
class="text-xs mt-1.5 text-white/90">
|
||||||
|
By {{ sections[0].author }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
|
<div class="px-4 sm:px-6 py-5 sm:py-6">
|
||||||
|
<div class="max-w-2xl">
|
||||||
|
<div
|
||||||
|
class="magazine-prose text-sm sm:text-base leading-relaxed"
|
||||||
|
:class="isDark ? 'text-white/85' : 'text-gray-700'"
|
||||||
|
v-html="formatContent(sections[0].content)"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
v-if="sections[0].url"
|
||||||
|
href="#"
|
||||||
|
class="inline-block mt-4 text-xs font-medium underline underline-offset-2 hover:no-underline"
|
||||||
|
:class="isDark ? 'text-white/70 hover:text-white' : 'text-gray-600 hover:text-gray-900'"
|
||||||
|
@click.prevent="openLink(sections[0].url!)"
|
||||||
|
>
|
||||||
|
Read more →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<!-- Secondary stories grid (single col when many sections for readability) -->
|
<!-- Section: Today's Mix / The Lede -->
|
||||||
<div
|
<div v-if="sections.length > 1" class="px-4 sm:px-6 py-6 sm:py-8">
|
||||||
class="gap-4"
|
<h3 class="text-[10px] uppercase tracking-[0.25em] font-semibold mb-5"
|
||||||
:class="sections.length > 4 ? 'flex flex-col' : 'grid sm:grid-cols-2'"
|
:class="isDark ? 'text-white/50' : 'text-gray-500'">
|
||||||
>
|
{{ sections.length > 2 ? "Today's Mix" : 'More' }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
<article
|
<article
|
||||||
v-for="(section, i) in sections.slice(1)"
|
v-for="(section, i) in sections.slice(1)"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="path-glass-bubble rounded-2xl p-3 sm:p-4 relative"
|
class="group"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<h4 class="font-serif text-base sm:text-lg font-semibold leading-snug mb-1.5"
|
||||||
<div class="min-w-0 flex-1">
|
:class="isDark ? 'text-white/95 group-hover:text-white' : 'text-gray-900 group-hover:text-gray-800'">
|
||||||
<h3 class="text-sm font-bold mb-1"
|
{{ section.title }}
|
||||||
:class="isDark ? 'text-white/85' : 'text-gray-800'">
|
</h4>
|
||||||
{{ section.title }}
|
<p v-if="section.author"
|
||||||
</h3>
|
class="text-xs mb-2"
|
||||||
<p v-if="section.author"
|
:class="isDark ? 'text-white/55' : 'text-gray-500'">
|
||||||
class="text-[10px] mb-2"
|
By {{ section.author }}
|
||||||
:class="isDark ? 'text-white/45' : 'text-gray-500'">
|
</p>
|
||||||
{{ section.author }}
|
<div
|
||||||
</p>
|
class="magazine-prose text-sm leading-relaxed"
|
||||||
<p class="text-xs leading-relaxed"
|
:class="isDark ? 'text-white/75' : 'text-gray-600'"
|
||||||
:class="isDark ? 'text-white/75' : 'text-gray-600'"
|
v-html="formatContent(section.content)"
|
||||||
v-html="formatContent(section.content)"
|
/>
|
||||||
/>
|
<a
|
||||||
</div>
|
v-if="section.url"
|
||||||
<button
|
href="#"
|
||||||
v-if="section.url"
|
class="inline-block mt-2 text-xs font-medium underline underline-offset-2 hover:no-underline"
|
||||||
class="shrink-0 p-1.5 rounded-lg transition-colors"
|
:class="isDark ? 'text-white/60 hover:text-white' : 'text-gray-500 hover:text-gray-800'"
|
||||||
:class="isDark ? 'text-white/40 hover:bg-white/10 hover:text-white/60' : 'text-gray-400 hover:bg-black/5 hover:text-gray-600'"
|
@click.prevent="openLink(section.url!)"
|
||||||
title="Open in new window"
|
>
|
||||||
aria-label="Open link"
|
Read more →
|
||||||
@click.stop="openLink(section.url!)"
|
</a>
|
||||||
>
|
|
||||||
<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>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="sections.length === 0" class="flex items-center justify-center py-12 px-4 pt-5">
|
<div v-if="sections.length === 0" class="flex items-center justify-center py-16 px-4">
|
||||||
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
|
<p class="text-sm" :class="isDark ? 'text-white/40' : 'text-gray-400'">
|
||||||
No sections to display
|
No sections to display
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Images below content: equally sized containers -->
|
<!-- Below the fold: image pair (Daily Cartoon / Shouts & Murmurs style) -->
|
||||||
<div class="px-4 pt-6 grid grid-cols-2 gap-4">
|
<section class="px-4 sm:px-6 py-8 border-t" :class="isDark ? 'border-white/8' : 'border-black/6'">
|
||||||
<div
|
<h3 class="text-[10px] uppercase tracking-[0.25em] font-semibold mb-4"
|
||||||
class="path-glass-bubble rounded-2xl overflow-hidden aspect-[4/3]"
|
:class="isDark ? 'text-white/50' : 'text-gray-500'">
|
||||||
>
|
In Case You Missed It
|
||||||
<div class="relative w-full h-full overflow-hidden">
|
</h3>
|
||||||
<img
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
:src="heroImageDisplay"
|
<div class="overflow-hidden" :class="isDark ? 'bg-white/5' : 'bg-black/[0.04]'">
|
||||||
alt=""
|
<div class="aspect-[4/3] relative">
|
||||||
class="absolute inset-0 w-full h-full object-cover"
|
<img
|
||||||
loading="lazy"
|
:src="heroImageDisplay"
|
||||||
/>
|
alt=""
|
||||||
<div class="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent pointer-events-none" />
|
class="absolute inset-0 w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="overflow-hidden relative" :class="isDark ? 'bg-white/5' : 'bg-black/[0.04]'">
|
||||||
<div
|
<div class="aspect-[4/3] relative">
|
||||||
class="path-glass-bubble rounded-2xl overflow-hidden aspect-[4/3]"
|
<img
|
||||||
>
|
:src="memeImageUrl"
|
||||||
<div class="relative w-full h-full overflow-hidden">
|
alt=""
|
||||||
<img
|
class="absolute inset-0 w-full h-full object-cover"
|
||||||
:src="memeImageUrl"
|
loading="lazy"
|
||||||
alt=""
|
/>
|
||||||
class="absolute inset-0 w-full h-full object-cover"
|
<div class="absolute bottom-0 left-0 right-0 p-3"
|
||||||
loading="lazy"
|
:class="isDark ? 'bg-black/80' : 'bg-black/70'">
|
||||||
/>
|
<p class="text-[11px] font-medium text-white/95">
|
||||||
<div class="absolute bottom-0 left-0 right-0 p-2 text-center"
|
{{ memeCaption }}
|
||||||
:class="isDark ? 'bg-black/70' : 'bg-black/50'">
|
</p>
|
||||||
<p class="text-[11px] font-bold"
|
</div>
|
||||||
:class="isDark ? 'text-white/95' : 'text-white'">
|
|
||||||
{{ memeCaption }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -224,10 +167,8 @@ import type { MagazineSection } from '@/composables/useContentPanel'
|
|||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
sections: MagazineSection[]
|
sections: MagazineSection[]
|
||||||
/** Hero image URL (from response or web results) */
|
|
||||||
heroImageUrl?: string | null
|
heroImageUrl?: string | null
|
||||||
title?: string
|
title?: string
|
||||||
/** User's prompt for headline banner */
|
|
||||||
query?: string
|
query?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
heroImageUrl: null,
|
heroImageUrl: null,
|
||||||
@@ -242,26 +183,23 @@ function isSafeImgUrl(u: string | undefined | null): u is string {
|
|||||||
return !!u && typeof u === 'string' && /^https?:\/\//i.test(u.trim())
|
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(() => {
|
const heroImageDisplay = computed(() => {
|
||||||
if (props.heroImageUrl && isSafeImgUrl(props.heroImageUrl)) return props.heroImageUrl
|
if (props.heroImageUrl && isSafeImgUrl(props.heroImageUrl)) return props.heroImageUrl
|
||||||
const seed = (props.query || 'magazine').toLowerCase().replace(/\s+/g, '-').slice(0, 30) || 'brief'
|
const seed = (props.query || 'magazine').toLowerCase().replace(/\s+/g, '-').slice(0, 30) || 'brief'
|
||||||
return `https://picsum.photos/seed/${seed}/800/450`
|
return `https://picsum.photos/seed/${seed}/800/450`
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Meme image: contextual meme templates (imgflip) */
|
|
||||||
const memeImageUrl = computed(() => {
|
const memeImageUrl = computed(() => {
|
||||||
const text = props.sections.map((s) => s.title + ' ' + s.content).join(' ').toLowerCase()
|
const text = props.sections.map((s) => s.title + ' ' + s.content).join(' ').toLowerCase()
|
||||||
const q = (props.query || '').toLowerCase()
|
const q = (props.query || '').toLowerCase()
|
||||||
const combined = text + ' ' + q
|
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 (/\bbearish|bear|fear|dump|crash|extreme fear\b/.test(combined)) return 'https://i.imgflip.com/wxica.jpg'
|
||||||
if (/\bbull|rally|moon|pump|buy the dip\b/.test(combined)) return 'https://i.imgflip.com/1bhk.jpg' // Success Kid
|
if (/\bbull|rally|moon|pump|buy the dip\b/.test(combined)) return 'https://i.imgflip.com/1bhk.jpg'
|
||||||
if (/\bbitcoin|btc\b/.test(combined)) return 'https://i.imgflip.com/30b1gx.jpg' // Drake
|
if (/\bbitcoin|btc\b/.test(combined)) return 'https://i.imgflip.com/30b1gx.jpg'
|
||||||
if (/\bmacro|fed|rate|inflation\b/.test(combined)) return 'https://i.imgflip.com/1ur9b0.jpg' // Distracted Boyfriend
|
if (/\bmacro|fed|rate|inflation\b/.test(combined)) return 'https://i.imgflip.com/1ur9b0.jpg'
|
||||||
return 'https://i.imgflip.com/1bij.jpg' // One does not simply
|
return 'https://i.imgflip.com/1bij.jpg'
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Meme caption: short contextual phrase */
|
|
||||||
const memeCaption = computed(() => {
|
const memeCaption = computed(() => {
|
||||||
const text = props.sections.map((s) => s.title + ' ' + s.content).join(' ').toLowerCase()
|
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 (/\bbearish|fear|extreme fear\b/.test(text)) return 'Me checking my portfolio'
|
||||||
@@ -281,14 +219,31 @@ const headlineText = computed(() => {
|
|||||||
return q.length > 100 ? q.slice(0, 97) + '…' : q
|
return q.length > 100 ? q.slice(0, 97) + '…' : q
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Render content with **bold** preserved and newlines as paragraphs (sanitized). */
|
|
||||||
function formatContent(text: string): string {
|
function formatContent(text: string): string {
|
||||||
const safe = text
|
const safe = text
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||||
const withParas = safe.replace(/\n\n+/g, '</p><p class="mt-2">').replace(/\n/g, '<br/>')
|
const withParas = safe.replace(/\n\n+/g, '</p><p class="mt-3">').replace(/\n/g, '<br/>')
|
||||||
return `<p>${withParas}</p>`
|
return `<p>${withParas}</p>`
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.magazine-newyorker {
|
||||||
|
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||||
|
}
|
||||||
|
.magazine-light {
|
||||||
|
background-color: #faf9f6;
|
||||||
|
}
|
||||||
|
.magazine-dark {
|
||||||
|
background-color: #0a0a0a;
|
||||||
|
}
|
||||||
|
.magazine-prose :deep(strong) {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.magazine-prose :deep(p + p) {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { WebSearchResult } from '@aiui/core/types/message'
|
import type { WebSearchResult } from '@aiui/core/types/message'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
import { isSafeUrl, formatDomain } from '@/utils/html'
|
||||||
|
|
||||||
const props = defineProps<{ article: WebSearchResult }>()
|
const props = defineProps<{ article: WebSearchResult }>()
|
||||||
defineEmits<{ 'select-article': [article: WebSearchResult] }>()
|
defineEmits<{ 'select-article': [article: WebSearchResult] }>()
|
||||||
@@ -58,14 +59,10 @@ defineEmits<{ 'select-article': [article: WebSearchResult] }>()
|
|||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const imgFailed = ref(false)
|
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(() => {
|
const imgSrc = computed(() => {
|
||||||
if (imgFailed.value) return null
|
if (imgFailed.value) return null
|
||||||
const u = props.article.imgSrc
|
const u = props.article.imgSrc
|
||||||
return isSafeImgUrl(u) ? u : null
|
return isSafeUrl(u) ? u : null
|
||||||
})
|
})
|
||||||
|
|
||||||
const faviconUrl = computed(() => {
|
const faviconUrl = computed(() => {
|
||||||
@@ -79,11 +76,4 @@ const faviconUrl = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatDomain(url: string): string {
|
|
||||||
try {
|
|
||||||
return new URL(url).hostname.replace(/^www\./, '')
|
|
||||||
} catch {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { searchWeb } from '@/composables/useWebSearch'
|
|||||||
type Provider = 'claude' | 'openrouter' | 'mock'
|
type Provider = 'claude' | 'openrouter' | 'mock'
|
||||||
|
|
||||||
const CLAUDE_PATH = '/api/claude/v1/messages'
|
const CLAUDE_PATH = '/api/claude/v1/messages'
|
||||||
const OPENROUTER_PATH = '/api/openrouter/api/v1/chat/completions'
|
const OPENROUTER_PATH = '/api/openrouter'
|
||||||
|
|
||||||
import { mockFilms } from '@/mocks/films'
|
import { mockFilms } from '@/mocks/films'
|
||||||
import { mockSongs } from '@/mocks/songs'
|
import { mockSongs } from '@/mocks/songs'
|
||||||
@@ -54,9 +54,6 @@ ${songContext}
|
|||||||
The user's podcast library:
|
The user's podcast library:
|
||||||
${podcastContext}`
|
${podcastContext}`
|
||||||
|
|
||||||
const openrouterApiKey = import.meta.env.VITE_OPENROUTER_API_KEY ?? ''
|
|
||||||
const hasOpenRouter = !!openrouterApiKey
|
|
||||||
|
|
||||||
const activeProvider = ref<Provider>('claude')
|
const activeProvider = ref<Provider>('claude')
|
||||||
|
|
||||||
const activeModel = ref('claude-sonnet-4')
|
const activeModel = ref('claude-sonnet-4')
|
||||||
@@ -73,18 +70,16 @@ const availableProviders = computed(() => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
if (hasOpenRouter) {
|
providers.push({
|
||||||
providers.push({
|
id: 'openrouter',
|
||||||
id: 'openrouter',
|
name: 'OpenRouter',
|
||||||
name: 'OpenRouter',
|
models: [
|
||||||
models: [
|
{ id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick' },
|
||||||
{ id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick' },
|
{ id: 'qwen/qwen3-235b-a22b-thinking-2507', name: 'Qwen3 235B Thinking' },
|
||||||
{ id: 'qwen/qwen3-235b-a22b-thinking-2507', name: 'Qwen3 235B Thinking' },
|
{ id: 'mistralai/mistral-small-3.1-24b-instruct:free', name: 'Mistral Small 3.1 (free)' },
|
||||||
{ id: 'mistralai/mistral-small-3.1-24b-instruct:free', name: 'Mistral Small 3.1 (free)' },
|
{ id: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)' },
|
||||||
{ id: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)' },
|
],
|
||||||
],
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
providers.push({
|
providers.push({
|
||||||
id: 'mock',
|
id: 'mock',
|
||||||
name: 'Local (no API)',
|
name: 'Local (no API)',
|
||||||
@@ -166,11 +161,6 @@ async function streamOpenRouter(
|
|||||||
onError: (err: string) => void,
|
onError: (err: string) => void,
|
||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!openrouterApiKey) {
|
|
||||||
onError('Missing VITE_OPENROUTER_API_KEY in .env.local')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const orMessages = [
|
const orMessages = [
|
||||||
{ role: 'system' as const, content: systemPrompt },
|
{ role: 'system' as const, content: systemPrompt },
|
||||||
...messages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })),
|
...messages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })),
|
||||||
@@ -180,7 +170,6 @@ async function streamOpenRouter(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${openrouterApiKey}`,
|
|
||||||
'HTTP-Referer': window.location.origin,
|
'HTTP-Referer': window.location.origin,
|
||||||
'X-Title': 'AIUI',
|
'X-Title': 'AIUI',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function usePlayer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
plyrInstance!.on('ready', () => {
|
plyrInstance!.on('ready', () => {
|
||||||
plyrInstance!.play().catch(() => { /* autoplay blocked */ })
|
Promise.resolve(plyrInstance!.play()).catch(() => { /* autoplay blocked */ })
|
||||||
})
|
})
|
||||||
plyrInstance!.on('timeupdate', () => {
|
plyrInstance!.on('timeupdate', () => {
|
||||||
currentTime.value = plyrInstance!.currentTime ?? 0
|
currentTime.value = plyrInstance!.currentTime ?? 0
|
||||||
|
|||||||
@@ -103,6 +103,6 @@ export function searchPodcasts(query: string): Podcast[] {
|
|||||||
(p) =>
|
(p) =>
|
||||||
p.title.toLowerCase().includes(q) ||
|
p.title.toLowerCase().includes(q) ||
|
||||||
(p.host ?? '').toLowerCase().includes(q) ||
|
(p.host ?? '').toLowerCase().includes(q) ||
|
||||||
(p.genres ?? []).some((g) => g.toLowerCase().includes(q))
|
(p.genres ?? []).some((g: string) => g.toLowerCase().includes(q))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
function setMessageWebResults(conversationId: string, messageId: string, results: WebSearchResult[]) {
|
function setMessageWebResults(conversationId: string, messageId: string, results: WebSearchResult[]) {
|
||||||
const conv = conversations.value.get(conversationId)
|
const conv = conversations.value.get(conversationId)
|
||||||
if (!conv) return
|
if (!conv) return
|
||||||
const msg = conv.messages.find((m) => m.id === messageId)
|
const msg = conv.messages.find((m: { id: string }) => m.id === messageId)
|
||||||
if (msg) msg.webResults = results
|
if (msg) msg.webResults = results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
--font-mono: 'Menlo', 'Monaco', 'Courier New', monospace;
|
--font-mono: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||||
|
--font-serif: Georgia, 'Times New Roman', Times, serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/** Check if a URL has a safe http(s) protocol */
|
||||||
|
export function isSafeImgSrc(src: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(src)
|
||||||
|
return /^https?:$/i.test(u.protocol)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type-guard variant: check if value is a defined, safe http(s) URL */
|
||||||
|
export function isSafeUrl(u: string | undefined): u is string {
|
||||||
|
return !!u && typeof u === 'string' && /^https?:\/\//i.test(u.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_TAGS = new Set(['p', 'br', 'a', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'span', 'div'])
|
||||||
|
const DANGEROUS_TAGS = new Set(['script', 'style', 'iframe', 'object', 'embed'])
|
||||||
|
|
||||||
|
/** Sanitize HTML: allow safe tags, strip scripts and dangerous attributes */
|
||||||
|
export function sanitizeHtml(html: string): string {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.innerHTML = html
|
||||||
|
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 (DANGEROUS_TAGS.has(tag)) return ''
|
||||||
|
if (!ALLOWED_TAGS.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('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape HTML entities for safe rendering in a text context */
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract a domain from a URL, stripping www prefix */
|
||||||
|
export function formatDomain(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.replace(/^www\./, '')
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,5 @@
|
|||||||
"@aiui/core/*": ["../core/src/*"]
|
"@aiui/core/*": ["../core/src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
"include": ["src/**/*.ts", "src/**/*.vue", "../core/src/**/*.ts"]
|
||||||
"references": [
|
|
||||||
{ "path": "../core" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ async function searchJamendo(q: string, clientId: string, title?: string, artist
|
|||||||
)
|
)
|
||||||
: results[0]
|
: results[0]
|
||||||
if (!track?.audio) return null
|
if (!track?.audio) return null
|
||||||
|
if (!/^https?:\/\//i.test(track.audio)) return null
|
||||||
if (title && artist && scoreJamendoTrack(track, title, artist) === 0) {
|
if (title && artist && scoreJamendoTrack(track, title, artist) === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,8 +56,29 @@ async function tryParseFeed(parser: Parser, feedUrl: string): Promise<RssArticle
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPrivateUrl(urlStr: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(urlStr)
|
||||||
|
const hostname = u.hostname.toLowerCase()
|
||||||
|
// Block localhost
|
||||||
|
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]') return true
|
||||||
|
// Block private IPv4 ranges
|
||||||
|
if (/^10\./.test(hostname)) return true
|
||||||
|
if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return true
|
||||||
|
if (/^192\.168\./.test(hostname)) return true
|
||||||
|
// Block link-local
|
||||||
|
if (/^169\.254\./.test(hostname)) return true
|
||||||
|
// Block 0.0.0.0
|
||||||
|
if (hostname === '0.0.0.0') return true
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function discoverFeedUrl(siteUrl: string): string[] {
|
function discoverFeedUrl(siteUrl: string): string[] {
|
||||||
try {
|
try {
|
||||||
|
if (isPrivateUrl(siteUrl)) return []
|
||||||
const u = new URL(siteUrl)
|
const u = new URL(siteUrl)
|
||||||
const base = `${u.protocol}//${u.host}`
|
const base = `${u.protocol}//${u.host}`
|
||||||
return RSS_PATHS.map((path) => base + path)
|
return RSS_PATHS.map((path) => base + path)
|
||||||
@@ -107,7 +128,7 @@ function createRssMiddleware() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const safe = urls.filter((u) => /^https?:\/\//i.test(u.trim())).slice(0, 8)
|
const safe = urls.filter((u) => /^https?:\/\//i.test(u.trim()) && !isPrivateUrl(u.trim())).slice(0, 8)
|
||||||
if (safe.length === 0) {
|
if (safe.length === 0) {
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' })
|
res.writeHead(400, { 'Content-Type': 'application/json' })
|
||||||
res.end(JSON.stringify({ error: 'No valid https URLs' }))
|
res.end(JSON.stringify({ error: 'No valid https URLs' }))
|
||||||
|
|||||||
@@ -92,9 +92,9 @@ export default defineConfig({
|
|||||||
rewrite: (path) => path.replace(/^\/api\/claude/, ''),
|
rewrite: (path) => path.replace(/^\/api\/claude/, ''),
|
||||||
},
|
},
|
||||||
'/api/openrouter': {
|
'/api/openrouter': {
|
||||||
target: 'https://openrouter.ai',
|
target: 'http://localhost:3141',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/api\/openrouter/, ''),
|
rewrite: (path) => path.replace(/^\/api\/openrouter/, '/v1/openrouter'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,12 +26,12 @@
|
|||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "latest",
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
"vite": "latest",
|
"vite": "^7.3.1",
|
||||||
"vue": "latest",
|
"vue": "^3.5.29",
|
||||||
"vue-tsc": "latest",
|
"vue-tsc": "^3.2.5",
|
||||||
"vitest": "latest",
|
"vitest": "^4.0.18",
|
||||||
"eslint": "latest",
|
"eslint": "^10.0.2",
|
||||||
"typescript": "~5.8.0"
|
"typescript": "~5.8.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ref, readonly } from 'vue'
|
import { type DeepReadonly, type Ref, ref, readonly } from 'vue'
|
||||||
import type { AIUIPlugin, PluginType } from '../types/plugin'
|
import type { AIUIPlugin, PluginType } from '../types/plugin'
|
||||||
import type { RendererDefinition } from '../types/renderer'
|
import type { RendererDefinition } from '../types/renderer'
|
||||||
|
|
||||||
@@ -47,5 +47,5 @@ export function getAllRenderers(): RendererDefinition[] {
|
|||||||
return Array.from(renderers.value.values())
|
return Array.from(renderers.value.values())
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pluginRegistry = readonly(plugins)
|
export const pluginRegistry: DeepReadonly<Ref<Map<string, AIUIPlugin>>> = readonly(plugins)
|
||||||
export const rendererRegistry = readonly(renderers)
|
export const rendererRegistry: DeepReadonly<Ref<Map<string, RendererDefinition>>> = readonly(renderers)
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
|
|||||||
Generated
+17
-17
@@ -9,7 +9,7 @@ importers:
|
|||||||
.:
|
.:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
turbo:
|
turbo:
|
||||||
specifier: latest
|
specifier: ^2.8.12
|
||||||
version: 2.8.12
|
version: 2.8.12
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ~5.8.0
|
specifier: ~5.8.0
|
||||||
@@ -21,38 +21,38 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core
|
version: link:../core
|
||||||
pinia:
|
pinia:
|
||||||
specifier: latest
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(typescript@5.8.3)(vue@3.5.29(typescript@5.8.3))
|
version: 3.0.4(typescript@5.8.3)(vue@3.5.29(typescript@5.8.3))
|
||||||
plyr:
|
plyr:
|
||||||
specifier: ^3.8.4
|
specifier: ^3.8.4
|
||||||
version: 3.8.4
|
version: 3.8.4
|
||||||
vue:
|
vue:
|
||||||
specifier: latest
|
specifier: ^3.5.29
|
||||||
version: 3.5.29(typescript@5.8.3)
|
version: 3.5.29(typescript@5.8.3)
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: latest
|
specifier: ^5.0.3
|
||||||
version: 5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.8.3)(vue@3.5.29(typescript@5.8.3)))(vue@3.5.29(typescript@5.8.3))
|
version: 5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.8.3)(vue@3.5.29(typescript@5.8.3)))(vue@3.5.29(typescript@5.8.3))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.49.0
|
specifier: ^1.49.0
|
||||||
version: 1.58.2
|
version: 1.58.2
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: latest
|
specifier: ^4.2.1
|
||||||
version: 4.2.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
version: 4.2.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
'@vitejs/plugin-vue':
|
'@vitejs/plugin-vue':
|
||||||
specifier: latest
|
specifier: ^6.0.4
|
||||||
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))
|
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:
|
duck-duck-scrape:
|
||||||
specifier: ^2.2.7
|
specifier: ^2.2.7
|
||||||
version: 2.2.7
|
version: 2.2.7
|
||||||
eslint:
|
eslint:
|
||||||
specifier: latest
|
specifier: ^10.0.2
|
||||||
version: 10.0.2(jiti@2.6.1)
|
version: 10.0.2(jiti@2.6.1)
|
||||||
rss-parser:
|
rss-parser:
|
||||||
specifier: ^3.13.0
|
specifier: ^3.13.0
|
||||||
version: 3.13.0
|
version: 3.13.0
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: latest
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.21.0
|
specifier: ^4.21.0
|
||||||
@@ -61,40 +61,40 @@ importers:
|
|||||||
specifier: ~5.8.0
|
specifier: ~5.8.0
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
vite:
|
vite:
|
||||||
specifier: latest
|
specifier: ^7.3.1
|
||||||
version: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
version: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
vite-plugin-pwa:
|
vite-plugin-pwa:
|
||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.2.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)
|
version: 1.2.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: latest
|
specifier: ^4.0.18
|
||||||
version: 4.0.18(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
version: 4.0.18(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
vue-tsc:
|
vue-tsc:
|
||||||
specifier: latest
|
specifier: ^3.2.5
|
||||||
version: 3.2.5(typescript@5.8.3)
|
version: 3.2.5(typescript@5.8.3)
|
||||||
|
|
||||||
packages/core:
|
packages/core:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@vitejs/plugin-vue':
|
'@vitejs/plugin-vue':
|
||||||
specifier: latest
|
specifier: ^6.0.4
|
||||||
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))
|
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))
|
||||||
eslint:
|
eslint:
|
||||||
specifier: latest
|
specifier: ^10.0.2
|
||||||
version: 10.0.2(jiti@2.6.1)
|
version: 10.0.2(jiti@2.6.1)
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ~5.8.0
|
specifier: ~5.8.0
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
vite:
|
vite:
|
||||||
specifier: latest
|
specifier: ^7.3.1
|
||||||
version: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
version: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: latest
|
specifier: ^4.0.18
|
||||||
version: 4.0.18(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
version: 4.0.18(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
vue:
|
vue:
|
||||||
specifier: latest
|
specifier: ^3.5.29
|
||||||
version: 3.5.29(typescript@5.8.3)
|
version: 3.5.29(typescript@5.8.3)
|
||||||
vue-tsc:
|
vue-tsc:
|
||||||
specifier: latest
|
specifier: ^3.2.5
|
||||||
version: 3.2.5(typescript@5.8.3)
|
version: 3.2.5(typescript@5.8.3)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|||||||
Reference in New Issue
Block a user