mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
refactor(memory-ui): extract shared icon, empty-state, skeleton and section components (#148)
This commit is contained in:
parent
7a650dc667
commit
8a0c0a76a8
@ -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>
|
||||
|
||||
@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
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>
|
||||
|
||||
34
mateclaw-ui/src/views/Memory/components/MemoryEmptyState.vue
Normal file
34
mateclaw-ui/src/views/Memory/components/MemoryEmptyState.vue
Normal 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>
|
||||
28
mateclaw-ui/src/views/Memory/components/MemoryIcon.vue
Normal file
28
mateclaw-ui/src/views/Memory/components/MemoryIcon.vue
Normal 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>
|
||||
155
mateclaw-ui/src/views/Memory/components/MemorySection.vue
Normal file
155
mateclaw-ui/src/views/Memory/components/MemorySection.vue
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
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>
|
||||
27
mateclaw-ui/src/views/Memory/components/MemorySkeleton.vue
Normal file
27
mateclaw-ui/src/views/Memory/components/MemorySkeleton.vue
Normal 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>
|
||||
@ -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">×</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);
|
||||
|
||||
29
mateclaw-ui/src/views/Memory/components/icons.ts
Normal file
29
mateclaw-ui/src/views/Memory/components/icons.ts
Normal 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
|
||||
10
mateclaw-ui/src/views/Memory/components/types.ts
Normal file
10
mateclaw-ui/src/views/Memory/components/types.ts
Normal 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
|
||||
}
|
||||
@ -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">×</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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user