Files
nexus/frontend/src/views/MemoryView.vue
T
bao eeb6174de0 Initial commit: Nexus Mission Control Platform
- ASP.NET Core 10 Backend (JWT Auth, Agent config API)
- Vue 3 Frontend (Dashboard, Team, Agents, Config Editor)
- PostgreSQL Database
- Docker Compose setup
- Mission Control Dashboard redesign
2026-06-09 16:31:56 +02:00

464 lines
11 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { FileText, Search, ArrowLeft, Clock, Database, Loader2 } from '@lucide/vue'
import { apiFetch } from '../services/api'
import { renderMarkdown } from '../utils/markdown'
import type { MemoryFile, MemoryDetail, MemorySearchResult } from '../types'
// State
const memories = ref<MemoryFile[]>([])
const loading = ref(false)
const error = ref('')
const searchQuery = ref('')
const searchResults = ref<MemorySearchResult[]>([])
const searchLoading = ref(false)
const searchDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
const selectedMemory = ref<MemoryDetail | null>(null)
const contentLoading = ref(false)
// Sorted memories (newest first)
const sortedMemories = computed(() => {
return [...memories.value].sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime())
})
async function loadMemories() {
loading.value = true
error.value = ''
try {
const response = await apiFetch('/api/v1/memory')
if (!response.ok) throw new Error('Failed to load memory files')
memories.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load memory files'
} finally {
loading.value = false
}
}
async function loadMemoryContent(name: string) {
contentLoading.value = true
selectedMemory.value = null
try {
const response = await apiFetch(`/api/v1/memory/${encodeURIComponent(name)}`)
if (!response.ok) throw new Error('Failed to load memory content')
selectedMemory.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load memory content'
} finally {
contentLoading.value = false
}
}
async function doSearch(q: string) {
if (q.length < 2) {
searchResults.value = []
return
}
searchLoading.value = true
try {
const response = await apiFetch(`/api/v1/memory/search?q=${encodeURIComponent(q)}`)
if (!response.ok) throw new Error('Search failed')
searchResults.value = await response.json()
} catch (e) {
searchResults.value = []
} finally {
searchLoading.value = false
}
}
function onSearchInput() {
if (searchDebounce.value) clearTimeout(searchDebounce.value)
const q = searchQuery.value.trim()
if (q.length < 2) {
searchResults.value = []
return
}
searchDebounce.value = setTimeout(() => doSearch(q), 300)
}
function selectMemory(name: string) {
searchResults.value = []
searchQuery.value = ''
loadMemoryContent(name)
}
function goBack() {
selectedMemory.value = null
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
onMounted(loadMemories)
</script>
<template>
<div class="page-heading">
<div>
<span class="eyebrow">LIFECYCLE</span>
<h1>Memory</h1>
<p>Browse agent memory files stored across workspaces.</p>
</div>
</div>
<div class="memory-search-bar">
<Search :size="16" />
<input
v-model="searchQuery"
placeholder="Search memory files..."
@input="onSearchInput"
/>
<kbd v-if="!searchQuery">Type at least 2 characters</kbd>
</div>
<div class="memory-layout">
<!-- Left column: file list or search results -->
<aside class="memory-sidebar">
<!-- Search results -->
<template v-if="searchQuery.trim().length >= 2">
<div v-if="searchLoading" class="memory-status">
<Loader2 :size="16" class="spin" />
Searching...
</div>
<template v-else-if="searchResults.length">
<div class="memory-list-header">Search results ({{ searchResults.length }})</div>
<button
v-for="result in searchResults"
:key="result.name"
class="memory-file-item"
@click="selectMemory(result.name)"
>
<div class="memory-file-icon"><FileText :size="14" /></div>
<div class="memory-file-info">
<strong>{{ result.name }}</strong>
<span class="memory-file-excerpt">{{ result.excerpt }}</span>
</div>
</button>
</template>
<div v-else class="memory-status">No results found</div>
</template>
<!-- File list (default) -->
<template v-else>
<div v-if="loading" class="memory-status">
<Loader2 :size="16" class="spin" />
Loading memory files...
</div>
<div v-else-if="error" class="memory-status error">{{ error }}</div>
<template v-else-if="sortedMemories.length">
<div class="memory-list-header">{{ sortedMemories.length }} files</div>
<button
v-for="mem in sortedMemories"
:key="mem.name"
:class="['memory-file-item', { active: selectedMemory?.name === mem.name }]"
@click="loadMemoryContent(mem.name)"
>
<div class="memory-file-icon"><FileText :size="14" /></div>
<div class="memory-file-info">
<strong>{{ mem.name }}</strong>
<span class="memory-file-meta">
<Clock :size="10" /> {{ formatDate(mem.modifiedAt) }}
· {{ formatSize(mem.size) }}
</span>
</div>
</button>
</template>
<div v-else class="memory-status">No memory files available</div>
</template>
</aside>
<!-- Right column: content -->
<main class="memory-content">
<template v-if="contentLoading">
<div class="memory-status">
<Loader2 :size="20" class="spin" />
Loading content...
</div>
</template>
<template v-else-if="selectedMemory">
<header class="memory-content-header">
<button class="memory-back-btn" @click="goBack">
<ArrowLeft :size="14" />
Back
</button>
<div>
<strong>{{ selectedMemory.name }}</strong>
<span class="memory-content-meta">
<Clock :size="10" /> {{ formatDate(selectedMemory.modifiedAt) }}
· {{ formatSize(selectedMemory.size) }}
</span>
</div>
</header>
<article
class="memory-rendered"
v-html="renderMarkdown(selectedMemory.content)"
></article>
</template>
<template v-else>
<div class="memory-empty-state">
<Database :size="28" />
<h3>Select a memory file</h3>
<p>Choose a file from the list or search to view its contents.</p>
</div>
</template>
</main>
</div>
</template>
<style scoped>
.memory-search-bar {
display: flex;
align-items: center;
gap: 9px;
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
margin-bottom: 16px;
color: #6f7889;
}
.memory-search-bar input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: #e8eaf0;
font: inherit;
font-size: 13px;
}
.memory-search-bar kbd {
padding: 2px 5px;
border: 1px solid #2c313d;
border-radius: 4px;
color: #606979;
font-size: 9px;
}
.memory-layout {
display: grid;
grid-template-columns: 320px 1fr;
gap: 12px;
min-height: 480px;
}
.memory-sidebar {
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
padding: 8px;
max-height: 640px;
overflow-y: auto;
}
.memory-list-header {
font-size: 9px;
font-weight: 700;
color: #7065c8;
text-transform: uppercase;
letter-spacing: 0.14em;
padding: 10px 8px 6px;
}
.memory-file-item {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 10px;
border: none;
border-radius: 7px;
background: transparent;
color: #e8eaf0;
text-align: left;
cursor: pointer;
font: inherit;
}
.memory-file-item:hover,
.memory-file-item.active {
background: var(--accent-soft);
}
.memory-file-icon {
flex-shrink: 0;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 6px;
color: #a99cf5;
background: rgba(139,124,246,.1);
}
.memory-file-info strong {
display: block;
font-size: 11px;
margin-bottom: 3px;
word-break: break-word;
}
.memory-file-meta,
.memory-file-excerpt {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: #7e8799;
line-height: 1.5;
}
.memory-file-excerpt {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.memory-content {
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
padding: 24px;
min-height: 480px;
overflow-y: auto;
}
.memory-content-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.memory-content-header > div strong {
display: block;
font-size: 13px;
margin-bottom: 3px;
}
.memory-content-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: #7e8799;
}
.memory-back-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: #8991a1;
font-size: 10px;
cursor: pointer;
flex-shrink: 0;
}
.memory-back-btn:hover {
color: #e8eaf0;
background: var(--accent-soft);
}
.memory-status {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px;
color: #7e8799;
font-size: 11px;
}
.memory-status.error {
color: #e16e75;
}
.memory-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
min-height: 360px;
color: #6b7385;
}
.memory-empty-state h3 {
margin: 12px 0 6px;
font-size: 14px;
color: #a5adba;
}
.memory-empty-state p {
margin: 0;
font-size: 10px;
color: #7e8799;
}
.memory-rendered {
font-size: 12px;
line-height: 1.7;
color: #d0d4dd;
}
.memory-rendered :deep(h1),
.memory-rendered :deep(h2),
.memory-rendered :deep(h3) {
color: #e8eaf0;
margin: 1.2em 0 0.5em;
}
.memory-rendered :deep(h1) { font-size: 1.3rem; }
.memory-rendered :deep(h2) { font-size: 1.1rem; }
.memory-rendered :deep(h3) { font-size: 1rem; }
.memory-rendered :deep(p) { margin: 0.6em 0; }
.memory-rendered :deep(code) {
padding: 2px 5px;
background: rgba(139,124,246,.08);
border-radius: 4px;
font-size: 11px;
}
.memory-rendered :deep(pre) {
padding: 12px;
border-radius: 8px;
background: #0d1016;
border: 1px solid var(--line);
overflow-x: auto;
}
.memory-rendered :deep(pre code) {
background: none;
padding: 0;
}
.memory-rendered :deep(a) {
color: #a99cf5;
text-decoration: none;
}
.memory-rendered :deep(a:hover) {
text-decoration: underline;
}
.memory-rendered :deep(ul) {
padding-left: 20px;
}
.memory-rendered :deep(li) {
margin: 0.3em 0;
}
.memory-rendered :deep(hr) {
border: none;
border-top: 1px solid var(--line);
margin: 1.2em 0;
}
.memory-rendered :deep(strong) {
color: #e8eaf0;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 900px) {
.memory-layout {
grid-template-columns: 1fr;
}
.memory-sidebar {
max-height: 280px;
}
}
</style>