mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-09 23:59:40 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { useSSE, useStats, useTimeline } from '@/composables'
|
||||
import Header from '@/components/Header.vue'
|
||||
import StatsCards from '@/components/StatsCards.vue'
|
||||
import FilterTabs from '@/components/FilterTabs.vue'
|
||||
import Timeline from '@/components/Timeline.vue'
|
||||
import Sidebar from '@/components/Sidebar.vue'
|
||||
|
||||
// Composables
|
||||
const { isConnected, isProcessing, queueDepth, lastEvent } = useSSE()
|
||||
const { stats } = useStats()
|
||||
const {
|
||||
filteredItems,
|
||||
loading,
|
||||
observationCount,
|
||||
promptCount,
|
||||
summaryCount,
|
||||
currentFilter,
|
||||
currentProject,
|
||||
currentTypeFilter,
|
||||
currentConceptFilter,
|
||||
refresh,
|
||||
setFilter,
|
||||
setProject,
|
||||
setTypeFilter,
|
||||
setConceptFilter
|
||||
} = useTimeline()
|
||||
|
||||
// Refresh timeline when new events arrive
|
||||
watch(lastEvent, (event) => {
|
||||
if (event && (event.type === 'observation' || event.type === 'prompt')) {
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<!-- Header -->
|
||||
<Header
|
||||
:is-connected="isConnected"
|
||||
:is-processing="isProcessing"
|
||||
@refresh="refresh"
|
||||
/>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 py-6">
|
||||
<!-- Stats Cards -->
|
||||
<StatsCards
|
||||
:stats="stats"
|
||||
:queue-depth="queueDepth"
|
||||
/>
|
||||
|
||||
<!-- Two Column Layout -->
|
||||
<div class="flex gap-6">
|
||||
<!-- Sidebar -->
|
||||
<Sidebar
|
||||
:stats="stats"
|
||||
:observation-count="observationCount"
|
||||
:prompt-count="promptCount"
|
||||
:summary-count="summaryCount"
|
||||
:current-project="currentProject"
|
||||
@update:project="setProject"
|
||||
/>
|
||||
|
||||
<!-- Activity Timeline Section -->
|
||||
<section class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<i class="fas fa-list text-claude-400" />
|
||||
<h2 class="text-lg font-semibold text-white">Activity Timeline</h2>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<FilterTabs
|
||||
:current-filter="currentFilter"
|
||||
:current-type-filter="currentTypeFilter"
|
||||
:current-concept-filter="currentConceptFilter"
|
||||
:observation-count="observationCount"
|
||||
:prompt-count="promptCount"
|
||||
@update:filter="setFilter"
|
||||
@update:type-filter="setTypeFilter"
|
||||
@update:concept-filter="setConceptFilter"
|
||||
/>
|
||||
|
||||
<!-- Timeline -->
|
||||
<Timeline
|
||||
:items="filteredItems"
|
||||
:loading="loading"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
/* Glass morphism effect */
|
||||
.glass {
|
||||
@apply bg-white/5 backdrop-blur-xl;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
@apply bg-black/10 rounded;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
@apply bg-white/20 rounded;
|
||||
}
|
||||
|
||||
/* Pulse animation for status dots */
|
||||
.pulse-dot {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
icon?: string
|
||||
colorClass?: string
|
||||
bgClass?: string
|
||||
borderClass?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full border"
|
||||
:class="[bgClass || 'bg-slate-500/20', colorClass || 'text-slate-300', borderClass || 'border-slate-500/40']"
|
||||
>
|
||||
<i v-if="icon" class="fas" :class="icon" />
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
gradient?: string
|
||||
borderClass?: string
|
||||
highlight?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="p-4 rounded-xl border-2 transition-all"
|
||||
:class="[
|
||||
gradient || 'bg-gradient-to-br from-slate-800/50 to-slate-900/50',
|
||||
borderClass || 'border-slate-700/50',
|
||||
highlight ? 'ring-2 ring-claude-500/20' : '',
|
||||
'hover:border-opacity-70'
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import type { FilterType, ObservationType, ConceptType } from '@/types'
|
||||
import { OBSERVATION_TYPES, CONCEPT_TYPES } from '@/types/observation'
|
||||
|
||||
defineProps<{
|
||||
currentFilter: FilterType
|
||||
currentTypeFilter: ObservationType | null
|
||||
currentConceptFilter: ConceptType | null
|
||||
observationCount: number
|
||||
promptCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:filter': [filter: FilterType]
|
||||
'update:typeFilter': [type: ObservationType | null]
|
||||
'update:conceptFilter': [concept: ConceptType | null]
|
||||
}>()
|
||||
|
||||
const tabs: { key: FilterType; label: string; icon: string }[] = [
|
||||
{ key: 'all', label: 'All', icon: 'fa-layer-group' },
|
||||
{ key: 'observations', label: 'Observations', icon: 'fa-brain' },
|
||||
{ key: 'summaries', label: 'Summaries', icon: 'fa-clipboard-list' },
|
||||
{ key: 'prompts', label: 'Prompts', icon: 'fa-comment' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass rounded-xl p-4 mb-4 border border-white/10">
|
||||
<!-- Main Filter Tabs -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="[
|
||||
currentFilter === tab.key
|
||||
? 'bg-claude-500 text-white'
|
||||
: 'bg-white/5 text-slate-400 hover:bg-white/10 hover:text-white'
|
||||
]"
|
||||
@click="emit('update:filter', tab.key)"
|
||||
>
|
||||
<i class="fas mr-1.5" :class="tab.icon" />
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="ml-auto flex items-center gap-3 text-xs text-slate-500">
|
||||
<span>{{ observationCount }} obs</span>
|
||||
<span>·</span>
|
||||
<span>{{ promptCount }} prompts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub-filters (when observations selected) -->
|
||||
<div v-if="currentFilter === 'observations' || currentFilter === 'all'" class="mt-3 pt-3 border-t border-white/10">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<!-- Type Filter -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-slate-500 mr-1">Type:</span>
|
||||
<select
|
||||
class="bg-white/5 border border-white/10 rounded px-2 py-1 text-xs text-slate-300 focus:outline-none focus:border-claude-500"
|
||||
:value="currentTypeFilter || ''"
|
||||
@change="emit('update:typeFilter', ($event.target as HTMLSelectElement).value as ObservationType || null)"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option v-for="type in OBSERVATION_TYPES" :key="type" :value="type">
|
||||
{{ type }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Concept Filter -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-slate-500 mr-1">Concept:</span>
|
||||
<select
|
||||
class="bg-white/5 border border-white/10 rounded px-2 py-1 text-xs text-slate-300 focus:outline-none focus:border-claude-500"
|
||||
:value="currentConceptFilter || ''"
|
||||
@change="emit('update:conceptFilter', ($event.target as HTMLSelectElement).value as ConceptType || null)"
|
||||
>
|
||||
<option value="">All Concepts</option>
|
||||
<option v-for="concept in CONCEPT_TYPES" :key="concept" :value="concept">
|
||||
{{ concept }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
isConnected: boolean
|
||||
isProcessing: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="glass border-b border-white/10 sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Logo & Title -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-claude-500 to-claude-700 flex items-center justify-center shadow-lg">
|
||||
<i class="fas fa-brain text-xl text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-white">Claude <span class="text-claude-400">Mnemonic</span></h1>
|
||||
<p class="text-xs text-slate-400">Persistent Memory System</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status & Actions -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Connection Status -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="[
|
||||
isConnected ? 'bg-green-500' : 'bg-red-500',
|
||||
isProcessing ? 'animate-pulse' : ''
|
||||
]"
|
||||
/>
|
||||
<span class="text-sm text-slate-400">
|
||||
{{ isConnected ? (isProcessing ? 'Processing' : 'Connected') : 'Disconnected' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
class="p-2 rounded-lg bg-white/5 hover:bg-white/10 transition-colors text-slate-400 hover:text-white"
|
||||
title="Refresh"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<i class="fas fa-rotate" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
icon: string
|
||||
gradient?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-xl bg-gradient-to-br shadow-lg flex-shrink-0"
|
||||
:class="[
|
||||
gradient || 'from-claude-500 to-claude-700',
|
||||
{
|
||||
'w-8 h-8': size === 'sm',
|
||||
'w-12 h-12': size === 'md' || !size,
|
||||
'w-16 h-16': size === 'lg'
|
||||
}
|
||||
]"
|
||||
>
|
||||
<i
|
||||
class="fas text-white"
|
||||
:class="[
|
||||
icon,
|
||||
{
|
||||
'text-base': size === 'sm',
|
||||
'text-2xl': size === 'md' || !size,
|
||||
'text-3xl': size === 'lg'
|
||||
}
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import type { ObservationFeedItem } from '@/types'
|
||||
import { TYPE_CONFIG, CONCEPT_CONFIG } from '@/types/observation'
|
||||
import { formatRelativeTime } from '@/utils/formatters'
|
||||
import Card from './Card.vue'
|
||||
import IconBox from './IconBox.vue'
|
||||
import Badge from './Badge.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
observation: ObservationFeedItem
|
||||
highlight?: boolean
|
||||
}>()
|
||||
|
||||
const config = computed(() => TYPE_CONFIG[props.observation.type] || TYPE_CONFIG.change)
|
||||
|
||||
const concepts = computed(() => {
|
||||
const raw = props.observation.concepts
|
||||
if (Array.isArray(raw)) return raw
|
||||
return []
|
||||
})
|
||||
|
||||
const facts = computed(() => {
|
||||
const raw = props.observation.facts
|
||||
if (Array.isArray(raw)) return raw
|
||||
return []
|
||||
})
|
||||
|
||||
const filesRead = computed(() => {
|
||||
const raw = props.observation.files_read
|
||||
if (Array.isArray(raw)) return raw
|
||||
return []
|
||||
})
|
||||
|
||||
const filesModified = computed(() => {
|
||||
const raw = props.observation.files_modified
|
||||
if (Array.isArray(raw)) return raw
|
||||
return []
|
||||
})
|
||||
|
||||
const hasFiles = computed(() => filesRead.value.length > 0 || filesModified.value.length > 0)
|
||||
|
||||
// Split path into project root and relative path for styling
|
||||
// e.g., /Users/foo/project/src/file.go → { root: 'project', path: 'src/file.go' }
|
||||
const splitPath = (path: string, components = 3) => {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
if (parts.length <= components) {
|
||||
return { root: '', path: path }
|
||||
}
|
||||
const relevant = parts.slice(-components)
|
||||
return {
|
||||
root: relevant[0],
|
||||
path: relevant.slice(1).join('/')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
:gradient="`bg-gradient-to-br from-amber-500/10 to-orange-500/5`"
|
||||
:border-class="config.borderClass"
|
||||
:highlight="highlight"
|
||||
class="mb-4 hover:border-amber-400/50"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Icon -->
|
||||
<IconBox :icon="config.icon" :gradient="config.gradient" />
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<Badge
|
||||
:icon="config.icon"
|
||||
:color-class="config.colorClass"
|
||||
:bg-class="config.bgClass"
|
||||
:border-class="config.borderClass"
|
||||
>
|
||||
{{ observation.type.toUpperCase() }}
|
||||
</Badge>
|
||||
<span class="text-xs text-slate-500">{{ formatRelativeTime(observation.created_at) }}</span>
|
||||
<span v-if="observation.project" class="text-xs text-slate-500 flex items-center gap-1">
|
||||
<span class="text-slate-600">·</span>
|
||||
<i class="fas fa-folder text-slate-600 text-[10px]" />
|
||||
<span class="text-amber-600/80 font-mono">{{ observation.project.split('/').pop() }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Title & Subtitle -->
|
||||
<h3 class="text-lg font-semibold text-amber-100 mb-1">
|
||||
{{ observation.title || 'Untitled' }}
|
||||
</h3>
|
||||
<p v-if="observation.subtitle || observation.narrative" class="text-sm text-slate-300 mb-2">
|
||||
{{ observation.subtitle || observation.narrative }}
|
||||
</p>
|
||||
|
||||
<!-- Concepts -->
|
||||
<div v-if="concepts.length > 0" class="flex flex-wrap gap-1.5 mt-2">
|
||||
<Badge
|
||||
v-for="concept in concepts"
|
||||
:key="concept"
|
||||
:icon="CONCEPT_CONFIG[concept as keyof typeof CONCEPT_CONFIG]?.icon || 'fa-tag'"
|
||||
:color-class="CONCEPT_CONFIG[concept as keyof typeof CONCEPT_CONFIG]?.colorClass"
|
||||
:bg-class="CONCEPT_CONFIG[concept as keyof typeof CONCEPT_CONFIG]?.bgClass"
|
||||
:border-class="CONCEPT_CONFIG[concept as keyof typeof CONCEPT_CONFIG]?.borderClass"
|
||||
>
|
||||
{{ concept }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Facts -->
|
||||
<div v-if="facts.length > 0" class="mt-3 space-y-1.5">
|
||||
<div class="text-xs text-slate-500 uppercase tracking-wide mb-1">Key Facts</div>
|
||||
<div v-for="(fact, index) in facts" :key="index" class="flex items-start gap-2 text-sm text-slate-300">
|
||||
<i class="fas fa-check text-amber-500/70 mt-0.5 flex-shrink-0 text-xs" />
|
||||
<span>{{ fact }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Files -->
|
||||
<div v-if="hasFiles" class="mt-3 pt-3 border-t border-slate-700/50">
|
||||
<div class="space-y-1 text-xs">
|
||||
<div v-if="filesRead.length > 0" class="flex items-start gap-1.5">
|
||||
<i class="fas fa-eye text-slate-600 mt-0.5" />
|
||||
<span class="text-slate-600">Read:</span>
|
||||
<div class="flex flex-wrap gap-x-2 gap-y-0.5">
|
||||
<span v-for="(file, index) in filesRead" :key="file" :title="file" class="cursor-default font-mono">
|
||||
<span class="text-amber-600">{{ splitPath(file).root }}</span><span v-if="splitPath(file).root && splitPath(file).path">/</span><span class="text-slate-400 hover:text-slate-300">{{ splitPath(file).path }}</span><span v-if="index < filesRead.length - 1" class="text-slate-600">,</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filesModified.length > 0" class="flex items-start gap-1.5">
|
||||
<i class="fas fa-pen text-slate-600 mt-0.5" />
|
||||
<span class="text-slate-600">Modified:</span>
|
||||
<div class="flex flex-wrap gap-x-2 gap-y-0.5">
|
||||
<span v-for="(file, index) in filesModified" :key="file" :title="file" class="cursor-default font-mono">
|
||||
<span class="text-amber-600">{{ splitPath(file).root }}</span><span v-if="splitPath(file).root && splitPath(file).path">/</span><span class="text-slate-400 hover:text-slate-300">{{ splitPath(file).path }}</span><span v-if="index < filesModified.length - 1" class="text-slate-600">,</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { fetchProjects } from '@/utils/api'
|
||||
|
||||
const props = defineProps<{
|
||||
currentProject: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:project': [project: string | null]
|
||||
}>()
|
||||
|
||||
const projects = ref<string[]>([])
|
||||
const searchQuery = ref('')
|
||||
const isOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
if (!searchQuery.value) return projects.value
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return projects.value.filter(p => p.toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
const selectedProjectName = computed(() => {
|
||||
if (!props.currentProject) return 'All Projects'
|
||||
// Extract just the directory name from the full path
|
||||
const parts = props.currentProject.split('/')
|
||||
return parts[parts.length - 1] || props.currentProject
|
||||
})
|
||||
|
||||
async function loadProjects() {
|
||||
loading.value = true
|
||||
try {
|
||||
projects.value = await fetchProjects()
|
||||
} catch (err) {
|
||||
console.error('[ProjectFilter] Failed to load projects:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectProject(project: string | null) {
|
||||
emit('update:project', project)
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
loadProjects()
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.project-filter')) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
loadProjects()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="project-filter relative">
|
||||
<!-- Trigger Button -->
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg text-sm text-slate-200 transition-colors w-full"
|
||||
>
|
||||
<i class="fas fa-folder text-claude-400" />
|
||||
<span class="truncate flex-1 text-left">{{ selectedProjectName }}</span>
|
||||
<i
|
||||
class="fas fa-chevron-down text-slate-500 transition-transform"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute z-50 w-full mt-2 bg-slate-800 border border-slate-700 rounded-lg shadow-xl overflow-hidden"
|
||||
>
|
||||
<!-- Search Input -->
|
||||
<div class="p-2 border-b border-slate-700">
|
||||
<div class="relative">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
class="w-full pl-9 pr-3 py-2 bg-slate-900 border border-slate-600 rounded text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-claude-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project List -->
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<!-- All Projects Option -->
|
||||
<button
|
||||
@click="selectProject(null)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-slate-700 transition-colors flex items-center gap-2"
|
||||
:class="{ 'bg-slate-700 text-claude-400': !currentProject, 'text-slate-300': currentProject }"
|
||||
>
|
||||
<i class="fas fa-globe text-slate-500" />
|
||||
<span>All Projects</span>
|
||||
<i v-if="!currentProject" class="fas fa-check ml-auto text-claude-400" />
|
||||
</button>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="px-4 py-3 text-slate-500 text-sm text-center">
|
||||
<i class="fas fa-spinner fa-spin mr-2" />
|
||||
Loading projects...
|
||||
</div>
|
||||
|
||||
<!-- No Results -->
|
||||
<div v-else-if="filteredProjects.length === 0" class="px-4 py-3 text-slate-500 text-sm text-center">
|
||||
No projects found
|
||||
</div>
|
||||
|
||||
<!-- Project Items -->
|
||||
<button
|
||||
v-else
|
||||
v-for="project in filteredProjects"
|
||||
:key="project"
|
||||
@click="selectProject(project)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-slate-700 transition-colors flex items-center gap-2"
|
||||
:class="{ 'bg-slate-700 text-claude-400': currentProject === project, 'text-slate-300': currentProject !== project }"
|
||||
>
|
||||
<i class="fas fa-folder text-slate-500" />
|
||||
<span class="truncate">{{ project.split('/').pop() }}</span>
|
||||
<i v-if="currentProject === project" class="fas fa-check ml-auto text-claude-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import type { PromptFeedItem } from '@/types'
|
||||
import { formatRelativeTime, truncate } from '@/utils/formatters'
|
||||
import Card from './Card.vue'
|
||||
import Badge from './Badge.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
prompt: PromptFeedItem
|
||||
highlight?: boolean
|
||||
}>()
|
||||
|
||||
const displayText = computed(() => {
|
||||
const text = props.prompt.prompt_text || ''
|
||||
return truncate(text, 500)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
gradient="bg-gradient-to-br from-emerald-500/10 to-green-500/5"
|
||||
border-class="border-emerald-500/30"
|
||||
:highlight="highlight"
|
||||
class="mb-4 hover:border-emerald-400/50"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- User Icon -->
|
||||
<div class="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-user text-emerald-400" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 flex-wrap mb-2">
|
||||
<!-- Memory Match Badge -->
|
||||
<Badge
|
||||
v-if="prompt.matched_observations > 0"
|
||||
icon="fa-brain"
|
||||
color-class="text-amber-300"
|
||||
bg-class="bg-amber-500/20"
|
||||
border-class="border-amber-500/40"
|
||||
>
|
||||
{{ prompt.matched_observations }} memory match{{ prompt.matched_observations !== 1 ? 'es' : '' }}
|
||||
</Badge>
|
||||
|
||||
<!-- Prompt Number & Time -->
|
||||
<span class="text-xs text-slate-500">
|
||||
#{{ prompt.prompt_number }} · {{ formatRelativeTime(prompt.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Text -->
|
||||
<p class="text-sm text-slate-300 whitespace-pre-wrap">{{ displayText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import type { Stats } from '@/types'
|
||||
import ProjectFilter from './ProjectFilter.vue'
|
||||
|
||||
defineProps<{
|
||||
stats: Stats | null
|
||||
observationCount: number
|
||||
promptCount: number
|
||||
summaryCount: number
|
||||
currentProject: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:project': [project: string | null]
|
||||
}>()
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
|
||||
return n.toString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="w-72 flex-shrink-0 space-y-4">
|
||||
<!-- Project Filter -->
|
||||
<div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<i class="fas fa-filter text-claude-400" />
|
||||
<h3 class="text-sm font-semibold text-white">Filter by Project</h3>
|
||||
</div>
|
||||
<ProjectFilter
|
||||
:current-project="currentProject"
|
||||
@update:project="$emit('update:project', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Memory Stats -->
|
||||
<div class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<i class="fas fa-brain text-purple-400" />
|
||||
<h3 class="text-sm font-semibold text-white">Memory Contents</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Observations -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-lightbulb text-amber-400 w-4" />
|
||||
<span class="text-slate-400 text-sm">Observations</span>
|
||||
</div>
|
||||
<span class="text-white font-medium">{{ formatNumber(observationCount) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Prompts -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-comment text-blue-400 w-4" />
|
||||
<span class="text-slate-400 text-sm">Prompts</span>
|
||||
</div>
|
||||
<span class="text-white font-medium">{{ formatNumber(promptCount) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Summaries -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-clipboard-list text-green-400 w-4" />
|
||||
<span class="text-slate-400 text-sm">Summaries</span>
|
||||
</div>
|
||||
<span class="text-white font-medium">{{ formatNumber(summaryCount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Retrieval Stats -->
|
||||
<div v-if="stats?.retrieval" class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<i class="fas fa-search text-cyan-400" />
|
||||
<h3 class="text-sm font-semibold text-white">Retrieval Stats</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Total Requests -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-400 text-sm">Total Requests</span>
|
||||
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.TotalRequests) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Observations Served -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-400 text-sm">Obs Served</span>
|
||||
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.ObservationsServed) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Search Requests -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-400 text-sm">Searches</span>
|
||||
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.SearchRequests) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Context Injections -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-400 text-sm">Injections</span>
|
||||
<span class="text-white font-medium">{{ formatNumber(stats.retrieval.ContextInjections) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Verified Stale -->
|
||||
<div v-if="stats.retrieval.VerifiedStale > 0" class="flex items-center justify-between">
|
||||
<span class="text-slate-400 text-sm">Verified Stale</span>
|
||||
<span class="text-amber-400 font-medium">{{ formatNumber(stats.retrieval.VerifiedStale) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Deleted Invalid -->
|
||||
<div v-if="stats.retrieval.DeletedInvalid > 0" class="flex items-center justify-between">
|
||||
<span class="text-slate-400 text-sm">Deleted Invalid</span>
|
||||
<span class="text-red-400 font-medium">{{ formatNumber(stats.retrieval.DeletedInvalid) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Info -->
|
||||
<div v-if="stats" class="bg-slate-800/50 rounded-lg p-4 border border-slate-700/50">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<i class="fas fa-clock text-slate-400" />
|
||||
<h3 class="text-sm font-semibold text-white">Worker Info</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Uptime -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-400 text-sm">Uptime</span>
|
||||
<span class="text-white font-medium text-xs">{{ stats.uptime }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Sessions Today -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-400 text-sm">Sessions Today</span>
|
||||
<span class="text-white font-medium">{{ stats.sessionsToday }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Connected Clients -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-400 text-sm">Connected Clients</span>
|
||||
<span class="text-white font-medium">{{ stats.connectedClients }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import type { Stats } from '@/types'
|
||||
import { formatUptime } from '@/utils/formatters'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
stats: Stats | null
|
||||
queueDepth: number
|
||||
}>()
|
||||
|
||||
const uptime = computed(() => {
|
||||
if (!props.stats?.uptime) return '-'
|
||||
return formatUptime(props.stats.uptime)
|
||||
})
|
||||
|
||||
const status = computed(() => {
|
||||
if (!props.stats) return 'Loading'
|
||||
if (props.queueDepth > 0) return 'Processing'
|
||||
if (props.stats.activeSessions > 0) return 'Active'
|
||||
return 'Idle'
|
||||
})
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (status.value === 'Processing') return 'text-yellow-400'
|
||||
if (status.value === 'Active') return 'text-green-400'
|
||||
return 'text-slate-400'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Uptime -->
|
||||
<div class="glass rounded-xl p-4 border border-white/10">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 uppercase tracking-wide">Uptime</p>
|
||||
<p class="text-2xl font-bold text-claude-400">{{ uptime }}</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-claude-500/20 flex items-center justify-center">
|
||||
<i class="fas fa-clock text-claude-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<div class="glass rounded-xl p-4 border border-white/10">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 uppercase tracking-wide">Active Sessions</p>
|
||||
<p class="text-2xl font-bold text-blue-400">{{ stats?.activeSessions ?? 0 }}</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
|
||||
<i class="fas fa-terminal text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue Depth -->
|
||||
<div class="glass rounded-xl p-4 border border-white/10">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 uppercase tracking-wide">Queue Depth</p>
|
||||
<p class="text-2xl font-bold text-purple-400">{{ queueDepth }}</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
|
||||
<i class="fas fa-layer-group text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="glass rounded-xl p-4 border border-white/10">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 uppercase tracking-wide">Status</p>
|
||||
<p class="text-2xl font-bold" :class="statusColor">{{ status }}</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<i class="fas fa-signal text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import type { SummaryFeedItem } from '@/types'
|
||||
import { formatRelativeTime } from '@/utils/formatters'
|
||||
import Card from './Card.vue'
|
||||
import IconBox from './IconBox.vue'
|
||||
|
||||
defineProps<{
|
||||
summary: SummaryFeedItem
|
||||
highlight?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
gradient="bg-gradient-to-br from-blue-500/10 to-indigo-500/5"
|
||||
border-class="border-blue-500/30"
|
||||
:highlight="highlight"
|
||||
class="mb-4 hover:border-blue-400/50"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Icon -->
|
||||
<IconBox icon="fa-clipboard-list" gradient="from-blue-500 to-indigo-700" />
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-blue-500/20 text-blue-300 font-semibold uppercase tracking-wide">
|
||||
<i class="fas fa-clipboard-list mr-1" /> Summary
|
||||
</span>
|
||||
<span class="text-xs text-slate-500">{{ formatRelativeTime(summary.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Request -->
|
||||
<div v-if="summary.request" class="mb-3">
|
||||
<h4 class="text-xs text-slate-500 uppercase tracking-wide mb-1">Request</h4>
|
||||
<p class="text-sm text-blue-100">{{ summary.request }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Sections -->
|
||||
<div class="grid gap-3 text-sm">
|
||||
<!-- Completed -->
|
||||
<div v-if="summary.completed" class="bg-green-500/10 rounded-lg p-3 border border-green-500/20">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<i class="fas fa-check-circle text-green-400" />
|
||||
<span class="text-xs text-green-300 uppercase tracking-wide font-medium">Completed</span>
|
||||
</div>
|
||||
<p class="text-slate-300">{{ summary.completed }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Learned -->
|
||||
<div v-if="summary.learned" class="bg-purple-500/10 rounded-lg p-3 border border-purple-500/20">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<i class="fas fa-graduation-cap text-purple-400" />
|
||||
<span class="text-xs text-purple-300 uppercase tracking-wide font-medium">Learned</span>
|
||||
</div>
|
||||
<p class="text-slate-300">{{ summary.learned }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Next Steps -->
|
||||
<div v-if="summary.next_steps" class="bg-cyan-500/10 rounded-lg p-3 border border-cyan-500/20">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<i class="fas fa-arrow-right text-cyan-400" />
|
||||
<span class="text-xs text-cyan-300 uppercase tracking-wide font-medium">Next Steps</span>
|
||||
</div>
|
||||
<p class="text-slate-300">{{ summary.next_steps }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import type { FeedItem } from '@/types'
|
||||
import ObservationCard from './ObservationCard.vue'
|
||||
import PromptCard from './PromptCard.vue'
|
||||
import SummaryCard from './SummaryCard.vue'
|
||||
|
||||
defineProps<{
|
||||
items: FeedItem[]
|
||||
loading: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="timeline">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading && items.length === 0" class="text-center py-12">
|
||||
<i class="fas fa-spinner fa-spin text-3xl text-claude-500 mb-4" />
|
||||
<p class="text-slate-400">Loading timeline...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="items.length === 0" class="text-center py-12">
|
||||
<i class="fas fa-inbox text-3xl text-slate-600 mb-4" />
|
||||
<p class="text-slate-400">No items to display</p>
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
<template v-else>
|
||||
<template v-for="(item, index) in items" :key="`${item.itemType}-${item.id}`">
|
||||
<ObservationCard
|
||||
v-if="item.itemType === 'observation'"
|
||||
:observation="item"
|
||||
:highlight="index === 0"
|
||||
/>
|
||||
<PromptCard
|
||||
v-else-if="item.itemType === 'prompt'"
|
||||
:prompt="item"
|
||||
:highlight="index === 0"
|
||||
/>
|
||||
<SummaryCard
|
||||
v-else-if="item.itemType === 'summary'"
|
||||
:summary="item"
|
||||
:highlight="index === 0"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
export { default as Badge } from './Badge.vue'
|
||||
export { default as Card } from './Card.vue'
|
||||
export { default as IconBox } from './IconBox.vue'
|
||||
export { default as Header } from './Header.vue'
|
||||
export { default as StatsCards } from './StatsCards.vue'
|
||||
export { default as FilterTabs } from './FilterTabs.vue'
|
||||
export { default as Timeline } from './Timeline.vue'
|
||||
export { default as ObservationCard } from './ObservationCard.vue'
|
||||
export { default as PromptCard } from './PromptCard.vue'
|
||||
export { default as SummaryCard } from './SummaryCard.vue'
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useSSE } from './useSSE'
|
||||
export { useStats } from './useStats'
|
||||
export { useTimeline } from './useTimeline'
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import type { SSEEvent } from '@/types'
|
||||
|
||||
export function useSSE() {
|
||||
const isConnected = ref(false)
|
||||
const isProcessing = ref(false)
|
||||
const queueDepth = ref(0)
|
||||
const lastEvent = ref<SSEEvent | null>(null)
|
||||
|
||||
let eventSource: EventSource | null = null
|
||||
let reconnectTimeout: number | null = null
|
||||
|
||||
const connect = () => {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
}
|
||||
|
||||
eventSource = new EventSource('/api/events')
|
||||
|
||||
eventSource.onopen = () => {
|
||||
isConnected.value = true
|
||||
console.log('[SSE] Connected')
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data: SSEEvent = JSON.parse(event.data)
|
||||
lastEvent.value = data
|
||||
|
||||
if (data.type === 'processing_status') {
|
||||
isProcessing.value = data.isProcessing ?? false
|
||||
queueDepth.value = data.queueDepth ?? 0
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SSE] Parse error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
isConnected.value = false
|
||||
eventSource?.close()
|
||||
eventSource = null
|
||||
|
||||
// Reconnect after 5 seconds
|
||||
reconnectTimeout = window.setTimeout(() => {
|
||||
console.log('[SSE] Reconnecting...')
|
||||
connect()
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout)
|
||||
reconnectTimeout = null
|
||||
}
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
isConnected.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
isProcessing,
|
||||
queueDepth,
|
||||
lastEvent,
|
||||
reconnect: connect
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import type { Stats } from '@/types'
|
||||
import { fetchStats } from '@/utils/api'
|
||||
|
||||
export function useStats(pollInterval: number = 5000) {
|
||||
const stats = ref<Stats | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
let intervalId: number | null = null
|
||||
|
||||
const refresh = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
stats.value = await fetchStats()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch stats'
|
||||
console.error('[Stats] Error:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
if (intervalId) return
|
||||
intervalId = window.setInterval(refresh, pollInterval)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
return {
|
||||
stats,
|
||||
loading,
|
||||
error,
|
||||
refresh
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import type { FeedItem, FilterType, ObservationType, ConceptType } from '@/types'
|
||||
import { fetchObservations, fetchPrompts, fetchSummaries, combineTimeline } from '@/utils/api'
|
||||
import { useSSE } from './useSSE'
|
||||
|
||||
export function useTimeline() {
|
||||
const observations = ref<FeedItem[]>([])
|
||||
const prompts = ref<FeedItem[]>([])
|
||||
const summaries = ref<FeedItem[]>([])
|
||||
const allItems = ref<FeedItem[]>([])
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const currentFilter = ref<FilterType>('all')
|
||||
const currentProject = ref<string | null>(null)
|
||||
const currentTypeFilter = ref<ObservationType | null>(null)
|
||||
const currentConceptFilter = ref<ConceptType | null>(null)
|
||||
|
||||
// SSE for real-time updates
|
||||
const { lastEvent } = useSSE()
|
||||
|
||||
// Counts (reflect data fetched for current project/all)
|
||||
const observationCount = computed(() => observations.value.length)
|
||||
const promptCount = computed(() => prompts.value.length)
|
||||
const summaryCount = computed(() => summaries.value.length)
|
||||
|
||||
// Filtered items (further filter by type/concept within fetched data)
|
||||
const filteredItems = computed(() => {
|
||||
let items = [...allItems.value]
|
||||
|
||||
// Filter by main type
|
||||
if (currentFilter.value === 'observations') {
|
||||
items = items.filter(item => item.itemType === 'observation')
|
||||
} else if (currentFilter.value === 'summaries') {
|
||||
items = items.filter(item => item.itemType === 'summary')
|
||||
} else if (currentFilter.value === 'prompts') {
|
||||
items = items.filter(item => item.itemType === 'prompt')
|
||||
}
|
||||
|
||||
// Filter by observation type
|
||||
if (currentTypeFilter.value) {
|
||||
items = items.filter(item => {
|
||||
if (item.itemType !== 'observation') return false
|
||||
return item.type === currentTypeFilter.value
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by concept
|
||||
if (currentConceptFilter.value) {
|
||||
items = items.filter(item => {
|
||||
if (item.itemType !== 'observation') return false
|
||||
const concepts = item.concepts || []
|
||||
return concepts.includes(currentConceptFilter.value!)
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Use different limits based on project selection:
|
||||
// All projects: 50 of each type
|
||||
// Specific project: 100 of each type
|
||||
const project = currentProject.value || undefined
|
||||
const limit = project ? 100 : 50
|
||||
|
||||
const [obs, prm, sum] = await Promise.all([
|
||||
fetchObservations(limit, project),
|
||||
fetchPrompts(limit, project),
|
||||
fetchSummaries(limit, project)
|
||||
])
|
||||
|
||||
// Combine into timeline
|
||||
allItems.value = combineTimeline(obs, prm, sum)
|
||||
|
||||
// Update individual arrays for counting (data already filtered by project from API)
|
||||
observations.value = allItems.value.filter(i => i.itemType === 'observation')
|
||||
prompts.value = allItems.value.filter(i => i.itemType === 'prompt')
|
||||
summaries.value = allItems.value.filter(i => i.itemType === 'summary')
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch timeline'
|
||||
console.error('[Timeline] Error:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setFilter = (filter: FilterType) => {
|
||||
currentFilter.value = filter
|
||||
}
|
||||
|
||||
const setProject = (project: string | null) => {
|
||||
currentProject.value = project
|
||||
// Refresh when project changes to fetch correct data
|
||||
refresh()
|
||||
}
|
||||
|
||||
const setTypeFilter = (type: ObservationType | null) => {
|
||||
currentTypeFilter.value = type
|
||||
}
|
||||
|
||||
const setConceptFilter = (concept: ConceptType | null) => {
|
||||
currentConceptFilter.value = concept
|
||||
}
|
||||
|
||||
// Watch for SSE events and refresh
|
||||
watch(lastEvent, (event) => {
|
||||
if (event && (event.type === 'observation' || event.type === 'prompt' || event.type === 'summary')) {
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
})
|
||||
|
||||
return {
|
||||
allItems,
|
||||
filteredItems,
|
||||
loading,
|
||||
error,
|
||||
observationCount,
|
||||
promptCount,
|
||||
summaryCount,
|
||||
currentFilter,
|
||||
currentProject,
|
||||
currentTypeFilter,
|
||||
currentConceptFilter,
|
||||
refresh,
|
||||
setFilter,
|
||||
setProject,
|
||||
setTypeFilter,
|
||||
setConceptFilter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './assets/main.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Observation } from './observation'
|
||||
import type { UserPrompt } from './prompt'
|
||||
import type { SessionSummary } from './summary'
|
||||
|
||||
export type FeedItemType = 'observation' | 'prompt' | 'summary'
|
||||
|
||||
export interface ObservationFeedItem extends Observation {
|
||||
itemType: 'observation'
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export interface PromptFeedItem extends UserPrompt {
|
||||
itemType: 'prompt'
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export interface SummaryFeedItem extends SessionSummary {
|
||||
itemType: 'summary'
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export type FeedItem = ObservationFeedItem | PromptFeedItem | SummaryFeedItem
|
||||
|
||||
export interface RetrievalStats {
|
||||
TotalRequests: number
|
||||
ObservationsServed: number
|
||||
VerifiedStale: number
|
||||
DeletedInvalid: number
|
||||
SearchRequests: number
|
||||
ContextInjections: number
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
uptime: string
|
||||
activeSessions: number
|
||||
queueDepth: number
|
||||
isProcessing: boolean
|
||||
connectedClients: number
|
||||
sessionsToday: number
|
||||
retrieval: RetrievalStats
|
||||
}
|
||||
|
||||
export interface SSEEvent {
|
||||
type: 'processing_status' | 'observation' | 'session' | 'prompt' | 'summary' | 'heartbeat' | 'connected'
|
||||
title?: string
|
||||
action?: string
|
||||
project?: string
|
||||
isProcessing?: boolean
|
||||
queueDepth?: number
|
||||
}
|
||||
|
||||
export type FilterType = 'all' | 'observations' | 'summaries' | 'prompts'
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './observation'
|
||||
export * from './prompt'
|
||||
export * from './summary'
|
||||
export * from './api'
|
||||
@@ -0,0 +1,55 @@
|
||||
export type ObservationType = 'bugfix' | 'feature' | 'refactor' | 'discovery' | 'decision' | 'change'
|
||||
export type ObservationScope = 'project' | 'global'
|
||||
export type ConceptType = 'gotcha' | 'pattern' | 'problem-solution' | 'trade-off' | 'how-it-works' | 'why-it-exists' | 'what-changed'
|
||||
|
||||
export interface Observation {
|
||||
id: number
|
||||
sdk_session_id: string
|
||||
project: string
|
||||
scope: ObservationScope
|
||||
type: ObservationType
|
||||
title: string
|
||||
subtitle: string
|
||||
narrative: string
|
||||
facts: string[]
|
||||
concepts: string[]
|
||||
files_read: string[]
|
||||
files_modified: string[]
|
||||
file_mtimes: Record<string, number>
|
||||
prompt_number: number
|
||||
discovery_tokens: number
|
||||
created_at: string
|
||||
created_at_epoch: number
|
||||
is_stale?: boolean
|
||||
}
|
||||
|
||||
export const OBSERVATION_TYPES: ObservationType[] = ['bugfix', 'feature', 'refactor', 'discovery', 'decision', 'change']
|
||||
|
||||
export const CONCEPT_TYPES: ConceptType[] = [
|
||||
'gotcha',
|
||||
'pattern',
|
||||
'problem-solution',
|
||||
'trade-off',
|
||||
'how-it-works',
|
||||
'why-it-exists',
|
||||
'what-changed'
|
||||
]
|
||||
|
||||
export const TYPE_CONFIG: Record<ObservationType, { icon: string; colorClass: string; bgClass: string; borderClass: string; gradient: string }> = {
|
||||
bugfix: { icon: 'fa-bug', colorClass: 'text-red-300', bgClass: 'bg-red-500/20', borderClass: 'border-red-500/30', gradient: 'from-red-500 to-red-700' },
|
||||
feature: { icon: 'fa-star', colorClass: 'text-purple-300', bgClass: 'bg-purple-500/20', borderClass: 'border-purple-500/30', gradient: 'from-purple-500 to-purple-700' },
|
||||
refactor: { icon: 'fa-rotate', colorClass: 'text-blue-300', bgClass: 'bg-blue-500/20', borderClass: 'border-blue-500/30', gradient: 'from-blue-500 to-blue-700' },
|
||||
change: { icon: 'fa-pen', colorClass: 'text-slate-300', bgClass: 'bg-slate-500/20', borderClass: 'border-slate-500/30', gradient: 'from-slate-500 to-slate-700' },
|
||||
discovery: { icon: 'fa-magnifying-glass', colorClass: 'text-cyan-300', bgClass: 'bg-cyan-500/20', borderClass: 'border-cyan-500/30', gradient: 'from-cyan-500 to-cyan-700' },
|
||||
decision: { icon: 'fa-scale-balanced', colorClass: 'text-yellow-300', bgClass: 'bg-yellow-500/20', borderClass: 'border-yellow-500/30', gradient: 'from-yellow-500 to-yellow-700' },
|
||||
}
|
||||
|
||||
export const CONCEPT_CONFIG: Record<ConceptType, { icon: string; colorClass: string; bgClass: string; borderClass: string }> = {
|
||||
gotcha: { icon: 'fa-triangle-exclamation', colorClass: 'text-red-300', bgClass: 'bg-red-500/20', borderClass: 'border-red-500/40' },
|
||||
pattern: { icon: 'fa-puzzle-piece', colorClass: 'text-purple-300', bgClass: 'bg-purple-500/20', borderClass: 'border-purple-500/40' },
|
||||
'problem-solution': { icon: 'fa-lightbulb', colorClass: 'text-blue-300', bgClass: 'bg-blue-500/20', borderClass: 'border-blue-500/40' },
|
||||
'trade-off': { icon: 'fa-scale-balanced', colorClass: 'text-yellow-300', bgClass: 'bg-yellow-500/20', borderClass: 'border-yellow-500/40' },
|
||||
'how-it-works': { icon: 'fa-gear', colorClass: 'text-cyan-300', bgClass: 'bg-cyan-500/20', borderClass: 'border-cyan-500/40' },
|
||||
'why-it-exists': { icon: 'fa-circle-question', colorClass: 'text-green-300', bgClass: 'bg-green-500/20', borderClass: 'border-green-500/40' },
|
||||
'what-changed': { icon: 'fa-clipboard-list', colorClass: 'text-slate-300', bgClass: 'bg-slate-500/20', borderClass: 'border-slate-500/40' },
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface UserPrompt {
|
||||
id: number
|
||||
claude_session_id: string
|
||||
sdk_session_id: string
|
||||
project: string
|
||||
prompt_number: number
|
||||
prompt_text: string
|
||||
matched_observations: number
|
||||
created_at: string
|
||||
created_at_epoch: number
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface SessionSummary {
|
||||
id: number
|
||||
sdk_session_id: string
|
||||
project: string
|
||||
request: string
|
||||
investigated: string
|
||||
learned: string
|
||||
completed: string
|
||||
next_steps: string
|
||||
notes: string
|
||||
prompt_number: number
|
||||
discovery_tokens: number
|
||||
created_at: string
|
||||
created_at_epoch: number
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Observation, UserPrompt, SessionSummary, Stats, FeedItem, ObservationFeedItem, PromptFeedItem, SummaryFeedItem } from '@/types'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function fetchObservations(limit: number = 100, project?: string): Promise<Observation[]> {
|
||||
const params = new URLSearchParams({ limit: String(limit) })
|
||||
if (project) params.append('project', project)
|
||||
return fetchJson<Observation[]>(`${API_BASE}/observations?${params}`)
|
||||
}
|
||||
|
||||
export async function fetchPrompts(limit: number = 100, project?: string): Promise<UserPrompt[]> {
|
||||
const params = new URLSearchParams({ limit: String(limit) })
|
||||
if (project) params.append('project', project)
|
||||
return fetchJson<UserPrompt[]>(`${API_BASE}/prompts?${params}`)
|
||||
}
|
||||
|
||||
export async function fetchSummaries(limit: number = 50, project?: string): Promise<SessionSummary[]> {
|
||||
const params = new URLSearchParams({ limit: String(limit) })
|
||||
if (project) params.append('project', project)
|
||||
return fetchJson<SessionSummary[]>(`${API_BASE}/summaries?${params}`)
|
||||
}
|
||||
|
||||
export async function fetchStats(): Promise<Stats> {
|
||||
return fetchJson<Stats>(`${API_BASE}/stats`)
|
||||
}
|
||||
|
||||
export async function fetchProjects(): Promise<string[]> {
|
||||
return fetchJson<string[]>(`${API_BASE}/projects`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine and sort all feed items by timestamp
|
||||
*/
|
||||
export function combineTimeline(
|
||||
observations: Observation[],
|
||||
prompts: UserPrompt[],
|
||||
summaries: SessionSummary[]
|
||||
): FeedItem[] {
|
||||
const obsItems: ObservationFeedItem[] = observations.map(o => ({
|
||||
...o,
|
||||
itemType: 'observation' as const,
|
||||
timestamp: new Date(o.created_at)
|
||||
}))
|
||||
|
||||
const promptItems: PromptFeedItem[] = prompts.map(p => ({
|
||||
...p,
|
||||
itemType: 'prompt' as const,
|
||||
timestamp: new Date(p.created_at)
|
||||
}))
|
||||
|
||||
const summaryItems: SummaryFeedItem[] = summaries.map(s => ({
|
||||
...s,
|
||||
itemType: 'summary' as const,
|
||||
timestamp: new Date(s.created_at)
|
||||
}))
|
||||
|
||||
return [...obsItems, ...promptItems, ...summaryItems]
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Format a timestamp as relative time (e.g., "5m ago", "2h ago")
|
||||
*/
|
||||
export function formatRelativeTime(dateOrEpoch: string | number): string {
|
||||
const timestamp = typeof dateOrEpoch === 'number' ? dateOrEpoch : new Date(dateOrEpoch).getTime()
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (seconds < 60) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
if (days < 7) return `${days}d ago`
|
||||
|
||||
return new Date(timestamp).toLocaleDateString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format uptime duration string
|
||||
*/
|
||||
export function formatUptime(uptimeStr: string): string {
|
||||
// Parse Go duration string (e.g., "1h30m45.123456789s")
|
||||
const match = uptimeStr.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?/)
|
||||
if (!match) return uptimeStr
|
||||
|
||||
const hours = parseInt(match[1] || '0', 10)
|
||||
const minutes = parseInt(match[2] || '0', 10)
|
||||
const seconds = Math.floor(parseFloat(match[3] || '0'))
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to a maximum length with ellipsis
|
||||
*/
|
||||
export function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text
|
||||
return text.slice(0, maxLength - 3) + '...'
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML entities
|
||||
*/
|
||||
export function escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
return text.replace(/[&<>"']/g, m => map[m])
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON safely with fallback
|
||||
*/
|
||||
export function parseJsonSafe<T>(value: unknown, fallback: T): T {
|
||||
if (Array.isArray(value)) return value as T
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value) as T
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Get string value safely from potentially nullable fields
|
||||
*/
|
||||
export function getString(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (value === null || value === undefined) return ''
|
||||
return String(value)
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
Reference in New Issue
Block a user