mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +08:00
feat(memory): enable Memory Browser tab + audit fixes
This commit is contained in:
parent
0351cc369e
commit
96b89414f7
@ -1796,6 +1796,11 @@ export default {
|
|||||||
candidates: 'candidates',
|
candidates: 'candidates',
|
||||||
loadMore: 'Load more',
|
loadMore: 'Load more',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
|
memoryBrowser: {
|
||||||
|
header: 'Overview',
|
||||||
|
empty: 'No memory content yet',
|
||||||
|
userEdited: 'User edited',
|
||||||
|
},
|
||||||
time: {
|
time: {
|
||||||
justNow: 'just now',
|
justNow: 'just now',
|
||||||
hoursAgo: '{n}h ago',
|
hoursAgo: '{n}h ago',
|
||||||
|
|||||||
@ -1806,6 +1806,11 @@ export default {
|
|||||||
candidates: '条候选',
|
candidates: '条候选',
|
||||||
loadMore: '加载更多',
|
loadMore: '加载更多',
|
||||||
retry: '重试',
|
retry: '重试',
|
||||||
|
memoryBrowser: {
|
||||||
|
header: '概述',
|
||||||
|
empty: '暂无记忆内容',
|
||||||
|
userEdited: '用户编辑',
|
||||||
|
},
|
||||||
time: {
|
time: {
|
||||||
justNow: '刚刚',
|
justNow: '刚刚',
|
||||||
hoursAgo: '{n}小时前',
|
hoursAgo: '{n}小时前',
|
||||||
|
|||||||
267
mateclaw-ui/src/views/Memory/components/MemoryBrowser.vue
Normal file
267
mateclaw-ui/src/views/Memory/components/MemoryBrowser.vue
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
<template>
|
||||||
|
<div class="memory-browser">
|
||||||
|
<!-- File selector -->
|
||||||
|
<div class="file-nav">
|
||||||
|
<button v-for="f in files" :key="f.filename"
|
||||||
|
class="file-nav-btn" :class="{ active: currentFile === f.filename }"
|
||||||
|
@click="loadFile(f.filename)">
|
||||||
|
<span class="file-nav-icon">{{ fileIcon(f.filename) }}</span>
|
||||||
|
<span class="file-nav-name">{{ f.filename }}</span>
|
||||||
|
<span class="file-nav-size">{{ formatSize(f.fileSize) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="browser-loading">
|
||||||
|
<div class="skeleton-card" v-for="i in 3" :key="i"><div class="skeleton-line" /><div class="skeleton-line short" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sections -->
|
||||||
|
<div v-else-if="sections.length > 0" class="section-list">
|
||||||
|
<div v-for="(sec, idx) in sections" :key="idx" class="section-card">
|
||||||
|
<div class="section-header">
|
||||||
|
<h4 class="section-title">{{ sec.heading }}</h4>
|
||||||
|
<div class="section-actions">
|
||||||
|
<button v-if="editingIdx !== idx" class="section-btn" @click="startEdit(idx)">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View mode -->
|
||||||
|
<div v-if="editingIdx !== idx" class="section-body" v-html="renderMd(sec.body)" />
|
||||||
|
|
||||||
|
<!-- Edit mode -->
|
||||||
|
<div v-else class="section-edit">
|
||||||
|
<textarea v-model="editText" class="section-textarea" rows="6" />
|
||||||
|
<div class="edit-bar">
|
||||||
|
<button class="edit-btn cancel" @click="editingIdx = -1">{{ t('memory.hil.cancel') }}</button>
|
||||||
|
<button class="edit-btn save" :disabled="saving" @click="saveSection(idx)">
|
||||||
|
{{ saving ? '...' : t('memory.hil.save') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User-edited badge -->
|
||||||
|
<div v-if="sec.userEdited" class="edited-badge">
|
||||||
|
{{ t('memory.memoryBrowser.userEdited') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<div v-else class="browser-empty">
|
||||||
|
<div class="empty-icon">📝</div>
|
||||||
|
<p>{{ t('memory.memoryBrowser.empty') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { http } from '@/api'
|
||||||
|
|
||||||
|
const props = defineProps<{ agentId: number }>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
interface FileInfo { filename: string; fileSize: number; enabled: boolean }
|
||||||
|
interface Section { heading: string; body: string; userEdited: boolean; raw: string }
|
||||||
|
|
||||||
|
const files = ref<FileInfo[]>([])
|
||||||
|
const currentFile = ref('')
|
||||||
|
const sections = ref<Section[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const editingIdx = ref(-1)
|
||||||
|
const editText = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.agentId, () => { loadFileList() }, { immediate: true })
|
||||||
|
|
||||||
|
async function loadFileList() {
|
||||||
|
try {
|
||||||
|
const res = await http.get(`/agents/${props.agentId}/workspace/files`)
|
||||||
|
const allFiles: FileInfo[] = res.data || []
|
||||||
|
// Show memory-relevant files: MEMORY.md, PROFILE.md, SOUL.md, structured/*.md
|
||||||
|
files.value = allFiles.filter((f: FileInfo) =>
|
||||||
|
['MEMORY.md', 'PROFILE.md', 'SOUL.md'].includes(f.filename) ||
|
||||||
|
f.filename.startsWith('structured/')
|
||||||
|
)
|
||||||
|
// Auto-load MEMORY.md
|
||||||
|
if (files.value.some(f => f.filename === 'MEMORY.md')) {
|
||||||
|
loadFile('MEMORY.md')
|
||||||
|
} else if (files.value.length > 0) {
|
||||||
|
loadFile(files.value[0].filename)
|
||||||
|
}
|
||||||
|
} catch { files.value = [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFile(filename: string) {
|
||||||
|
currentFile.value = filename
|
||||||
|
loading.value = true
|
||||||
|
editingIdx.value = -1
|
||||||
|
try {
|
||||||
|
const res = await http.get(`/agents/${props.agentId}/workspace/files/${encodeURIComponent(filename)}`)
|
||||||
|
const content: string = res.data?.content || ''
|
||||||
|
sections.value = parseSections(content)
|
||||||
|
} catch { sections.value = [] }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSections(content: string): Section[] {
|
||||||
|
if (!content.trim()) return []
|
||||||
|
const parts = content.split(/(?=^## )/m)
|
||||||
|
const result: Section[] = []
|
||||||
|
for (const part of parts) {
|
||||||
|
const match = part.match(/^## (.+)\n([\s\S]*)/)
|
||||||
|
if (match) {
|
||||||
|
const heading = match[1].trim()
|
||||||
|
const body = match[2].trim()
|
||||||
|
const userEdited = body.includes('<!-- user-edited')
|
||||||
|
result.push({ heading, body, userEdited, raw: part })
|
||||||
|
} else if (part.trim() && result.length === 0) {
|
||||||
|
// Content before first ## heading
|
||||||
|
result.push({ heading: t('memory.memoryBrowser.header'), body: part.trim(), userEdited: false, raw: part })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(idx: number) {
|
||||||
|
editingIdx.value = idx
|
||||||
|
editText.value = sections.value[idx].body
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSection(idx: number) {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
// Use HiL edit endpoint to write back with user-edited metadata
|
||||||
|
const heading = sections.value[idx].heading
|
||||||
|
await http.post(
|
||||||
|
`/memory/${props.agentId}/dream/reports/0/entries/${encodeURIComponent(heading)}/edit`,
|
||||||
|
{ content: editText.value }
|
||||||
|
)
|
||||||
|
ElMessage.success(t('memory.hil.saved'))
|
||||||
|
editingIdx.value = -1
|
||||||
|
// Reload file to see changes
|
||||||
|
await loadFile(currentFile.value)
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e.message || 'Save failed')
|
||||||
|
} finally { saving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMd(body: string): string {
|
||||||
|
// Simple markdown rendering (lists, bold, line breaks)
|
||||||
|
return body
|
||||||
|
.split('\n')
|
||||||
|
.map(line => {
|
||||||
|
let html = line
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||||
|
if (html.startsWith('- ')) return `<li>${html.slice(2)}</li>`
|
||||||
|
if (html.trim() === '') return ''
|
||||||
|
return `<p>${html}</p>`
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIcon(filename: string): string {
|
||||||
|
if (filename === 'MEMORY.md') return '🧠'
|
||||||
|
if (filename === 'PROFILE.md') return '👤'
|
||||||
|
if (filename === 'SOUL.md') return '💫'
|
||||||
|
if (filename.startsWith('structured/')) return '📋'
|
||||||
|
return '📄'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (!bytes) return '0B'
|
||||||
|
if (bytes < 1024) return bytes + 'B'
|
||||||
|
return (bytes / 1024).toFixed(1) + 'KB'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* File nav */
|
||||||
|
.file-nav {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.file-nav-btn {
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
padding: 5px 10px; border: 1px solid var(--mc-border); border-radius: 8px;
|
||||||
|
background: transparent; font-size: 12px; color: var(--mc-text-secondary);
|
||||||
|
cursor: pointer; transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.file-nav-btn:hover { border-color: var(--mc-primary); color: var(--mc-text-primary); }
|
||||||
|
.file-nav-btn.active { background: var(--mc-primary-bg); border-color: var(--mc-primary); color: var(--mc-text-primary); }
|
||||||
|
.file-nav-icon { font-size: 14px; }
|
||||||
|
.file-nav-name { font-weight: 500; }
|
||||||
|
.file-nav-size { color: var(--mc-text-tertiary); font-size: 10px; }
|
||||||
|
|
||||||
|
/* Section cards */
|
||||||
|
.section-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.section-card {
|
||||||
|
padding: 14px 16px; border-radius: 12px; background: var(--mc-bg-sunken);
|
||||||
|
border: 1px solid transparent; position: relative;
|
||||||
|
}
|
||||||
|
.section-card:hover { border-color: var(--mc-border-light); }
|
||||||
|
.section-header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.section-title { margin: 0; font-size: 13px; font-weight: 600; color: var(--mc-text-primary); }
|
||||||
|
.section-actions { opacity: 0; transition: opacity 0.15s; }
|
||||||
|
.section-card:hover .section-actions { opacity: 1; }
|
||||||
|
.section-btn {
|
||||||
|
width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
|
||||||
|
border: none; border-radius: 6px; background: transparent; color: var(--mc-text-tertiary);
|
||||||
|
cursor: pointer; transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.section-btn:hover { background: var(--mc-bg-elevated); color: var(--mc-primary); }
|
||||||
|
|
||||||
|
.section-body {
|
||||||
|
margin-top: 8px; font-size: 13px; color: var(--mc-text-secondary); line-height: 1.6;
|
||||||
|
}
|
||||||
|
.section-body :deep(li) { margin-left: 16px; margin-bottom: 2px; }
|
||||||
|
.section-body :deep(code) {
|
||||||
|
padding: 1px 4px; border-radius: 3px; background: var(--mc-bg-elevated);
|
||||||
|
font-size: 12px; font-family: 'SF Mono', Menlo, monospace;
|
||||||
|
}
|
||||||
|
.section-body :deep(strong) { color: var(--mc-text-primary); }
|
||||||
|
.section-body :deep(p) { margin: 2px 0; }
|
||||||
|
|
||||||
|
/* Edit mode */
|
||||||
|
.section-edit { margin-top: 8px; }
|
||||||
|
.section-textarea {
|
||||||
|
width: 100%; padding: 10px 12px; border: 1px solid var(--mc-border); border-radius: 8px;
|
||||||
|
background: var(--mc-bg-elevated); font-size: 13px; color: var(--mc-text-primary);
|
||||||
|
font-family: 'SF Mono', Menlo, monospace; resize: vertical; outline: none;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.section-textarea:focus { border-color: var(--mc-primary); }
|
||||||
|
.edit-bar { display: flex; justify-content: flex-end; gap: 6px; margin-top: 8px; }
|
||||||
|
.edit-btn {
|
||||||
|
padding: 5px 14px; border-radius: 8px; font-size: 12px; font-weight: 500;
|
||||||
|
cursor: pointer; transition: all 0.12s; border: 1px solid var(--mc-border);
|
||||||
|
}
|
||||||
|
.edit-btn.cancel { background: transparent; color: var(--mc-text-secondary); }
|
||||||
|
.edit-btn.cancel:hover { border-color: var(--mc-text-tertiary); }
|
||||||
|
.edit-btn.save { background: var(--mc-primary); color: #fff; border-color: var(--mc-primary); }
|
||||||
|
.edit-btn.save:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* User-edited badge */
|
||||||
|
.edited-badge {
|
||||||
|
position: absolute; top: 8px; right: 10px;
|
||||||
|
font-size: 10px; font-weight: 500; color: var(--mc-primary);
|
||||||
|
background: var(--mc-primary-bg); padding: 2px 6px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* States */
|
||||||
|
.browser-loading { padding: 8px 0; }
|
||||||
|
.skeleton-card { padding: 14px; margin-bottom: 8px; }
|
||||||
|
.skeleton-line { height: 10px; border-radius: 4px; background: var(--mc-border-light); margin-bottom: 8px; }
|
||||||
|
.skeleton-line.short { width: 60%; }
|
||||||
|
.browser-empty { display: flex; flex-direction: column; align-items: center; padding: 40px 0; color: var(--mc-text-tertiary); }
|
||||||
|
.empty-icon { font-size: 28px; margin-bottom: 8px; }
|
||||||
|
</style>
|
||||||
@ -101,8 +101,13 @@
|
|||||||
|
|
||||||
<!-- Right: Content panel -->
|
<!-- Right: Content panel -->
|
||||||
<div class="memory-detail">
|
<div class="memory-detail">
|
||||||
|
<!-- Memory tab -->
|
||||||
|
<template v-if="activeTab === 'memory' && selectedAgentId">
|
||||||
|
<MemoryBrowser :agent-id="selectedAgentId" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Facts tab -->
|
<!-- Facts tab -->
|
||||||
<template v-if="activeTab === 'facts' && selectedAgentId">
|
<template v-else-if="activeTab === 'facts' && selectedAgentId">
|
||||||
<FactList :agent-id="selectedAgentId" />
|
<FactList :agent-id="selectedAgentId" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -181,6 +186,7 @@ import { useAgentStore } from '@/stores/useAgentStore'
|
|||||||
import { useMemoryStore, type DreamReportItem } from '@/stores/useMemoryStore'
|
import { useMemoryStore, type DreamReportItem } from '@/stores/useMemoryStore'
|
||||||
import MorningCard from './components/MorningCard.vue'
|
import MorningCard from './components/MorningCard.vue'
|
||||||
import FactList from './components/FactList.vue'
|
import FactList from './components/FactList.vue'
|
||||||
|
import MemoryBrowser from './components/MemoryBrowser.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const agentStore = useAgentStore()
|
const agentStore = useAgentStore()
|
||||||
@ -201,7 +207,7 @@ const dreamRunning = ref(false)
|
|||||||
|
|
||||||
const tabs = computed(() => [
|
const tabs = computed(() => [
|
||||||
{ key: 'timeline', label: t('memory.tabTimeline'), disabled: false },
|
{ key: 'timeline', label: t('memory.tabTimeline'), disabled: false },
|
||||||
{ key: 'memory', label: t('memory.tabMemory'), disabled: true },
|
{ key: 'memory', label: t('memory.tabMemory'), disabled: false },
|
||||||
{ key: 'facts', label: t('memory.tabFacts'), disabled: false },
|
{ key: 'facts', label: t('memory.tabFacts'), disabled: false },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user