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:
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user