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:
Dorian
2026-03-02 23:50:46 +00:00
co-authored by Claude Opus 4.6
parent b71c88f03b
commit c82b4ef54f
27 changed files with 423 additions and 370 deletions
Submodule .claude/worktrees/hungry-sinoussi deleted from 5414060225
Submodule .claude/worktrees/unruffled-carson deleted from 5414060225
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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"), {
+11 -11
View File
@@ -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
View File
@@ -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"
} }
} }
+29
View File
@@ -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
+84 -3
View File
@@ -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, '&quot;')}"`)
}
if (tag === 'img' && el.getAttribute('src')) {
const src = el.getAttribute('src') ?? ''
if (/^https?:\/\//i.test(src)) attrs.push(`src="${src.replace(/"/g, '&quot;')}"`)
}
const inner = [...node.childNodes].map(walk).join('')
return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>${inner}</${tag}>`
}
return [...div.childNodes].map(walk).join('')
}
const sanitizedContent = computed(() => { const 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</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, '&quot;')}"`)
}
if (tag === 'img' && el.getAttribute('src')) {
const src = el.getAttribute('src') ?? ''
if (/^https?:\/\//i.test(src)) attrs.push(`src="${src.replace(/"/g, '&quot;')}"`)
}
const inner = [...node.childNodes].map(walk).join('')
return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>${inner}</${tag}>`
}
return [...div.childNodes].map(walk).join('')
}
const sanitizedContent = computed(() => { const 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</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, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.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>
+11 -22
View File
@@ -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',
}, },
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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))
) )
} }
+1 -1
View File
@@ -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
} }
+1
View File
@@ -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 {
+57
View File
@@ -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, '&quot;')}"`)
}
if (tag === 'img' && el.getAttribute('src')) {
const src = el.getAttribute('src') ?? ''
if (/^https?:\/\//i.test(src)) attrs.push(`src="${src.replace(/"/g, '&quot;')}"`)
}
const inner = [...node.childNodes].map(walk).join('')
return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>${inner}</${tag}>`
}
return [...div.childNodes].map(walk).join('')
}
/** Escape HTML entities for safe rendering in a text context */
export function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** 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
}
}
+1 -4
View File
@@ -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" }
]
} }
+1
View File
@@ -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
} }
+22 -1
View File
@@ -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' }))
+2 -2
View File
@@ -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'),
}, },
}, },
}, },
+6 -6
View File
@@ -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"
} }
} }
+3 -3
View File
@@ -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)
+3
View File
@@ -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/*"]
+17 -17
View File
@@ -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: