mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(memory): dream-v2 D2 — Morning Card + HiL (Confirm/Edit)
This commit is contained in:
parent
90067d1c9f
commit
8ecc6d10ca
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
@ -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)
|
||||
);
|
||||
@ -20,4 +20,8 @@
|
||||
---
|
||||
|
||||
请围绕主题 "{topic}" 分析以上内容,将相关的稳定信息整合到 MEMORY.md 中。
|
||||
非主题类的高分候选如有重要内容也一并整合。严格输出 JSON 格式。
|
||||
非主题类的高分候选如有重要内容也一并整合。
|
||||
|
||||
**重要**:MEMORY.md 中含有 `<!-- user-edited -->` 标记的段落是用户手动编辑过的,不要覆盖或修改这些段落的内容,只在其后追加新信息。
|
||||
|
||||
严格输出 JSON 格式。
|
||||
|
||||
@ -17,4 +17,8 @@
|
||||
---
|
||||
|
||||
请分析以上内容,优先将高分召回候选中的稳定信息整合到 MEMORY.md 中。
|
||||
低分或未被引用的内容仅作补充参考。严格输出 JSON 格式。
|
||||
低分或未被引用的内容仅作补充参考。
|
||||
|
||||
**重要**:MEMORY.md 中含有 `<!-- user-edited -->` 标记的段落是用户手动编辑过的,不要覆盖或修改这些段落的内容,只在其后追加新信息。
|
||||
|
||||
严格输出 JSON 格式。
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
136
mateclaw-ui/src/views/Memory/components/MemoryMdSection.vue
Normal file
136
mateclaw-ui/src/views/Memory/components/MemoryMdSection.vue
Normal 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>
|
||||
90
mateclaw-ui/src/views/Memory/components/MorningCard.vue
Normal file
90
mateclaw-ui/src/views/Memory/components/MorningCard.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user