Skip to content

Commit a13c784

Browse files
authored
Merge pull request #1 from Ray0907/claude/add-recall-feature-4ZvfK
Claude/add recall feature 4
2 parents 2896684 + b0fdeae commit a13c784

File tree

12 files changed

+1153
-93
lines changed

12 files changed

+1153
-93
lines changed

.pnpm-approvals.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
better-sqlite3

app/layouts/default.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ const links_main: NavigationMenuItem[] = [{
6868
icon: 'i-lucide-puzzle',
6969
to: '/plugins',
7070
onSelect: () => { open.value = false }
71+
}, {
72+
label: 'Monitoring',
73+
icon: 'i-lucide-activity',
74+
to: '/monitoring',
75+
onSelect: () => { open.value = false }
7176
}, {
7277
label: 'Context Health',
7378
icon: 'i-lucide-heart-pulse',

app/pages/ai/sessions.vue

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,22 @@ const { data, refresh, status } = useFetch('/api/ai/sessions', {
2323
const list_sessions = computed(() => data.value?.sessions ?? [])
2424
const list_projects = computed(() => data.value?.projects ?? [])
2525
26+
// Full-text search (FTS) — triggers when query >= 3 chars
27+
const is_content_search = computed(() => (query_debounced.value?.length ?? 0) >= 3)
28+
const search_params = computed(() => ({
29+
q: query_debounced.value || undefined,
30+
project: filter_project.value || undefined,
31+
limit: 50
32+
}))
33+
34+
const { data: data_search, status: status_search } = useFetch('/api/ai/sessions/search', {
35+
query: search_params,
36+
watch: [search_params],
37+
default: () => ({ results: [] as Array<{ session_id: string, project: string, title: string, cwd: string, time_modified: string, snippet: string, score: number }>, query: '' })
38+
})
39+
40+
const list_search_results = computed(() => data_search.value?.results ?? [])
41+
2642
// Archive data
2743
const { data: list_archive, refresh: refreshArchive } = useFetch('/api/ai/sessions/archive', {
2844
default: () => [] as Array<{ id: string; title: string; project: string; deleted_at: string }>
@@ -312,10 +328,48 @@ async function handleEmptyTrash(): Promise<void> {
312328
</div>
313329

314330
<!-- Loading state -->
315-
<div v-if="status === 'pending'" class="px-4 space-y-2">
331+
<div v-if="status === 'pending' && !is_content_search" class="px-4 space-y-2">
316332
<div v-for="i in 6" :key="i" class="h-12 rounded-lg bg-elevated/30 animate-pulse" />
317333
</div>
318334

335+
<!-- FTS content search results -->
336+
<div v-else-if="is_content_search" class="px-4 pb-4">
337+
<div v-if="status_search === 'pending'" class="space-y-2">
338+
<div v-for="i in 4" :key="i" class="h-16 rounded-lg bg-elevated/30 animate-pulse" />
339+
</div>
340+
<div v-else-if="list_search_results.length > 0" class="space-y-1">
341+
<p class="text-[10px] text-dimmed/50 px-3 py-1">
342+
{{ list_search_results.length }} content match{{ list_search_results.length === 1 ? '' : 'es' }}
343+
</p>
344+
<div
345+
v-for="result in list_search_results"
346+
:key="result.session_id"
347+
class="w-full flex flex-col gap-1 px-3 py-2.5 rounded-lg text-left text-sm hover:bg-elevated/50 transition-colors cursor-pointer group"
348+
@click="handleResumeSession({ id: result.session_id, cwd: result.cwd })"
349+
>
350+
<div class="flex items-center gap-3 min-w-0">
351+
<UIcon name="i-lucide-search" class="size-3.5 text-dimmed shrink-0 group-hover:text-primary transition-colors" />
352+
<span class="flex-1 truncate">{{ result.title || 'Untitled session' }}</span>
353+
<UBadge color="neutral" variant="subtle" size="xs" class="shrink-0">
354+
{{ result.project }}
355+
</UBadge>
356+
<span class="text-[10px] text-dimmed/50 shrink-0">{{ formatTimeAgo(result.time_modified) }}</span>
357+
</div>
358+
<!-- eslint-disable-next-line vue/no-v-html -->
359+
<p class="text-xs text-dimmed/70 pl-6.5 line-clamp-2 [&>mark]:bg-primary/20 [&>mark]:text-primary [&>mark]:rounded-sm [&>mark]:px-0.5" v-html="result.snippet" />
360+
</div>
361+
</div>
362+
<div v-else class="flex flex-col items-center justify-center py-16 text-center">
363+
<UIcon name="i-lucide-search-x" class="size-10 text-dimmed/30 mb-3" />
364+
<p class="text-sm text-dimmed">
365+
No content matches
366+
</p>
367+
<p class="text-xs text-dimmed/50 mt-1">
368+
Try different keywords or a shorter query
369+
</p>
370+
</div>
371+
</div>
372+
319373
<!-- Session list -->
320374
<div v-else-if="list_sessions.length > 0" class="px-4 pb-4 space-y-1">
321375
<!-- Select all row (when no selection yet) -->

app/pages/monitoring.vue

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
<script setup lang="ts">
2+
useSeoMeta({ title: 'Monitoring' })
3+
4+
const { resumeSession } = useAiChat()
5+
6+
const filter_days = ref(7)
7+
const is_loading_session = ref(false)
8+
9+
const url_params = computed(() => ({
10+
days: filter_days.value,
11+
limit: 100
12+
}))
13+
14+
const { data, status } = useFetch('/api/monitoring', {
15+
query: url_params,
16+
watch: [url_params],
17+
default: () => ({
18+
sessions: [] as Array<Record<string, unknown>>,
19+
totals: { tokens_input: 0, tokens_output: 0, cost_usd: 0, count_sessions: 0, count_tool_calls: 0 }
20+
})
21+
})
22+
23+
const list_sessions = computed(() => data.value?.sessions ?? [])
24+
const totals = computed(() => data.value?.totals ?? { tokens_input: 0, tokens_output: 0, cost_usd: 0, count_sessions: 0, count_tool_calls: 0 })
25+
26+
const list_stats = computed(() => [
27+
{ key: 'sessions', label: 'Sessions', value: String(totals.value.count_sessions), icon: 'i-lucide-layers' },
28+
{ key: 'input', label: 'Input Tokens', value: formatCompact(totals.value.tokens_input), icon: 'i-lucide-arrow-down-to-line' },
29+
{ key: 'output', label: 'Output Tokens', value: formatCompact(totals.value.tokens_output), icon: 'i-lucide-arrow-up-from-line' },
30+
{ key: 'cost', label: 'Est. Cost', value: `$${totals.value.cost_usd.toFixed(2)}`, icon: 'i-lucide-coins' },
31+
{ key: 'tools', label: 'Tool Calls', value: formatCompact(totals.value.count_tool_calls), icon: 'i-lucide-wrench' }
32+
])
33+
34+
// Aggregate by project
35+
const list_projects = computed(() => {
36+
const map = new Map<string, { project: string, sessions: number, tokens: number, cost: number, tools: number }>()
37+
for (const s of list_sessions.value) {
38+
const entry = map.get(s.project) ?? { project: s.project, sessions: 0, tokens: 0, cost: 0, tools: 0 }
39+
entry.sessions++
40+
entry.tokens += s.tokens_input + s.tokens_output
41+
entry.cost += s.cost_usd
42+
entry.tools += s.count_tool_calls
43+
map.set(s.project, entry)
44+
}
45+
return Array.from(map.values()).sort((a, b) => b.cost - a.cost)
46+
})
47+
48+
function formatDuration(seconds: number): string {
49+
if (seconds < 60) return `${seconds}s`
50+
const m = Math.floor(seconds / 60)
51+
const s = seconds % 60
52+
if (m < 60) return `${m}m ${s}s`
53+
const h = Math.floor(m / 60)
54+
return `${h}h ${m % 60}m`
55+
}
56+
57+
function tokenBar(input: number, output: number): { input_pct: number, output_pct: number } {
58+
const total = input + output
59+
if (total === 0) return { input_pct: 0, output_pct: 0 }
60+
return {
61+
input_pct: Math.round((input / total) * 100),
62+
output_pct: Math.round((output / total) * 100)
63+
}
64+
}
65+
66+
// Find max cost to scale bars
67+
const max_cost = computed(() => {
68+
const costs = list_sessions.value.map(s => s.cost_usd)
69+
return Math.max(...costs, 0.01)
70+
})
71+
72+
async function handleClickSession(session: { id: string, cwd: string }): Promise<void> {
73+
if (is_loading_session.value) return
74+
is_loading_session.value = true
75+
try {
76+
const data = await $fetch<{ id: string, cwd: string, messages: Array<{ role: 'user' | 'assistant', content: string, timestamp: string }> }>(`/api/ai/sessions/${session.id}`)
77+
resumeSession(data.id, data.cwd, data.messages)
78+
navigateTo('/ai')
79+
} catch (error) {
80+
console.error('Failed to load session:', error)
81+
} finally {
82+
is_loading_session.value = false
83+
}
84+
}
85+
</script>
86+
87+
<template>
88+
<UDashboardPanel id="monitoring">
89+
<template #header>
90+
<UDashboardNavbar title="Monitoring">
91+
<template #leading>
92+
<UDashboardSidebarCollapse />
93+
</template>
94+
<template #right>
95+
<div class="flex items-center gap-2">
96+
<select
97+
v-model.number="filter_days"
98+
class="h-8 rounded-md border border-default bg-default text-sm px-2 pr-7 outline-none text-dimmed appearance-none cursor-pointer"
99+
>
100+
<option :value="1">
101+
Last 24h
102+
</option>
103+
<option :value="7">
104+
Last 7 days
105+
</option>
106+
<option :value="30">
107+
Last 30 days
108+
</option>
109+
<option :value="90">
110+
Last 90 days
111+
</option>
112+
</select>
113+
</div>
114+
</template>
115+
</UDashboardNavbar>
116+
</template>
117+
118+
<template #body>
119+
<div class="p-6 space-y-6">
120+
<!-- Summary stats -->
121+
<div v-if="status === 'pending'" class="grid grid-cols-2 lg:grid-cols-5 gap-4">
122+
<USkeleton v-for="i in 5" :key="i" class="h-24" />
123+
</div>
124+
<div v-else class="grid grid-cols-2 lg:grid-cols-5 gap-4">
125+
<UCard v-for="item in list_stats" :key="item.key">
126+
<div class="flex items-center gap-3">
127+
<div class="flex items-center justify-center size-10 rounded-lg bg-primary/10">
128+
<UIcon :name="item.icon" class="size-5 text-primary" />
129+
</div>
130+
<div>
131+
<p class="text-sm text-dimmed">
132+
{{ item.label }}
133+
</p>
134+
<p class="text-2xl font-bold tabular-nums">
135+
{{ item.value }}
136+
</p>
137+
</div>
138+
</div>
139+
</UCard>
140+
</div>
141+
142+
<!-- Project breakdown -->
143+
<UCard v-if="list_projects.length > 0">
144+
<template #header>
145+
<h3 class="text-sm font-medium text-dimmed">
146+
Cost by Project
147+
</h3>
148+
</template>
149+
<div class="space-y-3">
150+
<div v-for="proj in list_projects" :key="proj.project" class="flex items-center gap-4">
151+
<span class="text-sm font-mono w-48 truncate" :title="proj.project">{{ proj.project }}</span>
152+
<div class="flex-1 h-5 bg-elevated/50 rounded-full overflow-hidden">
153+
<div
154+
class="h-full bg-primary/60 rounded-full transition-all"
155+
:style="{ width: `${Math.max(2, (proj.cost / (totals.cost_usd || 1)) * 100)}%` }"
156+
/>
157+
</div>
158+
<span class="text-sm tabular-nums w-20 text-right font-medium">${{ proj.cost.toFixed(2) }}</span>
159+
<span class="text-xs text-dimmed w-16 text-right">{{ proj.sessions }} sess.</span>
160+
</div>
161+
</div>
162+
</UCard>
163+
164+
<!-- Session list -->
165+
<UCard>
166+
<template #header>
167+
<div class="flex items-center justify-between">
168+
<h3 class="text-sm font-medium text-dimmed">
169+
Sessions
170+
</h3>
171+
<span class="text-xs text-dimmed">
172+
{{ list_sessions.length }} sessions
173+
</span>
174+
</div>
175+
</template>
176+
177+
<div v-if="status === 'pending'" class="space-y-2">
178+
<USkeleton v-for="i in 8" :key="i" class="h-14" />
179+
</div>
180+
181+
<div v-else-if="list_sessions.length > 0" class="divide-y divide-default">
182+
<div
183+
v-for="session in list_sessions"
184+
:key="session.id"
185+
class="flex items-center gap-4 py-3 px-1 hover:bg-elevated/30 rounded-lg transition-colors cursor-pointer group"
186+
@click="handleClickSession(session)"
187+
>
188+
<!-- Title + meta -->
189+
<div class="flex-1 min-w-0">
190+
<div class="flex items-center gap-2 mb-1">
191+
<span class="text-sm truncate group-hover:text-primary transition-colors">{{ session.title || 'Untitled' }}</span>
192+
<UBadge color="neutral" variant="subtle" size="xs">
193+
{{ session.project }}
194+
</UBadge>
195+
</div>
196+
<div class="flex items-center gap-3 text-[10px] text-dimmed/60">
197+
<span>{{ formatTimeAgo(session.time_end) }}</span>
198+
<span>{{ formatDuration(session.duration_seconds) }}</span>
199+
<span>{{ session.count_turns }} turns</span>
200+
<span>{{ session.count_tool_calls }} tools</span>
201+
</div>
202+
</div>
203+
204+
<!-- Token ratio bar -->
205+
<div class="w-24 flex flex-col gap-0.5">
206+
<div class="flex h-1.5 rounded-full overflow-hidden bg-elevated/50">
207+
<div class="bg-blue-400/70" :style="{ width: `${tokenBar(session.tokens_input, session.tokens_output).input_pct}%` }" />
208+
<div class="bg-emerald-400/70" :style="{ width: `${tokenBar(session.tokens_input, session.tokens_output).output_pct}%` }" />
209+
</div>
210+
<div class="flex justify-between text-[9px] text-dimmed/50">
211+
<span>{{ formatCompact(session.tokens_input) }} in</span>
212+
<span>{{ formatCompact(session.tokens_output) }} out</span>
213+
</div>
214+
</div>
215+
216+
<!-- Cost bar -->
217+
<div class="w-28 flex items-center gap-2">
218+
<div class="flex-1 h-1.5 rounded-full overflow-hidden bg-elevated/50">
219+
<div
220+
class="h-full bg-amber-400/60 rounded-full"
221+
:style="{ width: `${Math.max(2, (session.cost_usd / max_cost) * 100)}%` }"
222+
/>
223+
</div>
224+
<span class="text-xs tabular-nums text-right w-14 font-medium">${{ session.cost_usd.toFixed(3) }}</span>
225+
</div>
226+
</div>
227+
</div>
228+
229+
<div v-else class="flex flex-col items-center justify-center py-16 text-center">
230+
<UIcon name="i-lucide-activity" class="size-10 text-dimmed/30 mb-3" />
231+
<p class="text-sm text-dimmed">
232+
No session data found
233+
</p>
234+
<p class="text-xs text-dimmed/50 mt-1">
235+
Start a conversation in Claude Code to see metrics here
236+
</p>
237+
</div>
238+
</UCard>
239+
</div>
240+
</template>
241+
</UDashboardPanel>
242+
</template>

nuxt.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export default defineNuxtConfig({
4444
anthropicApiKey: ''
4545
},
4646

47+
nitro: {
48+
externals: {
49+
inline: ['better-sqlite3']
50+
}
51+
},
52+
4753
compatibilityDate: '2025-01-01',
4854

4955
eslint: {

package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@unovis/vue": "^1.6.2",
2323
"@vueuse/core": "^14.1.0",
2424
"@vueuse/nuxt": "^14.1.0",
25+
"better-sqlite3": "^12.6.2",
2526
"date-fns": "^4.1.0",
2627
"fuse.js": "^7.1.0",
2728
"gray-matter": "^4.0.3",
@@ -32,10 +33,16 @@
3233
},
3334
"devDependencies": {
3435
"@nuxt/eslint": "^1.13.0",
36+
"@types/better-sqlite3": "^7.6.13",
3537
"@types/node": "^25.2.1",
3638
"eslint": "^9.39.2",
3739
"typescript": "^5.9.3",
3840
"vue-tsc": "^3.2.4"
3941
},
42+
"pnpm": {
43+
"onlyBuiltDependencies": [
44+
"better-sqlite3"
45+
]
46+
},
4047
"packageManager": "pnpm@10.28.1"
4148
}

0 commit comments

Comments
 (0)