diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f5d355f..b3a0ad4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -401,6 +401,28 @@ export async function triggerJournalPrep(date?: string): Promise<{ ok: boolean; return apiPost('/api/journal/trigger-prep', date ? { date } : {}); } +export interface CuratorRunResult { + conv_id: number; + user_id: number; + model: string; + messages_examined: number; + tool_calls: Array<{ + name: string; + arguments: Record; + status: 'success' | 'error' | 'pending'; + error: string | null; + }>; + tools_attempted: number; + tools_succeeded: number; + summary: string; + duration_ms: number; + error: string | null; +} + +export async function runJournalCurator(convId: number): Promise { + return apiPost(`/api/journal/curator/run/${convId}`, {}); +} + export async function listJournalMoments(params: Record = {}): Promise { const qs = new URLSearchParams(); for (const [k, v] of Object.entries(params)) qs.set(k, String(v)); diff --git a/frontend/src/views/JournalView.vue b/frontend/src/views/JournalView.vue index 0ad856a..d50779a 100644 --- a/frontend/src/views/JournalView.vue +++ b/frontend/src/views/JournalView.vue @@ -4,7 +4,7 @@ import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh' import { useChatStore } from '@/stores/chat' import ChatPanel from '@/components/ChatPanel.vue' import WeatherCard from '@/components/WeatherCard.vue' -import { RotateCcw } from 'lucide-vue-next' +import { RotateCcw, Sparkles } from 'lucide-vue-next' import { apiGet, apiPost, @@ -13,8 +13,12 @@ import { getJournalDays, triggerJournalPrep, listEvents, + listJournalMoments, + runJournalCurator, type EventEntry, + type JournalMoment, } from '@/api/client' +import { useToastStore } from '@/stores/toast' interface WeatherDay { day: string @@ -47,6 +51,7 @@ interface CurrentConditions { } const chatStore = useChatStore() +const toastStore = useToastStore() // ── Day picker + conversation state ────────────────────────────────────────── const days = ref([]) @@ -55,6 +60,70 @@ const selectedDay = ref(null) const dayConvId = ref(null) const isToday = computed(() => selectedDay.value !== null && selectedDay.value === todayDate.value) +// ── Curator + captures panel (Phase 1b) ────────────────────────────────────── +// The journal chat model has no tools — moments / tasks land via a curator +// pass (services/curator.py). The "Process captures now" button triggers +// a pass manually; the captures panel shows everything captured for the +// selected day. +const moments = ref([]) +const momentsLoading = ref(false) +const curatorRunning = ref(false) + +async function loadMoments() { + if (!selectedDay.value) { + moments.value = [] + return + } + momentsLoading.value = true + try { + moments.value = await listJournalMoments({ date: selectedDay.value, limit: 100 }) + } catch { + /* silent — moments may not exist yet */ + } finally { + momentsLoading.value = false + } +} + +async function triggerCurator() { + if (curatorRunning.value || !dayConvId.value) return + curatorRunning.value = true + try { + const result = await runJournalCurator(dayConvId.value) + if (result.error) { + toastStore.show(`Curator error: ${result.error}`, 'error') + } else { + const captured = result.tools_succeeded + const total = result.tools_attempted + const detail = captured === total + ? `${captured} tool calls` + : `${captured}/${total} tool calls` + // toastStore only supports success/error/warning; use success for + // both "captured something" and the no-op case (success in the + // sense that the curator ran cleanly). + toastStore.show( + captured > 0 + ? `Captured ${detail} (${result.duration_ms}ms)` + : `Nothing new to capture (${result.duration_ms}ms)`, + 'success', + ) + } + await loadMoments() + } catch (e) { + const msg = e instanceof Error ? e.message : 'Curator failed' + toastStore.show(`Curator failed: ${msg}`, 'error') + } finally { + curatorRunning.value = false + } +} + +function formatMomentTime(iso: string): string { + try { + return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + } catch { + return '' + } +} + // ── Weather panel ──────────────────────────────────────────────────────────── const weatherData = ref([]) const selectedWeatherIdx = ref(0) @@ -171,12 +240,17 @@ async function loadAll() { } } catch { /* silent */ } - await Promise.all([loadWeather(), loadCurrentConditions(), loadEvents()]) + await Promise.all([loadWeather(), loadCurrentConditions(), loadEvents(), loadMoments()]) } watch(selectedDay, async (iso) => { - if (!iso || iso === todayDate.value) return - try { await loadDay(iso) } catch { /* silent */ } + if (!iso) return + // For non-today dates, loadDay handles its own day-data fetch; in both + // cases we want the captures panel to reflect the selected day. + try { + if (iso !== todayDate.value) await loadDay(iso) + await loadMoments() + } catch { /* silent */ } }) // ── Manual prep regeneration ───────────────────────────────────────────────── @@ -289,6 +363,54 @@ onMounted(async () => { /> + +
+
+
Captures
+ +
+
+ Loading… +
+
+ No captures yet for {{ isToday ? 'today' : 'this day' }}. + Talk in the journal, then press "Process captures". +
+
    +
  • +
    {{ formatMomentTime(m.occurred_at) }}
    +
    +
    {{ m.content }}
    +
    + + {{ m.people.map(p => p.title).join(', ') }} + + + @ {{ m.places.map(p => p.title).join(', ') }} + + + {{ m.task_ids.length }} task{{ m.task_ids.length === 1 ? '' : 's' }} + + + {{ m.note_ids.length }} note{{ m.note_ids.length === 1 ? '' : 's' }} + +
    +
    +
  • +
+
+
Upcoming
@@ -478,6 +600,87 @@ onMounted(async () => { } .panel-label-row { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; } +/* Captures panel — moments extracted by the curator (services/curator.py). + Sits above the events panel; the chat is in the center column. */ +.captures-section { padding: 0.75rem 1rem; border-top: 1px solid var(--color-border); } +.captures-trigger-btn { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.72rem; + padding: 0.3rem 0.6rem; + border-radius: 999px; + border: 1px solid var(--color-border); + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.captures-trigger-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-primary) 8%, transparent); + color: var(--color-primary); + border-color: var(--color-primary); +} +.captures-trigger-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.captures-trigger-btn.spinning :first-child { + animation: captures-spin 1.2s linear infinite; +} +@keyframes captures-spin { from { transform: rotate(0); } to { transform: rotate(360deg); } } + +.captures-empty { + font-size: 0.78rem; + color: var(--color-text-muted); + font-style: italic; + padding: 0.75rem 0; + line-height: 1.45; +} +.captures-list { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.55rem; + max-height: 380px; + overflow-y: auto; +} +.capture-row { + display: flex; + gap: 0.6rem; + padding: 0.4rem 0.5rem; + border-radius: 8px; + border-left: 2px solid color-mix(in srgb, var(--color-primary) 35%, transparent); + background: color-mix(in srgb, var(--color-primary) 3%, transparent); +} +.capture-time { + font-size: 0.7rem; + color: var(--color-text-muted); + flex-shrink: 0; + padding-top: 0.1rem; + font-variant-numeric: tabular-nums; +} +.capture-body { min-width: 0; flex: 1; } +.capture-content { + font-size: 0.85rem; + line-height: 1.4; + color: var(--color-text); +} +.capture-meta { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-top: 0.3rem; +} +.capture-chip { + font-size: 0.68rem; + padding: 0.1rem 0.45rem; + border-radius: 999px; + background: color-mix(in srgb, var(--color-text-muted) 12%, transparent); + color: var(--color-text-muted); +} +.capture-chip--task { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); } +.capture-chip--note { background: color-mix(in srgb, var(--color-warning, #b88a2c) 14%, transparent); color: var(--color-warning, #b88a2c); } + @media (max-width: 900px) { .journal-shell { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; } .journal-center { grid-column: 1; grid-row: 2; }