Files
memind/src/components/MessageList.tsx
T

411 lines
13 KiB
TypeScript
Raw Normal View History

2026-06-29 22:20:04 +08:00
import { useRef, useState } from 'react';
import { useUserAvatar } from '../hooks/useUserAvatar';
import type { Message } from '../types';
import { getDisplayText, getImageUrls, getRenderableImageUrls, getThinking, shouldShowChatMessage } 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';
2026-06-27 21:57:21 +08:00
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>
);
}
2026-07-02 18:48:16 +08:00
function ToolActivityIcon({ spinning }: { spinning: boolean }) {
2026-06-27 21:57:21 +08:00
return (
2026-07-02 18:48:16 +08:00
<span className={`tool-badge-spinner${spinning ? ' is-spinning' : ''}`} aria-hidden="true">
<span />
</span>
2026-06-27 21:57:21 +08:00
);
}
2026-07-02 18:48:16 +08:00
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');
2026-06-29 07:00:29 +08:00
}
2026-07-02 18:48:16 +08:00
function isToolOnlyAssistantMessage(message: Message) {
return (
message.role === 'assistant' &&
hasToolActivity(message) &&
!getDisplayText(message).trim() &&
!getThinking(message) &&
getImageUrls(message).length === 0
);
}
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;
2026-06-27 21:57:21 +08:00
return (
<div className="tool-badges">
2026-07-02 18:48:16 +08:00
{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>
);
}
2026-06-29 22:20:04 +08:00
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,
2026-06-27 21:57:21 +08:00
sessionId,
compact = false,
2026-07-02 18:48:16 +08:00
activeToolMessage = false,
}: {
message: Message;
avatarUrl: string | null;
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
saveDisabled?: boolean;
publishUserId?: string;
publishUsername?: string;
2026-06-27 21:57:21 +08:00
sessionId?: string;
compact?: boolean;
2026-07-02 18:48:16 +08:00
activeToolMessage?: 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">
2026-07-02 18:48:16 +08:00
<ToolBadge message={message} active={activeToolMessage} />
</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) }}
/>
)}
2026-07-02 18:48:16 +08:00
<ToolBadge message={message} active={activeToolMessage} />
</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>
2026-06-29 22:20:04 +08:00
{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>
2026-06-29 22:20:04 +08:00
{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,
2026-06-27 21:57:21 +08:00
sessionId,
compact = false,
}: {
messages: Message[];
streaming: boolean;
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
publishUserId?: string;
publishUsername?: string;
2026-06-27 21:57:21 +08:00
sessionId?: string;
compact?: boolean;
}) {
const { avatarUrl } = useUserAvatar();
2026-07-02 18:48:16 +08:00
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,
);
return (
<div className="message-list">
2026-06-27 21:57:21 +08:00
{messages.length === 0 && <ChatWelcomePanel compact={compact} />}
2026-07-02 18:48:16 +08:00
{visibleMessages.map((message, index) => {
const prev = index > 0 ? visibleMessages[index - 1] : undefined;
const showTime = shouldShowTimestamp(message.created, prev?.created);
2026-06-29 22:20:04 +08:00
const anchorId = message.id ?? `msg-${index}-${message.role}-${message.created}`;
return (
2026-06-29 22:20:04 +08:00
<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}
2026-06-27 21:57:21 +08:00
sessionId={sessionId}
compact={compact}
2026-07-02 18:48:16 +08:00
activeToolMessage={message === activeToolMessage}
/>
</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>
);
}