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
<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)
[![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">
</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)
[![文档](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) {
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();
// Fallback: use abs(hashCode) to avoid negative IDs, add offset to avoid collision with real IDs
return Math.abs((long) auth.getName().hashCode()) + 1_000_000_000L;
} catch (Exception e) {
return null;
}

View File

@ -1,11 +1,13 @@
package vip.mate.memory.fact.projection;
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.MemoryProperties;
import vip.mate.memory.fact.extraction.CompositeEntityExtractor;
import vip.mate.memory.fact.extraction.ExtractedFact;
import vip.mate.memory.fact.model.FactEntity;
import vip.mate.memory.fact.repository.FactMapper;
import vip.mate.workspace.document.WorkspaceFileService;
import vip.mate.workspace.document.model.WorkspaceFileEntity;
@ -18,9 +20,10 @@ import java.util.List;
* Rebuilds the fact projection from canonical sources.
* <p>
* 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>
* 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
*/
@ -65,13 +68,11 @@ public class FactProjectionBuilder {
allFacts.addAll(extractor.extract(agentId, "MEMORY.md", memoryFile.getContent()));
}
// Upsert all extracted facts
// Upsert all extracted facts (dialect-safe)
LocalDateTime now = LocalDateTime.now();
List<String> keepRefs = new ArrayList<>();
for (ExtractedFact fact : allFacts) {
factMapper.upsertDerivedH2(agentId, fact.sourceRef(), fact.category(),
fact.subject(), fact.predicate(), fact.objectValue(),
fact.confidence(), 0.5, fact.extractedBy(), now, now);
upsertDerived(agentId, fact, now);
keepRefs.add(fact.sourceRef());
}
@ -93,11 +94,50 @@ public class FactProjectionBuilder {
List<ExtractedFact> facts = extractor.extract(agentId, filename, content);
LocalDateTime now = LocalDateTime.now();
for (ExtractedFact fact : facts) {
factMapper.upsertDerivedH2(agentId, fact.sourceRef(), fact.category(),
fact.subject(), fact.predicate(), fact.objectValue(),
fact.confidence(), 0.5, fact.extractedBy(), now, now);
upsertDerived(agentId, fact, now);
}
log.debug("[FactProjection] rebuildOne: agent={}, file={}, facts={}", agentId, filename, 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:
* - Derived columns: only FactProjectionBuilder may write
* - Derived columns: only FactProjectionBuilder may write (via upsertDerived)
* - Accumulated columns: only bumpUseCount may write
*
* @author MateClaw Team
@ -19,29 +19,6 @@ import java.util.List;
@Mapper
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.
* 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.
*/
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
DreamReportEntity latestReport = dreamReportMapper.selectOne(
new LambdaQueryWrapper<DreamReportEntity>()

View File

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

View File

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

View File

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

View File

@ -63,6 +63,13 @@
<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.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">
<div class="empty-state">
<div class="empty-icon">🌙</div>
@ -208,6 +215,7 @@ function selectAgent(agent: any) {
selectedAgentId.value = agent.id
agentDropdownOpen.value = false
selectedReportId.value = null
currentPage.value = 1
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-icon { font-size: 28px; margin-bottom: 8px; }
.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 ========== */
.memory-detail {