diff --git a/mateclaw-server/src/main/java/vip/mate/memory/controller/DreamController.java b/mateclaw-server/src/main/java/vip/mate/memory/controller/DreamController.java index 004b26a1..aa4aa4f7 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/controller/DreamController.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/controller/DreamController.java @@ -8,6 +8,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; +import org.springframework.http.MediaType; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import vip.mate.memory.model.DreamReportEntity; import vip.mate.memory.repository.DreamReportMapper; import vip.mate.memory.service.MorningCardService; @@ -32,6 +34,7 @@ public class DreamController { private final DreamReportMapper dreamReportMapper; private final MorningCardService morningCardService; private final MemoryHilService hilService; + private final DreamEventBroadcaster eventBroadcaster; @Operation(summary = "List dream reports (paginated, newest first)") @GetMapping("/reports") @@ -72,6 +75,15 @@ public class DreamController { return R.ok(entity); } + // ==================== SSE Events ==================== + + @Operation(summary = "Subscribe to dream events (SSE)") + @GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @RequireWorkspaceRole("viewer") + public SseEmitter subscribeDreamEvents(@PathVariable Long agentId) { + return eventBroadcaster.register(agentId); + } + // ==================== Morning Card ==================== @Operation(summary = "Get morning card for current user + agent") diff --git a/mateclaw-server/src/main/java/vip/mate/memory/controller/DreamEventBroadcaster.java b/mateclaw-server/src/main/java/vip/mate/memory/controller/DreamEventBroadcaster.java new file mode 100644 index 00000000..44d5bf37 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/controller/DreamEventBroadcaster.java @@ -0,0 +1,92 @@ +package vip.mate.memory.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import vip.mate.memory.event.DreamCompletedEvent; +import vip.mate.memory.event.DreamFailedEvent; +import vip.mate.memory.service.DreamReport; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Broadcasts dream events to connected SSE clients. + * Clients subscribe per agentId via GET /dream/events. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DreamEventBroadcaster { + + private final ObjectMapper objectMapper; + private final List emitters = new CopyOnWriteArrayList<>(); + + record EmitterEntry(Long agentId, SseEmitter emitter) {} + + /** + * Register a new SSE emitter for an agent. + */ + public SseEmitter register(Long agentId) { + SseEmitter emitter = new SseEmitter(300_000L); // 5 min timeout + EmitterEntry entry = new EmitterEntry(agentId, emitter); + emitters.add(entry); + emitter.onCompletion(() -> emitters.remove(entry)); + emitter.onTimeout(() -> emitters.remove(entry)); + emitter.onError(e -> emitters.remove(entry)); + log.debug("[DreamSSE] Client connected for agent={}, total={}", agentId, emitters.size()); + return emitter; + } + + @Async + @EventListener + public void onDreamCompleted(DreamCompletedEvent event) { + broadcast(event.report(), "dream.completed"); + } + + @Async + @EventListener + public void onDreamFailed(DreamFailedEvent event) { + broadcast(event.report(), "dream.failed"); + } + + private void broadcast(DreamReport report, String eventType) { + Long agentId = report.agentId(); + String json; + try { + json = objectMapper.writeValueAsString(Map.of( + "type", eventType, + "agentId", agentId, + "mode", report.mode().name(), + "topic", report.topic() != null ? report.topic() : "", + "status", report.status().name(), + "promotedCount", report.promotedCount(), + "rejectedCount", report.rejectedCount() + )); + } catch (Exception e) { + log.warn("[DreamSSE] Failed to serialize event: {}", e.getMessage()); + return; + } + + List dead = new java.util.ArrayList<>(); + for (EmitterEntry entry : emitters) { + if (!entry.agentId().equals(agentId)) continue; + try { + entry.emitter().send(SseEmitter.event() + .name(eventType) + .data(json)); + } catch (Exception e) { + dead.add(entry); + } + } + emitters.removeAll(dead); + log.debug("[DreamSSE] Broadcast {} to {} clients for agent={}", eventType, emitters.size(), agentId); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/event/DreamCompletedEvent.java b/mateclaw-server/src/main/java/vip/mate/memory/event/DreamCompletedEvent.java new file mode 100644 index 00000000..f9ab878f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/event/DreamCompletedEvent.java @@ -0,0 +1,11 @@ +package vip.mate.memory.event; + +import vip.mate.memory.service.DreamReport; + +/** + * Published when a dream consolidation completes successfully. + * + * @param report the structured dream report + * @author MateClaw Team + */ +public record DreamCompletedEvent(DreamReport report) {} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/event/DreamFailedEvent.java b/mateclaw-server/src/main/java/vip/mate/memory/event/DreamFailedEvent.java new file mode 100644 index 00000000..2c19f7f3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/memory/event/DreamFailedEvent.java @@ -0,0 +1,11 @@ +package vip.mate.memory.event; + +import vip.mate.memory.service.DreamReport; + +/** + * Published when a dream consolidation fails. + * + * @param report the structured dream report (status=FAILED) + * @author MateClaw Team + */ +public record DreamFailedEvent(DreamReport report) {} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java index 7703b8a6..5cfbd84c 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java @@ -11,6 +11,8 @@ import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import vip.mate.memory.event.DreamCompletedEvent; +import vip.mate.memory.event.DreamFailedEvent; import vip.mate.memory.event.MemoryWriteEvent; import vip.mate.agent.AgentGraphBuilder; import vip.mate.agent.prompt.PromptLoader; @@ -384,6 +386,12 @@ public class MemoryEmergenceService { dreamReportMapper.insert(entity); log.debug("[Memory] DreamReport persisted: agent={}, mode={}, status={}", report.agentId(), report.mode(), report.status()); + // Publish event for SSE broadcast + if (report.status() == DreamStatus.SUCCESS) { + eventPublisher.publishEvent(new DreamCompletedEvent(report)); + } else if (report.status() == DreamStatus.FAILED) { + eventPublisher.publishEvent(new DreamFailedEvent(report)); + } } catch (Exception e) { log.warn("[Memory] Failed to persist DreamReport for agent={}: {}", report.agentId(), e.getMessage()); } diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index bfef5adf..9962420b 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1818,5 +1818,17 @@ export default { confirmed: 'Confirmed', 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"...', + trigger: 'Start', + success: 'Dream completed, timeline updated', + skipped: 'Nothing to consolidate', + }, + diff: { + title: 'Change Summary', + }, }, } as const diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index b543821a..bdfcb8ea 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1828,5 +1828,17 @@ export default { confirmed: '已确认', saved: '已保存到 MEMORY.md', }, + focused: { + btn: '现在想一想', + title: 'Focused Dream', + desc: '围绕一个主题让 Agent 重新整理记忆。Agent 会优先提炼该主题相关的信息。', + placeholder: '输入主题,如"项目架构决策"、"用户偏好"...', + trigger: '开始整理', + success: 'Dream 完成,时间线已更新', + skipped: '没有需要整合的内容', + }, + diff: { + title: '变更摘要', + }, }, } as const diff --git a/mateclaw-ui/src/stores/useMemoryStore.ts b/mateclaw-ui/src/stores/useMemoryStore.ts index 79588eac..203a4b31 100644 --- a/mateclaw-ui/src/stores/useMemoryStore.ts +++ b/mateclaw-ui/src/stores/useMemoryStore.ts @@ -25,6 +25,7 @@ export const useMemoryStore = defineStore('memory', () => { const total = ref(0) const loading = ref(false) const currentReport = ref(null) + let eventSource: EventSource | null = null async function fetchReports(agentId: number, page = 1, size = 20) { loading.value = true @@ -49,5 +50,42 @@ export const useMemoryStore = defineStore('memory', () => { } } - return { reports, total, loading, currentReport, fetchReports, fetchReport } + /** + * Subscribe to dream SSE events for an agent. + * Automatically refreshes the report list on new dream events. + */ + function subscribeEvents(agentId: number) { + unsubscribeEvents() + const token = localStorage.getItem('token') + const url = `/api/v1/memory/${agentId}/dream/events` + eventSource = new EventSource(url) + + eventSource.addEventListener('dream.completed', (e) => { + // Refresh the report list to show the new dream + fetchReports(agentId, 1, 20) + }) + + eventSource.addEventListener('dream.failed', (e) => { + fetchReports(agentId, 1, 20) + }) + + eventSource.onerror = () => { + // Reconnect after 5s on error + unsubscribeEvents() + setTimeout(() => subscribeEvents(agentId), 5000) + } + } + + function unsubscribeEvents() { + if (eventSource) { + eventSource.close() + eventSource = null + } + } + + return { + reports, total, loading, currentReport, + fetchReports, fetchReport, + subscribeEvents, unsubscribeEvents, + } }) diff --git a/mateclaw-ui/src/views/Memory/components/DreamDiffViewer.vue b/mateclaw-ui/src/views/Memory/components/DreamDiffViewer.vue new file mode 100644 index 00000000..d97d65e5 --- /dev/null +++ b/mateclaw-ui/src/views/Memory/components/DreamDiffViewer.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/mateclaw-ui/src/views/Memory/components/FocusedDreamDialog.vue b/mateclaw-ui/src/views/Memory/components/FocusedDreamDialog.vue new file mode 100644 index 00000000..1c7ba152 --- /dev/null +++ b/mateclaw-ui/src/views/Memory/components/FocusedDreamDialog.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/mateclaw-ui/src/views/Memory/index.vue b/mateclaw-ui/src/views/Memory/index.vue index 46f26e6b..6239984d 100644 --- a/mateclaw-ui/src/views/Memory/index.vue +++ b/mateclaw-ui/src/views/Memory/index.vue @@ -21,6 +21,13 @@ + +
+ + {{ t('memory.focused.btn') }} + +
+
+ + +