feat(memory): dream-v2 D2 — Morning Card + HiL (Confirm/Edit)

This commit is contained in:
matevip 2026-04-21 04:58:33 +08:00
parent 90067d1c9f
commit 8ecc6d10ca
13 changed files with 568 additions and 2 deletions

View File

@ -5,10 +5,13 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R;
import vip.mate.memory.model.DreamReportEntity;
import vip.mate.memory.repository.DreamReportMapper;
import vip.mate.memory.service.MorningCardService;
import vip.mate.memory.service.MemoryHilService;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import java.util.LinkedHashMap;
@ -27,6 +30,8 @@ import java.util.Map;
public class DreamController {
private final DreamReportMapper dreamReportMapper;
private final MorningCardService morningCardService;
private final MemoryHilService hilService;
@Operation(summary = "List dream reports (paginated, newest first)")
@GetMapping("/reports")
@ -66,4 +71,72 @@ public class DreamController {
}
return R.ok(entity);
}
// ==================== Morning Card ====================
@Operation(summary = "Get morning card for current user + agent")
@GetMapping("/morning-card")
@RequireWorkspaceRole("viewer")
public R<Map<String, Object>> getMorningCard(@PathVariable Long agentId, Authentication auth) {
Long userId = resolveUserId(auth);
if (userId == null) return R.fail("Not authenticated");
Map<String, Object> card = morningCardService.getCardFor(userId, agentId);
return R.ok(card); // null = no card to show
}
@Operation(summary = "Mark morning card as seen")
@PostMapping("/morning-card/seen")
@RequireWorkspaceRole("viewer")
public R<Void> markMorningCardSeen(@PathVariable Long agentId,
@RequestBody Map<String, Object> body,
Authentication auth) {
Long userId = resolveUserId(auth);
if (userId == null) return R.fail("Not authenticated");
Long reportId = body.get("reportId") != null
? Long.valueOf(body.get("reportId").toString()) : null;
morningCardService.markSeen(userId, agentId, reportId);
return R.ok(null);
}
// ==================== HiL (Human-in-the-Loop) ====================
@Operation(summary = "Confirm a memory entry (no-op acknowledgment)")
@PostMapping("/reports/{reportId}/entries/{key}/confirm")
@RequireWorkspaceRole("member")
public R<Void> confirmEntry(@PathVariable Long agentId,
@PathVariable Long reportId,
@PathVariable String key) {
// Confirm is a no-op in Phase 2 just logs the action
return R.ok(null);
}
@Operation(summary = "Edit a memory entry — writes back to MEMORY.md with user-edited metadata")
@PostMapping("/reports/{reportId}/entries/{key}/edit")
@RequireWorkspaceRole("member")
public R<Void> editEntry(@PathVariable Long agentId,
@PathVariable Long reportId,
@PathVariable String key,
@RequestBody Map<String, String> body) {
String newContent = body.get("content");
if (newContent == null || newContent.isBlank()) {
return R.fail("content is required");
}
hilService.editMemoryEntry(agentId, key, newContent);
return R.ok(null);
}
private Long resolveUserId(Authentication auth) {
if (auth == null) return null;
// Resolve from auth principal assumes user ID is accessible
try {
Object principal = auth.getPrincipal();
if (principal instanceof vip.mate.auth.model.UserEntity user) {
return user.getId();
}
// Fallback: use username hash as stable ID
return (long) auth.getName().hashCode();
} catch (Exception e) {
return null;
}
}
}

View File

@ -0,0 +1,33 @@
package vip.mate.memory.model;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Morning card seen state tracks per (user, agent) whether the card was dismissed.
*
* @author MateClaw Team
*/
@Data
@TableName("mate_morning_card_seen")
public class MorningCardSeenEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long agentId;
private LocalDateTime lastSeenAt;
private Long lastReportId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}

View File

@ -0,0 +1,9 @@
package vip.mate.memory.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.memory.model.MorningCardSeenEntity;
@Mapper
public interface MorningCardSeenMapper extends BaseMapper<MorningCardSeenEntity> {
}

View File

