Files
memind/src/components/MessageList.tsx
T
2026-06-29 22:20:04 +08:00

410 lines
12 KiB
TypeScript

import { useRef, useState } from 'react';
import { useUserAvatar } from '../hooks/useUserAvatar';
import type { Message } from '../types';
import { getDisplayText, getImageUrls, getRenderableImageUrls, getThinking } from '../utils/message';
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 ToolDoneIcon() {
return (
<svg width="14" height="14" 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 resolveToolCallName(
tool: Extract<Message['content'][number], { type: 'toolRequest' }>,
): string {
const toolCall = tool.toolCall as {
functionName?: string;
value?: { name?: string };
};
return String(toolCall.functionName ?? toolCall.value?.name ?? '').trim();
}
function ToolBadge({ message }: { message: Message }) {
const tools = message.content.filter((c) => c.type === 'toolRequest' || c.type === 'toolResponse');
if (tools.length === 0) return null;
const completed = tools.filter((t) => t.type === 'toolResponse').length;
const total = tools.length;
return (
<div className="tool-badges">
{tools.map((tool, i) => {
const isRequest = tool.type === 'toolRequest';
const toolName = isRequest ? resolveToolCallName(tool) : '';
let label: string;
if (isRequest && !toolName) {
label = `正在加载 tools (${completed}/${total})`;
} else {
label = isRequest ? toolName : '工具完成';
}
return (
<span
key={`${message.id}-${i}`}
className={`tool-badge${isRequest ? ' is-pending' : ' is-complete'}`}
>
{!isRequest ? <ToolDoneIcon /> : null}
<span>{label}</span>
</span>
);
})}
</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()}
title={url}
>
{copied ? '已复制' : '复制链接'}
</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,
saveDisabled,
publishUserId,
publishUsername,
sessionId,
compact = false,
}: {
message: Message;
avatarUrl: string | null;
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
saveDisabled?: boolean;
publishUserId?: string;
publishUsername?: string;
sessionId?: string;
compact?: boolean;
}) {
const [actionsOpen, setActionsOpen] = useState(false);
const rawText = getDisplayText(message);
const text = filterText(rawText);
const saveActions = getMessageSaveActions(text, { userId: publishUserId, username: publishUsername });
const thinking = getThinking(message);
const isUser = message.role === 'user';
const imageUrls = getImageUrls(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">
<ToolBadge message={message} />
</div>
</div>
);
}
const hasAssistantActions =
!isUser && Boolean(message.id && onSaveAsPage && copyText);
const showActionsToggle = compact && Boolean(copyText);
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>
)}
{text && (
<div
className="bubble-text"
dangerouslySetInnerHTML={{ __html: renderMarkdown(text) }}
/>
)}
<ToolBadge message={message} />
</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 && (
<>
<button
type="button"
className="msg-save-page"
disabled={saveDisabled}
onClick={() => onSaveAsPage!(message)}
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
</button>
{saveActions.previewUrl && (
<PublicLinkCopyButton url={saveActions.previewUrl} />
)}
</>
)}
</div>
)}
{copyText && compact && actionsOpen && (
<div className="msg-actions msg-actions-compact">
<CopyButton text={copyText} />
{hasAssistantActions && (
<>
<button
type="button"
className="msg-save-page"
disabled={saveDisabled}
onClick={() => onSaveAsPage!(message)}
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
</button>
{saveActions.previewUrl && (
<PublicLinkCopyButton url={saveActions.previewUrl} />
)}
</>
)}
</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,
publishUserId,
publishUsername,
sessionId,
compact = false,
}: {
messages: Message[];
streaming: boolean;
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
publishUserId?: string;
publishUsername?: string;
sessionId?: string;
compact?: boolean;
}) {
const { avatarUrl } = useUserAvatar();
return (
<div className="message-list">
{messages.length === 0 && <ChatWelcomePanel compact={compact} />}
{messages.map((message, index) => {
const prev = index > 0 ? messages[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}
saveDisabled={streaming}
publishUserId={publishUserId}
publishUsername={publishUsername}
sessionId={sessionId}
compact={compact}
/>
</div>
);
})}
{streaming && (
<div className="msg-row msg-row-assistant">
<TKMindAvatar />
<div className="msg-content">
<div className="msg-bubble msg-bubble-assistant msg-typing">
<span className="typing-dots">
<span />
<span />
<span />
</span>
</div>
</div>
</div>
)}
</div>
);
}