mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
refactor(ui): complete Memory view redesign — kill EP, match design system
This commit is contained in:
parent
eece5e96f5
commit
d9d677e762
@ -1784,14 +1784,27 @@ export default {
|
||||
memory: {
|
||||
kicker: 'Memory',
|
||||
title: 'Memory',
|
||||
desc: 'What your Agent thinks about, remembers, and lets go.',
|
||||
desc: 'What your agent thinks about, remembers, and lets go.',
|
||||
selectAgent: 'Select Agent',
|
||||
selectAgentHint: 'Select an agent to view its memory',
|
||||
tabTimeline: 'Dream Timeline',
|
||||
tabMemory: 'Long-term Memory',
|
||||
tabProfile: 'Profile',
|
||||
tabTimeline: 'Timeline',
|
||||
tabMemory: 'Long-term',
|
||||
tabFacts: 'Facts',
|
||||
noReports: 'No dream reports yet',
|
||||
modeFocused: 'Focused',
|
||||
modeNightly: 'Nightly',
|
||||
candidates: 'candidates',
|
||||
loadMore: 'Load more',
|
||||
time: {
|
||||
justNow: 'just now',
|
||||
hoursAgo: '{n}h ago',
|
||||
daysAgo: '{n}d ago',
|
||||
},
|
||||
status: {
|
||||
success: 'Success',
|
||||
failed: 'Failed',
|
||||
skipped: 'Skipped',
|
||||
},
|
||||
report: {
|
||||
mode: 'Mode',
|
||||
topic: 'Topic',
|
||||
@ -1819,16 +1832,11 @@ export default {
|
||||
saved: 'Saved to MEMORY.md',
|
||||
},
|
||||
focused: {
|
||||
btn: 'Think Now',
|
||||
title: 'Focused Dream',
|
||||
desc: 'Let the Agent re-organize memories around a specific topic. It will prioritize consolidating information related to your theme.',
|
||||
placeholder: 'Enter a topic, e.g. "architecture decisions", "user preferences"...',
|
||||
desc: 'Re-organize memories around a specific topic, prioritizing related information.',
|
||||
placeholder: 'Enter a topic, e.g. "architecture decisions"…',
|
||||
trigger: 'Start',
|
||||
success: 'Dream completed, timeline updated',
|
||||
skipped: 'Nothing to consolidate',
|
||||
},
|
||||
diff: {
|
||||
title: 'Change Summary',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
@ -1792,16 +1792,29 @@ export default {
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
kicker: 'Memory',
|
||||
kicker: '记忆',
|
||||
title: '记忆',
|
||||
desc: '你的 Agent 在想什么、记住了什么、遗忘了什么。',
|
||||
selectAgent: '选择 Agent',
|
||||
selectAgentHint: '请先选择一个 Agent 查看其记忆',
|
||||
tabTimeline: 'Dream 时间线',
|
||||
desc: '你的智能体在想什么、记住了什么、遗忘了什么。',
|
||||
selectAgent: '选择智能体',
|
||||
selectAgentHint: '请先选择一个智能体查看其记忆',
|
||||
tabTimeline: '整理时间线',
|
||||
tabMemory: '长期记忆',
|
||||
tabProfile: '用户画像',
|
||||
tabFacts: '事实库',
|
||||
noReports: '暂无 Dream 记录',
|
||||
noReports: '暂无整理记录',
|
||||
modeFocused: '专题',
|
||||
modeNightly: '夜间',
|
||||
candidates: '条候选',
|
||||
loadMore: '加载更多',
|
||||
time: {
|
||||
justNow: '刚刚',
|
||||
hoursAgo: '{n}小时前',
|
||||
daysAgo: '{n}天前',
|
||||
},
|
||||
status: {
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
skipped: '跳过',
|
||||
},
|
||||
report: {
|
||||
mode: '模式',
|
||||
topic: '主题',
|
||||
@ -1811,7 +1824,7 @@ export default {
|
||||
promoted: '已整合',
|
||||
rejected: '未采纳',
|
||||
trigger: '触发方式',
|
||||
reason: 'LLM 理由',
|
||||
reason: '整理理由',
|
||||
diff: '变更摘要',
|
||||
},
|
||||
morningCard: {
|
||||
@ -1829,16 +1842,11 @@ export default {
|
||||
saved: '已保存到 MEMORY.md',
|
||||
},
|
||||
focused: {
|
||||
btn: '现在想一想',
|
||||
title: 'Focused Dream',
|
||||
desc: '围绕一个主题让 Agent 重新整理记忆。Agent 会优先提炼该主题相关的信息。',
|
||||
placeholder: '输入主题,如"项目架构决策"、"用户偏好"...',
|
||||
desc: '围绕一个主题让智能体重新整理记忆,优先提炼相关信息。',
|
||||
placeholder: '输入主题,如"项目架构"、"用户偏好"…',
|
||||
trigger: '开始整理',
|
||||
success: 'Dream 完成,时间线已更新',
|
||||
success: '整理完成,时间线已更新',
|
||||
skipped: '没有需要整合的内容',
|
||||
},
|
||||
diff: {
|
||||
title: '变更摘要',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
<template>
|
||||
<div class="diff-viewer" v-if="diff">
|
||||
<div class="diff-header">
|
||||
<span class="diff-icon">📝</span>
|
||||
<span class="diff-label">{{ t('memory.diff.title') }}</span>
|
||||
</div>
|
||||
<pre class="diff-content">{{ diff }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
defineProps<{ diff: string | null }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.diff-viewer {
|
||||
margin-top: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color-extra-light);
|
||||
}
|
||||
.diff-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.diff-icon { font-size: 14px; }
|
||||
.diff-content {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
color: var(--el-text-color-regular);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@ -1,114 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" :title="panelTitle" size="480px" @close="emit('close')">
|
||||
<div v-if="!report" class="panel-loading">
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
<div v-else class="panel-content">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item :label="t('memory.report.mode')">
|
||||
<el-tag :type="report.mode === 'FOCUSED' ? 'warning' : 'info'" size="small">{{ report.mode }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="report.topic" :label="t('memory.report.topic')">
|
||||
{{ report.topic }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('memory.report.status')">
|
||||
<el-tag :type="statusType(report.status)" size="small">{{ report.status }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('memory.report.time')">
|
||||
{{ formatTime(report.startedAt) }} ~ {{ formatTime(report.finishedAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('memory.report.candidates')">
|
||||
{{ report.candidateCount }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('memory.report.promoted')">
|
||||
<span class="text-success">{{ report.promotedCount }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('memory.report.rejected')">
|
||||
<span class="text-danger">{{ report.rejectedCount }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('memory.report.trigger')">
|
||||
{{ report.triggerSource }} / {{ report.triggeredBy }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div v-if="report.llmReason" class="section">
|
||||
<h4>{{ t('memory.report.reason') }}</h4>
|
||||
<p class="reason-text">{{ report.llmReason }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="report.memoryDiff" class="section">
|
||||
<h4>{{ t('memory.report.diff') }}</h4>
|
||||
<code class="diff-text">{{ report.memoryDiff }}</code>
|
||||
</div>
|
||||
|
||||
<div v-if="report.errorMessage" class="section error-section">
|
||||
<h4>Error</h4>
|
||||
<p class="error-text">{{ report.errorMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { DreamReportItem } from '@/stores/useMemoryStore'
|
||||
|
||||
const props = defineProps<{ report: DreamReportItem | null }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const visible = computed(() => props.report !== null)
|
||||
|
||||
const panelTitle = computed(() => {
|
||||
if (!props.report) return ''
|
||||
return `Dream Report — ${props.report.mode}`
|
||||
})
|
||||
|
||||
function statusType(status: string) {
|
||||
if (status === 'SUCCESS') return 'success'
|
||||
if (status === 'FAILED') return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function formatTime(isoStr: string) {
|
||||
if (!isoStr) return ''
|
||||
return new Date(isoStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.panel-content {
|
||||
padding: 0 4px;
|
||||
}
|
||||
.panel-loading {
|
||||
padding: 20px;
|
||||
}
|
||||
.section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.section h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.reason-text {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.diff-text {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.error-section .error-text {
|
||||
color: var(--el-color-danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
.text-success { color: var(--el-color-success); font-weight: 600; }
|
||||
.text-danger { color: var(--el-color-danger); font-weight: 600; }
|
||||
</style>
|
||||
@ -1,196 +0,0 @@
|
||||
<template>
|
||||
<div class="dream-timeline">
|
||||
<div v-if="store.loading" class="loading-state">
|
||||
<div class="skeleton-card mc-surface-card" v-for="i in 3" :key="i">
|
||||
<el-skeleton :rows="2" animated />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.reports.length === 0" class="empty-state">
|
||||
<div class="empty-icon">🌙</div>
|
||||
<p>{{ t('memory.noReports') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="timeline-cards">
|
||||
<div
|
||||
v-for="report in store.reports"
|
||||
:key="report.id"
|
||||
class="dream-card mc-surface-card"
|
||||
:class="{ selected: selectedId === report.id }"
|
||||
@click="selectReport(report)"
|
||||
>
|
||||
<div class="card-top">
|
||||
<span class="status-dot" :class="report.status.toLowerCase()" />
|
||||
<span class="card-mode">{{ report.mode }}</span>
|
||||
<span class="card-time">{{ formatRelativeTime(report.startedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="report.topic" class="card-topic">{{ report.topic }}</div>
|
||||
|
||||
<div class="card-stats">
|
||||
<span class="stat promoted">+{{ report.promotedCount }}</span>
|
||||
<span class="stat rejected">-{{ report.rejectedCount }}</span>
|
||||
<span class="stat total">{{ report.candidateCount }} candidates</span>
|
||||
</div>
|
||||
|
||||
<p v-if="report.llmReason" class="card-reason">{{ truncate(report.llmReason, 120) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="store.total > pageSize" class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="store.total"
|
||||
layout="prev, pager, next"
|
||||
small
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail drawer -->
|
||||
<DreamReportPanel v-if="selectedId" :report="store.currentReport" @close="selectedId = null" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMemoryStore, type DreamReportItem } from '@/stores/useMemoryStore'
|
||||
import DreamReportPanel from './DreamReportPanel.vue'
|
||||
|
||||
const props = defineProps<{ agentId: number }>()
|
||||
const { t } = useI18n()
|
||||
const store = useMemoryStore()
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
const selectedId = ref<string | null>(null)
|
||||
|
||||
watch(() => props.agentId, () => {
|
||||
currentPage.value = 1
|
||||
selectedId.value = null
|
||||
loadReports()
|
||||
}, { immediate: true })
|
||||
|
||||
function loadReports() {
|
||||
store.fetchReports(props.agentId, currentPage.value, pageSize)
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
currentPage.value = page
|
||||
loadReports()
|
||||
}
|
||||
|
||||
function selectReport(report: DreamReportItem) {
|
||||
selectedId.value = report.id
|
||||
store.fetchReport(props.agentId, report.id)
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoStr: string) {
|
||||
if (!isoStr) return ''
|
||||
const d = new Date(isoStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffH = Math.floor(diffMs / 3600000)
|
||||
if (diffH < 1) return 'just now'
|
||||
if (diffH < 24) return `${diffH}h ago`
|
||||
const diffD = Math.floor(diffH / 24)
|
||||
if (diffD < 7) return `${diffD}d ago`
|
||||
return d.toLocaleDateString()
|
||||
}
|
||||
|
||||
function truncate(str: string, max: number) {
|
||||
return str.length > max ? str.slice(0, max) + '...' : str
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dream-timeline {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.timeline-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.dream-card {
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.dream-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
.dream-card.selected {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 2px var(--el-color-primary-light-8);
|
||||
}
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--el-color-info-light-3);
|
||||
}
|
||||
.status-dot.success { background: var(--el-color-success); }
|
||||
.status-dot.failed { background: var(--el-color-danger); }
|
||||
.status-dot.skipped { background: var(--el-color-warning); }
|
||||
.card-mode {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.card-time { margin-left: auto; }
|
||||
.card-topic {
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.card-stats {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stat.promoted { color: var(--el-color-success); font-weight: 600; }
|
||||
.stat.rejected { color: var(--el-color-danger-light-3); }
|
||||
.stat.total { color: var(--el-text-color-secondary); }
|
||||
.card-reason {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.pagination-wrap {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.skeleton-card {
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48px 0;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
.empty-icon { font-size: 32px; margin-bottom: 12px; }
|
||||
</style>
|
||||
@ -1,75 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="t('memory.focused.title')"
|
||||
width="420px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="focused-form">
|
||||
<p class="focused-desc">{{ t('memory.focused.desc') }}</p>
|
||||
<el-input
|
||||
v-model="topic"
|
||||
:placeholder="t('memory.focused.placeholder')"
|
||||
:rows="3"
|
||||
type="textarea"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">{{ t('memory.hil.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="running" :disabled="!topic.trim()" @click="trigger">
|
||||
{{ t('memory.focused.trigger') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { http } from '@/api'
|
||||
|
||||
const props = defineProps<{ agentId: number }>()
|
||||
const emit = defineEmits<{ triggered: []; 'update:modelValue': [val: boolean] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
const topic = ref('')
|
||||
const running = ref(false)
|
||||
|
||||
async function trigger() {
|
||||
if (!topic.value.trim()) return
|
||||
running.value = true
|
||||
try {
|
||||
const res = await http.post(`/memory/${props.agentId}/dreaming/focused`, {
|
||||
topic: topic.value.trim(),
|
||||
})
|
||||
if (res.data?.status === 'SUCCESS') {
|
||||
ElMessage.success(t('memory.focused.success'))
|
||||
} else {
|
||||
ElMessage.info(t('memory.focused.skipped'))
|
||||
}
|
||||
emit('triggered')
|
||||
visible.value = false
|
||||
topic.value = ''
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || 'Focused dream failed')
|
||||
} finally {
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.focused-form {
|
||||
padding: 4px 0;
|
||||
}
|
||||
.focused-desc {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@ -1,136 +0,0 @@
|
||||
<template>
|
||||
<div class="memory-section">
|
||||
<div class="section-header">
|
||||
<h4>{{ title }}</h4>
|
||||
<div class="section-actions">
|
||||
<el-button size="small" text type="success" @click="onConfirm">
|
||||
{{ t('memory.hil.confirm') }}
|
||||
</el-button>
|
||||
<el-button size="small" text type="primary" @click="startEdit">
|
||||
{{ t('memory.hil.edit') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!editing" class="section-content" v-html="renderedContent" />
|
||||
|
||||
<div v-else class="section-edit">
|
||||
<el-input
|
||||
v-model="editText"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
:placeholder="t('memory.hil.editPlaceholder')"
|
||||
/>
|
||||
<div class="edit-actions">
|
||||
<el-button size="small" @click="cancelEdit">{{ t('memory.hil.cancel') }}</el-button>
|
||||
<el-button size="small" type="primary" @click="saveEdit" :loading="saving">
|
||||
{{ t('memory.hil.save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { http } from '@/api'
|
||||
|
||||
const props = defineProps<{
|
||||
agentId: number
|
||||
reportId: string
|
||||
sectionKey: string
|
||||
title: string
|
||||
content: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ confirmed: []; edited: [newContent: string] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const editing = ref(false)
|
||||
const editText = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
// Simple markdown-to-HTML for display (just paragraphs and lists)
|
||||
return props.content
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
if (line.startsWith('- ')) return `<li>${line.slice(2)}</li>`
|
||||
if (line.trim() === '') return '<br/>'
|
||||
return `<p>${line}</p>`
|
||||
})
|
||||
.join('')
|
||||
})
|
||||
|
||||
function onConfirm() {
|
||||
http.post(`/memory/${props.agentId}/dream/reports/${props.reportId}/entries/${props.sectionKey}/confirm`)
|
||||
.then(() => {
|
||||
ElMessage.success(t('memory.hil.confirmed'))
|
||||
emit('confirmed')
|
||||
})
|
||||
.catch(() => ElMessage.error('Confirm failed'))
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
editText.value = props.content
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editing.value = false
|
||||
editText.value = ''
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editText.value.trim()) return
|
||||
saving.value = true
|
||||
try {
|
||||
await http.post(
|
||||
`/memory/${props.agentId}/dream/reports/${props.reportId}/entries/${props.sectionKey}/edit`,
|
||||
{ content: editText.value }
|
||||
)
|
||||
ElMessage.success(t('memory.hil.saved'))
|
||||
emit('edited', editText.value)
|
||||
editing.value = false
|
||||
} catch {
|
||||
ElMessage.error('Save failed')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.memory-section {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.section-header h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.section-content {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.section-edit {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.edit-actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@ -1,21 +1,20 @@
|
||||
<template>
|
||||
<transition name="slide-down">
|
||||
<Transition name="slide-down">
|
||||
<div v-if="card" class="morning-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🌅</span>
|
||||
<span class="card-title">{{ t('memory.morningCard.title') }}</span>
|
||||
<el-button text size="small" @click="dismiss">{{ t('memory.morningCard.dismiss') }}</el-button>
|
||||
<div class="morning-card__header">
|
||||
<span class="morning-card__icon">🌅</span>
|
||||
<span class="morning-card__title">{{ t('memory.morningCard.title') }}</span>
|
||||
<button class="morning-card__close" @click="dismiss">×</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<el-tag :type="card.mode === 'FOCUSED' ? 'warning' : 'info'" size="small">{{ card.mode }}</el-tag>
|
||||
<span v-if="card.topic" class="card-topic">{{ card.topic }}</span>
|
||||
<p class="card-summary">
|
||||
<div class="morning-card__body">
|
||||
<span class="morning-card__mode">{{ card.mode === 'FOCUSED' ? t('memory.modeFocused') : t('memory.modeNightly') }}</span>
|
||||
<span v-if="card.topic" class="morning-card__topic">{{ card.topic }}</span>
|
||||
<p class="morning-card__summary">
|
||||
{{ t('memory.morningCard.promoted', { count: card.promotedCount }) }}
|
||||
<span v-if="card.llmReason" class="card-reason">— {{ truncate(card.llmReason, 100) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@ -31,60 +30,40 @@ onMounted(async () => {
|
||||
try {
|
||||
const res = await http.get(`/memory/${props.agentId}/dream/morning-card`)
|
||||
card.value = res.data
|
||||
} catch {
|
||||
// No card or error — silent
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
})
|
||||
|
||||
async function dismiss() {
|
||||
if (!card.value) return
|
||||
try {
|
||||
await http.post(`/memory/${props.agentId}/dream/morning-card/seen`, {
|
||||
reportId: card.value.reportId,
|
||||
})
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
try { await http.post(`/memory/${props.agentId}/dream/morning-card/seen`, { reportId: card.value.reportId }) } catch {}
|
||||
card.value = null
|
||||
}
|
||||
|
||||
function truncate(str: string, max: number) {
|
||||
return str && str.length > max ? str.slice(0, max) + '...' : str
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.morning-card {
|
||||
margin: 12px 0;
|
||||
padding: 12px 16px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
border-radius: 8px;
|
||||
margin: 0 12px 8px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--mc-primary-bg);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.card-header {
|
||||
.morning-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
.card-icon { font-size: 18px; }
|
||||
.card-title { font-weight: 600; font-size: 14px; flex: 1; }
|
||||
.card-body {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
.morning-card__icon { font-size: 16px; }
|
||||
.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);
|
||||
cursor: pointer; line-height: 1;
|
||||
}
|
||||
.card-topic {
|
||||
margin-left: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.card-summary { margin: 4px 0 0; }
|
||||
.card-reason { color: var(--el-text-color-secondary); font-style: italic; }
|
||||
.morning-card__body { margin-top: 6px; font-size: 12px; color: var(--mc-text-secondary); }
|
||||
.morning-card__mode { font-weight: 600; text-transform: uppercase; font-size: 10px; letter-spacing: 0.5px; }
|
||||
.morning-card__topic { margin-left: 6px; font-weight: 500; }
|
||||
.morning-card__summary { margin: 4px 0 0; }
|
||||
|
||||
.slide-down-enter-active, .slide-down-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.slide-down-enter-from, .slide-down-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
.slide-down-enter-active, .slide-down-leave-active { transition: all 0.25s ease; }
|
||||
.slide-down-enter-from, .slide-down-leave-to { opacity: 0; transform: translateY(-8px); }
|
||||
</style>
|
||||
|
||||
@ -1,65 +1,157 @@
|
||||
<template>
|
||||
<div class="mc-page-shell memory-shell">
|
||||
<div class="mc-page-frame memory-frame">
|
||||
<div class="mc-page-inner memory-inner">
|
||||
<div class="mc-page-header">
|
||||
<div>
|
||||
<div class="mc-page-kicker">{{ t('memory.kicker') }}</div>
|
||||
<h1 class="mc-page-title">{{ t('memory.title') }}</h1>
|
||||
<p class="mc-page-desc">{{ t('memory.desc') }}</p>
|
||||
<div class="memory-layout mc-surface-card">
|
||||
<!-- Left: Timeline -->
|
||||
<div class="memory-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header-copy">
|
||||
<div class="panel-kicker">Memory</div>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
<el-select
|
||||
v-model="selectedAgentId"
|
||||
:placeholder="t('memory.selectAgent')"
|
||||
class="agent-selector"
|
||||
@change="onAgentChange"
|
||||
>
|
||||
<el-option v-for="agent in agents" :key="agent.id" :label="agent.name" :value="agent.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- Morning Card -->
|
||||
<MorningCard v-if="selectedAgentId" :agent-id="selectedAgentId" />
|
||||
|
||||
<!-- Action bar -->
|
||||
<div v-if="selectedAgentId" class="memory-actions">
|
||||
<el-button size="small" type="primary" plain @click="showFocusedDialog = true">
|
||||
{{ t('memory.focused.btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Tab navigation: minimal, no borders -->
|
||||
<div class="memory-nav">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="memory-nav-btn"
|
||||
:class="{ active: activeTab === tab.key, disabled: tab.disabled }"
|
||||
:disabled="tab.disabled"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="memory-content">
|
||||
<DreamTimeline v-if="activeTab === 'timeline' && selectedAgentId" :agent-id="selectedAgentId" />
|
||||
<div v-else-if="!selectedAgentId" class="empty-state">
|
||||
<p>{{ t('memory.selectAgentHint') }}</p>
|
||||
<!-- Agent selector (ChatConsole pattern) -->
|
||||
<div class="agent-selector">
|
||||
<button class="agent-select-trigger" @click="agentDropdownOpen = !agentDropdownOpen">
|
||||
<span class="agent-select-trigger__icon">{{ currentAgent?.icon || '🧠' }}</span>
|
||||
<span class="agent-select-trigger__name">{{ currentAgent?.name || t('memory.selectAgent') }}</span>
|
||||
<svg class="agent-select-trigger__arrow" :class="{ open: agentDropdownOpen }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<Transition name="fade">
|
||||
<div v-if="agentDropdownOpen" class="agent-dropdown-backdrop" @click="agentDropdownOpen = false"></div>
|
||||
</Transition>
|
||||
<Transition name="agent-dropdown">
|
||||
<div v-if="agentDropdownOpen" class="agent-dropdown">
|
||||
<div
|
||||
v-for="agent in agents"
|
||||
:key="agent.id"
|
||||
class="agent-dropdown-item"
|
||||
:class="{ active: agent.id === selectedAgentId }"
|
||||
@click="selectAgent(agent)"
|
||||
>
|
||||
<span class="agent-dropdown-item__icon">{{ agent.icon || '🤖' }}</span>
|
||||
<div class="agent-dropdown-item__info">
|
||||
<span class="agent-dropdown-item__name">{{ agent.name }}</span>
|
||||
<span class="agent-dropdown-item__desc">{{ agent.description || agent.agentType }}</span>
|
||||
</div>
|
||||
<span v-if="agent.id === selectedAgentId" class="agent-dropdown-item__check">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>Coming soon</p>
|
||||
|
||||
<!-- Tab nav (segment control) -->
|
||||
<div class="memory-nav">
|
||||
<button v-for="tab in tabs" :key="tab.key"
|
||||
class="memory-nav-btn" :class="{ active: activeTab === tab.key, disabled: tab.disabled }"
|
||||
:disabled="tab.disabled" @click="activeTab = tab.key">
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Morning Card -->
|
||||
<MorningCard v-if="selectedAgentId" :agent-id="selectedAgentId" />
|
||||
|
||||
<!-- 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.reports.length === 0">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🌙</div>
|
||||
<p>{{ t('memory.noReports') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="report in store.reports" :key="report.id"
|
||||
class="dream-card" :class="{ selected: selectedReportId === report.id }"
|
||||
@click="selectReport(report)">
|
||||
<div class="card-row-top">
|
||||
<span class="status-dot" :class="report.status.toLowerCase()" />
|
||||
<span class="card-mode">{{ report.mode === 'FOCUSED' ? t('memory.modeFocused') : t('memory.modeNightly') }}</span>
|
||||
<span class="card-time">{{ relTime(report.startedAt) }}</span>
|
||||
</div>
|
||||
<div v-if="report.topic" class="card-topic">{{ report.topic }}</div>
|
||||
<div class="card-stats">
|
||||
<span class="stat-up">+{{ report.promotedCount }}</span>
|
||||
<span class="stat-down">-{{ report.rejectedCount }}</span>
|
||||
<span class="stat-total">{{ report.candidateCount }} {{ t('memory.candidates') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="store.total > 20" class="load-more">
|
||||
<button class="load-more-btn" @click="currentPage++; loadReports()">{{ t('memory.loadMore') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Focused Dream Dialog -->
|
||||
<FocusedDreamDialog
|
||||
v-if="selectedAgentId"
|
||||
v-model="showFocusedDialog"
|
||||
:agent-id="selectedAgentId"
|
||||
@triggered="onDreamTriggered"
|
||||
/>
|
||||
<!-- Right: Detail panel -->
|
||||
<div class="memory-detail">
|
||||
<!-- Inline dream input (replaces dialog) -->
|
||||
<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>
|
||||
<span>Dream</span>
|
||||
<button class="close-btn" @click="dreamInputOpen = false">×</button>
|
||||
</div>
|
||||
<p class="dream-input-desc">{{ t('memory.focused.desc') }}</p>
|
||||
<input v-model="dreamTopic" class="dream-topic-input" :placeholder="t('memory.focused.placeholder')" @keydown.enter="triggerDream" />
|
||||
<button class="dream-go-btn" :disabled="!dreamTopic.trim() || dreamRunning" @click="triggerDream">
|
||||
{{ dreamRunning ? '...' : t('memory.focused.trigger') }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Report detail -->
|
||||
<template v-if="store.currentReport">
|
||||
<div class="detail-header">
|
||||
<div class="detail-mode-badge" :class="store.currentReport.mode.toLowerCase()">
|
||||
{{ store.currentReport.mode === 'FOCUSED' ? t('memory.modeFocused') : t('memory.modeNightly') }}
|
||||
</div>
|
||||
<span v-if="store.currentReport.topic" class="detail-topic">{{ store.currentReport.topic }}</span>
|
||||
<div class="detail-status" :class="store.currentReport.status.toLowerCase()">
|
||||
{{ t('memory.status.' + store.currentReport.status.toLowerCase()) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="detail-kv"><span class="kv-label">{{ t('memory.report.time') }}</span><span class="kv-value">{{ fmtTime(store.currentReport.startedAt) }}</span></div>
|
||||
<div class="detail-kv"><span class="kv-label">{{ t('memory.report.candidates') }}</span><span class="kv-value">{{ store.currentReport.candidateCount }}</span></div>
|
||||
<div class="detail-kv"><span class="kv-label">{{ t('memory.report.promoted') }}</span><span class="kv-value text-up">{{ store.currentReport.promotedCount }}</span></div>
|
||||
<div class="detail-kv"><span class="kv-label">{{ t('memory.report.rejected') }}</span><span class="kv-value text-down">{{ store.currentReport.rejectedCount }}</span></div>
|
||||
<div class="detail-kv"><span class="kv-label">{{ t('memory.report.trigger') }}</span><span class="kv-value">{{ store.currentReport.triggerSource }}</span></div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.currentReport.llmReason" class="detail-section">
|
||||
<div class="section-label">{{ t('memory.report.reason') }}</div>
|
||||
<p class="section-body">{{ store.currentReport.llmReason }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="store.currentReport.memoryDiff" class="detail-section">
|
||||
<div class="section-label">{{ t('memory.report.diff') }}</div>
|
||||
<pre class="diff-block">{{ store.currentReport.memoryDiff }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="store.currentReport.errorMessage" class="detail-section error">
|
||||
<div class="section-label">Error</div>
|
||||
<p class="section-body">{{ store.currentReport.errorMessage }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="detail-empty">
|
||||
<div class="detail-empty-icon">🧠</div>
|
||||
<p>{{ t('memory.desc') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -68,24 +160,32 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { http } from '@/api'
|
||||
import { useAgentStore } from '@/stores/useAgentStore'
|
||||
import { useMemoryStore } from '@/stores/useMemoryStore'
|
||||
import DreamTimeline from './components/DreamTimeline.vue'
|
||||
import { useMemoryStore, type DreamReportItem } from '@/stores/useMemoryStore'
|
||||
import MorningCard from './components/MorningCard.vue'
|
||||
import FocusedDreamDialog from './components/FocusedDreamDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const agentStore = useAgentStore()
|
||||
const memoryStore = useMemoryStore()
|
||||
const store = useMemoryStore()
|
||||
|
||||
const agents = ref<any[]>([])
|
||||
const selectedAgentId = ref<number | null>(null)
|
||||
const currentAgent = computed(() => agents.value.find(a => a.id === selectedAgentId.value))
|
||||
const agentDropdownOpen = ref(false)
|
||||
const activeTab = ref('timeline')
|
||||
const showFocusedDialog = ref(false)
|
||||
const selectedReportId = ref<string | null>(null)
|
||||
const currentPage = ref(1)
|
||||
|
||||
// Dream input (inline, no dialog)
|
||||
const dreamInputOpen = ref(false)
|
||||
const dreamTopic = ref('')
|
||||
const dreamRunning = ref(false)
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ key: 'timeline', label: t('memory.tabTimeline'), disabled: false },
|
||||
{ key: 'memory', label: t('memory.tabMemory'), disabled: true },
|
||||
{ key: 'profile', label: t('memory.tabProfile'), disabled: true },
|
||||
{ key: 'facts', label: t('memory.tabFacts'), disabled: true },
|
||||
])
|
||||
|
||||
@ -97,85 +197,295 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// SSE subscription management
|
||||
watch(selectedAgentId, (id) => {
|
||||
if (id) memoryStore.subscribeEvents(id)
|
||||
else memoryStore.unsubscribeEvents()
|
||||
if (id) { store.subscribeEvents(id); loadReports() }
|
||||
else store.unsubscribeEvents()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
memoryStore.unsubscribeEvents()
|
||||
})
|
||||
onUnmounted(() => store.unsubscribeEvents())
|
||||
|
||||
function onAgentChange() {
|
||||
activeTab.value = 'timeline'
|
||||
function selectAgent(agent: any) {
|
||||
selectedAgentId.value = agent.id
|
||||
agentDropdownOpen.value = false
|
||||
selectedReportId.value = null
|
||||
store.currentReport = null
|
||||
}
|
||||
|
||||
function onDreamTriggered() {
|
||||
// Refresh timeline after focused dream
|
||||
if (selectedAgentId.value) {
|
||||
memoryStore.fetchReports(selectedAgentId.value, 1, 20)
|
||||
function loadReports() {
|
||||
if (selectedAgentId.value) store.fetchReports(selectedAgentId.value, currentPage.value, 20)
|
||||
}
|
||||
|
||||
function selectReport(report: DreamReportItem) {
|
||||
selectedReportId.value = report.id
|
||||
if (selectedAgentId.value) store.fetchReport(selectedAgentId.value, report.id)
|
||||
}
|
||||
|
||||
function startDream() { dreamInputOpen.value = true }
|
||||
|
||||
async function triggerDream() {
|
||||
if (!dreamTopic.value.trim() || !selectedAgentId.value) return
|
||||
dreamRunning.value = true
|
||||
try {
|
||||
await http.post(`/memory/${selectedAgentId.value}/dreaming/focused`, { topic: dreamTopic.value.trim() })
|
||||
ElMessage.success(t('memory.focused.success'))
|
||||
dreamTopic.value = ''
|
||||
dreamInputOpen.value = false
|
||||
loadReports()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || 'Dream failed')
|
||||
} finally {
|
||||
dreamRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function relTime(iso: string) {
|
||||
if (!iso) return ''
|
||||
const ms = Date.now() - new Date(iso).getTime()
|
||||
const h = Math.floor(ms / 3600000)
|
||||
if (h < 1) return t('memory.time.justNow')
|
||||
if (h < 24) return t('memory.time.hoursAgo', { n: h })
|
||||
const d = Math.floor(h / 24)
|
||||
if (d < 7) return t('memory.time.daysAgo', { n: d })
|
||||
return new Date(iso).toLocaleDateString()
|
||||
}
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
return iso ? new Date(iso).toLocaleString() : ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.memory-shell {
|
||||
--memory-max-width: 960px;
|
||||
}
|
||||
.memory-frame {
|
||||
max-width: var(--memory-max-width);
|
||||
}
|
||||
.agent-selector {
|
||||
width: 180px;
|
||||
/* ========== Shell ========== */
|
||||
.memory-frame { max-width: 1200px; }
|
||||
.memory-layout {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 120px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Minimal tab navigation — inspired by Apple's segment control */
|
||||
.memory-nav {
|
||||
/* ========== Left sidebar ========== */
|
||||
.memory-sidebar {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin: 24px 0 16px;
|
||||
padding: 3px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
width: fit-content;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--mc-border-light);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 20px 16px 12px;
|
||||
}
|
||||
.sidebar-header-copy { display: flex; flex-direction: column; }
|
||||
.panel-kicker {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.panel-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 2px 0 0;
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
.dream-trigger-btn {
|
||||
width: 32px; height: 32px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 10px;
|
||||
background: var(--mc-bg-sunken);
|
||||
color: var(--mc-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.dream-trigger-btn:hover {
|
||||
border-color: var(--mc-primary);
|
||||
color: var(--mc-primary);
|
||||
background: var(--mc-bg-elevated);
|
||||
}
|
||||
|
||||
/* Agent selector (same CSS as ChatConsole) */
|
||||
.agent-selector { position: relative; padding: 0 12px 8px; }
|
||||
.agent-select-trigger {
|
||||
width: 100%; display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 12px;
|
||||
font-size: 13px; color: var(--mc-text-primary); background: var(--mc-bg-sunken);
|
||||
cursor: pointer; outline: none; transition: all 0.15s;
|
||||
}
|
||||
.agent-select-trigger:hover { border-color: var(--mc-primary); background: var(--mc-bg-elevated); }
|
||||
.agent-select-trigger__icon { font-size: 18px; line-height: 1; }
|
||||
.agent-select-trigger__name { flex: 1; text-align: left; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.agent-select-trigger__arrow { flex-shrink: 0; color: var(--mc-text-tertiary); transition: transform 0.2s; }
|
||||
.agent-select-trigger__arrow.open { transform: rotate(180deg); }
|
||||
.agent-dropdown-backdrop { position: fixed; inset: 0; z-index: 99; }
|
||||
.agent-dropdown {
|
||||
position: absolute; top: calc(100% + 4px); left: 12px; right: 12px; min-width: 240px; z-index: 100;
|
||||
background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 14px;
|
||||
padding: 6px; box-shadow: 0 8px 32px rgba(0,0,0,0.12); max-height: 320px; overflow-y: auto;
|
||||
}
|
||||
.agent-dropdown-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
|
||||
border-radius: 10px; cursor: pointer; transition: background 0.12s;
|
||||
}
|
||||
.agent-dropdown-item:hover { background: var(--mc-bg-sunken); }
|
||||
.agent-dropdown-item.active { background: var(--mc-primary-bg); }
|
||||
.agent-dropdown-item__icon { font-size: 24px; line-height: 1; flex-shrink: 0; }
|
||||
.agent-dropdown-item__info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.agent-dropdown-item__name { font-size: 13px; font-weight: 500; color: var(--mc-text-primary); }
|
||||
.agent-dropdown-item__desc { font-size: 11px; color: var(--mc-text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.agent-dropdown-item__check { color: var(--mc-primary); flex-shrink: 0; }
|
||||
|
||||
/* Segment control */
|
||||
.memory-nav {
|
||||
display: flex; gap: 2px; margin: 4px 12px 8px; padding: 3px;
|
||||
background: var(--mc-bg-sunken); border-radius: 10px; width: fit-content;
|
||||
}
|
||||
.memory-nav-btn {
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.memory-nav-btn:hover:not(.disabled) {
|
||||
color: var(--el-text-color-primary);
|
||||
padding: 5px 14px; border: none; border-radius: 8px; background: transparent;
|
||||
font-size: 12px; font-weight: 500; color: var(--mc-text-tertiary);
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.memory-nav-btn:hover:not(.disabled) { color: var(--mc-text-primary); }
|
||||
.memory-nav-btn.active {
|
||||
background: var(--el-bg-color);
|
||||
color: var(--el-text-color-primary);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
background: var(--mc-bg-elevated); color: var(--mc-text-primary);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
.memory-nav-btn.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
.memory-nav-btn.disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
|
||||
/* Timeline scroll */
|
||||
.timeline-scroll { flex: 1; overflow-y: auto; padding: 0 8px 12px; }
|
||||
.dream-card {
|
||||
padding: 12px 14px; margin-bottom: 6px; border-radius: 12px;
|
||||
cursor: pointer; transition: all 0.12s; border: 1px solid transparent;
|
||||
}
|
||||
.dream-card:hover { background: var(--mc-bg-sunken); }
|
||||
.dream-card.selected { background: var(--mc-primary-bg); border-color: var(--mc-primary); }
|
||||
.card-row-top { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--mc-text-tertiary); }
|
||||
.status-dot { width: 7px; height: 7px; border-radius: 50%; }
|
||||
.status-dot.success { background: #34c759; }
|
||||
.status-dot.failed { background: #ff3b30; }
|
||||
.status-dot.skipped { background: #ff9f0a; }
|
||||
.card-mode { font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.card-time { margin-left: auto; }
|
||||
.card-topic { margin-top: 4px; font-size: 13px; font-weight: 500; color: var(--mc-text-primary); }
|
||||
.card-stats { margin-top: 4px; display: flex; gap: 8px; font-size: 11px; }
|
||||
.stat-up { color: #34c759; font-weight: 600; }
|
||||
.stat-down { color: var(--mc-text-tertiary); }
|
||||
.stat-total { color: var(--mc-text-tertiary); }
|
||||
.load-more { padding: 8px; text-align: center; }
|
||||
.load-more-btn {
|
||||
padding: 6px 16px; border: 1px solid var(--mc-border); border-radius: 8px;
|
||||
background: transparent; font-size: 12px; color: var(--mc-text-secondary);
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.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 { font-size: 28px; margin-bottom: 8px; }
|
||||
.empty-state p { font-size: 13px; }
|
||||
|
||||
/* ========== Right detail ========== */
|
||||
.memory-detail {
|
||||
flex: 1; min-width: 0; padding: 24px; overflow-y: auto;
|
||||
}
|
||||
|
||||
.memory-content {
|
||||
min-height: 300px;
|
||||
/* Inline dream input */
|
||||
.dream-input-area {
|
||||
padding: 16px; margin-bottom: 20px; border-radius: 14px;
|
||||
background: var(--mc-bg-sunken); border: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.memory-actions {
|
||||
margin: 16px 0 0;
|
||||
.dream-input-header {
|
||||
display: flex; align-items: center; gap: 6px; font-size: 14px; font-weight: 600;
|
||||
color: var(--mc-text-primary); margin-bottom: 8px;
|
||||
}
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 14px;
|
||||
.close-btn {
|
||||
margin-left: auto; background: none; border: none; font-size: 18px;
|
||||
color: var(--mc-text-tertiary); cursor: pointer; line-height: 1;
|
||||
}
|
||||
.dream-input-desc { font-size: 12px; color: var(--mc-text-tertiary); margin: 0 0 10px; line-height: 1.4; }
|
||||
.dream-topic-input {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid var(--mc-border); border-radius: 10px;
|
||||
background: var(--mc-bg-elevated); font-size: 13px; color: var(--mc-text-primary);
|
||||
outline: none; transition: border-color 0.15s;
|
||||
}
|
||||
.dream-topic-input:focus { border-color: var(--mc-primary); }
|
||||
.dream-topic-input::placeholder { color: var(--mc-text-tertiary); }
|
||||
.dream-go-btn {
|
||||
margin-top: 10px; padding: 8px 20px; border: none; border-radius: 10px;
|
||||
background: var(--mc-primary); color: #fff; font-size: 13px; font-weight: 500;
|
||||
cursor: pointer; transition: opacity 0.15s;
|
||||
}
|
||||
.dream-go-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* Detail header */
|
||||
.detail-header { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.detail-mode-badge {
|
||||
padding: 4px 10px; border-radius: 8px; font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.detail-mode-badge.nightly { background: rgba(88,86,214,0.12); color: #5856d6; }
|
||||
.detail-mode-badge.focused { background: rgba(255,159,10,0.12); color: #ff9f0a; }
|
||||
.detail-topic { font-size: 16px; font-weight: 600; color: var(--mc-text-primary); }
|
||||
.detail-status { margin-left: auto; font-size: 11px; font-weight: 500; padding: 3px 8px; border-radius: 6px; }
|
||||
.detail-status.success { background: rgba(52,199,89,0.12); color: #34c759; }
|
||||
.detail-status.failed { background: rgba(255,59,48,0.12); color: #ff3b30; }
|
||||
.detail-status.skipped { background: rgba(255,159,10,0.12); color: #ff9f0a; }
|
||||
|
||||
/* KV grid */
|
||||
.detail-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px; margin-bottom: 24px;
|
||||
}
|
||||
.detail-kv {
|
||||
padding: 12px 14px; border-radius: 12px; background: var(--mc-bg-sunken);
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.kv-label { font-size: 11px; color: var(--mc-text-tertiary); font-weight: 500; }
|
||||
.kv-value { font-size: 16px; font-weight: 600; color: var(--mc-text-primary); }
|
||||
.kv-value.text-up { color: #34c759; }
|
||||
.kv-value.text-down { color: #ff3b30; }
|
||||
|
||||
/* Sections */
|
||||
.detail-section { margin-bottom: 20px; }
|
||||
.section-label { font-size: 12px; font-weight: 600; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
||||
.section-body { font-size: 13px; color: var(--mc-text-secondary); line-height: 1.6; margin: 0; }
|
||||
.diff-block {
|
||||
margin: 0; padding: 12px 14px; border-radius: 10px; background: var(--mc-bg-sunken);
|
||||
font-size: 12px; font-family: 'SF Mono', Menlo, monospace; color: var(--mc-text-secondary);
|
||||
white-space: pre-wrap; line-height: 1.5; border: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.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 { font-size: 36px; margin-bottom: 12px; }
|
||||
.detail-empty p { font-size: 14px; max-width: 260px; line-height: 1.5; }
|
||||
|
||||
/* ========== Transitions ========== */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
.agent-dropdown-enter-active { transition: all 0.2s ease; }
|
||||
.agent-dropdown-leave-active { transition: all 0.12s ease; }
|
||||
.agent-dropdown-enter-from { opacity: 0; transform: translateY(-6px) scale(0.97); }
|
||||
.agent-dropdown-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
|
||||
.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); }
|
||||
|
||||
/* ========== Responsive ========== */
|
||||
@media (max-width: 768px) {
|
||||
.memory-layout { flex-direction: column; }
|
||||
.memory-sidebar { width: 100%; max-height: 50vh; border-right: none; border-bottom: 1px solid var(--mc-border-light); }
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user