a773c11aa0
- Embed RSS items at fetch time (nomic-embed-text); backfill at startup
- Semantic news search injected into chat system prompt ("Recent News You've Seen")
when items match query above 0.55 cosine threshold (independent of note RAG)
- "Discuss in chat" button on news cards — creates a seeded conversation with
the article title + full content, navigates directly to the new chat
- Briefing compilation now passes 500-char article excerpts (not just headlines)
to the LLM and uses 8192 num_ctx to accommodate the larger prompt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
407 lines
9.2 KiB
Vue
407 lines
9.2 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import {
|
|
getBriefingFeeds,
|
|
postRssReaction,
|
|
deleteRssReaction,
|
|
getNewsItems,
|
|
openArticleInChat,
|
|
type BriefingFeed,
|
|
} from '@/api/client'
|
|
import type { NewsItem } from '@/types/news'
|
|
|
|
const router = useRouter()
|
|
|
|
const LIMIT = 40
|
|
|
|
const items = ref<NewsItem[]>([])
|
|
const offset = ref(0)
|
|
const hasMore = ref(true)
|
|
const loading = ref(false)
|
|
const feeds = ref<BriefingFeed[]>([])
|
|
const selectedFeedId = ref<number | null>(null)
|
|
|
|
// Reactions map: item id → current reaction
|
|
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
|
|
// Track which items are currently being opened in chat
|
|
const openingChat = ref<Set<number>>(new Set())
|
|
|
|
async function loadMore() {
|
|
if (loading.value || !hasMore.value) return
|
|
loading.value = true
|
|
try {
|
|
const data = await getNewsItems({
|
|
days: 90,
|
|
limit: LIMIT,
|
|
offset: offset.value,
|
|
feed_id: selectedFeedId.value,
|
|
})
|
|
for (const item of data.items) {
|
|
if (reactions.value[item.id] === undefined) {
|
|
reactions.value[item.id] = item.reaction
|
|
}
|
|
}
|
|
items.value = [...items.value, ...data.items]
|
|
offset.value += data.items.length
|
|
hasMore.value = data.items.length === LIMIT
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function onFeedChange() {
|
|
items.value = []
|
|
offset.value = 0
|
|
hasMore.value = true
|
|
reactions.value = {}
|
|
loadMore()
|
|
}
|
|
|
|
async function handleReaction(itemId: number, reaction: 'up' | 'down') {
|
|
const current = reactions.value[itemId]
|
|
reactions.value[itemId] = current === reaction ? null : reaction
|
|
try {
|
|
if (current === reaction) {
|
|
await deleteRssReaction(itemId)
|
|
} else {
|
|
await postRssReaction(itemId, reaction)
|
|
}
|
|
} catch {
|
|
reactions.value[itemId] = current ?? null
|
|
}
|
|
}
|
|
|
|
function formatRelativeDate(iso: string | null): string {
|
|
if (!iso) return ''
|
|
const d = new Date(iso)
|
|
const now = new Date()
|
|
const diffH = (now.getTime() - d.getTime()) / 3_600_000
|
|
if (diffH < 1) return 'Just now'
|
|
if (diffH < 24) return `${Math.round(diffH)}h ago`
|
|
if (diffH < 48) return 'Yesterday'
|
|
const days = Math.floor(diffH / 24)
|
|
if (days < 7) return `${days}d ago`
|
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
}
|
|
|
|
async function openInChat(itemId: number) {
|
|
if (openingChat.value.has(itemId)) return
|
|
openingChat.value.add(itemId)
|
|
try {
|
|
const result = await openArticleInChat(itemId)
|
|
router.push(`/chat/${result.conversation_id}`)
|
|
} catch {
|
|
// silently fail — button returns to enabled state
|
|
} finally {
|
|
openingChat.value.delete(itemId)
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
feeds.value = await getBriefingFeeds().catch(() => [])
|
|
await loadMore()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="news-root">
|
|
<div class="news-header">
|
|
<div class="news-header-left">
|
|
<h1 class="news-title">News</h1>
|
|
<span class="news-subtitle">Last 90 days</span>
|
|
</div>
|
|
<div class="news-header-right">
|
|
<select
|
|
v-model="selectedFeedId"
|
|
class="feed-select"
|
|
@change="onFeedChange"
|
|
>
|
|
<option :value="null">All feeds</option>
|
|
<option v-for="feed in feeds" :key="feed.id" :value="feed.id">
|
|
{{ feed.title }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="news-list">
|
|
<div v-if="!items.length && !loading" class="news-empty">
|
|
No articles found for the selected feed.
|
|
</div>
|
|
|
|
<div
|
|
v-for="item in items"
|
|
:key="item.id"
|
|
class="news-card"
|
|
>
|
|
<div class="news-card-meta">
|
|
<span class="news-source">{{ item.source }}</span>
|
|
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
|
|
</div>
|
|
<a
|
|
v-if="item.url"
|
|
:href="item.url"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="news-card-title"
|
|
>{{ item.title }}</a>
|
|
<p v-else class="news-card-title news-card-title--plain">{{ item.title }}</p>
|
|
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
|
|
<div v-if="item.topics?.length" class="news-topics">
|
|
<span v-for="topic in item.topics" :key="topic" class="news-topic">{{ topic }}</span>
|
|
</div>
|
|
<div class="news-reactions">
|
|
<button
|
|
class="reaction-btn"
|
|
:class="{ active: reactions[item.id] === 'up' }"
|
|
@click="handleReaction(item.id, 'up')"
|
|
title="Interested"
|
|
>👍</button>
|
|
<button
|
|
class="reaction-btn"
|
|
:class="{ active: reactions[item.id] === 'down' }"
|
|
@click="handleReaction(item.id, 'down')"
|
|
title="Not interested"
|
|
>👎</button>
|
|
<button
|
|
class="reaction-btn open-chat-btn"
|
|
:class="{ busy: openingChat.has(item.id) }"
|
|
:disabled="openingChat.has(item.id)"
|
|
@click="openInChat(item.id)"
|
|
title="Discuss in chat"
|
|
>{{ openingChat.has(item.id) ? '…' : '💬' }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="news-footer">
|
|
<button
|
|
v-if="hasMore"
|
|
class="btn-load-more"
|
|
@click="loadMore"
|
|
:disabled="loading"
|
|
>{{ loading ? 'Loading…' : 'Load more' }}</button>
|
|
<div v-if="loading && !items.length" class="news-loading">Loading…</div>
|
|
<p v-if="!hasMore && items.length" class="news-end">All articles loaded</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.news-root {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.news-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1.25rem 1.5rem 1rem;
|
|
border-bottom: 1px solid var(--color-border);
|
|
flex-shrink: 0;
|
|
flex-wrap: wrap;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.news-header-left {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.news-title {
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
font-size: 1.3rem;
|
|
font-weight: 700;
|
|
margin: 0;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.news-subtitle {
|
|
font-size: 0.82rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.news-header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.feed-select {
|
|
padding: 0.35rem 0.6rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 6px;
|
|
background: var(--color-bg-card);
|
|
color: var(--color-text);
|
|
font-size: 0.82rem;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.news-list {
|
|
padding: 1rem 1.5rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
max-width: 860px;
|
|
width: 100%;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.news-empty,
|
|
.news-loading {
|
|
text-align: center;
|
|
padding: 3rem 1rem;
|
|
color: var(--color-text-muted);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.news-card {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 10px;
|
|
padding: 0.85rem 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.3rem;
|
|
}
|
|
|
|
.news-card-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.news-source {
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.news-date {
|
|
font-size: 0.72rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.news-card-title {
|
|
font-size: 0.95rem;
|
|
font-weight: 600;
|
|
color: var(--color-text);
|
|
line-height: 1.4;
|
|
text-decoration: none;
|
|
margin: 0;
|
|
}
|
|
|
|
a.news-card-title:hover {
|
|
text-decoration: underline;
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.news-snippet {
|
|
font-size: 0.82rem;
|
|
color: var(--color-text-muted);
|
|
line-height: 1.5;
|
|
margin: 0;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 3;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.news-topics {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.25rem;
|
|
margin-top: 0.1rem;
|
|
}
|
|
|
|
.news-topic {
|
|
font-size: 0.68rem;
|
|
padding: 0.15rem 0.5rem;
|
|
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
|
color: var(--color-primary);
|
|
border-radius: 99px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.news-reactions {
|
|
display: flex;
|
|
gap: 0.3rem;
|
|
margin-top: 0.2rem;
|
|
}
|
|
|
|
.reaction-btn {
|
|
background: none;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 6px;
|
|
padding: 0.1rem 0.4rem;
|
|
cursor: pointer;
|
|
font-size: 0.82rem;
|
|
line-height: 1.4;
|
|
opacity: 0.55;
|
|
transition: opacity 0.15s, border-color 0.15s;
|
|
}
|
|
|
|
.reaction-btn:hover {
|
|
opacity: 1;
|
|
border-color: var(--color-primary);
|
|
}
|
|
|
|
.reaction-btn.active {
|
|
opacity: 1;
|
|
border-color: var(--color-primary);
|
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
}
|
|
|
|
.open-chat-btn {
|
|
margin-left: auto;
|
|
}
|
|
|
|
.open-chat-btn.busy {
|
|
opacity: 0.4;
|
|
cursor: wait;
|
|
}
|
|
|
|
.news-footer {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 1rem 0 0.5rem;
|
|
}
|
|
|
|
.btn-load-more {
|
|
padding: 0.5rem 1.5rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 8px;
|
|
background: var(--color-bg-card);
|
|
color: var(--color-text-muted);
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.btn-load-more:hover:not(:disabled) {
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.btn-load-more:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.news-end {
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
margin: 0;
|
|
}
|
|
</style>
|