From c82b4ef54f13052a86c2e9465423d6f9d9c0c72a Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 2 Mar 2026 23:50:46 +0000 Subject: [PATCH] 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 --- .claude/worktrees/hungry-sinoussi | 1 - .claude/worktrees/unruffled-carson | 1 - package.json | 2 +- packages/app/dev-dist/sw.js | 2 +- packages/app/e2e/content-surfaces.spec.ts | 22 +- packages/app/package.json | 22 +- packages/app/scripts/dev.sh | 29 ++ packages/app/server/claude-proxy.ts | 87 ++++- .../app/src/components/chat/ChatMessage.vue | 4 +- .../src/components/content/ArticleDetail.vue | 39 +- .../src/components/content/ArticleOverlay.vue | 40 +- .../src/components/content/MagazineGrid.vue | 345 ++++++++---------- .../app/src/components/content/NewsCard.vue | 14 +- packages/app/src/composables/useAI.ts | 33 +- packages/app/src/composables/usePlayer.ts | 2 +- packages/app/src/mocks/podcasts.ts | 2 +- packages/app/src/stores/chat.ts | 2 +- packages/app/src/styles/main.css | 1 + packages/app/src/utils/html.ts | 57 +++ packages/app/tsconfig.json | 5 +- packages/app/vite-music-search.ts | 1 + packages/app/vite-rss.ts | 23 +- packages/app/vite.config.ts | 4 +- packages/core/package.json | 12 +- packages/core/src/plugins/registry.ts | 6 +- packages/core/tsconfig.json | 3 + pnpm-lock.yaml | 34 +- 27 files changed, 423 insertions(+), 370 deletions(-) delete mode 160000 .claude/worktrees/hungry-sinoussi delete mode 160000 .claude/worktrees/unruffled-carson create mode 100755 packages/app/scripts/dev.sh create mode 100644 packages/app/src/utils/html.ts diff --git a/.claude/worktrees/hungry-sinoussi b/.claude/worktrees/hungry-sinoussi deleted file mode 160000 index 54140602..00000000 --- a/.claude/worktrees/hungry-sinoussi +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5414060225ac72a64bf22b99e524105d2a52b7c5 diff --git a/.claude/worktrees/unruffled-carson b/.claude/worktrees/unruffled-carson deleted file mode 160000 index 54140602..00000000 --- a/.claude/worktrees/unruffled-carson +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5414060225ac72a64bf22b99e524105d2a52b7c5 diff --git a/package.json b/package.json index b41a8520..7a5f9ac4 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "clean": "turbo clean" }, "devDependencies": { - "turbo": "latest", + "turbo": "^2.8.12", "typescript": "~5.8.0" }, "packageManager": "pnpm@10.30.3", diff --git a/packages/app/dev-dist/sw.js b/packages/app/dev-dist/sw.js index f69ca72e..8f8ae12b 100644 --- a/packages/app/dev-dist/sw.js +++ b/packages/app/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-cf23aef7'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.h2t4rjv3m6" + "revision": "0.ndi22oikhk8" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/packages/app/e2e/content-surfaces.spec.ts b/packages/app/e2e/content-surfaces.spec.ts index 40043341..eb5f4518 100644 --- a/packages/app/e2e/content-surfaces.spec.ts +++ b/packages/app/e2e/content-surfaces.spec.ts @@ -23,7 +23,7 @@ test.describe('Content surfaces', () => { ]) await expect(page.getByText('Recommend some sci-fi films')).toBeVisible({ timeout: 10000 }) 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 }) => { @@ -33,7 +33,7 @@ test.describe('Content surfaces', () => { ]) 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 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 }) => { @@ -41,7 +41,7 @@ test.describe('Content surfaces', () => { page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), 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 expect(page.getByText(/BIP 110|Pro camp|Summary/i).first()).toBeVisible({ timeout: 8000 }) 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.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 expect(page.getByText('Never Meant').first()).toBeVisible({ timeout: 8000 }) 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 }) => { @@ -65,11 +65,11 @@ test.describe('Content surfaces', () => { page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), 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 expect(page.getByText('What Bitcoin Did').first()).toBeVisible({ timeout: 8000 }) 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 }) => { @@ -77,11 +77,11 @@ test.describe('Content surfaces', () => { page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), 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 expect(page.getByText('Bitcoin Magazine').first()).toBeVisible({ timeout: 8000 }) 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 }) => { @@ -89,10 +89,10 @@ test.describe('Content surfaces', () => { page.waitForResponse((res) => res.url().includes('dev-chats') && res.status() === 200, { timeout: 15000 }), 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 expect(page.getByText(/Bitcoin hits|ETF inflows/i).first()).toBeVisible({ timeout: 8000 }) 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 }) }) }) diff --git a/packages/app/package.json b/packages/app/package.json index acaddeb9..f8eaab04 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -6,7 +6,7 @@ "license": "MIT", "type": "module", "scripts": { - "dev": "tsx server/claude-proxy.ts & vite", + "dev": "bash scripts/dev.sh", "dev:vite": "vite", "dev:proxy": "tsx server/claude-proxy.ts", "build": "vue-tsc --noEmit && vite build", @@ -20,24 +20,24 @@ }, "dependencies": { "@aiui/core": "workspace:*", - "pinia": "latest", + "pinia": "^3.0.4", "plyr": "^3.8.4", - "vue": "latest", - "vue-router": "latest" + "vue": "^3.5.29", + "vue-router": "^5.0.3" }, "devDependencies": { - "@tailwindcss/vite": "latest", - "@vitejs/plugin-vue": "latest", + "@tailwindcss/vite": "^4.2.1", + "@vitejs/plugin-vue": "^6.0.4", "duck-duck-scrape": "^2.2.7", - "eslint": "latest", + "eslint": "^10.0.2", "rss-parser": "^3.13.0", - "tailwindcss": "latest", + "tailwindcss": "^4.2.1", "tsx": "^4.21.0", "typescript": "~5.8.0", - "vite": "latest", + "vite": "^7.3.1", "vite-plugin-pwa": "^1.2.0", "@playwright/test": "^1.49.0", - "vitest": "latest", - "vue-tsc": "latest" + "vitest": "^4.0.18", + "vue-tsc": "^3.2.5" } } diff --git a/packages/app/scripts/dev.sh b/packages/app/scripts/dev.sh new file mode 100755 index 00000000..ff3eebfe --- /dev/null +++ b/packages/app/scripts/dev.sh @@ -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 diff --git a/packages/app/server/claude-proxy.ts b/packages/app/server/claude-proxy.ts index 41166c8f..556b033e 100644 --- a/packages/app/server/claude-proxy.ts +++ b/packages/app/server/claude-proxy.ts @@ -57,6 +57,7 @@ function getAnthropicCredential(): string | undefined { } 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 SEARCH_WEB_TOOL = { @@ -202,6 +203,80 @@ async function streamViaAnthropicApi( } } +async function streamOpenRouterProxy( + reqBody: string, + res: import('http').ServerResponse, +): Promise { + 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) => { if (req.method === 'OPTIONS') { res.writeHead(204, { @@ -213,7 +288,7 @@ const server = createServer((req, res) => { 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.end(JSON.stringify({ error: 'Not found' })) return @@ -222,6 +297,11 @@ const server = createServer((req, res) => { let body = '' req.on('data', (chunk) => { body += chunk }) req.on('end', () => { + if (req.url === '/v1/openrouter') { + console.log('[proxy] → OpenRouter proxy') + streamOpenRouterProxy(body, res) + return + } try { const payload = JSON.parse(body) const { model, messages, system, webSearch } = payload @@ -361,8 +441,9 @@ server.listen(PORT, () => { console.log(` Binary: ${CLAUDE_BIN}`) if (ANTHROPIC_CREDENTIAL) { 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 { - 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`) }) diff --git a/packages/app/src/components/chat/ChatMessage.vue b/packages/app/src/components/chat/ChatMessage.vue index 8131e0a9..1014680f 100644 --- a/packages/app/src/components/chat/ChatMessage.vue +++ b/packages/app/src/components/chat/ChatMessage.vue @@ -112,7 +112,7 @@ import { computed } from 'vue' import type { Message, WebSearchResult } from '@aiui/core/types/message' import type { Film, Song, Podcast } from '@aiui/core/types/content' import { useTheme } from '@/composables/useTheme' -import { useContentPanel } from '@/composables/useContentPanel' +import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel' import { useArticleOverlayStore } from '@/stores/articleOverlay' import FilmCard from '@/components/content/FilmCard.vue' import SongCard from '@/components/content/SongCard.vue' @@ -135,7 +135,7 @@ const overlayStore = useArticleOverlayStore() const isUser = computed(() => props.message.role === 'user') const inlineContent = computed(() => { - if (isUser.value) return { films: [] as Film[], songs: [] as Song[], podcasts: [] as Podcast[], newsLinks: [], websitesLinks: [] } + 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 ?? []) }) diff --git a/packages/app/src/components/content/ArticleDetail.vue b/packages/app/src/components/content/ArticleDetail.vue index af6ee1cf..61c2f16b 100644 --- a/packages/app/src/components/content/ArticleDetail.vue +++ b/packages/app/src/components/content/ArticleDetail.vue @@ -67,6 +67,7 @@ import { computed } from 'vue' import type { WebSearchResult } from '@aiui/core/types/message' import { useTheme } from '@/composables/useTheme' +import { isSafeImgSrc, sanitizeHtml, escapeHtml } from '@/utils/html' const props = defineProps<{ article: WebSearchResult }>() 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%)` }) -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}` - } - return [...div.childNodes].map(walk).join('') -} - const sanitizedContent = computed(() => { const c = props.article.content if (!c) return '' if (/<[a-z][\s\S]*>/i.test(c)) return sanitizeHtml(c) - return `

${c.replace(/&/g, '&').replace(//g, '>')}

` + return `

${escapeHtml(c)}

` }) diff --git a/packages/app/src/components/content/ArticleOverlay.vue b/packages/app/src/components/content/ArticleOverlay.vue index 3444ea03..c3dc191f 100644 --- a/packages/app/src/components/content/ArticleOverlay.vue +++ b/packages/app/src/components/content/ArticleOverlay.vue @@ -88,7 +88,7 @@
{ - 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}` - } - return [...div.childNodes].map(walk).join('') -} - const sanitizedContent = computed(() => { const c = store.content if (!c) return '' if (/<[a-z][\s\S]*>/i.test(c)) return sanitizeHtml(c) - return `

