6f3e53a56a
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>
625 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|