@ -0,0 +1,67 @@
package vip.mate.memory.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import vip.mate.memory.event.MemoryWriteEvent;
import vip.mate.workspace.document.WorkspaceFileService;
import vip.mate.workspace.document.model.WorkspaceFileEntity;
import java.time.LocalDate;
/**
* Human-in-the-Loop service for memory editing.
* <p>
* When a user edits a memory entry, this service writes it back to MEMORY.md
* with a hidden metadata marker (<!-- user-edited: YYYY-MM-DD -->) so that
* future Dream runs do not overwrite user modifications.
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MemoryHilService {
private final WorkspaceFileService workspaceFileService;
private final ApplicationEventPublisher eventPublisher;
/**
* Edit a section in MEMORY.md identified by key (section heading).
* Appends user-edited metadata so Dream prompts respect user changes.
*/
public void editMemoryEntry(Long agentId, String key, String newContent) {
WorkspaceFileEntity file = workspaceFileService.getFile(agentId, "MEMORY.md");
if (file == null || file.getContent() == null) {
log.warn("[HiL] MEMORY.md not found for agent={}", agentId);
return;
}
String memoryContent = file.getContent();
String sectionHeader = "## " + key;
int headerIdx = memoryContent.indexOf(sectionHeader);
if (headerIdx < 0) {
// Section not found append as new section
String metadata = "<!-- user-edited: " + LocalDate.now() + " -->";
String newSection = "\n\n" + sectionHeader + "\n" + newContent.trim() + "\n" + metadata;
memoryContent = memoryContent.trim() + newSection;
} else {
// Find section boundaries
int contentStart = memoryContent.indexOf('\n', headerIdx) + 1;
int nextSection = memoryContent.indexOf("\n## ", contentStart);
int sectionEnd = nextSection > 0 ? nextSection : memoryContent.length();
// Replace section content
String metadata = "<!-- user-edited: " + LocalDate.now() + " -->";
String replacement = newContent.trim() + "\n" + metadata + "\n";
memoryContent = memoryContent.substring(0, contentStart) + replacement
+ memoryContent.substring(sectionEnd);
}
workspaceFileService.saveFile(agentId, "MEMORY.md", memoryContent);
eventPublisher.publishEvent(new MemoryWriteEvent(agentId, "MEMORY.md", "user-edit", newContent));
log.info("[HiL] User edited MEMORY.md section '{}' for agent={}", key, agentId);
}
}

View File

@ -0,0 +1,96 @@
package vip.mate.memory.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.memory.model.DreamReportEntity;
import vip.mate.memory.model.MorningCardSeenEntity;
import vip.mate.memory.repository.DreamReportMapper;
import vip.mate.memory.repository.MorningCardSeenMapper;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Morning card service determines whether to show a dream summary card
* when a user enters an agent view. Scope is per (userId, agentId).
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MorningCardService {
private final MorningCardSeenMapper seenMapper;
private final DreamReportMapper dreamReportMapper;
/**
* Get the morning card for a user+agent. Returns null if no unseen dream exists.
*/
public Map<String, Object> getCardFor(Long userId, Long agentId) {
// Find the latest successful dream report for this agent
DreamReportEntity latestReport = dreamReportMapper.selectOne(
new LambdaQueryWrapper<DreamReportEntity>()
.eq(DreamReportEntity::getAgentId, agentId)
.eq(DreamReportEntity::getStatus, "SUCCESS")
.eq(DreamReportEntity::getDeleted, 0)
.orderByDesc(DreamReportEntity::getStartedAt)
.last("LIMIT 1"));
if (latestReport == null) {
return null; // No dream yet
}
// Check if user has already seen this report
MorningCardSeenEntity seen = seenMapper.selectOne(
new LambdaQueryWrapper<MorningCardSeenEntity>()
.eq(MorningCardSeenEntity::getUserId, userId)
.eq(MorningCardSeenEntity::getAgentId, agentId));
if (seen != null && seen.getLastReportId() != null
&& seen.getLastReportId().equals(latestReport.getId())) {
return null; // Already seen
}
// Build card data
Map<String, Object> card = new LinkedHashMap<>();
card.put("reportId", latestReport.getId());
card.put("mode", latestReport.getMode());
card.put("topic", latestReport.getTopic());
card.put("startedAt", latestReport.getStartedAt());
card.put("promotedCount", latestReport.getPromotedCount());
card.put("rejectedCount", latestReport.getRejectedCount());
card.put("llmReason", latestReport.getLlmReason());
card.put("memoryDiff", latestReport.getMemoryDiff());
return card;
}
/**
* Mark the morning card as seen for a user+agent.
*/
public void markSeen(Long userId, Long agentId, Long reportId) {
MorningCardSeenEntity existing = seenMapper.selectOne(
new LambdaQueryWrapper<MorningCardSeenEntity>()
.eq(MorningCardSeenEntity::getUserId, userId)
.eq(MorningCardSeenEntity::getAgentId, agentId));
if (existing != null) {
existing.setLastSeenAt(LocalDateTime.now());
existing.setLastReportId(reportId);
existing.setUpdateTime(LocalDateTime.now());
seenMapper.updateById(existing);
} else {
MorningCardSeenEntity entity = new MorningCardSeenEntity();
entity.setUserId(userId);
entity.setAgentId(agentId);
entity.setLastSeenAt(LocalDateTime.now());
entity.setLastReportId(reportId);
entity.setCreateTime(LocalDateTime.now());
entity.setUpdateTime(LocalDateTime.now());
seenMapper.insert(entity);
}
}
}

