284dcd1c63
POST /api/briefing/articles/<id>/discuss injects stored article content as a persisted read_article tool exchange before triggering generation. The LLM sees the article as already read; follow-ups retain context via the fixed history builder. Frontend Discuss button now calls the new endpoint instead of inlining article text in the user message. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
578 lines
15 KiB
Vue
578 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
|
import { useChatStore } from '@/stores/chat'
|
|
import ChatPanel from '@/components/ChatPanel.vue'
|
|
import WeatherCard from '@/components/WeatherCard.vue'
|
|
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
|
import {
|
|
apiGet,
|
|
apiPost,
|
|
getBriefingConfig,
|
|
getBriefingConversations,
|
|
getBriefingToday,
|
|
triggerBriefingSlot,
|
|
postRssReaction,
|
|
deleteRssReaction,
|
|
getNewsItems,
|
|
type BriefingConversation,
|
|
} from '@/api/client'
|
|
import type { NewsItem } from '@/types/news'
|
|
|
|
interface WeatherData {
|
|
location: string
|
|
fetched_at: string
|
|
current_temp: number
|
|
condition: string
|
|
today_high: number | null
|
|
today_low: number | null
|
|
yesterday_high: number | null
|
|
yesterday_low: number | null
|
|
wind_unit?: string
|
|
forecast: { day: string; condition: string; high: number; low: number; precip_probability: number | null; precip_mm: number | null; windspeed_max: number }[]
|
|
}
|
|
|
|
const chatStore = useChatStore()
|
|
|
|
// Setup wizard
|
|
const showWizard = ref(false)
|
|
const wizardChecked = ref(false)
|
|
|
|
async function checkSetup() {
|
|
const config = await getBriefingConfig()
|
|
if (!config.enabled) showWizard.value = true
|
|
wizardChecked.value = true
|
|
}
|
|
|
|
async function onWizardDone() {
|
|
showWizard.value = false
|
|
await loadAll()
|
|
}
|
|
|
|
// Conversations list for the dropdown
|
|
const conversations = ref<BriefingConversation[]>([])
|
|
const selectedConvId = ref<number | null>(null)
|
|
const todayConvId = ref<number | null>(null)
|
|
|
|
const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
|
|
|
// Weather panel (left column)
|
|
const weatherData = ref<WeatherData[]>([])
|
|
const tempUnit = ref<string>('C')
|
|
|
|
async function loadWeather() {
|
|
try {
|
|
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
|
|
weatherData.value = data.locations ?? []
|
|
tempUnit.value = data.temp_unit ?? 'C'
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
// News panel (right column)
|
|
const newsItems = ref<NewsItem[]>([])
|
|
|
|
async function loadNews() {
|
|
try {
|
|
const data = await getNewsItems({ days: 2, limit: 40 })
|
|
newsItems.value = data.items
|
|
// Seed reactions from API response
|
|
for (const item of data.items) {
|
|
if (reactions.value[item.id] === undefined) {
|
|
reactions.value[item.id] = item.reaction
|
|
}
|
|
}
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
async function loadAll() {
|
|
const [convList, today] = await Promise.all([
|
|
getBriefingConversations(),
|
|
getBriefingToday().catch(() => null),
|
|
loadWeather(),
|
|
loadNews(),
|
|
])
|
|
conversations.value = convList
|
|
if (today) {
|
|
todayConvId.value = today.id
|
|
if (!convList.find((c) => c.id === today.id)) {
|
|
conversations.value = [
|
|
{ id: today.id, title: today.title ?? 'Today', briefing_date: null, message_count: 0, created_at: new Date().toISOString() },
|
|
...convList,
|
|
]
|
|
}
|
|
selectedConvId.value = today.id
|
|
await chatStore.fetchConversation(today.id)
|
|
}
|
|
}
|
|
|
|
watch(selectedConvId, async (id) => {
|
|
if (!id) return
|
|
try {
|
|
await chatStore.fetchConversation(id)
|
|
} catch {
|
|
// Historical conversation unavailable — do nothing
|
|
}
|
|
})
|
|
|
|
async function discussArticle(item: NewsItem) {
|
|
if (!todayConvId.value || chatStore.streaming) return
|
|
if (!isToday.value) selectedConvId.value = todayConvId.value
|
|
await nextTick(() => {
|
|
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
})
|
|
try {
|
|
await apiPost<{ assistant_message_id: number }>(`/api/briefing/articles/${item.id}/discuss`, { conv_id: todayConvId.value })
|
|
} catch {
|
|
return
|
|
}
|
|
await chatStore.fetchConversation(todayConvId.value)
|
|
await chatStore.reconnectIfGenerating(todayConvId.value)
|
|
}
|
|
|
|
// RSS reactions: map of rss_item_id -> 'up' | 'down' | null
|
|
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
|
|
|
|
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 < 24) return `${Math.round(diffH)}h ago`
|
|
if (diffH < 48) return 'Yesterday'
|
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
}
|
|
|
|
// Manual trigger
|
|
const triggering = ref(false)
|
|
async function triggerNow() {
|
|
triggering.value = true
|
|
try {
|
|
await triggerBriefingSlot('compilation')
|
|
// Guard: user may have navigated away during the long compilation
|
|
if (_mounted) await loadAll()
|
|
} finally {
|
|
if (_mounted) triggering.value = false
|
|
}
|
|
}
|
|
|
|
// Dropdown label
|
|
function convLabel(c: BriefingConversation): string {
|
|
if (c.id === todayConvId.value) return 'Today'
|
|
if (c.briefing_date) {
|
|
const d = new Date(c.briefing_date)
|
|
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
|
}
|
|
return c.title || 'Briefing'
|
|
}
|
|
|
|
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
|
async function _backgroundRefreshMessages() {
|
|
try {
|
|
const today = await getBriefingToday()
|
|
if (!today) return
|
|
if (_mounted && isToday.value && chatStore.currentConversation?.id === todayConvId.value) {
|
|
await chatStore.fetchConversation(today.id)
|
|
}
|
|
await loadNews()
|
|
} catch { /* silent — don't disturb the UI on network hiccup */ }
|
|
}
|
|
|
|
useBackgroundRefresh(
|
|
_backgroundRefreshMessages,
|
|
60_000,
|
|
() => !chatStore.streaming && isToday.value && !!todayConvId.value,
|
|
)
|
|
|
|
let _mounted = true
|
|
onUnmounted(() => { _mounted = false })
|
|
|
|
onMounted(async () => {
|
|
await checkSetup()
|
|
if (!showWizard.value) await loadAll()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="briefing-root">
|
|
<!-- Setup wizard overlay -->
|
|
<BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />
|
|
|
|
<!-- Main view -->
|
|
<div class="briefing-shell" v-if="wizardChecked && !showWizard">
|
|
<!-- Header spans all columns -->
|
|
<header class="briefing-header">
|
|
<div class="briefing-header-left">
|
|
<h1 class="briefing-title">Briefing</h1>
|
|
<span class="briefing-today-badge">{{ new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) }}</span>
|
|
</div>
|
|
<div class="briefing-header-right">
|
|
<select v-if="conversations.length" v-model="selectedConvId" class="briefing-conv-select">
|
|
<option v-for="c in conversations" :key="c.id" :value="c.id">{{ convLabel(c) }}</option>
|
|
</select>
|
|
<button
|
|
class="btn-trigger"
|
|
@click="triggerNow"
|
|
:disabled="triggering"
|
|
title="Manually trigger morning briefing now"
|
|
>{{ triggering ? '…' : 'Refresh' }}</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Left column: Weather -->
|
|
<div class="briefing-left">
|
|
<div class="panel-label">Weather</div>
|
|
<template v-if="weatherData.length">
|
|
<WeatherCard
|
|
v-for="loc in weatherData"
|
|
:key="(loc as WeatherData).location"
|
|
:weather="loc"
|
|
:temp-unit="tempUnit"
|
|
/>
|
|
</template>
|
|
<div v-else class="panel-empty">No weather configured</div>
|
|
</div>
|
|
|
|
<!-- Center column: Chat -->
|
|
<div class="briefing-center">
|
|
<ChatPanel
|
|
variant="full"
|
|
briefingMode
|
|
:readOnly="!isToday"
|
|
placeholder="Reply to your briefing…"
|
|
class="briefing-chat-panel"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Right column: News -->
|
|
<div class="briefing-right">
|
|
<div class="panel-label-row">
|
|
<div class="panel-label">Today's News</div>
|
|
<span v-if="newsItems.length" class="news-count">{{ newsItems.length }} items</span>
|
|
</div>
|
|
<div v-if="!newsItems.length" class="panel-empty">No articles in the last 2 days</div>
|
|
<div
|
|
v-for="item in newsItems"
|
|
: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-title"
|
|
>{{ item.title }}</a>
|
|
<p v-else class="news-title news-title--plain">{{ item.title }}</p>
|
|
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
|
|
<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
|
|
v-if="isToday && todayConvId"
|
|
class="reaction-btn discuss-btn"
|
|
@click="discussArticle(item)"
|
|
title="Discuss in briefing chat"
|
|
>💬</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.briefing-root {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
.briefing-shell {
|
|
display: grid;
|
|
grid-template-columns: 1fr 2fr 1fr;
|
|
grid-template-rows: auto 1fr;
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
.briefing-header {
|
|
grid-column: 1 / -1;
|
|
grid-row: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1.25rem 1rem 1rem;
|
|
border-bottom: 1px solid var(--color-border);
|
|
flex-shrink: 0;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.briefing-header-left {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.briefing-title {
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
font-size: 1.3rem;
|
|
font-weight: 700;
|
|
margin: 0;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.briefing-today-badge {
|
|
font-size: 0.82rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.briefing-header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.briefing-conv-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;
|
|
}
|
|
|
|
.btn-trigger {
|
|
padding: 0.35rem 0.8rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 6px;
|
|
background: var(--color-bg-card);
|
|
color: var(--color-text-muted);
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
transition: all 0.15s;
|
|
font-family: inherit;
|
|
}
|
|
.btn-trigger:hover:not(:disabled) {
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
}
|
|
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
/* ─── Left column (Weather) ──────────────────────────────────────────────── */
|
|
|
|
.briefing-left {
|
|
grid-column: 1;
|
|
grid-row: 2;
|
|
border-right: 1px solid var(--color-border);
|
|
overflow-y: auto;
|
|
padding: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.briefing-left :deep(.weather-card) {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
/* ─── Center column (Chat) ───────────────────────────────────────────────── */
|
|
|
|
.briefing-center {
|
|
grid-column: 2;
|
|
grid-row: 2;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
.briefing-chat-panel {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* ─── Right column (News) ────────────────────────────────────────────────── */
|
|
|
|
.briefing-right {
|
|
grid-column: 3;
|
|
grid-row: 2;
|
|
border-left: 1px solid var(--color-border);
|
|
overflow-y: auto;
|
|
padding: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.panel-label {
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--color-primary);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.panel-label-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.news-count {
|
|
font-size: 0.72rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.panel-empty {
|
|
font-size: 0.82rem;
|
|
color: var(--color-text-muted);
|
|
padding: 0.5rem 0;
|
|
}
|
|
|
|
.news-card {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 10px;
|
|
padding: 0.65rem 0.85rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.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-title {
|
|
font-size: 0.88rem;
|
|
font-weight: 600;
|
|
color: var(--color-text);
|
|
line-height: 1.35;
|
|
text-decoration: none;
|
|
margin: 0;
|
|
}
|
|
|
|
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
|
|
|
|
.news-snippet {
|
|
font-size: 0.78rem;
|
|
color: var(--color-text-muted);
|
|
line-height: 1.45;
|
|
margin: 0;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.news-reactions {
|
|
display: flex;
|
|
gap: 0.3rem;
|
|
margin-top: 0.15rem;
|
|
}
|
|
|
|
.reaction-btn {
|
|
background: none;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 6px;
|
|
padding: 0.1rem 0.35rem;
|
|
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);
|
|
}
|
|
|
|
/* ─── Responsive ─────────────────────────────────────────────────────────── */
|
|
|
|
@media (max-width: 900px) {
|
|
.briefing-shell {
|
|
grid-template-columns: 1fr;
|
|
grid-template-rows: auto auto 1fr auto;
|
|
}
|
|
.briefing-header {
|
|
grid-column: 1;
|
|
grid-row: 1;
|
|
}
|
|
.briefing-left {
|
|
grid-column: 1;
|
|
grid-row: 2;
|
|
border-right: none;
|
|
border-bottom: 1px solid var(--color-border);
|
|
max-height: 220px;
|
|
}
|
|
.briefing-center {
|
|
grid-column: 1;
|
|
grid-row: 3;
|
|
}
|
|
.briefing-right {
|
|
grid-column: 1;
|
|
grid-row: 4;
|
|
border-left: none;
|
|
border-top: 1px solid var(--color-border);
|
|
max-height: 260px;
|
|
}
|
|
}
|
|
</style>
|