Replace the three separate memory editors with a card layout that shows the source file, a plain-text summary, the last modified time, and the character count for each section, so the stored files are visible without opening them. Rename the sidebar "用户" entry to "Agent" in both locales and add the memory.editTitle, memory.writeTo, and memory.charCount strings.
439 lines
12 KiB
Vue
439 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, computed } from 'vue'
|
||
import { useMessage } from '@/composables/useMessage'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { fetchMemory, saveMemory, type MemoryData } from '@/api/hermes/skills'
|
||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||
|
||
type MemorySection = 'memory' | 'user' | 'soul'
|
||
|
||
const { t } = useI18n()
|
||
const message = useMessage()
|
||
const profilesStore = useProfilesStore()
|
||
const loading = ref(false)
|
||
const data = ref<MemoryData | null>(null)
|
||
const editingSection = ref<MemorySection | null>(null)
|
||
const editContent = ref('')
|
||
const saving = ref(false)
|
||
|
||
// 三段记忆固定对应三个文件,落盘路径在服务端决定,前端只做展示。
|
||
const SECTION_FILES: Record<MemorySection, string> = {
|
||
memory: 'memories/MEMORY.md',
|
||
user: 'memories/USER.md',
|
||
soul: 'SOUL.md',
|
||
}
|
||
|
||
const SECTIONS: { key: MemorySection; labelKey: string }[] = [
|
||
{ key: 'memory', labelKey: 'memory.myNotes' },
|
||
{ key: 'user', labelKey: 'memory.userProfile' },
|
||
{ key: 'soul', labelKey: 'memory.soul' },
|
||
]
|
||
|
||
// 各段的修改时间在接口里是平铺字段,key 不可由 section 直接拼出(soul_mtime 等)。
|
||
const MTIME_KEY: Record<MemorySection, 'memory_mtime' | 'user_mtime' | 'soul_mtime'> = {
|
||
memory: 'memory_mtime',
|
||
user: 'user_mtime',
|
||
soul: 'soul_mtime',
|
||
}
|
||
|
||
onMounted(loadMemory)
|
||
|
||
async function loadMemory() {
|
||
loading.value = true
|
||
try {
|
||
if (!profilesStore.activeProfileName || profilesStore.profiles.length === 0) {
|
||
await profilesStore.fetchProfiles()
|
||
}
|
||
data.value = await fetchMemory()
|
||
} catch (err: any) {
|
||
console.error('Failed to load memory:', err)
|
||
message.error(t('memory.loadFailed'))
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function startEdit(section: MemorySection) {
|
||
editingSection.value = section
|
||
editContent.value = data.value?.[section] || ''
|
||
}
|
||
|
||
function cancelEdit() {
|
||
editingSection.value = null
|
||
editContent.value = ''
|
||
}
|
||
|
||
async function handleSave() {
|
||
if (!editingSection.value) return
|
||
saving.value = true
|
||
try {
|
||
await saveMemory(editingSection.value, editContent.value)
|
||
await loadMemory()
|
||
editingSection.value = null
|
||
editContent.value = ''
|
||
message.success(t('common.saved'))
|
||
} catch (err: any) {
|
||
message.error(`${t('common.saveFailed')}: ${err.message}`)
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
function formatTime(ts: number | null): string {
|
||
if (!ts) return '—'
|
||
return new Date(ts).toLocaleString([], {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
// 存储态用 § 分隔段落;展示与摘要都按纯文本处理。
|
||
function plainText(raw: string | undefined): string {
|
||
return (raw || '').replace(/§/g, ' ').replace(/\s+/g, ' ').trim()
|
||
}
|
||
|
||
interface MemoryCard {
|
||
key: MemorySection
|
||
title: string
|
||
file: string
|
||
summary: string
|
||
mtime: number | null
|
||
chars: number
|
||
empty: boolean
|
||
// 进度条按 28 字符折算 1%,与原型一致。
|
||
percent: number
|
||
}
|
||
|
||
const cards = computed<MemoryCard[]>(() =>
|
||
SECTIONS.map(({ key, labelKey }) => {
|
||
const raw = data.value?.[key] || ''
|
||
const plain = plainText(raw)
|
||
const chars = raw.length
|
||
return {
|
||
key,
|
||
title: t(labelKey),
|
||
file: SECTION_FILES[key],
|
||
summary: plain.length > 120 ? `${plain.slice(0, 120)}…` : plain,
|
||
mtime: data.value?.[MTIME_KEY[key]] ?? null,
|
||
chars,
|
||
empty: !raw.trim(),
|
||
percent: Math.min(100, Math.max(3, Math.round(chars / 28))),
|
||
}
|
||
}),
|
||
)
|
||
|
||
const SECTION_LABEL_KEY: Record<MemorySection, string> = {
|
||
memory: 'memory.myNotes',
|
||
user: 'memory.userProfile',
|
||
soul: 'memory.soul',
|
||
}
|
||
|
||
const SECTION_PLACEHOLDER_KEY: Record<MemorySection, string> = {
|
||
memory: 'memory.notesPlaceholder',
|
||
user: 'memory.profilePlaceholder',
|
||
soul: 'memory.soulPlaceholder',
|
||
}
|
||
|
||
const SECTION_EMPTY_KEY: Record<MemorySection, string> = {
|
||
memory: 'memory.noNotes',
|
||
user: 'memory.noProfile',
|
||
soul: 'memory.noSoul',
|
||
}
|
||
|
||
const editingTitle = computed(() =>
|
||
editingSection.value
|
||
? t('memory.editTitle', { name: t(SECTION_LABEL_KEY[editingSection.value]) })
|
||
: '',
|
||
)
|
||
|
||
const editingFile = computed(() => (editingSection.value ? SECTION_FILES[editingSection.value] : ''))
|
||
|
||
const editingPlaceholder = computed(() =>
|
||
editingSection.value ? t(SECTION_PLACEHOLDER_KEY[editingSection.value]) : '',
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="memory-view">
|
||
<header class="page-header">
|
||
<h2 class="header-title">{{ t('memory.title') }}</h2>
|
||
<div class="header-actions">
|
||
<a-button size="small" type="text" :loading="loading" @click="loadMemory">
|
||
<template #icon>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="23 4 23 10 17 10" />
|
||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||
</svg>
|
||
</template>
|
||
{{ t('memory.refresh') }}
|
||
</a-button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="memory-content cy-view">
|
||
<div v-if="loading && !data" class="memory-loading">{{ t('common.loading') }}</div>
|
||
|
||
<div v-else class="memcards">
|
||
<article v-for="card in cards" :key="card.key" class="memcard cy-panel">
|
||
<header class="memcard-h cy-panel-h">
|
||
<span class="memcard-t">
|
||
<span class="mi">
|
||
<svg v-if="card.key === 'memory'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<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>
|
||
<svg v-else-if="card.key === 'user'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||
<circle cx="12" cy="7" r="4" />
|
||
</svg>
|
||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" />
|
||
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
||
<line x1="9" y1="9" x2="9.01" y2="9" />
|
||
<line x1="15" y1="9" x2="15.01" y2="9" />
|
||
</svg>
|
||
</span>
|
||
<h3>{{ card.title }}</h3>
|
||
</span>
|
||
<div class="cy-panel-actions">
|
||
<a-button size="mini" type="text" @click="startEdit(card.key)">
|
||
<template #icon>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<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>
|
||
</template>
|
||
{{ t('common.edit') }}
|
||
</a-button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="memcard-b">
|
||
<p v-if="!card.empty" class="memcard-sum">{{ card.summary }}</p>
|
||
<p v-else class="memcard-sum empty-text">{{ t(SECTION_EMPTY_KEY[card.key]) }}</p>
|
||
<div class="memcard-meta">
|
||
<span class="memcard-file">{{ card.file }}</span>
|
||
<span class="grow"></span>
|
||
<span class="memcard-time">{{ formatTime(card.mtime) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="memcard-bar">
|
||
<span class="cy-meter"><i :style="{ width: `${card.percent}%` }"></i></span>
|
||
<span class="memcard-chars">{{ t('memory.charCount', { count: card.chars }) }}</span>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</div>
|
||
|
||
<a-modal
|
||
:visible="editingSection !== null"
|
||
:title="editingTitle"
|
||
width="min(1080px, calc(100vw - 32px))"
|
||
:mask-closable="!saving"
|
||
:closable="!saving"
|
||
:esc-to-close="!saving"
|
||
@cancel="cancelEdit"
|
||
>
|
||
<div class="edit-body">
|
||
<div class="cy-kv">
|
||
<div class="cy-kr">
|
||
<span class="cy-kk">{{ t('memory.writeTo') }}</span>
|
||
<span class="cy-kv-v edit-file">{{ editingFile }}</span>
|
||
</div>
|
||
</div>
|
||
<textarea
|
||
v-model="editContent"
|
||
class="edit-textarea"
|
||
:placeholder="editingPlaceholder"
|
||
spellcheck="false"
|
||
></textarea>
|
||
</div>
|
||
<template #footer>
|
||
<a-button size="small" :disabled="saving" @click="cancelEdit">{{ t('common.cancel') }}</a-button>
|
||
<a-button size="small" type="primary" :loading="saving" @click="handleSave">{{ t('common.save') }}</a-button>
|
||
</template>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
@use '@/styles/variables' as *;
|
||
|
||
.memory-loading {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 13px;
|
||
color: $text-muted;
|
||
}
|
||
|
||
// 三张卡片横排,宽度不足时自动折行 —— 对应原型 .memcards。
|
||
// 最小列宽比原型的 280px 更紧:侧栏占了 240px,
|
||
// 280px 的门槛会让中等宽度下折成 2+1,原型是三连排。
|
||
.memcards {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||
gap: 14px;
|
||
margin-top: 4px;
|
||
|
||
@media (max-width: $breakpoint-mobile) {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.memcard {
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
transition: border-color $transition-fast, transform $transition-fast;
|
||
|
||
// 三卡并排,不吃 .cy-panel 的纵向堆叠间距。
|
||
& + .memcard {
|
||
margin-top: 0;
|
||
}
|
||
|
||
&:hover {
|
||
border-color: rgba(var(--accent-primary-rgb), 0.38);
|
||
transform: translateY(-1px);
|
||
}
|
||
}
|
||
|
||
.memcard-h {
|
||
flex-shrink: 0;
|
||
background: $bg-secondary;
|
||
}
|
||
|
||
.memcard-t {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
|
||
h3 {
|
||
margin: 0;
|
||
font-family: $font-code;
|
||
font-size: 13.5px;
|
||
font-weight: 600;
|
||
line-height: 1.3;
|
||
color: $text-primary;
|
||
}
|
||
|
||
.mi {
|
||
display: flex;
|
||
color: $accent-primary;
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
|
||
.memcard-b {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 11px;
|
||
padding: 13px 14px;
|
||
}
|
||
|
||
.memcard-sum {
|
||
margin: 0;
|
||
font-size: 12.5px;
|
||
line-height: 1.72;
|
||
color: $text-secondary;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 5;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.empty-text {
|
||
color: $text-muted;
|
||
font-style: italic;
|
||
}
|
||
|
||
.memcard-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: auto;
|
||
padding-top: 10px;
|
||
border-top: 1px dashed $border-light;
|
||
|
||
.grow {
|
||
flex: 1;
|
||
}
|
||
}
|
||
|
||
.memcard-file {
|
||
font-family: $font-code;
|
||
font-size: 11px;
|
||
color: $text-faint;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.memcard-time {
|
||
font-size: 11px;
|
||
color: $text-faint;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.memcard-bar {
|
||
flex: 0 0 auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 9px 14px;
|
||
border-top: 1px solid $border-light;
|
||
background: $bg-secondary;
|
||
|
||
.cy-meter {
|
||
flex: 1;
|
||
}
|
||
}
|
||
|
||
.memcard-chars {
|
||
font-size: 11px;
|
||
color: $text-muted;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.edit-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.edit-file {
|
||
font-family: $font-code;
|
||
font-size: 12.5px;
|
||
color: $text-primary;
|
||
}
|
||
|
||
.edit-textarea {
|
||
width: 100%;
|
||
// 弹窗比原型更宽更高,编辑区跟着放大。
|
||
min-height: min(60vh, 560px);
|
||
resize: vertical;
|
||
outline: none;
|
||
padding: 12px 13px;
|
||
border: 1px solid $border-color;
|
||
border-radius: $radius-md;
|
||
background: $bg-input;
|
||
color: $text-primary;
|
||
font-family: $font-code;
|
||
font-size: 12.5px;
|
||
line-height: 1.72;
|
||
|
||
&:focus {
|
||
border-color: $accent-primary;
|
||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||
}
|
||
}
|
||
</style>
|