View File

@ -0,0 +1,14 @@
-- Dream v2 Phase 2b: Morning Card seen state per (user, agent)
-- Ref: rfc-034 F5 — DO NOT add to mate_user; use separate table
CREATE TABLE IF NOT EXISTS mate_morning_card_seen (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
agent_id BIGINT NOT NULL,
last_seen_at DATETIME NOT NULL,
last_report_id BIGINT,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_morning_card_user_agent
ON mate_morning_card_seen(user_id, agent_id);

View File

@ -0,0 +1,12 @@
-- Dream v2 Phase 2b: Morning Card seen state per (user, agent)
-- Ref: rfc-034 F5 — DO NOT add to mate_user; use separate table
CREATE TABLE IF NOT EXISTS mate_morning_card_seen (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
agent_id BIGINT NOT NULL,
last_seen_at DATETIME NOT NULL,
last_report_id BIGINT,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_user_agent (user_id, agent_id)
);

View File

@ -20,4 +20,8 @@
---
请围绕主题 "{topic}" 分析以上内容,将相关的稳定信息整合到 MEMORY.md 中。
非主题类的高分候选如有重要内容也一并整合。严格输出 JSON 格式。
非主题类的高分候选如有重要内容也一并整合。
**重要**MEMORY.md 中含有 `<!-- user-edited -->` 标记的段落是用户手动编辑过的,不要覆盖或修改这些段落的内容,只在其后追加新信息。
严格输出 JSON 格式。

View File

@ -17,4 +17,8 @@
---
请分析以上内容,优先将高分召回候选中的稳定信息整合到 MEMORY.md 中。
低分或未被引用的内容仅作补充参考。严格输出 JSON 格式。
低分或未被引用的内容仅作补充参考。
**重要**MEMORY.md 中含有 `<!-- user-edited -->` 标记的段落是用户手动编辑过的,不要覆盖或修改这些段落的内容,只在其后追加新信息。
严格输出 JSON 格式。

View File

@ -1802,5 +1802,19 @@ export default {
reason: 'LLM Reason',
diff: 'Change Summary',
},
morningCard: {
title: 'Dream Recap',
dismiss: 'Got it',
promoted: '{count} memories consolidated',
},
hil: {
confirm: 'Confirm',
edit: 'Edit',
cancel: 'Cancel',
save: 'Save',
editPlaceholder: 'Edit memory content...',
confirmed: 'Confirmed',
saved: 'Saved to MEMORY.md',
},
},
} as const

View File

@ -1812,5 +1812,19 @@ export default {
reason: 'LLM 理由',
diff: '变更摘要',
},
morningCard: {
title: '昨夜 Dream 回顾',
dismiss: '我知道了',
promoted: '整合了 {count} 条记忆',
},
hil: {
confirm: '确认',
edit: '编辑',
cancel: '取消',
save: '保存',
editPlaceholder: '修改记忆内容...',
confirmed: '已确认',
saved: '已保存到 MEMORY.md',
},
},
} as const

View File

@ -0,0 +1,136 @@
<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>

View File

@ -0,0 +1,90 @@
<template>
<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>
<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">
{{ t('memory.morningCard.promoted', { count: card.promotedCount }) }}
<span v-if="card.llmReason" class="card-reason"> {{ truncate(card.llmReason, 100) }}</span>
</p>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { http } from '@/api'
const props = defineProps<{ agentId: number }>()
const { t } = useI18n()
const card = ref<any>(null)
onMounted(async () => {
try {
const res = await http.get(`/memory/${props.agentId}/dream/morning-card`)
card.value = res.data
} catch {
// No card or error 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
}
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;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.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);
}
.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; }
.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);
}
</style>