refactor(memory-ui): extract shared icon, empty-state, skeleton and section components (#148)

This commit is contained in:
matevip 2026-05-18 16:26:19 +08:00
parent 7a650dc667
commit 8a0c0a76a8
10 changed files with 367 additions and 293 deletions

View File

@ -14,9 +14,7 @@
<div v-if="contradictions.length > 0" class="contradiction-bar" @click="showContradictions = !showContradictions">
<span class="contradiction-dot" />
<span>{{ t('memory.facts.contradictions', { count: contradictions.length }) }}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline :points="showContradictions ? '18 15 12 9 6 15' : '6 9 12 15 18 9'" />
</svg>
<MemoryIcon :name="showContradictions ? 'chevron-up' : 'chevron-down'" :size="12" />
</div>
<!-- Contradiction inbox -->
@ -33,19 +31,10 @@
</Transition>
<!-- Loading -->
<div v-if="loading" class="fact-loading">
<div class="skeleton-card" v-for="i in 3" :key="i"><div class="skeleton-line" /><div class="skeleton-line short" /></div>
</div>
<MemorySkeleton v-if="loading" />
<!-- Empty -->
<div v-else-if="facts.length === 0" class="fact-empty">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="21 8 21 21 3 21 3 8"/>
<rect x="1" y="3" width="22" height="5"/>
<line x1="10" y1="12" x2="14" y2="12"/>
</svg>
<p>{{ t('memory.facts.empty') }}</p>
</div>
<MemoryEmptyState v-else-if="facts.length === 0" icon="archive" :text="t('memory.facts.empty')" />
<!-- Fact cards -->
<div v-else class="fact-cards">
@ -63,20 +52,13 @@
<span class="fact-use-count" v-if="fact.useCount > 0">{{ t('memory.facts.used', { n: fact.useCount }) }}</span>
<div class="fact-actions">
<button class="action-btn helpful" @click="feedback(fact.id, 'HELPFUL')" :title="t('memory.facts.helpful')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/>
</svg>
<MemoryIcon name="thumbs-up" :size="14" />
</button>
<button class="action-btn unhelpful" @click="feedback(fact.id, 'UNHELPFUL')" :title="t('memory.facts.unhelpful')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/>
</svg>
<MemoryIcon name="thumbs-down" :size="14" />
</button>
<button class="action-btn forget" @click="forget(fact.id)" :title="t('memory.facts.forget')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
<MemoryIcon name="trash" :size="14" />
</button>
</div>
</div>
@ -86,11 +68,14 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { http } from '@/api'
import FactTrustBar from './FactTrustBar.vue'
import MemoryIcon from './MemoryIcon.vue'
import MemorySkeleton from './MemorySkeleton.vue'
import MemoryEmptyState from './MemoryEmptyState.vue'
const props = defineProps<{ agentId: string | number }>()
const { t } = useI18n()
@ -126,7 +111,7 @@ function search() { loadFacts() }
async function feedback(factId: number, kind: string) {
try {
await http.post(`/memory/${props.agentId}/facts/${factId}/feedback`, { kind })
mcToast.success(kind === 'HELPFUL' ? '👍' : '👎')
mcToast.success(t('memory.facts.' + (kind === 'HELPFUL' ? 'helpful' : 'unhelpful')))
loadFacts()
} catch (e: any) { mcToast.error(e.message || 'Failed') }
}
@ -212,14 +197,6 @@ async function resolve(contradictionId: number, resolution: string) {
.action-btn.unhelpful:hover { color: #ff9f0a; }
.action-btn.forget:hover { background: rgba(255,59,48,0.1); color: #ff3b30; }
/* States */
.fact-loading { padding: 8px 0; }
.skeleton-card { padding: 12px; margin-bottom: 6px; }
.skeleton-line { height: 10px; border-radius: 4px; background: var(--mc-border-light); margin-bottom: 8px; }
.skeleton-line.short { width: 60%; }
.fact-empty { display: flex; flex-direction: column; align-items: center; padding: 40px 0; color: var(--mc-text-tertiary); }
.empty-icon { width: 32px; height: 32px; margin-bottom: 10px; opacity: 0.7; }
.slide-down-enter-active, .slide-down-leave-active { transition: all 0.2s ease; }
.slide-down-enter-from, .slide-down-leave-to { opacity: 0; transform: translateY(-8px); }
</style>

View File

@ -6,62 +6,26 @@
class="file-nav-btn" :class="{ active: currentFile === f.filename }"
:title="f.filename"
@click="loadFile(f.filename)">
<span class="file-nav-icon" v-html="fileIcon(f.filename)" />
<MemoryIcon :name="fileIconName(f.filename)" :size="14" />
<span class="file-nav-name">{{ fileLabel(f.filename) }}</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>
<MemorySkeleton v-if="loading" />
<!-- 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>
<MemorySection
v-for="(sec, idx) in sections"
:key="`${props.agentId}-${currentFile}-${idx}`"
:section="sec"
:save-handler="(body) => saveSection(sec, body)"
/>
</div>
<!-- Empty -->
<div v-else class="browser-empty">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<p>{{ t('memory.memoryBrowser.empty') }}</p>
</div>
<MemoryEmptyState v-else icon="file-text" :text="t('memory.memoryBrowser.empty')" />
</div>
</template>
@ -70,22 +34,22 @@ import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { http, agentContextApi } from '@/api'
import MemoryIcon from './MemoryIcon.vue'
import MemorySkeleton from './MemorySkeleton.vue'
import MemoryEmptyState from './MemoryEmptyState.vue'
import MemorySection from './MemorySection.vue'
import type { MemoryIconName } from './icons'
import type { MemorySectionData } from './types'
const props = defineProps<{ agentId: string | number }>()
const { t } = useI18n()
interface FileInfo { filename: string; fileSize: number; enabled: boolean }
// `synthetic` marks the pseudo-section built from content before the first
// `## ` heading it has no heading key the HiL endpoint can address.
interface Section { heading: string; body: string; userEdited: boolean; raw: string; synthetic?: boolean }
const files = ref<FileInfo[]>([])
const currentFile = ref('')
const sections = ref<Section[]>([])
const sections = ref<MemorySectionData[]>([])
const loading = ref(false)
const editingIdx = ref(-1)
const editText = ref('')
const saving = ref(false)
watch(() => props.agentId, () => { loadFileList() }, { immediate: true })
@ -111,7 +75,6 @@ async function loadFileList() {
async function loadFile(filename: string) {
currentFile.value = filename
loading.value = true
editingIdx.value = -1
try {
const res: any = await agentContextApi.getFile(props.agentId, filename)
const content: string = res.data?.content || ''
@ -120,10 +83,10 @@ async function loadFile(filename: string) {
finally { loading.value = false }
}
function parseSections(content: string): Section[] {
function parseSections(content: string): MemorySectionData[] {
if (!content.trim()) return []
const parts = content.split(/(?=^## )/m)
const result: Section[] = []
const result: MemorySectionData[] = []
for (const part of parts) {
const match = part.match(/^## (.+)\n([\s\S]*)/)
if (match) {
@ -131,11 +94,11 @@ function parseSections(content: string): Section[] {
const rawBody = match[2].trim()
const userEdited = rawBody.includes('<!-- user-edited')
// Strip the hidden marker from the display body it is metadata, and
// since renderMd now escapes HTML it would otherwise show as raw text.
result.push({ heading, body: stripMarker(rawBody), userEdited, raw: part })
// since renderMarkdown escapes HTML it would otherwise show as raw text.
result.push({ heading, body: stripMarker(rawBody), userEdited })
} else if (part.trim() && result.length === 0) {
// Content before the first ## heading a synthetic "preamble" section.
result.push({ heading: t('memory.memoryBrowser.header'), body: part.trim(), userEdited: false, raw: part, synthetic: true })
result.push({ heading: t('memory.memoryBrowser.header'), body: part.trim(), userEdited: false, synthetic: true })
}
}
return result
@ -147,85 +110,44 @@ function stripMarker(body: string): string {
return body.replace(/^[ \t]*<!-- user-edited:.*-->[ \t]*$/gm, '').trim()
}
function startEdit(idx: number) {
editingIdx.value = idx
editText.value = stripMarker(sections.value[idx].body)
/**
* Persist an edited section. Throws on failure so MemorySection can keep the
* editor open and surface the error.
*/
async function saveSection(sec: MemorySectionData, body: string) {
if (sec.synthetic) {
// The preamble (content before the first ## heading) has no section key
// the HiL endpoint can address rewrite it through the workspace file
// API, preserving every real `## ` section that follows.
const res: any = await agentContextApi.getFile(props.agentId, currentFile.value)
const content: string = res.data?.content || ''
const headingIdx = content.search(/^## /m)
const rest = headingIdx >= 0 ? content.slice(headingIdx) : ''
const preamble = body.trim()
const merged = preamble && rest ? `${preamble}\n\n${rest}` : preamble + rest
await agentContextApi.saveFile(props.agentId, currentFile.value, merged)
} else {
// Use HiL edit endpoint to write back with user-edited metadata.
// `filename` tells the backend which memory file to edit without it the
// backend defaults to MEMORY.md and PROFILE.md / SOUL.md edits fail.
await http.post(
`/memory/${props.agentId}/dream/reports/0/entries/${encodeURIComponent(sec.heading)}/edit`,
{ content: body, filename: currentFile.value }
)
}
mcToast.success(t('memory.hil.saved'))
// Reload file to see changes
await loadFile(currentFile.value)
}
async function saveSection(idx: number) {
saving.value = true
try {
const sec = sections.value[idx]
if (sec.synthetic) {
// The preamble (content before the first ## heading) has no section key
// the HiL endpoint can address rewrite it through the workspace file
// API, preserving every real `## ` section that follows.
const res: any = await agentContextApi.getFile(props.agentId, currentFile.value)
const content: string = res.data?.content || ''
const headingIdx = content.search(/^## /m)
const rest = headingIdx >= 0 ? content.slice(headingIdx) : ''
const preamble = editText.value.trim()
const merged = preamble && rest ? `${preamble}\n\n${rest}` : preamble + rest
await agentContextApi.saveFile(props.agentId, currentFile.value, merged)
} else {
// Use HiL edit endpoint to write back with user-edited metadata.
// `filename` tells the backend which memory file to edit without it the
// backend defaults to MEMORY.md and PROFILE.md / SOUL.md edits fail.
await http.post(
`/memory/${props.agentId}/dream/reports/0/entries/${encodeURIComponent(sec.heading)}/edit`,
{ content: editText.value, filename: currentFile.value }
)
}
mcToast.success(t('memory.hil.saved'))
editingIdx.value = -1
// Reload file to see changes
await loadFile(currentFile.value)
} catch (e: any) {
mcToast.error(e.message || 'Save failed')
} finally { saving.value = false }
}
function renderMd(body: string): string {
// Minimal Markdown bold, inline code, italic, lists, blockquotes.
// HTML is escaped first so a section body can never inject markup via v-html.
const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const inline = (s: string) =>
esc(s)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+?)`/g, '<code>$1</code>')
// Italic require a boundary before the marker so snake_case is left alone.
.replace(/(^|[\s(])([*_])(?=\S)([^*_]+?)\2(?=[\s).,;:!?,。;:!?]|$)/g, '$1<em>$3</em>')
return body
.split('\n')
.map(line => {
if (line.trim() === '') return ''
if (line.startsWith('- ')) return `<li>${inline(line.slice(2))}</li>`
if (line.startsWith('> ')) return `<blockquote>${inline(line.slice(2))}</blockquote>`
return `<p>${inline(line)}</p>`
})
.filter(Boolean)
.join('')
}
// Inline line-art icons (Feather style, 14px) keep the file tabs visually
// consistent with the sidebar nav and the rest of the admin UI.
function svgIcon(inner: string): string {
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`
}
function fileIcon(filename: string): string {
if (filename === 'MEMORY.md')
return svgIcon('<path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/><path d="M16 14H8a4 4 0 0 0-4 4v2h16v-2a4 4 0 0 0-4-4z"/><line x1="12" y1="11" x2="12" y2="14"/>')
if (filename === 'PROFILE.md')
return svgIcon('<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>')
if (filename === 'SOUL.md')
return svgIcon('<path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3L12 3z"/>')
if (filename === 'structured/reference.md')
return svgIcon('<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>')
if (filename.startsWith('structured/'))
return svgIcon('<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>')
return svgIcon('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>')
// Registry icon key for each memory file type.
function fileIconName(filename: string): MemoryIconName {
if (filename === 'MEMORY.md') return 'memory'
if (filename === 'PROFILE.md') return 'profile'
if (filename === 'SOUL.md') return 'soul'
if (filename === 'structured/reference.md') return 'link'
if (filename.startsWith('structured/')) return 'list'
return 'file'
}
// Rank for display order: 0 = primary brain, 1 = persona, 2 = extracted facts.
@ -273,74 +195,8 @@ function fileLabel(filename: string): string {
border-color: var(--mc-primary);
color: var(--mc-text-primary);
}
.file-nav-icon { display: inline-flex; align-items: center; }
.file-nav-name { letter-spacing: 0.1px; }
/* Section cards */
/* Section list */
.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(em) { font-style: italic; }
.section-body :deep(p) { margin: 2px 0; }
.section-body :deep(blockquote) {
margin: 4px 0; padding: 1px 0 1px 10px;
border-left: 2px solid var(--mc-border); color: var(--mc-text-tertiary);
}
/* 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 { width: 32px; height: 32px; margin-bottom: 10px; color: var(--mc-text-tertiary); opacity: 0.7; }
</style>

View File

@ -0,0 +1,34 @@
<template>
<div class="mc-empty" :class="{ 'mc-empty--lg': large }">
<MemoryIcon :name="icon" :size="large ? 40 : 30" class="mc-empty__icon" />
<p class="mc-empty__text">{{ text }}</p>
<!-- Optional action (e.g. a retry button). -->
<slot />
</div>
</template>
<script setup lang="ts">
import MemoryIcon from './MemoryIcon.vue'
import type { MemoryIconName } from './icons'
withDefaults(defineProps<{
icon: MemoryIconName
text: string
/** Large centered variant for full-panel empty states. */
large?: boolean
}>(), {
large: false,
})
</script>
<style scoped>
.mc-empty {
display: flex; flex-direction: column; align-items: center;
padding: 40px 0; color: var(--mc-text-tertiary); text-align: center;
}
.mc-empty--lg { justify-content: center; height: 100%; padding: 0; }
.mc-empty__icon { margin-bottom: 10px; opacity: 0.7; }
.mc-empty--lg .mc-empty__icon { margin-bottom: 14px; opacity: 0.6; }
.mc-empty__text { margin: 0; font-size: 13px; }
.mc-empty--lg .mc-empty__text { font-size: 14px; max-width: 260px; line-height: 1.5; }
</style>

View File

@ -0,0 +1,28 @@
<template>
<svg
:width="size" :height="size" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true" v-html="path"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { memoryIcons, type MemoryIconName } from './icons'
const props = withDefaults(defineProps<{
/** Icon key from the memory icon registry. */
name: MemoryIconName
/** Rendered width/height in pixels. */
size?: number | string
}>(), {
size: 16,
})
const path = computed(() => memoryIcons[props.name] ?? '')
</script>
<style scoped>
svg { display: block; flex-shrink: 0; }
</style>

View File

@ -0,0 +1,155 @@
<template>
<div class="section-card">
<div class="section-header">
<h4 class="section-title">{{ section.heading }}</h4>
<div class="section-actions">
<button v-if="!editing" class="section-btn" :title="t('memory.hil.edit')" @click="startEdit">
<MemoryIcon name="edit" :size="12" />
</button>
</div>
</div>
<!-- View mode -->
<div v-if="!editing" class="section-body" v-html="rendered" />
<!-- Edit mode -->
<div v-else class="section-edit">
<textarea v-model="draft" class="section-textarea" rows="6" />
<div class="edit-bar">
<button class="edit-btn cancel" @click="editing = false">{{ t('memory.hil.cancel') }}</button>
<button class="edit-btn save" :disabled="saving" @click="onSave">
{{ saving ? '…' : t('memory.hil.save') }}
</button>
</div>
</div>
<!-- User-edited badge -->
<div v-if="section.userEdited" class="edited-badge">
{{ t('memory.memoryBrowser.userEdited') }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import MemoryIcon from './MemoryIcon.vue'
import type { MemorySectionData } from './types'
const props = defineProps<{
section: MemorySectionData
/** Persists the edited body — resolves on success, rejects on failure. */
saveHandler: (body: string) => Promise<void>
}>()
const { t } = useI18n()
const editing = ref(false)
const draft = ref('')
const saving = ref(false)
const rendered = computed(() => renderMarkdown(props.section.body))
function startEdit() {
// section.body is already marker-free (stripped when the file is parsed).
draft.value = props.section.body
editing.value = true
}
async function onSave() {
saving.value = true
try {
await props.saveHandler(draft.value)
editing.value = false
} catch (e: any) {
mcToast.error(e?.message || 'Save failed')
} finally {
saving.value = false
}
}
/**
* Minimal Markdown bold, inline code, italic, lists, blockquotes. HTML is
* escaped first so a section body can never inject markup through v-html.
*/
function renderMarkdown(body: string): string {
const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const inline = (s: string) =>
esc(s)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+?)`/g, '<code>$1</code>')
// Italic require a boundary before the marker so snake_case is left alone.
.replace(/(^|[\s(])([*_])(?=\S)([^*_]+?)\2(?=[\s).,;:!?,。;:!?]|$)/g, '$1<em>$3</em>')
return body
.split('\n')
.map(line => {
if (line.trim() === '') return ''
if (line.startsWith('- ')) return `<li>${inline(line.slice(2))}</li>`
if (line.startsWith('> ')) return `<blockquote>${inline(line.slice(2))}</blockquote>`
return `<p>${inline(line)}</p>`
})
.filter(Boolean)
.join('')
}
</script>
<style scoped>
.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(em) { font-style: italic; }
.section-body :deep(p) { margin: 2px 0; }
.section-body :deep(blockquote) {
margin: 4px 0; padding: 1px 0 1px 10px;
border-left: 2px solid var(--mc-border); color: var(--mc-text-tertiary);
}
/* 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;
}
</style>

View File

@ -0,0 +1,27 @@
<template>
<div class="mc-skeleton">
<div v-for="i in count" :key="i" class="mc-skeleton__card">
<div class="mc-skeleton__line" />
<div class="mc-skeleton__line mc-skeleton__line--short" />
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
/** Number of placeholder cards to render. */
count?: number
}>(), {
count: 3,
})
</script>
<style scoped>
.mc-skeleton { padding: 8px 0; }
.mc-skeleton__card { padding: 14px; margin-bottom: 8px; }
.mc-skeleton__line {
height: 10px; border-radius: 4px;
background: var(--mc-bg-sunken); margin-bottom: 8px;
}
.mc-skeleton__line--short { width: 60%; }
</style>

View File

@ -2,14 +2,7 @@
<Transition name="slide-down">
<div v-if="card" class="morning-card">
<div class="morning-card__header">
<span class="morning-card__icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 18a5 5 0 0 0-10 0"/><line x1="12" y1="2" x2="12" y2="9"/>
<line x1="4.22" y1="10.22" x2="5.64" y2="11.64"/><line x1="1" y1="18" x2="3" y2="18"/>
<line x1="21" y1="18" x2="23" y2="18"/><line x1="18.36" y1="11.64" x2="19.78" y2="10.22"/>
<line x1="23" y1="22" x2="1" y2="22"/><polyline points="8 6 12 2 16 6"/>
</svg>
</span>
<MemoryIcon name="sunrise" :size="16" class="morning-card__icon" />
<span class="morning-card__title">{{ t('memory.morningCard.title') }}</span>
<button class="morning-card__close" @click="dismiss">&times;</button>
</div>
@ -28,6 +21,7 @@
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { http } from '@/api'
import MemoryIcon from './MemoryIcon.vue'
const props = defineProps<{ agentId: string | number }>()
const { t } = useI18n()
@ -61,7 +55,7 @@ async function dismiss() {
align-items: center;
gap: 6px;
}
.morning-card__icon { display: inline-flex; align-items: center; color: var(--mc-primary); }
.morning-card__icon { color: var(--mc-primary); }
.morning-card__title { flex: 1; font-size: 13px; font-weight: 600; color: var(--mc-text-primary); }
.morning-card__close {
background: none; border: none; font-size: 16px; color: var(--mc-text-tertiary);

View File

@ -0,0 +1,29 @@
/**
* Line-art icon paths for the memory module Feather-style glyphs drawn on a
* 24x24 canvas and stroked with `currentColor`. Consumed by MemoryIcon.vue so
* every memory view shares one icon source instead of inlining raw SVG.
*/
export const memoryIcons = {
// File-type icons (long-term memory tabs)
memory: '<path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/><path d="M16 14H8a4 4 0 0 0-4 4v2h16v-2a4 4 0 0 0-4-4z"/><line x1="12" y1="11" x2="12" y2="14"/>',
profile: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
soul: '<path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3L12 3z"/>',
list: '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
file: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
// Empty-state icons
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>',
alert: '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
moon: '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
archive: '<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>',
// Action icons
edit: '<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"/>',
'thumbs-up': '<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/>',
'thumbs-down': '<path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/>',
trash: '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
sunrise: '<path d="M17 18a5 5 0 0 0-10 0"/><line x1="12" y1="2" x2="12" y2="9"/><line x1="4.22" y1="10.22" x2="5.64" y2="11.64"/><line x1="1" y1="18" x2="3" y2="18"/><line x1="21" y1="18" x2="23" y2="18"/><line x1="18.36" y1="11.64" x2="19.78" y2="10.22"/><line x1="23" y1="22" x2="1" y2="22"/><polyline points="8 6 12 2 16 6"/>',
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
'chevron-up': '<polyline points="18 15 12 9 6 15"/>',
} as const
export type MemoryIconName = keyof typeof memoryIcons

View File

@ -0,0 +1,10 @@
/** Shared types for the memory module components. */
/** One `## ` section of a memory file (or the synthetic pre-heading preamble). */
export interface MemorySectionData {
heading: string
body: string
userEdited: boolean
/** True for content before the first `## ` heading — has no addressable key. */
synthetic?: boolean
}

View File

@ -10,7 +10,7 @@
<h2 class="panel-title">{{ t('memory.title') }}</h2>
</div>
<button class="dream-trigger-btn" @click="startDream" title="Dream">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<MemoryIcon name="moon" :size="16" />
</button>
</div>
@ -39,27 +39,11 @@
<!-- Dream cards -->
<div class="timeline-scroll">
<template v-if="store.loading">
<div class="skeleton-card" v-for="i in 4" :key="i"><div class="skeleton-line" /><div class="skeleton-line short" /></div>
</template>
<template v-else-if="store.error">
<div class="empty-state error-state">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<p>{{ store.error }}</p>
<button class="retry-btn" @click="loadReports">{{ t('memory.retry') }}</button>
</div>
</template>
<template v-else-if="store.reports.length === 0">
<div class="empty-state">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
<p>{{ t('memory.noReports') }}</p>
</div>
</template>
<MemorySkeleton v-if="store.loading" :count="4" />
<MemoryEmptyState v-else-if="store.error" icon="alert" :text="store.error">
<button class="retry-btn" @click="loadReports">{{ t('memory.retry') }}</button>
</MemoryEmptyState>
<MemoryEmptyState v-else-if="store.reports.length === 0" icon="moon" :text="t('memory.noReports')" />
<template v-else>
<div v-for="report in store.reports" :key="report.id"
class="dream-card" :class="{ selected: selectedReportId === report.id }"
@ -101,7 +85,7 @@
<Transition name="slide-down">
<div v-if="dreamInputOpen" class="dream-input-area">
<div class="dream-input-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<MemoryIcon name="moon" :size="16" />
<span>Dream</span>
<button class="close-btn" @click="dreamInputOpen = false">&times;</button>
</div>
@ -149,14 +133,7 @@
</div>
</template>
<template v-else>
<div class="detail-empty">
<svg class="detail-empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/>
<path d="M16 14H8a4 4 0 0 0-4 4v2h16v-2a4 4 0 0 0-4-4z"/>
<line x1="12" y1="11" x2="12" y2="14"/>
</svg>
<p>{{ t('memory.desc') }}</p>
</div>
<MemoryEmptyState icon="memory" :text="t('memory.desc')" large />
</template>
</template><!-- /timeline tab -->
</div>
@ -176,6 +153,9 @@ import AgentPickerDialog from '@/components/common/AgentPickerDialog.vue'
import MorningCard from './components/MorningCard.vue'
import FactList from './components/FactList.vue'
import MemoryBrowser from './components/MemoryBrowser.vue'
import MemoryIcon from './components/MemoryIcon.vue'
import MemorySkeleton from './components/MemorySkeleton.vue'
import MemoryEmptyState from './components/MemoryEmptyState.vue'
const { t } = useI18n()
const agentStore = useAgentStore()
@ -364,15 +344,7 @@ function fmtTime(iso: string) {
}
.load-more-btn:hover { border-color: var(--mc-primary); color: var(--mc-primary); }
/* Skeleton */
.skeleton-card { padding: 14px; margin-bottom: 6px; }
.skeleton-line { height: 10px; border-radius: 4px; background: var(--mc-bg-sunken); margin-bottom: 8px; }
.skeleton-line.short { width: 60%; }
.empty-state { display: flex; flex-direction: column; align-items: center; padding: 40px 0; color: var(--mc-text-tertiary); }
.empty-icon { width: 30px; height: 30px; margin-bottom: 10px; opacity: 0.7; }
.empty-state p { font-size: 13px; }
.error-state p { color: var(--mc-text-secondary); }
/* Timeline retry button (slotted into MemoryEmptyState) */
.retry-btn {
margin-top: 8px; padding: 6px 16px; border: 1px solid var(--mc-border); border-radius: 8px;
background: transparent; font-size: 12px; color: var(--mc-text-secondary); cursor: pointer;
@ -451,14 +423,6 @@ function fmtTime(iso: string) {
}
.detail-section.error .section-body { color: #ff3b30; }
/* Empty detail */
.detail-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100%; color: var(--mc-text-tertiary); text-align: center;
}
.detail-empty-icon { width: 40px; height: 40px; margin-bottom: 14px; opacity: 0.6; }
.detail-empty p { font-size: 14px; max-width: 260px; line-height: 1.5; }
/* ========== Transitions ========== */
.slide-down-enter-active, .slide-down-leave-active { transition: all 0.2s ease; }
.slide-down-enter-from, .slide-down-leave-to { opacity: 0; transform: translateY(-8px); }