${c.replace(/&/g, '&').replace(//g, '>')}

` + return `

${escapeHtml(c)}

` }) -function isSafeImgSrc(src: string): boolean { - try { - const u = new URL(src) - return /^https?:$/i.test(u.protocol) - } catch { - return false - } -} const iframeRef = ref(null) const iframeRefreshKey = ref(0) const isRefreshing = ref(false) diff --git a/packages/app/src/components/content/MagazineGrid.vue b/packages/app/src/components/content/MagazineGrid.vue index f2e8c7ed..9ea11687 100644 --- a/packages/app/src/components/content/MagazineGrid.vue +++ b/packages/app/src/components/content/MagazineGrid.vue @@ -1,217 +1,160 @@ @@ -224,10 +167,8 @@ import type { MagazineSection } from '@/composables/useContentPanel' const props = withDefaults(defineProps<{ sections: MagazineSection[] - /** Hero image URL (from response or web results) */ heroImageUrl?: string | null title?: string - /** User's prompt for headline banner */ query?: string }>(), { heroImageUrl: null, @@ -242,26 +183,23 @@ function isSafeImgUrl(u: string | undefined | null): u is string { return !!u && typeof u === 'string' && /^https?:\/\//i.test(u.trim()) } -/** Hero image: use extracted/web result, or picsum fallback seeded by query */ const heroImageDisplay = computed(() => { if (props.heroImageUrl && isSafeImgUrl(props.heroImageUrl)) return props.heroImageUrl const seed = (props.query || 'magazine').toLowerCase().replace(/\s+/g, '-').slice(0, 30) || 'brief' return `https://picsum.photos/seed/${seed}/800/450` }) -/** Meme image: contextual meme templates (imgflip) */ const memeImageUrl = computed(() => { const text = props.sections.map((s) => s.title + ' ' + s.content).join(' ').toLowerCase() const q = (props.query || '').toLowerCase() const combined = text + ' ' + q - if (/\bbearish|bear|fear|dump|crash|extreme fear\b/.test(combined)) return 'https://i.imgflip.com/wxica.jpg' // This is Fine - if (/\bbull|rally|moon|pump|buy the dip\b/.test(combined)) return 'https://i.imgflip.com/1bhk.jpg' // Success Kid - if (/\bbitcoin|btc\b/.test(combined)) return 'https://i.imgflip.com/30b1gx.jpg' // Drake - if (/\bmacro|fed|rate|inflation\b/.test(combined)) return 'https://i.imgflip.com/1ur9b0.jpg' // Distracted Boyfriend - return 'https://i.imgflip.com/1bij.jpg' // One does not simply + 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' + 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' + return 'https://i.imgflip.com/1bij.jpg' }) -/** Meme caption: short contextual phrase */ const memeCaption = computed(() => { const text = props.sections.map((s) => s.title + ' ' + s.content).join(' ').toLowerCase() if (/\bbearish|fear|extreme fear\b/.test(text)) return 'Me checking my portfolio' @@ -281,14 +219,31 @@ const headlineText = computed(() => { return q.length > 100 ? q.slice(0, 97) + '…' : q }) -/** Render content with **bold** preserved and newlines as paragraphs (sanitized). */ function formatContent(text: string): string { const safe = text .replace(/&/g, '&') .replace(//g, '>') .replace(/\*\*([^*]+)\*\*/g, '$1') - const withParas = safe.replace(/\n\n+/g, '

').replace(/\n/g, '
') + const withParas = safe.replace(/\n\n+/g, '

').replace(/\n/g, '
') return `

${withParas}

` } + + diff --git a/packages/app/src/components/content/NewsCard.vue b/packages/app/src/components/content/NewsCard.vue index 227d3c41..a0993bbd 100644 --- a/packages/app/src/components/content/NewsCard.vue +++ b/packages/app/src/components/content/NewsCard.vue @@ -51,6 +51,7 @@ import { ref, computed } from 'vue' import type { WebSearchResult } from '@aiui/core/types/message' import { useTheme } from '@/composables/useTheme' +import { isSafeUrl, formatDomain } from '@/utils/html' const props = defineProps<{ article: WebSearchResult }>() defineEmits<{ 'select-article': [article: WebSearchResult] }>() @@ -58,14 +59,10 @@ defineEmits<{ 'select-article': [article: WebSearchResult] }>() const { isDark } = useTheme() const imgFailed = ref(false) -function isSafeImgUrl(u: string | undefined): u is string { - return !!u && typeof u === 'string' && /^https?:\/\//i.test(u.trim()) -} - const imgSrc = computed(() => { if (imgFailed.value) return null const u = props.article.imgSrc - return isSafeImgUrl(u) ? u : null + return isSafeUrl(u) ? u : null }) 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 - } -} diff --git a/packages/app/src/composables/useAI.ts b/packages/app/src/composables/useAI.ts index 1304230c..96a36cf2 100644 --- a/packages/app/src/composables/useAI.ts +++ b/packages/app/src/composables/useAI.ts @@ -5,7 +5,7 @@ import { searchWeb } from '@/composables/useWebSearch' type Provider = 'claude' | 'openrouter' | 'mock' 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 { mockSongs } from '@/mocks/songs' @@ -54,9 +54,6 @@ ${songContext} The user's podcast library: ${podcastContext}` -const openrouterApiKey = import.meta.env.VITE_OPENROUTER_API_KEY ?? '' -const hasOpenRouter = !!openrouterApiKey - const activeProvider = ref('claude') const activeModel = ref('claude-sonnet-4') @@ -73,18 +70,16 @@ const availableProviders = computed(() => { ], }, ] - if (hasOpenRouter) { - providers.push({ - id: 'openrouter', - name: 'OpenRouter', - models: [ - { id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick' }, - { 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: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)' }, - ], - }) - } + providers.push({ + id: 'openrouter', + name: 'OpenRouter', + models: [ + { id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick' }, + { 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: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)' }, + ], + }) providers.push({ id: 'mock', name: 'Local (no API)', @@ -166,11 +161,6 @@ async function streamOpenRouter( onError: (err: string) => void, systemPrompt: string, ): Promise { - if (!openrouterApiKey) { - onError('Missing VITE_OPENROUTER_API_KEY in .env.local') - return - } - const orMessages = [ { role: 'system' as const, content: systemPrompt }, ...messages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })), @@ -180,7 +170,6 @@ async function streamOpenRouter( method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${openrouterApiKey}`, 'HTTP-Referer': window.location.origin, 'X-Title': 'AIUI', }, diff --git a/packages/app/src/composables/usePlayer.ts b/packages/app/src/composables/usePlayer.ts index 109e1d17..70d15f47 100644 --- a/packages/app/src/composables/usePlayer.ts +++ b/packages/app/src/composables/usePlayer.ts @@ -84,7 +84,7 @@ export function usePlayer() { } plyrInstance!.on('ready', () => { - plyrInstance!.play().catch(() => { /* autoplay blocked */ }) + Promise.resolve(plyrInstance!.play()).catch(() => { /* autoplay blocked */ }) }) plyrInstance!.on('timeupdate', () => { currentTime.value = plyrInstance!.currentTime ?? 0 diff --git a/packages/app/src/mocks/podcasts.ts b/packages/app/src/mocks/podcasts.ts index 372411f4..bbbdecaf 100644 --- a/packages/app/src/mocks/podcasts.ts +++ b/packages/app/src/mocks/podcasts.ts @@ -103,6 +103,6 @@ export function searchPodcasts(query: string): Podcast[] { (p) => p.title.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)) ) } diff --git a/packages/app/src/stores/chat.ts b/packages/app/src/stores/chat.ts index d552dce1..2a862acd 100644 --- a/packages/app/src/stores/chat.ts +++ b/packages/app/src/stores/chat.ts @@ -143,7 +143,7 @@ export const useChatStore = defineStore('chat', () => { function setMessageWebResults(conversationId: string, messageId: string, results: WebSearchResult[]) { const conv = conversations.value.get(conversationId) if (!conv) return - const msg = conv.messages.find((m) => m.id === messageId) + const msg = conv.messages.find((m: { id: string }) => m.id === messageId) if (msg) msg.webResults = results } diff --git a/packages/app/src/styles/main.css b/packages/app/src/styles/main.css index bd578253..f90e5262 100644 --- a/packages/app/src/styles/main.css +++ b/packages/app/src/styles/main.css @@ -23,6 +23,7 @@ --font-sans: 'Inter', system-ui, -apple-system, sans-serif; --font-mono: 'Menlo', 'Monaco', 'Courier New', monospace; + --font-serif: Georgia, 'Times New Roman', Times, serif; } html { diff --git a/packages/app/src/utils/html.ts b/packages/app/src/utils/html.ts new file mode 100644 index 00000000..cb6c6f5f --- /dev/null +++ b/packages/app/src/utils/html.ts @@ -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}` + } + 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, '>') +} + +/** 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 + } +} diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index 79cbe885..f02a4cab 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -9,8 +9,5 @@ "@aiui/core/*": ["../core/src/*"] } }, - "include": ["src/**/*.ts", "src/**/*.vue"], - "references": [ - { "path": "../core" } - ] + "include": ["src/**/*.ts", "src/**/*.vue", "../core/src/**/*.ts"] } diff --git a/packages/app/vite-music-search.ts b/packages/app/vite-music-search.ts index f8948e5d..6025f1a3 100644 --- a/packages/app/vite-music-search.ts +++ b/packages/app/vite-music-search.ts @@ -111,6 +111,7 @@ async function searchJamendo(q: string, clientId: string, title?: string, artist ) : results[0] if (!track?.audio) return null + if (!/^https?:\/\//i.test(track.audio)) return null if (title && artist && scoreJamendoTrack(track, title, artist) === 0) { return null } diff --git a/packages/app/vite-rss.ts b/packages/app/vite-rss.ts index c8d51383..9fc1c9c1 100644 --- a/packages/app/vite-rss.ts +++ b/packages/app/vite-rss.ts @@ -56,8 +56,29 @@ async function tryParseFeed(parser: Parser, feedUrl: string): Promise base + path) @@ -107,7 +128,7 @@ function createRssMiddleware() { 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) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'No valid https URLs' })) diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index 876e2d07..9a4ce447 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -92,9 +92,9 @@ export default defineConfig({ rewrite: (path) => path.replace(/^\/api\/claude/, ''), }, '/api/openrouter': { - target: 'https://openrouter.ai', + target: 'http://localhost:3141', changeOrigin: true, - rewrite: (path) => path.replace(/^\/api\/openrouter/, ''), + rewrite: (path) => path.replace(/^\/api\/openrouter/, '/v1/openrouter'), }, }, }, diff --git a/packages/core/package.json b/packages/core/package.json index 8961e825..a65e0ae4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,12 +26,12 @@ "vue": "^3.5.0" }, "devDependencies": { - "@vitejs/plugin-vue": "latest", - "vite": "latest", - "vue": "latest", - "vue-tsc": "latest", - "vitest": "latest", - "eslint": "latest", + "@vitejs/plugin-vue": "^6.0.4", + "vite": "^7.3.1", + "vue": "^3.5.29", + "vue-tsc": "^3.2.5", + "vitest": "^4.0.18", + "eslint": "^10.0.2", "typescript": "~5.8.0" } } diff --git a/packages/core/src/plugins/registry.ts b/packages/core/src/plugins/registry.ts index 9b6ff971..b3212388 100644 --- a/packages/core/src/plugins/registry.ts +++ b/packages/core/src/plugins/registry.ts @@ -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 { RendererDefinition } from '../types/renderer' @@ -47,5 +47,5 @@ export function getAllRenderers(): RendererDefinition[] { return Array.from(renderers.value.values()) } -export const pluginRegistry = readonly(plugins) -export const rendererRegistry = readonly(renderers) +export const pluginRegistry: DeepReadonly>> = readonly(plugins) +export const rendererRegistry: DeepReadonly>> = readonly(renderers) diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 16a3f28c..52706c66 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -2,6 +2,9 @@ "extends": "../../tsconfig.json", "compilerOptions": { "composite": true, + "noEmit": false, + "declaration": true, + "declarationMap": true, "baseUrl": ".", "paths": { "@/*": ["./src/*"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb840836..22de7569 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ importers: .: devDependencies: turbo: - specifier: latest + specifier: ^2.8.12 version: 2.8.12 typescript: specifier: ~5.8.0 @@ -21,38 +21,38 @@ importers: specifier: workspace:* version: link:../core pinia: - specifier: latest + specifier: ^3.0.4 version: 3.0.4(typescript@5.8.3)(vue@3.5.29(typescript@5.8.3)) plyr: specifier: ^3.8.4 version: 3.8.4 vue: - specifier: latest + specifier: ^3.5.29 version: 3.5.29(typescript@5.8.3) 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)) devDependencies: '@playwright/test': specifier: ^1.49.0 version: 1.58.2 '@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)) '@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)) duck-duck-scrape: specifier: ^2.2.7 version: 2.2.7 eslint: - specifier: latest + specifier: ^10.0.2 version: 10.0.2(jiti@2.6.1) rss-parser: specifier: ^3.13.0 version: 3.13.0 tailwindcss: - specifier: latest + specifier: ^4.2.1 version: 4.2.1 tsx: specifier: ^4.21.0 @@ -61,40 +61,40 @@ importers: specifier: ~5.8.0 version: 5.8.3 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) vite-plugin-pwa: 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) 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) vue-tsc: - specifier: latest + specifier: ^3.2.5 version: 3.2.5(typescript@5.8.3) packages/core: devDependencies: '@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)) eslint: - specifier: latest + specifier: ^10.0.2 version: 10.0.2(jiti@2.6.1) typescript: specifier: ~5.8.0 version: 5.8.3 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) 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) vue: - specifier: latest + specifier: ^3.5.29 version: 3.5.29(typescript@5.8.3) vue-tsc: - specifier: latest + specifier: ^3.2.5 version: 3.2.5(typescript@5.8.3) packages: