docs: update README title and tagline

This commit is contained in:
matevip 2026-04-21 09:27:42 +08:00
parent d9d677e762
commit c252cf50a4
10 changed files with 89 additions and 62 deletions

View File

@ -6,7 +6,7 @@
# MateClaw # MateClaw
<p align="center"><b>Your AI needs a Plan B.</b></p> <p align="center"><b>Your second brain</b></p>
[![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/matevip/mateclaw) [![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/matevip/mateclaw)
[![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs) [![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs)

View File

@ -4,9 +4,9 @@
<img src="mateclaw-ui/public/logo/mateclaw_logo_s.png" alt="MateClaw Logo" width="120"> <img src="mateclaw-ui/public/logo/mateclaw_logo_s.png" alt="MateClaw Logo" width="120">
</p> </p>
# MateClaw # 太一(MateClaw
<p align="center"><b>AI 也该有 Plan B。</b></p> <p align="center"><b>你的超级大脑</b></p>
[![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/matevip/mateclaw) [![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/matevip/mateclaw)
[![文档](https://img.shields.io/badge/文档-在线-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs) [![文档](https://img.shields.io/badge/文档-在线-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs)

View File

@ -139,14 +139,13 @@ public class DreamController {
private Long resolveUserId(Authentication auth) { private Long resolveUserId(Authentication auth) {
if (auth == null) return null; if (auth == null) return null;
// Resolve from auth principal assumes user ID is accessible
try { try {
Object principal = auth.getPrincipal(); Object principal = auth.getPrincipal();
if (principal instanceof vip.mate.auth.model.UserEntity user) { if (principal instanceof vip.mate.auth.model.UserEntity user) {
return user.getId(); return user.getId();
} }
// Fallback: use username hash as stable ID // Fallback: use abs(hashCode) to avoid negative IDs, add offset to avoid collision with real IDs
return (long) auth.getName().hashCode(); return Math.abs((long) auth.getName().hashCode()) + 1_000_000_000L;
} catch (Exception e) { } catch (Exception e) {
return null; return null;
} }

View File

@ -1,11 +1,13 @@
package vip.mate.memory.fact.projection; package vip.mate.memory.fact.projection;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import vip.mate.memory.MemoryProperties; import vip.mate.memory.MemoryProperties;
import vip.mate.memory.fact.extraction.CompositeEntityExtractor; import vip.mate.memory.fact.extraction.CompositeEntityExtractor;
import vip.mate.memory.fact.extraction.ExtractedFact; import vip.mate.memory.fact.extraction.ExtractedFact;
import vip.mate.memory.fact.model.FactEntity;
import vip.mate.memory.fact.repository.FactMapper; import vip.mate.memory.fact.repository.FactMapper;
import vip.mate.workspace.document.WorkspaceFileService; import vip.mate.workspace.document.WorkspaceFileService;
import vip.mate.workspace.document.model.WorkspaceFileEntity; import vip.mate.workspace.document.model.WorkspaceFileEntity;
@ -18,9 +20,10 @@ import java.util.List;
* Rebuilds the fact projection from canonical sources. * Rebuilds the fact projection from canonical sources.
* <p> * <p>
* Derived columns are overwritten; accumulated columns (use_count, last_used_at) * Derived columns are overwritten; accumulated columns (use_count, last_used_at)
* are preserved via MERGE/upsert keyed on (agent_id, source_ref). * are preserved via select-then-update keyed on (agent_id, source_ref).
* <p> * <p>
* Only this class may write derived columns to mate_fact (core invariant). * Only this class may write derived columns to mate_fact (core invariant).
* Uses MyBatis Plus CRUD (dialect-safe for both H2 and MySQL).
* *
* @author MateClaw Team * @author MateClaw Team
*/ */
@ -65,13 +68,11 @@ public class FactProjectionBuilder {
allFacts.addAll(extractor.extract(agentId, "MEMORY.md", memoryFile.getContent())); allFacts.addAll(extractor.extract(agentId, "MEMORY.md", memoryFile.getContent()));
} }
// Upsert all extracted facts // Upsert all extracted facts (dialect-safe)
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
List<String> keepRefs = new ArrayList<>(); List<String> keepRefs = new ArrayList<>();
for (ExtractedFact fact : allFacts) { for (ExtractedFact fact : allFacts) {
factMapper.upsertDerivedH2(agentId, fact.sourceRef(), fact.category(), upsertDerived(agentId, fact, now);
fact.subject(), fact.predicate(), fact.objectValue(),
fact.confidence(), 0.5, fact.extractedBy(), now, now);
keepRefs.add(fact.sourceRef()); keepRefs.add(fact.sourceRef());
} }
@ -93,11 +94,50 @@ public class FactProjectionBuilder {
List<ExtractedFact> facts = extractor.extract(agentId, filename, content); List<ExtractedFact> facts = extractor.extract(agentId, filename, content);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
for (ExtractedFact fact : facts) { for (ExtractedFact fact : facts) {
factMapper.upsertDerivedH2(agentId, fact.sourceRef(), fact.category(), upsertDerived(agentId, fact, now);
fact.subject(), fact.predicate(), fact.objectValue(),
fact.confidence(), 0.5, fact.extractedBy(), now, now);
} }
log.debug("[FactProjection] rebuildOne: agent={}, file={}, facts={}", agentId, filename, facts.size()); log.debug("[FactProjection] rebuildOne: agent={}, file={}, facts={}", agentId, filename, facts.size());
return facts.size(); return facts.size();
} }
/**
* Dialect-safe upsert: select by (agent_id, source_ref), then insert or update.
* Preserves accumulated columns (use_count, last_used_at) on update.
*/
private void upsertDerived(Long agentId, ExtractedFact fact, LocalDateTime now) {
FactEntity existing = factMapper.selectOne(
new LambdaQueryWrapper<FactEntity>()
.eq(FactEntity::getAgentId, agentId)
.eq(FactEntity::getSourceRef, fact.sourceRef())
.last("LIMIT 1"));
if (existing != null) {
// Update derived columns only; preserve accumulated columns
existing.setCategory(fact.category());
existing.setSubject(fact.subject());
existing.setPredicate(fact.predicate());
existing.setObjectValue(fact.objectValue());
existing.setConfidence(fact.confidence());
existing.setExtractedBy(fact.extractedBy());
existing.setUpdateTime(now);
existing.setDeleted(0); // un-delete if previously soft-deleted
factMapper.updateById(existing);
} else {
FactEntity entity = new FactEntity();
entity.setAgentId(agentId);
entity.setSourceRef(fact.sourceRef());
entity.setCategory(fact.category());
entity.setSubject(fact.subject());
entity.setPredicate(fact.predicate());
entity.setObjectValue(fact.objectValue());
entity.setConfidence(fact.confidence());
entity.setTrust(0.5);
entity.setUseCount(0);
entity.setExtractedBy(fact.extractedBy());
entity.setCreateTime(now);
entity.setUpdateTime(now);
entity.setDeleted(0);
factMapper.insert(entity);
}
}
} }

View File

@ -11,7 +11,7 @@ import java.util.List;
/** /**
* Fact mapper limited write operations enforce the core invariant: * Fact mapper limited write operations enforce the core invariant:
* - Derived columns: only FactProjectionBuilder may write * - Derived columns: only FactProjectionBuilder may write (via upsertDerived)
* - Accumulated columns: only bumpUseCount may write * - Accumulated columns: only bumpUseCount may write
* *
* @author MateClaw Team * @author MateClaw Team
@ -19,29 +19,6 @@ import java.util.List;
@Mapper @Mapper
public interface FactMapper extends BaseMapper<FactEntity> { public interface FactMapper extends BaseMapper<FactEntity> {
/**
* Upsert derived columns by (agent_id, source_ref).
* Preserves accumulated columns (last_used_at, use_count).
*/
@Update("""
MERGE INTO mate_fact (agent_id, source_ref, category, subject, predicate, object_value,
confidence, trust, extracted_by, create_time, update_time, deleted)
KEY (agent_id, source_ref)
VALUES (#{agentId}, #{sourceRef}, #{category}, #{subject}, #{predicate}, #{objectValue},
#{confidence}, #{trust}, #{extractedBy}, #{createTime}, #{updateTime}, 0)
""")
void upsertDerivedH2(@Param("agentId") Long agentId,
@Param("sourceRef") String sourceRef,
@Param("category") String category,
@Param("subject") String subject,
@Param("predicate") String predicate,
@Param("objectValue") String objectValue,
@Param("confidence") Double confidence,
@Param("trust") Double trust,
@Param("extractedBy") String extractedBy,
@Param("createTime") LocalDateTime createTime,
@Param("updateTime") LocalDateTime updateTime);
/** /**
* Bump use_count and last_used_at for the given fact IDs. * Bump use_count and last_used_at for the given fact IDs.
* This is the ONLY path that writes accumulated columns. * This is the ONLY path that writes accumulated columns.

View File

@ -31,6 +31,7 @@ public class MorningCardService {
* Get the morning card for a user+agent. Returns null if no unseen dream exists. * Get the morning card for a user+agent. Returns null if no unseen dream exists.
*/ */
public Map<String, Object> getCardFor(Long userId, Long agentId) { public Map<String, Object> getCardFor(Long userId, Long agentId) {
if (userId == null || agentId == null) return null;
// Find the latest successful dream report for this agent // Find the latest successful dream report for this agent
DreamReportEntity latestReport = dreamReportMapper.selectOne( DreamReportEntity latestReport = dreamReportMapper.selectOne(
new LambdaQueryWrapper<DreamReportEntity>() new LambdaQueryWrapper<DreamReportEntity>()

View File

@ -1795,6 +1795,7 @@ export default {
modeNightly: 'Nightly', modeNightly: 'Nightly',
candidates: 'candidates', candidates: 'candidates',
loadMore: 'Load more', loadMore: 'Load more',
retry: 'Retry',
time: { time: {
justNow: 'just now', justNow: 'just now',
hoursAgo: '{n}h ago', hoursAgo: '{n}h ago',

View File

@ -1805,6 +1805,7 @@ export default {
modeNightly: '夜间', modeNightly: '夜间',
candidates: '条候选', candidates: '条候选',
loadMore: '加载更多', loadMore: '加载更多',
retry: '重试',
time: { time: {
justNow: '刚刚', justNow: '刚刚',
hoursAgo: '{n}小时前', hoursAgo: '{n}小时前',

View File

@ -24,17 +24,23 @@ export const useMemoryStore = defineStore('memory', () => {
const reports = ref<DreamReportItem[]>([]) const reports = ref<DreamReportItem[]>([])
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null)
const currentReport = ref<DreamReportItem | null>(null) const currentReport = ref<DreamReportItem | null>(null)
let eventSource: EventSource | null = null let pollTimer: ReturnType<typeof setInterval> | null = null
async function fetchReports(agentId: number, page = 1, size = 20) { async function fetchReports(agentId: number, page = 1, size = 20) {
loading.value = true loading.value = true
error.value = null
try { try {
const res = await http.get(`/memory/${agentId}/dream/reports`, { const res = await http.get(`/memory/${agentId}/dream/reports`, {
params: { page, size }, params: { page, size },
}) })
reports.value = res.data.records || [] reports.value = res.data.records || []
total.value = res.data.total || 0 total.value = res.data.total || 0
} catch (e: any) {
error.value = e.message || 'Failed to load reports'
reports.value = []
total.value = 0
} finally { } finally {
loading.value = false loading.value = false
} }
@ -45,46 +51,34 @@ export const useMemoryStore = defineStore('memory', () => {
try { try {
const res = await http.get(`/memory/${agentId}/dream/reports/${reportId}`) const res = await http.get(`/memory/${agentId}/dream/reports/${reportId}`)
currentReport.value = res.data currentReport.value = res.data
} catch {
currentReport.value = null
} finally { } finally {
loading.value = false loading.value = false
} }
} }
/** /**
* Subscribe to dream SSE events for an agent. * Poll for new dream events instead of SSE.
* Automatically refreshes the report list on new dream events. * SSE via EventSource doesn't support Authorization headers,
* causing 401 errors. Polling every 15s is sufficient for dream events.
*/ */
function subscribeEvents(agentId: number) { function subscribeEvents(agentId: number) {
unsubscribeEvents() unsubscribeEvents()
const token = localStorage.getItem('token') pollTimer = setInterval(() => {
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) fetchReports(agentId, 1, 20)
}) }, 15000)
eventSource.addEventListener('dream.failed', (e) => {
fetchReports(agentId, 1, 20)
})
eventSource.onerror = () => {
// Reconnect after 5s on error
unsubscribeEvents()
setTimeout(() => subscribeEvents(agentId), 5000)
}
} }
function unsubscribeEvents() { function unsubscribeEvents() {
if (eventSource) { if (pollTimer) {
eventSource.close() clearInterval(pollTimer)
eventSource = null pollTimer = null
} }
} }
return { return {
reports, total, loading, currentReport, reports, total, loading, error, currentReport,
fetchReports, fetchReport, fetchReports, fetchReport,
subscribeEvents, unsubscribeEvents, subscribeEvents, unsubscribeEvents,
} }

View File

@ -63,6 +63,13 @@
<template v-if="store.loading"> <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> <div class="skeleton-card" v-for="i in 4" :key="i"><div class="skeleton-line" /><div class="skeleton-line short" /></div>
</template> </template>
<template v-else-if="store.error">
<div class="empty-state error-state">
<div class="empty-icon"></div>
<p>{{ store.error }}</p>
<button class="retry-btn" @click="loadReports">{{ t('memory.retry') }}</button>
</div>
</template>
<template v-else-if="store.reports.length === 0"> <template v-else-if="store.reports.length === 0">
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon">🌙</div> <div class="empty-icon">🌙</div>
@ -208,6 +215,7 @@ function selectAgent(agent: any) {
selectedAgentId.value = agent.id selectedAgentId.value = agent.id
agentDropdownOpen.value = false agentDropdownOpen.value = false
selectedReportId.value = null selectedReportId.value = null
currentPage.value = 1
store.currentReport = null store.currentReport = null
} }
@ -392,6 +400,12 @@ function fmtTime(iso: string) {
.empty-state { display: flex; flex-direction: column; align-items: center; padding: 40px 0; color: var(--mc-text-tertiary); } .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-icon { font-size: 28px; margin-bottom: 8px; }
.empty-state p { font-size: 13px; } .empty-state p { font-size: 13px; }
.error-state p { color: var(--mc-text-secondary); }
.retry-btn {
margin-top: 8px; padding: 6px 16px; border: 1px solid var(--mc-border); border-radius: 8px;
background: transparent; font-size: 12px; color: var(--mc-text-secondary); cursor: pointer;
}
.retry-btn:hover { border-color: var(--mc-primary); color: var(--mc-primary); }
/* ========== Right detail ========== */ /* ========== Right detail ========== */
.memory-detail { .memory-detail {