2026-06-29 22:20:04 +08:00
|
|
|
import { useRef, useState } from 'react';
|
2026-06-15 15:04:43 -07:00
|
|
|
import { useUserAvatar } from '../hooks/useUserAvatar';
|
|
|
|
|
import type { Message } from '../types';
|
2026-06-30 09:30:51 +08:00
|
|
|
import { getDisplayText, getImageUrls, getRenderableImageUrls, getThinking, shouldShowChatMessage } from '../utils/message';
|
2026-06-15 15:04:43 -07:00
|
|
|
import { getMessageSaveActions } from '../utils/messageSave';
|
|
|
|
|
import { renderMarkdown } from '../utils/markdown';
|
2026-06-26 15:19:03 +08:00
|
|
|
import { filterText } from '../utils/wordFilter';
|
2026-06-15 15:04:43 -07:00
|
|
|
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';
|
2026-06-15 15:04:43 -07:00
|
|
|
|
|
|
|
|
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;
|
2026-06-15 15:04:43 -07:00
|
|
|
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
|
|
|
|
2026-06-15 15:04:43 -07: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} />
|
|
|
|
|
))}
|
2026-06-15 15:04:43 -07:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
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,
|
2026-06-26 15:19:03 +08:00
|
|
|
publishUserId,
|
2026-06-15 15:04:43 -07:00
|
|
|
publishUsername,
|
2026-06-27 21:57:21 +08:00
|
|
|
sessionId,
|
2026-06-15 15:04:43 -07:00
|
|
|
compact = false,
|
2026-07-02 18:48:16 +08:00
|
|
|
activeToolMessage = false,
|
2026-06-15 15:04:43 -07:00
|
|
|
}: {
|
|
|
|
|
message: Message;
|
|
|
|
|
avatarUrl: string | null;
|
|
|
|
|
onAvatarClick?: () => void;
|
|
|
|
|
onSaveAsPage?: (message: Message) => void;
|
|
|
|
|
saveDisabled?: boolean;
|
2026-06-26 15:19:03 +08:00
|
|
|
publishUserId?: string;
|
2026-06-15 15:04:43 -07:00
|
|
|
publishUsername?: string;
|
2026-06-27 21:57:21 +08:00
|
|
|
sessionId?: string;
|
2026-06-15 15:04:43 -07:00
|
|
|
compact?: boolean;
|
2026-07-02 18:48:16 +08:00
|
|
|
activeToolMessage?: boolean;
|
2026-06-15 15:04:43 -07:00
|
|
|
}) {
|
|
|
|
|
const [actionsOpen, setActionsOpen] = useState(false);
|
2026-06-26 15:19:03 +08:00
|
|
|
const rawText = getDisplayText(message);
|
|
|
|
|
const text = filterText(rawText);
|
|
|
|
|
const saveActions = getMessageSaveActions(text, { userId: publishUserId, username: publishUsername });
|
2026-06-15 15:04:43 -07:00
|
|
|
const thinking = getThinking(message);
|
|
|
|
|
const isUser = message.role === 'user';
|
2026-06-19 23:06:43 +08:00
|
|
|
const imageUrls = getImageUrls(message);
|
2026-06-28 12:18:26 +08:00
|
|
|
const renderableImageUrls = getRenderableImageUrls(message);
|
2026-06-15 15:04:43 -07:00
|
|
|
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} />
|
2026-06-15 15:04:43 -07:00
|
|
|
</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>
|
|
|
|
|
)}
|
2026-06-19 23:06:43 +08:00
|
|
|
{imageUrls.length > 0 && (
|
|
|
|
|
<div className="msg-image-gallery">
|
2026-06-28 12:18:26 +08:00
|
|
|
{renderableImageUrls.map((image, index) => (
|
2026-06-19 23:06:43 +08:00
|
|
|
<a
|
2026-06-28 12:18:26 +08:00
|
|
|
key={`${image.href}-${index}`}
|
|
|
|
|
href={image.href}
|
2026-06-19 23:06:43 +08:00
|
|
|
target="_blank"
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
className="msg-image-link"
|
|
|
|
|
>
|
|
|
|
|
<img
|
2026-06-28 12:18:26 +08:00
|
|
|
src={image.src}
|
2026-06-19 23:06:43 +08:00
|
|
|
alt={`图片 ${index + 1}`}
|
|
|
|
|
className="msg-image-thumb"
|
|
|
|
|
loading="lazy"
|
|
|
|
|
/>
|
|
|
|
|
</a>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-15 15:04:43 -07:00
|
|
|
{text && (
|
|
|
|
|
<div
|
|
|
|
|
className="bubble-text"
|
|
|
|
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(text) }}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-07-02 18:48:16 +08:00
|
|
|
<ToolBadge message={message} active={activeToolMessage} />
|
2026-06-15 15:04:43 -07:00
|
|
|
</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} />
|
2026-06-15 15:04:43 -07:00
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</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} />
|
2026-06-15 15:04:43 -07:00
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</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,
|
2026-06-26 15:19:03 +08:00
|
|
|
publishUserId,
|
2026-06-15 15:04:43 -07:00
|
|
|
publishUsername,
|
2026-06-27 21:57:21 +08:00
|
|
|
sessionId,
|
2026-06-15 15:04:43 -07:00
|
|
|
compact = false,
|
|
|
|
|
}: {
|
|
|
|
|
messages: Message[];
|
|
|
|
|
streaming: boolean;
|
|
|
|
|
onAvatarClick?: () => void;
|
|
|
|
|
onSaveAsPage?: (message: Message) => void;
|
2026-06-26 15:19:03 +08:00
|
|
|
publishUserId?: string;
|
2026-06-15 15:04:43 -07:00
|
|
|
publishUsername?: string;
|
2026-06-27 21:57:21 +08:00
|
|
|
sessionId?: string;
|
2026-06-15 15:04:43 -07:00
|
|
|
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,
|
|
|
|
|
);
|
2026-06-15 15:04:43 -07:00
|
|
|
|
|
|
|
|
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) => {
|
2026-06-30 09:30:51 +08:00
|
|
|
const prev = index > 0 ? visibleMessages[index - 1] : undefined;
|
2026-06-15 15:04:43 -07:00
|
|
|
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}`;
|
2026-06-15 15:04:43 -07:00
|
|
|
return (
|
2026-06-29 22:20:04 +08:00
|
|
|
<div
|
|
|
|
|
key={message.id ?? `msg-${index}`}
|
|
|
|
|
className="msg-block"
|
|
|
|
|
data-message-anchor={anchorId}
|
|
|
|
|
>
|
2026-06-15 15:04:43 -07:00
|
|
|
{showTime && <TimeDivider timestamp={message.created} />}
|
|
|
|
|
<MessageRow
|
|
|
|
|
message={message}
|
|
|
|
|
avatarUrl={avatarUrl}
|
|
|
|
|
onAvatarClick={onAvatarClick}
|
|
|
|
|
onSaveAsPage={onSaveAsPage}
|
|
|
|
|
saveDisabled={streaming}
|
2026-06-26 15:19:03 +08:00
|
|
|
publishUserId={publishUserId}
|
2026-06-15 15:04:43 -07:00
|
|
|
publishUsername={publishUsername}
|
2026-06-27 21:57:21 +08:00
|
|
|
sessionId={sessionId}
|
2026-06-15 15:04:43 -07:00
|
|
|
compact={compact}
|
2026-07-02 18:48:16 +08:00
|
|
|
activeToolMessage={message === activeToolMessage}
|
2026-06-15 15:04:43 -07:00
|
|
|
/>
|
|
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|