feat(memory-v2): close Phase A with auto-review, product events, and H5 recall UI.

Add candidate auto-review pipeline, shadow audit tooling, admin metrics page,
and user-visible memory recall hints in chat with phase-a readiness checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-01 17:14:06 +08:00
parent 666db0b939
commit 6f3e53a56a
50 changed files with 3730 additions and 38 deletions
+192
View File
@@ -0,0 +1,192 @@
import { useCallback, useEffect, useState } from 'react';
import {
fetchMemoryV2Candidates,
fetchMemoryV2Metrics,
reviewMemoryV2Candidate,
runMemoryV2CandidateAutoReview,
type MemoryV2CandidateRow,
type MemoryV2MetricsResponse,
} from '../../api/admin';
function formatTime(value: number | null | undefined) {
if (!value) return '—';
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
function formatPercent(value: number | null | undefined) {
if (value == null || !Number.isFinite(value)) return '—';
return `${(value * 100).toFixed(1)}%`;
}
export function MemoryV2Page() {
const [metrics, setMetrics] = useState<MemoryV2MetricsResponse | null>(null);
const [candidates, setCandidates] = useState<MemoryV2CandidateRow[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [reviewingId, setReviewingId] = useState<string | null>(null);
const [autoReviewBusy, setAutoReviewBusy] = useState(false);
const [autoReviewNote, setAutoReviewNote] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [metricsRes, candidatesRes] = await Promise.all([
fetchMemoryV2Metrics({ since: '7d' }),
fetchMemoryV2Candidates({ status: 'candidate', limit: 50 }),
]);
setMetrics(metricsRes);
setCandidates(candidatesRes.items);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
const runAutoReview = async () => {
setAutoReviewBusy(true);
setAutoReviewNote(null);
try {
const result = await runMemoryV2CandidateAutoReview({ limit: 200 });
setAutoReviewNote(
`自动审核完成:接受 ${result.accepted},拒绝 ${result.rejected},仍待人工 ${result.pending}`,
);
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : '自动审核失败');
} finally {
setAutoReviewBusy(false);
}
};
const review = async (id: string, status: 'accepted' | 'rejected') => {
setReviewingId(id);
try {
await reviewMemoryV2Candidate(id, status);
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : '审核失败');
} finally {
setReviewingId(null);
}
};
if (loading && !metrics) return <p></p>;
return (
<div className="grid">
{error && <p className="alert">{error}</p>}
{autoReviewNote && <p className="alert">{autoReviewNote}</p>}
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center' }}>
<div>
<h2 style={{ marginTop: 0, marginBottom: 4 }}>Memory V2 </h2>
<p style={{ color: '#68716c', margin: 0 }}> {metrics?.metrics.window.since ?? '7d'} shadow </p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn"
disabled={autoReviewBusy}
onClick={() => void runAutoReview()}
>
{autoReviewBusy ? '自动审核中…' : '执行自动审核'}
</button>
<button type="button" className="btn secondary" onClick={() => void reload()}>
</button>
</div>
</div>
{metrics && (
<>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
gap: 12,
marginTop: 16,
}}
>
{Object.entries(metrics.metrics.events).map(([key, count]) => (
<div key={key}>
<p style={{ color: '#68716c', marginBottom: 4 }}>{key}</p>
<strong style={{ fontSize: 24 }}>{count}</strong>
</div>
))}
</div>
<div style={{ marginTop: 16, display: 'grid', gap: 8 }}>
<p style={{ margin: 0 }}>{formatPercent(metrics.audit.falseStoreRate)}</p>
<p style={{ margin: 0 }}>{formatPercent(metrics.audit.autoAcceptRate)}</p>
<p style={{ margin: 0 }}>resolve {formatPercent(metrics.audit.resolveHitRate)}</p>
<p style={{ margin: 0 }}>
{Object.entries(metrics.candidateCounts)
.map(([status, count]) => `${status} × ${count}`)
.join('') || '—'}
</p>
</div>
</>
)}
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>{candidates.length}</h3>
<p style={{ color: '#68716c', marginTop: 0 }}>
canary //decision_signal
</p>
{candidates.length === 0 ? (
<p style={{ color: '#68716c' }}></p>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{candidates.map((item) => (
<div
key={item.id}
style={{
border: '1px solid #e6ece8',
borderRadius: 12,
padding: 12,
display: 'grid',
gap: 8,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<strong>{item.policyReason}</strong>
<span style={{ color: '#68716c' }}>{formatTime(item.createdAt)}</span>
</div>
<p style={{ margin: 0 }}>{item.content}</p>
<p style={{ margin: 0, color: '#68716c', fontSize: 13 }}>
user={item.userId} · type={item.memoryType} · session={item.sessionId ?? '—'}
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn"
disabled={reviewingId === item.id}
onClick={() => void review(item.id, 'accepted')}
>
</button>
<button
type="button"
className="btn secondary"
disabled={reviewingId === item.id}
onClick={() => void review(item.id, 'rejected')}
>
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+3
View File
@@ -71,6 +71,9 @@ export function SummaryPage() {
<Link className="btn secondary" to="/admin/llm" style={{ textAlign: 'center', textDecoration: 'none' }}>
/ H5
</Link>
<Link className="btn secondary" to="/admin/memory-v2" style={{ textAlign: 'center', textDecoration: 'none' }}>
Memory V2
</Link>
<Link className="btn secondary" to="/admin/goal-runs" style={{ textAlign: 'center', textDecoration: 'none' }}>
Goal Run
</Link>