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
+7
View File
@@ -39,6 +39,7 @@ import type {
PlanDefinition,
ActiveSubscription,
UserMemorySyncResponse,
UserMemoryRecallHintResponse,
} from '../types';
import { normalizeConversationMessages, normalizeUserMessageForApi } from '../utils/message';
import type { AgentRunCreateOptions, AgentRunValidation } from '../utils/agentRunMode';
@@ -238,6 +239,12 @@ export async function rememberUserMemory(sessionId: string): Promise<UserMemoryS
});
}
export async function fetchMemoryRecallHint(sessionId: string): Promise<UserMemoryRecallHintResponse> {
return apiFetch<UserMemoryRecallHintResponse>(
`/user-memory/v1/recall-hint?sessionId=${encodeURIComponent(sessionId)}`,
);
}
export async function syncUserMemory(sessionId: string): Promise<UserMemorySyncResponse> {
return apiFetch<UserMemorySyncResponse>('/user-memory/v1/sync', {
method: 'POST',
+2
View File
@@ -187,6 +187,7 @@ export function ChatView({
sessionsHasMore,
sessionSearchQuery,
messages,
memoryRecallByMessageId,
messageHistoryLoadingMore,
messageHistoryHasMore,
messageHistoryTotal,
@@ -559,6 +560,7 @@ export function ChatView({
variant="full"
user={user}
messages={messages}
memoryRecallByMessageId={memoryRecallByMessageId}
historyLoadingMore={messageHistoryLoadingMore}
historyHasMore={messageHistoryHasMore}
historyTotal={messageHistoryTotal}
+47 -1
View File
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
import { useUserAvatar } from '../hooks/useUserAvatar';
import type { Message } from '../types';
import { getDisplayText, getFileAttachments, getImageUrls, getRenderableImageUrls, getThinking, shouldShowChatMessage } from '../utils/message';
import { resolveMessageRecallKey } from '../utils/memoryFeedback';
import { getMessageSaveActions } from '../utils/messageSave';
import { renderMarkdown } from '../utils/markdown';
import { filterText } from '../utils/wordFilter';
@@ -169,6 +170,38 @@ function shouldShowEndTypingIndicator(messages: Message[], streaming: boolean) {
return true;
}
function MemoryRecallBadgeItem({
recall,
}: {
recall: { count: number; previews: string[] };
}) {
const [open, setOpen] = useState(false);
const hasPreviews = recall.previews.length > 0;
return (
<div className="memory-recall-badge-wrap">
<button
type="button"
className={`memory-recall-badge${open ? ' is-open' : ''}`}
aria-expanded={hasPreviews ? open : undefined}
onClick={() => {
if (hasPreviews) setOpen((current) => !current);
}}
>
<span> {recall.count} </span>
{hasPreviews && <ChevronIcon open={open} />}
</button>
{open && hasPreviews && (
<ul className="memory-recall-previews">
{recall.previews.map((preview, index) => (
<li key={`${index}-${preview.slice(0, 12)}`}>{preview}</li>
))}
</ul>
)}
</div>
);
}
function ToolBadge({ message, active }: { message: Message; active: boolean }) {
if (message.role !== 'assistant') return null;
const tools = message.content.filter((c) => c.type === 'toolRequest' || c.type === 'toolResponse');
@@ -291,6 +324,8 @@ function MessageRow({
compact = false,
activeToolMessage = false,
showInlineTyping = false,
memoryRecallByMessageId = {},
messageIndex = 0,
}: {
message: Message;
avatarUrl: string | null;
@@ -306,10 +341,13 @@ function MessageRow({
compact?: boolean;
activeToolMessage?: boolean;
showInlineTyping?: boolean;
memoryRecallByMessageId?: Record<string, { count: number; previews: string[] }>;
messageIndex?: number;
}) {
const [actionsOpen, setActionsOpen] = useState(false);
const rawText = getDisplayText(message);
const text = filterText(rawText);
const memoryRecall = memoryRecallByMessageId[resolveMessageRecallKey(message, messageIndex)];
const saveActions = getMessageSaveActions(text, { userId: publishUserId, username: publishUsername });
const thinking = getThinking(message);
const isUser = message.role === 'user';
@@ -326,7 +364,10 @@ function MessageRow({
{showInlineTyping ? (
<TypingBubble />
) : (
<ToolBadge message={message} active={activeToolMessage} />
<>
<ToolBadge message={message} active={activeToolMessage} />
{memoryRecall && <MemoryRecallBadgeItem recall={memoryRecall} />}
</>
)}
</div>
</div>
@@ -444,6 +485,7 @@ function MessageRow({
/>
)}
<ToolBadge message={message} active={activeToolMessage} />
{memoryRecall && <MemoryRecallBadgeItem recall={memoryRecall} />}
</div>
{showActionsToggle && (
<button
@@ -494,6 +536,7 @@ export function MessageList({
publishUserId,
publishUsername,
compact = false,
memoryRecallByMessageId = {},
}: {
messages: Message[];
streaming: boolean;
@@ -505,6 +548,7 @@ export function MessageList({
publishUserId?: string;
publishUsername?: string;
compact?: boolean;
memoryRecallByMessageId?: Record<string, { count: number; previews: string[] }>;
}) {
const { avatarUrl } = useUserAvatar();
const renderableMessages = messages.filter(shouldShowChatMessage);
@@ -561,6 +605,8 @@ export function MessageList({
compact={compact}
activeToolMessage={message === activeToolMessage}
showInlineTyping={showEndTyping && message === inlineTypingMessage}
memoryRecallByMessageId={memoryRecallByMessageId}
messageIndex={index}
/>
</div>
);
+21 -1
View File
@@ -1,12 +1,14 @@
import { useEffect, useMemo } from 'react';
import { useChat } from '../context/ChatProvider';
import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
import { useGoalRunBanner } from '../hooks/useGoalRunBanner';
import type { PageEditSubChatBridge } from '../hooks/usePageEditSubChat';
import type { MindSpaceChatContext, MindSpaceSaveCategory, PortalUser, Message } from '../types';
import { formatContextChip } from '../utils/mindspaceChatContext';
import { shouldShowChatMessage } from '../utils/message';
import { ChatPanel } from './ChatPanel';
import { TKMindAvatar } from './TKMindAvatar';
import { GoalRunAwaitingBanner } from './GoalRunAwaitingBanner';
export function SpaceChatPanel({
open,
@@ -53,7 +55,15 @@ export function SpaceChatPanel({
uploadChatAttachment,
retryConnect,
} = chat;
const { capabilities, grantedSkills } = mainChat;
const { capabilities, grantedSkills, followAgentRun, goalRunEnabled } = mainChat;
const goalRunBanner = useGoalRunBanner({
userId: user.id,
sessionId: session?.id,
chatState,
submit: (text, options) => void submit(text, options),
followAgentRun,
featureEnabled: goalRunEnabled,
});
useEffect(() => {
if (!open) return;
@@ -130,6 +140,16 @@ export function SpaceChatPanel({
</div>
)}
{goalRunBanner.enabled && (
<GoalRunAwaitingBanner
items={goalRunBanner.items}
busy={goalRunBanner.bannerBusy}
onContinue={goalRunBanner.handleContinue}
onDismiss={goalRunBanner.dismissItem}
onRefresh={goalRunBanner.refresh}
/>
)}
<ChatPanel
variant="compact"
user={user}
+33 -15
View File
@@ -16,6 +16,7 @@ import {
listSessions,
loadSessionDetail,
readConfig,
fetchMemoryRecallHint,
rememberUserMemory,
rememberProjectContext,
uploadMindSpaceAsset,
@@ -40,6 +41,7 @@ import type {
CapabilityMap,
ChatFileAttachment,
ChatState,
MemoryRecallBadge,
Message,
MindSpaceChatContext,
PortalUser,
@@ -78,6 +80,7 @@ import {
isRelayServerErrorMessage,
pushMessage,
} from '../utils/message';
import { formatMemoryHintNotice, formatUserMemorySyncNotice, buildMemoryRecallBadge, resolveMessageRecallKey } from '../utils/memoryFeedback';
import {
appendSessionLists,
prependUnique,
@@ -358,6 +361,7 @@ export function useTKMindChat(
const [sessionsHasMore, setSessionsHasMore] = useState(false);
const [sessionSearchQuery, setSessionSearchQueryState] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [memoryRecallByMessageId, setMemoryRecallByMessageId] = useState<Record<string, MemoryRecallBadge>>({});
const [messageHistoryLoadingMore, setMessageHistoryLoadingMore] = useState(false);
const [messageHistoryHasMore, setMessageHistoryHasMore] = useState(false);
const [messageHistoryTotal, setMessageHistoryTotal] = useState(0);
@@ -585,21 +589,8 @@ export function useTKMindChat(
}
}, [canUseProjectMemory]);
const formatUserMemoryNotice = useCallback((result: {
analyzed: number;
memories: number;
totalMemories: number;
syncedToSession: boolean;
}) => {
const fragments = [];
if (result.memories > 0) {
fragments.push(`新增 ${result.memories} 条长期记忆`);
} else if (result.analyzed > 0) {
fragments.push('已分析最近对话,暂无新的长期记忆');
} else {
fragments.push('没有新的用户对话可提炼');
}
fragments.push(`当前累计 ${result.totalMemories}`);
const formatUserMemoryNotice = useCallback((result: Parameters<typeof formatUserMemorySyncNotice>[0]) => {
const fragments = [formatUserMemorySyncNotice(result)];
if (result.syncedToSession) {
fragments.push('已同步到当前会话');
}
@@ -1022,6 +1013,30 @@ export function useTKMindChat(
}
void (async () => {
await syncSessionMessages(sessionId);
if (canUseLongTermMemory) {
try {
const hint = await fetchMemoryRecallHint(sessionId);
const memoryNotice = formatMemoryHintNotice(hint);
if (memoryNotice) setNotice(memoryNotice);
const recallBadge = buildMemoryRecallBadge(hint);
if (recallBadge) {
const currentMessages = messagesRef.current;
const lastAssistantIndex = currentMessages.findLastIndex(
(message) => message.role === 'assistant',
);
if (lastAssistantIndex >= 0) {
const target = currentMessages[lastAssistantIndex];
const recallKey = resolveMessageRecallKey(target, lastAssistantIndex);
setMemoryRecallByMessageId((prev) => ({
...prev,
[recallKey]: recallBadge,
}));
}
}
} catch {
// Memory hint is best-effort UX feedback.
}
}
const recentContext = messagesRef.current
.filter((message) => getDisplayText(message).trim())
.slice(-6)
@@ -1172,6 +1187,7 @@ export function useTKMindChat(
setMessageHistoryTotal(0);
setMessageHistoryLoadingMore(false);
setMessages([]);
setMemoryRecallByMessageId({});
setChatState('connecting');
}, [clearActiveRequestMissingTimer]);
@@ -1886,6 +1902,7 @@ export function useTKMindChat(
setMessageHistoryTotal(0);
setMessageHistoryLoadingMore(false);
setMessages([]);
setMemoryRecallByMessageId({});
setSession(null);
setError(null);
setPendingTool(null);
@@ -2042,6 +2059,7 @@ export function useTKMindChat(
sessionsHasMore,
sessionSearchQuery,
messages,
memoryRecallByMessageId,
messageHistoryLoadingMore,
messageHistoryHasMore,
messageHistoryTotal,
+87
View File
@@ -1062,6 +1062,54 @@ body,
border-bottom: 1px solid var(--color-border-warning);
}
.goal-run-awaiting-stack {
display: flex;
flex-direction: column;
gap: 0;
}
.goal-run-awaiting-banner {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
border-bottom: 1px solid var(--color-border-subtle);
}
.goal-run-awaiting-banner-body {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.goal-run-awaiting-summary {
color: var(--color-text-muted);
white-space: pre-wrap;
word-break: break-word;
}
.goal-run-awaiting-feedback {
width: 100%;
resize: vertical;
min-height: 52px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
padding: 8px 10px;
font: inherit;
background: var(--color-bg-surface);
}
.goal-run-awaiting-error {
color: var(--color-text-error);
}
.goal-run-awaiting-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
justify-content: flex-end;
}
.text-error {
color: var(--color-text-error);
}
@@ -2372,6 +2420,45 @@ body,
color: var(--color-text-muted);
}
.memory-recall-badge-wrap {
margin-top: var(--space-sm);
}
.memory-recall-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
background: color-mix(in srgb, var(--color-accent, #4f7cff) 12%, var(--color-bg-code));
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-pill);
color: var(--color-text-muted);
border: none;
cursor: pointer;
}
.memory-recall-badge:not(:has(+ .memory-recall-previews)) {
cursor: default;
}
.memory-recall-badge .msg-actions-chevron {
width: 12px;
height: 12px;
}
.memory-recall-previews {
list-style: none;
margin: var(--space-xs) 0 0;
padding: var(--space-xs) var(--space-sm);
border-left: 2px solid color-mix(in srgb, var(--color-accent, #4f7cff) 35%, transparent);
font-size: 12px;
color: var(--color-text-faint);
}
.memory-recall-previews li + li {
margin-top: 4px;
}
.tool-badge-spinner {
width: 14px;
height: 14px;
+26
View File
@@ -179,6 +179,20 @@ export type UserMemorySyncResponse = {
memories: number;
totalMemories: number;
syncedToSession: boolean;
personalMemory?: {
savedPreviews: Array<{
preview: string;
policyReason: string;
memoryType: string;
}>;
autoReviewed: number;
pendingReview: number;
};
};
export type MemoryRecallBadge = {
count: number;
previews: string[];
};
export type GoalRunCheckpoint = {
@@ -215,6 +229,18 @@ export type GoalRun = {
completedAt: number | null;
};
export type UserMemoryRecallHintResponse = {
ok: true;
memoryCount: number;
injectionEnabled: boolean;
mode: string | null;
createdAt: number | null;
runId?: string | null;
savedPreview?: string | null;
savedAt?: number | null;
memoryPreviews?: string[];
};
export type ChatState = 'idle' | 'loading' | 'connecting' | 'streaming' | 'waiting' | 'error';
export type ToolConfirmation = {
+56
View File
@@ -0,0 +1,56 @@
import type { UserMemoryRecallHintResponse, UserMemorySyncResponse } from '../types';
export type { MemoryRecallBadge } from '../types';
export function formatPersonalMemorySavedNotice(
result: Pick<UserMemorySyncResponse, 'personalMemory'>,
): string | null {
const preview = result.personalMemory?.savedPreviews?.[0]?.preview;
if (!preview) return null;
return `已记住:${preview}${preview.length >= 60 ? '…' : ''}`;
}
export function formatMemoryRecallNotice(count: number): string | null {
if (!Number.isFinite(count) || count <= 0) return null;
return `参考了你的 ${count} 条记忆`;
}
export function formatMemoryHintNotice(hint: UserMemoryRecallHintResponse): string | null {
if (hint.injectionEnabled && hint.memoryCount > 0) {
return formatMemoryRecallNotice(hint.memoryCount);
}
if (hint.savedPreview) {
const preview = hint.savedPreview;
return `已记住:${preview}${preview.length >= 60 ? '…' : ''}`;
}
return null;
}
export function formatUserMemorySyncNotice(result: UserMemorySyncResponse): string {
const savedNotice = formatPersonalMemorySavedNotice(result);
if (savedNotice) return savedNotice;
if (result.memories > 0) {
return `新增 ${result.memories} 条长期记忆,当前累计 ${result.totalMemories}`;
}
if (result.analyzed > 0) {
return `已分析最近对话,暂无新的长期记忆,当前累计 ${result.totalMemories}`;
}
return `没有新的用户对话可提炼,当前累计 ${result.totalMemories}`;
}
export function buildMemoryRecallBadge(
hint: UserMemoryRecallHintResponse,
): { count: number; previews: string[] } | null {
if (!hint.injectionEnabled || hint.memoryCount <= 0) return null;
return {
count: hint.memoryCount,
previews: hint.memoryPreviews ?? [],
};
}
export function resolveMessageRecallKey(
message: { id?: string | null; role: string; created: number },
index: number,
): string {
return message.id ?? `msg-${index}-${message.role}-${message.created}`;
}