Files
memind/src/components/MessageList.tsx
T
john 6f3e53a56a 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>
2026-08-01 17:14:06 +08:00

625 lines
20 KiB
TypeScript

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';
import { formatChatTime, shouldShowTimestamp } from '../utils/time';
import { TKMindAvatar } from './TKMindAvatar';
import { UserAvatar } from './UserAvatar';
import { ChatWelcomePanel } from './ChatWelcomePanel';
function CopyIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="8" y="8" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="1.8" />
<path
d="M6 16H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
);
}
function CheckIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M5 12.5l4.5 4.5L19 7.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function PageIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M7 3.5h7.5L19 8v12.5H7z"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path d="M14.5 3.5V8H19M10 12h6M10 15h6M10 18h4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
);
}
function ImageIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="4" y="5" width="16" height="14" rx="2" stroke="currentColor" strokeWidth="1.8" />
<path d="M8 15l3-3 3 3 2-2 3 3" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="9" cy="9" r="1.2" fill="currentColor" />
</svg>
);
}
function DocumentIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M6.5 4h8L18 7.5V20H6.5z"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path d="M14.5 4v4H18" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
<path
d="M8.7 11l1 5 1.3-4 1.3 4 1-5"
stroke="currentColor"
strokeWidth="1.45"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function PlazaIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" className="icon-plaza">
<rect x="4" y="4" width="7" height="7" rx="1.6" stroke="currentColor" strokeWidth="1.7" />
<rect x="13" y="4" width="7" height="7" rx="1.6" stroke="currentColor" strokeWidth="1.7" />
<rect x="4" y="13" width="7" height="7" rx="1.6" stroke="currentColor" strokeWidth="1.7" />
<path
d="M13 16.5h7M16.5 13v7"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
);
}
function LinkIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M10.5 13.5l3-3M9.5 8.5l1.2-1.2a4 4 0 0 1 5.7 5.7l-1.2 1.2M14.5 15.5l-1.2 1.2a4 4 0 0 1-5.7-5.7l1.2-1.2"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ToolActivityIcon({ spinning }: { spinning: boolean }) {
return (
<span className={`tool-badge-spinner${spinning ? ' is-spinning' : ''}`} aria-hidden="true">
<span />
</span>
);
}
function ToolBadgeItem({ spinning }: { spinning: boolean }) {
return (
<span className="tool-badge">
<ToolActivityIcon spinning={spinning} />
<span></span>
</span>
);
}
function hasToolActivity(message: Message) {
return message.content.some((c) => c.type === 'toolRequest' || c.type === 'toolResponse');
}
function isToolOnlyAssistantMessage(message: Message) {
return (
message.role === 'assistant' &&
hasToolActivity(message) &&
!getDisplayText(message).trim() &&
!getThinking(message) &&
getImageUrls(message).length === 0
);
}
function TypingBubble() {
return (
<div className="msg-bubble msg-bubble-assistant msg-typing">
<span className="typing-dots">
<span />
<span />
<span />
</span>
</div>
);
}
function shouldShowEndTypingIndicator(messages: Message[], streaming: boolean) {
if (!streaming) return false;
const lastUserIndex = messages.findLastIndex((message) => message.role === 'user');
if (lastUserIndex < 0) return true;
const assistantReply = messages
.slice(lastUserIndex + 1)
.find((message) => message.role === 'assistant');
if (!assistantReply) return true;
if (getDisplayText(assistantReply).trim() || getThinking(assistantReply)) return false;
if (hasToolActivity(assistantReply)) return false;
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');
if (tools.length === 0) return null;
return (
<div className="tool-badges">
{tools.map((tool, i) => (
<ToolBadgeItem key={`${message.id}-${tool.id}-${i}`} spinning={active} />
))}
</div>
);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}
};
return (
<button
type="button"
className={`msg-copy${copied ? ' msg-copy-done' : ''}`}
aria-label={copied ? '已复制' : '复制'}
title={copied ? '已复制' : '复制'}
onClick={() => void handleCopy()}
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
);
}
function PublicLinkCopyButton({ url }: { url: string }) {
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const copy = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
if (copyTimer.current) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
};
return (
<button
type="button"
className={`msg-public-share-link${copied ? ' is-copied' : ''}`}
onClick={() => void copy()}
aria-label={copied ? '链接已复制' : '复制链接'}
title={copied ? '链接已复制' : '复制链接'}
>
{copied ? <CheckIcon /> : <LinkIcon />}
</button>
);
}
function TimeDivider({ timestamp }: { timestamp: number }) {
return (
<div className="msg-time-divider">
<span>{formatChatTime(timestamp)}</span>
</div>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className={open ? 'msg-actions-chevron is-open' : 'msg-actions-chevron'}
>
<path
d="M9 6l6 6-6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function MessageRow({
message,
avatarUrl,
onAvatarClick,
onSaveAsPage,
onShareToPlaza,
onDownloadLongImage,
onDownloadDocx,
saveDisabled,
publishUserId,
publishUsername,
sessionId,
compact = false,
activeToolMessage = false,
showInlineTyping = false,
memoryRecallByMessageId = {},
messageIndex = 0,
}: {
message: Message;
avatarUrl: string | null;
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
onShareToPlaza?: (message: Message) => void;
onDownloadLongImage?: (message: Message, publicUrl: string) => void;
onDownloadDocx?: (message: Message) => void;
saveDisabled?: boolean;
publishUserId?: string;
publishUsername?: string;
sessionId?: string;
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';
const imageUrls = getImageUrls(message);
const fileAttachments = getFileAttachments(message);
const renderableImageUrls = getRenderableImageUrls(message);
const copyText = [thinking ? `【思考】\n${thinking}` : '', text].filter(Boolean).join('\n\n');
if (!text && !thinking && message.role === 'assistant') {
return (
<div className={`msg-row msg-row-assistant`}>
<TKMindAvatar />
<div className="msg-content">
{showInlineTyping ? (
<TypingBubble />
) : (
<>
<ToolBadge message={message} active={activeToolMessage} />
{memoryRecall && <MemoryRecallBadgeItem recall={memoryRecall} />}
</>
)}
</div>
</div>
);
}
const hasAssistantActions =
!isUser && Boolean(message.id && onSaveAsPage && copyText);
const hasPageDownloadActions = hasAssistantActions && saveActions.kind === 'page' && Boolean(saveActions.previewUrl);
const hasPlazaAction = hasPageDownloadActions && Boolean(onShareToPlaza);
const showActionsToggle = compact && Boolean(copyText);
const renderPageActions = () => (
<div className="msg-page-actions">
<button
type="button"
className="msg-save-page"
disabled={saveDisabled}
onClick={() => onSaveAsPage!(message)}
aria-label={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
title={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
>
<PageIcon />
</button>
{saveActions.previewUrl && <PublicLinkCopyButton url={saveActions.previewUrl} />}
{hasPageDownloadActions && (
<>
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadLongImage?.(message, saveActions.previewUrl!)}
aria-label="下载图片"
title="下载图片"
>
<ImageIcon />
</button>
{hasPlazaAction && (
<button
type="button"
className="msg-save-page icon-plaza"
disabled={saveDisabled}
onClick={() => onShareToPlaza!(message)}
aria-label="发布到 Plaza"
title="发布到 Plaza"
>
<PlazaIcon />
</button>
)}
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadDocx?.(message)}
aria-label="保存文档"
title="保存文档"
>
<DocumentIcon />
</button>
</>
)}
</div>
);
return (
<div className={`msg-row ${isUser ? 'msg-row-user' : 'msg-row-assistant'}`}>
{!isUser && <TKMindAvatar />}
<div className="msg-content">
<div className={`msg-bubble-wrap${compact ? ' msg-bubble-wrap-compact' : ''}`}>
<div className={compact ? 'msg-bubble-row' : undefined}>
<div className={`msg-bubble ${isUser ? 'msg-bubble-user' : 'msg-bubble-assistant'}`}>
{thinking && (
<details className="thinking">
<summary></summary>
<pre>{thinking}</pre>
</details>
)}
{imageUrls.length > 0 && (
<div className="msg-image-gallery">
{renderableImageUrls.map((image, index) => (
<a
key={`${image.href}-${index}`}
href={image.href}
target="_blank"
rel="noreferrer"
className="msg-image-link"
>
<img
src={image.src}
alt={`图片 ${index + 1}`}
className="msg-image-thumb"
loading="lazy"
/>
</a>
))}
</div>
)}
{fileAttachments.length > 0 && (
<div className="msg-file-attachments">
{fileAttachments.map((file, index) => (
<a
key={`${file.downloadUrl}-${index}`}
href={file.downloadUrl}
target="_blank"
rel="noreferrer"
className="msg-file-attachment-link"
>
{file.filename}
</a>
))}
</div>
)}
{text && (
<div
className="bubble-text"
dangerouslySetInnerHTML={{ __html: renderMarkdown(text) }}
/>
)}
<ToolBadge message={message} active={activeToolMessage} />
{memoryRecall && <MemoryRecallBadgeItem recall={memoryRecall} />}
</div>
{showActionsToggle && (
<button
type="button"
className={`msg-actions-toggle${actionsOpen ? ' is-open' : ''}`}
aria-label={actionsOpen ? '收起操作' : '展开操作'}
aria-expanded={actionsOpen}
onClick={() => setActionsOpen((current) => !current)}
>
<ChevronIcon open={actionsOpen} />
</button>
)}
</div>
{copyText && !compact && (
<div className="msg-actions">
<CopyButton text={copyText} />
{hasAssistantActions && renderPageActions()}
</div>
)}
{copyText && compact && actionsOpen && (
<div className="msg-actions msg-actions-compact">
<CopyButton text={copyText} />
{hasAssistantActions && renderPageActions()}
</div>
)}
</div>
</div>
{isUser && (
<UserAvatar
avatarUrl={avatarUrl}
className="msg-avatar msg-avatar-user"
onClick={onAvatarClick}
title={onAvatarClick ? '点击更换头像' : undefined}
/>
)}
</div>
);
}
export function MessageList({
messages,
streaming,
onAvatarClick,
onSaveAsPage,
onShareToPlaza,
onDownloadLongImage,
onDownloadDocx,
publishUserId,
publishUsername,
compact = false,
memoryRecallByMessageId = {},
}: {
messages: Message[];
streaming: boolean;
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
onShareToPlaza?: (message: Message) => void;
onDownloadLongImage?: (message: Message, publicUrl: string) => void;
onDownloadDocx?: (message: Message) => void;
publishUserId?: string;
publishUsername?: string;
compact?: boolean;
memoryRecallByMessageId?: Record<string, { count: number; previews: string[] }>;
}) {
const { avatarUrl } = useUserAvatar();
const renderableMessages = messages.filter(shouldShowChatMessage);
const lastRenderableIndex = renderableMessages.length - 1;
const activeToolMessage =
streaming &&
lastRenderableIndex >= 0 &&
hasToolActivity(renderableMessages[lastRenderableIndex])
? renderableMessages[lastRenderableIndex]
: null;
const visibleMessages = renderableMessages.filter(
(message) => !isToolOnlyAssistantMessage(message) || message === activeToolMessage,
);
const showEndTyping = shouldShowEndTypingIndicator(renderableMessages, streaming);
const lastUserIndex = renderableMessages.findLastIndex((message) => message.role === 'user');
const inlineTypingMessage =
lastUserIndex >= 0
? renderableMessages
.slice(lastUserIndex + 1)
.find(
(message) =>
message.role === 'assistant' &&
!getDisplayText(message).trim() &&
!getThinking(message) &&
!hasToolActivity(message),
) ?? null
: null;
return (
<div className="message-list">
{messages.length === 0 && <ChatWelcomePanel compact={compact} />}
{visibleMessages.map((message, index) => {
const prev = index > 0 ? visibleMessages[index - 1] : undefined;
const showTime = shouldShowTimestamp(message.created, prev?.created);
const anchorId = message.id ?? `msg-${index}-${message.role}-${message.created}`;
return (
<div
key={message.id ?? `msg-${index}`}
className="msg-block"
data-message-anchor={anchorId}
>
{showTime && <TimeDivider timestamp={message.created} />}
<MessageRow
message={message}
avatarUrl={avatarUrl}
onAvatarClick={onAvatarClick}
onSaveAsPage={onSaveAsPage}
onShareToPlaza={onShareToPlaza}
onDownloadLongImage={onDownloadLongImage}
onDownloadDocx={onDownloadDocx}
saveDisabled={streaming}
publishUserId={publishUserId}
publishUsername={publishUsername}
compact={compact}
activeToolMessage={message === activeToolMessage}
showInlineTyping={showEndTyping && message === inlineTypingMessage}
memoryRecallByMessageId={memoryRecallByMessageId}
messageIndex={index}
/>
</div>
);
})}
{showEndTyping && !inlineTypingMessage && (
<div className="msg-row msg-row-assistant" role="status" aria-live="polite" aria-label="助手正在回复">
<TKMindAvatar />
<div className="msg-content">
<TypingBubble />
</div>
</div>
)}
</div>
);
}