1686289af4
- BriefingView: soft chat UI with conversation history dropdown, SSE streaming via chat store, manual refresh trigger, today/past conv toggle - BriefingSetupWizard: 4-step first-time setup overlay (welcome → locations → office days → RSS feeds) - SettingsView: Briefing tab with enable toggle, location geocoding, office day picker, slot toggles, RSS feed management, notifications - AppHeader + router: /briefing route and nav link (desktop + mobile) - client.ts: briefing types and API functions (config, feeds, convs, geocode, trigger) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
396 lines
10 KiB
Vue
396 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, watch } from 'vue'
|
|
import { useChatStore } from '@/stores/chat'
|
|
import ChatMessage from '@/components/ChatMessage.vue'
|
|
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
|
import {
|
|
getBriefingConfig,
|
|
getBriefingConversations,
|
|
getBriefingToday,
|
|
getBriefingConvMessages,
|
|
triggerBriefingSlot,
|
|
type BriefingConversation,
|
|
type BriefingMessage,
|
|
} from '@/api/client'
|
|
import type { Message } from '@/types/chat'
|
|
|
|
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)
|
|
|
|
// Messages for selected conversation
|
|
const messages = ref<BriefingMessage[]>([])
|
|
const loadingMessages = ref(false)
|
|
|
|
async function loadAll() {
|
|
const [convList, today] = await Promise.all([
|
|
getBriefingConversations(),
|
|
getBriefingToday().catch(() => null),
|
|
])
|
|
conversations.value = convList
|
|
if (today) {
|
|
todayConvId.value = today.id
|
|
// Ensure today is in the list
|
|
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
|
|
messages.value = today.messages
|
|
// Load into chatStore so we can stream
|
|
await chatStore.fetchConversation(today.id)
|
|
}
|
|
}
|
|
|
|
watch(selectedConvId, async (id) => {
|
|
if (!id) return
|
|
if (id === todayConvId.value) {
|
|
await chatStore.fetchConversation(id)
|
|
messages.value = (chatStore.currentConversation?.messages ?? []) as unknown as BriefingMessage[]
|
|
return
|
|
}
|
|
loadingMessages.value = true
|
|
try {
|
|
messages.value = await getBriefingConvMessages(id)
|
|
} finally {
|
|
loadingMessages.value = false
|
|
}
|
|
})
|
|
|
|
// Refresh messages after streaming ends
|
|
watch(() => chatStore.streaming, async (streaming) => {
|
|
if (!streaming && selectedConvId.value === todayConvId.value && todayConvId.value) {
|
|
const today = await getBriefingToday().catch(() => null)
|
|
if (today) messages.value = today.messages
|
|
}
|
|
})
|
|
|
|
// Input
|
|
const input = ref('')
|
|
const sending = ref(false)
|
|
|
|
async function send() {
|
|
const text = input.value.trim()
|
|
if (!text || !todayConvId.value || chatStore.streaming || sending.value) return
|
|
// Ensure today's conv is loaded in chatStore
|
|
if (chatStore.currentConversation?.id !== todayConvId.value) {
|
|
await chatStore.fetchConversation(todayConvId.value)
|
|
}
|
|
input.value = ''
|
|
sending.value = true
|
|
try {
|
|
await chatStore.sendMessage(text)
|
|
} finally {
|
|
sending.value = false
|
|
}
|
|
}
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault()
|
|
send()
|
|
}
|
|
}
|
|
|
|
// Manual trigger
|
|
const triggering = ref(false)
|
|
async function triggerNow() {
|
|
triggering.value = true
|
|
try {
|
|
await triggerBriefingSlot('compilation')
|
|
// Reload
|
|
await loadAll()
|
|
} finally {
|
|
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'
|
|
}
|
|
|
|
// Convert BriefingMessage to Message for ChatMessage component
|
|
function toMsg(m: BriefingMessage): Message {
|
|
return {
|
|
id: m.id,
|
|
conversation_id: -1,
|
|
role: m.role,
|
|
content: m.content,
|
|
context_note_id: null,
|
|
context_note_title: null,
|
|
created_at: m.created_at,
|
|
}
|
|
}
|
|
|
|
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 (shown after wizard check and only if not showing wizard) -->
|
|
<div class="briefing-shell" v-if="wizardChecked && !showWizard">
|
|
<!-- Header -->
|
|
<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">
|
|
<!-- Conversation history dropdown -->
|
|
<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>
|
|
|
|
<!-- Messages -->
|
|
<div class="briefing-messages-wrap">
|
|
<div v-if="loadingMessages" class="briefing-loading">Loading…</div>
|
|
<template v-else>
|
|
<div v-if="!messages.length" class="briefing-empty">
|
|
<p>No briefing yet for today.</p>
|
|
<p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
|
|
</div>
|
|
<div v-else class="briefing-messages">
|
|
<ChatMessage
|
|
v-for="msg in messages"
|
|
:key="msg.id"
|
|
:message="toMsg(msg)"
|
|
:is-streaming="false"
|
|
/>
|
|
<!-- Live streaming bubble for today -->
|
|
<ChatMessage
|
|
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
|
|
:message="{
|
|
id: -1,
|
|
conversation_id: todayConvId ?? -1,
|
|
role: 'assistant',
|
|
content: chatStore.streamingContent,
|
|
context_note_id: null,
|
|
context_note_title: null,
|
|
created_at: new Date().toISOString(),
|
|
}"
|
|
:is-streaming="true"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Input bar (today only) -->
|
|
<div v-if="isToday" class="briefing-input-bar">
|
|
<textarea
|
|
v-model="input"
|
|
class="briefing-input"
|
|
placeholder="Reply to your briefing…"
|
|
rows="1"
|
|
@keydown="onKeydown"
|
|
></textarea>
|
|
<button
|
|
class="btn-send"
|
|
@click="send"
|
|
:disabled="!input.trim() || chatStore.streaming || sending"
|
|
aria-label="Send"
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.briefing-root {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
.briefing-shell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
min-height: 0;
|
|
max-width: 760px;
|
|
margin: 0 auto;
|
|
width: 100%;
|
|
padding: 0 1rem;
|
|
}
|
|
|
|
.briefing-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1.25rem 0 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; }
|
|
|
|
.briefing-messages-wrap {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 1rem 0;
|
|
}
|
|
|
|
.briefing-loading,
|
|
.briefing-empty {
|
|
text-align: center;
|
|
padding: 3rem 1rem;
|
|
color: var(--color-text-muted);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.briefing-empty-hint {
|
|
font-size: 0.8rem;
|
|
opacity: 0.7;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.briefing-messages {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.briefing-input-bar {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: flex-end;
|
|
padding: 0.75rem 0 1rem;
|
|
border-top: 1px solid var(--color-border);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.briefing-input {
|
|
flex: 1;
|
|
padding: 0.6rem 0.8rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 10px;
|
|
background: var(--color-bg-card);
|
|
color: var(--color-text);
|
|
font-size: 0.9rem;
|
|
resize: none;
|
|
outline: none;
|
|
font-family: inherit;
|
|
line-height: 1.4;
|
|
max-height: 120px;
|
|
overflow-y: auto;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.briefing-input:focus { border-color: var(--color-primary); }
|
|
|
|
.btn-send {
|
|
padding: 0.55rem 0.75rem;
|
|
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: opacity 0.15s;
|
|
flex-shrink: 0;
|
|
}
|
|
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
.btn-send:hover:not(:disabled) { opacity: 0.9; }
|
|
</style>
|