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:
@@ -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));
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user