feat(journal): right-rail captures panel + manual curator trigger (Phase 1b)

Frontend half of the conversation+curator architecture. Pairs with the
backend in commit a7002a8. With this commit, you can have a journal
conversation (chat model has no tools, doesn't try to capture), then
press a button and see what the curator extracts.

JournalView.vue:
- New "Captures" section in the right rail, above the existing
  "Upcoming" events block. Shows moments from the selected day with
  timestamp, content, and entity/task/note chips.
- "Process captures" button (Sparkles icon). Disabled for non-today
  days because we're not back-running the curator over historical
  conversations. Toast on success/failure with timing + tool-call
  count from the CuratorRunResult.
- Captures auto-load on day change AND immediately after a curator
  run completes — the right rail reflects current state without a
  page reload.
- Bound CSS scoped to the rail: cards with a primary-color left
  border, monospaced timestamps, chips for people/places/tasks/notes.

api/client.ts:
- CuratorRunResult type matching the backend dataclass.
- runJournalCurator(convId) helper.
- Pass empty body to apiPost() to satisfy the 2-arg signature
  (caller-side fix, not a backend change).

What's not in this commit (deferred):
- The captures panel doesn't show captures from days where the curator
  hasn't run yet, even if they would later be captured. Visible only
  AFTER a curator pass. (Phase 2's scheduler closes this gap by
  running automatically.)
- No edit/delete affordances on captures yet — that comes when we
  add the moment-editing UI (out of scope for the conversation+curator
  architecture commit chain).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 10:04:56 -04:00
parent a7002a89a0
commit a73dd17a1b
2 changed files with 229 additions and 4 deletions
+22
View File
@@ -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<string, unknown>;
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<CuratorRunResult> {
return apiPost<CuratorRunResult>(`/api/journal/curator/run/${convId}`, {});
}
export async function listJournalMoments(params: Record<string, string | number | boolean> = {}): Promise<JournalMoment[]> {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) qs.set(k, String(v));
+207 -4
View File
@@ -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<string[]>([])
@@ -55,6 +60,70 @@ const selectedDay = ref<string | null>(null)
const dayConvId = ref<number | null>(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<JournalMoment[]>([])
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<WeatherData[]>([])
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 () => {
/>
</div>
<!-- Captures panel: shows moments extracted by the curator for the
selected day. The chat model is tools=[]; the curator pass
(manual button below, scheduler in phase 2) populates this. -->
<div class="captures-section">
<div class="panel-label-row">
<div class="panel-label">Captures</div>
<button
class="captures-trigger-btn"
:class="{ spinning: curatorRunning }"
:disabled="curatorRunning || !dayConvId || !isToday"
:title="isToday ? 'Run curator on this conversation' : 'Curator only runs on today\\'s conversation'"
@click="triggerCurator"
>
<Sparkles :size="14" />
{{ curatorRunning ? 'Working…' : 'Process captures' }}
</button>
</div>
<div v-if="momentsLoading && !moments.length" class="captures-empty">
Loading
</div>
<div v-else-if="!moments.length" class="captures-empty">
No captures yet for {{ isToday ? 'today' : 'this day' }}.
<span v-if="isToday">Talk in the journal, then press "Process captures".</span>
</div>
<ul v-else class="captures-list">
<li v-for="m in moments" :key="m.id" class="capture-row">
<div class="capture-time">{{ formatMomentTime(m.occurred_at) }}</div>
<div class="capture-body">
<div class="capture-content">{{ m.content }}</div>
<div v-if="m.people.length || m.places.length || m.task_ids.length || m.note_ids.length" class="capture-meta">
<span v-if="m.people.length" class="capture-chip">
{{ m.people.map(p => p.title).join(', ') }}
</span>
<span v-if="m.places.length" class="capture-chip">
@ {{ m.places.map(p => p.title).join(', ') }}
</span>
<span v-if="m.task_ids.length" class="capture-chip capture-chip--task">
{{ m.task_ids.length }} task{{ m.task_ids.length === 1 ? '' : 's' }}
</span>
<span v-if="m.note_ids.length" class="capture-chip capture-chip--note">
{{ m.note_ids.length }} note{{ m.note_ids.length === 1 ? '' : 's' }}
</span>
</div>
</div>
</li>
</ul>
</div>
<div class="events-section" v-if="groupedEvents.length">
<div class="panel-label-row">
<div class="panel-label">Upcoming</div>
@@ -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; }