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 } : {});
|
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[]> {
|
export async function listJournalMoments(params: Record<string, string | number | boolean> = {}): Promise<JournalMoment[]> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
for (const [k, v] of Object.entries(params)) qs.set(k, String(v));
|
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 { useChatStore } from '@/stores/chat'
|
||||||
import ChatPanel from '@/components/ChatPanel.vue'
|
import ChatPanel from '@/components/ChatPanel.vue'
|
||||||
import WeatherCard from '@/components/WeatherCard.vue'
|
import WeatherCard from '@/components/WeatherCard.vue'
|
||||||
import { RotateCcw } from 'lucide-vue-next'
|
import { RotateCcw, Sparkles } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
apiGet,
|
apiGet,
|
||||||
apiPost,
|
apiPost,
|
||||||
@@ -13,8 +13,12 @@ import {
|
|||||||
getJournalDays,
|
getJournalDays,
|
||||||
triggerJournalPrep,
|
triggerJournalPrep,
|
||||||
listEvents,
|
listEvents,
|
||||||
|
listJournalMoments,
|
||||||
|
runJournalCurator,
|
||||||
type EventEntry,
|
type EventEntry,
|
||||||
|
type JournalMoment,
|
||||||
} from '@/api/client'
|
} from '@/api/client'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
|
||||||
interface WeatherDay {
|
interface WeatherDay {
|
||||||
day: string
|
day: string
|
||||||
@@ -47,6 +51,7 @@ interface CurrentConditions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
|
const toastStore = useToastStore()
|
||||||
|
|
||||||
// ── Day picker + conversation state ──────────────────────────────────────────
|
// ── Day picker + conversation state ──────────────────────────────────────────
|
||||||
const days = ref<string[]>([])
|
const days = ref<string[]>([])
|
||||||
@@ -55,6 +60,70 @@ const selectedDay = ref<string | null>(null)
|
|||||||
const dayConvId = ref<number | null>(null)
|
const dayConvId = ref<number | null>(null)
|
||||||
const isToday = computed(() => selectedDay.value !== null && selectedDay.value === todayDate.value)
|
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 ────────────────────────────────────────────────────────────
|
// ── Weather panel ────────────────────────────────────────────────────────────
|
||||||
const weatherData = ref<WeatherData[]>([])
|
const weatherData = ref<WeatherData[]>([])
|
||||||
const selectedWeatherIdx = ref(0)
|
const selectedWeatherIdx = ref(0)
|
||||||
@@ -171,12 +240,17 @@ async function loadAll() {
|
|||||||
}
|
}
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
|
|
||||||
await Promise.all([loadWeather(), loadCurrentConditions(), loadEvents()])
|
await Promise.all([loadWeather(), loadCurrentConditions(), loadEvents(), loadMoments()])
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(selectedDay, async (iso) => {
|
watch(selectedDay, async (iso) => {
|
||||||
if (!iso || iso === todayDate.value) return
|
if (!iso) return
|
||||||
try { await loadDay(iso) } catch { /* silent */ }
|
// 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 ─────────────────────────────────────────────────
|
// ── Manual prep regeneration ─────────────────────────────────────────────────
|
||||||
@@ -289,6 +363,54 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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="events-section" v-if="groupedEvents.length">
|
||||||
<div class="panel-label-row">
|
<div class="panel-label-row">
|
||||||
<div class="panel-label">Upcoming</div>
|
<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; }
|
.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) {
|
@media (max-width: 900px) {
|
||||||
.journal-shell { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
|
.journal-shell { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
|
||||||
.journal-center { grid-column: 1; grid-row: 2; }
|
.journal-center { grid-column: 1; grid-row: 2; }
|
||||||
|
|||||||
Reference in New Issue
Block a user