Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
import { useState } from 'react';
|
||||
import { useUserAvatar } from '../hooks/useUserAvatar';
|
||||
import type { Message } from '../types';
|
||||
import { getDisplayText, getThinking, getVisibleText } from '../utils/message';
|
||||
import { getMessageSaveActions } from '../utils/messageSave';
|
||||
import { renderMarkdown } from '../utils/markdown';
|
||||
import { formatChatTime, shouldShowTimestamp } from '../utils/time';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
import { UserAvatar } from './UserAvatar';
|
||||
|
||||
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 ToolBadge({ message }: { message: Message }) {
|
||||
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) => (
|
||||
<span key={`${message.id}-${i}`} className="tool-badge">
|
||||
{tool.type === 'toolRequest'
|
||||
? `🔧 ${tool.toolCall.functionName}`
|
||||
: `✓ 工具完成`}
|
||||
</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 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,
|
||||
publishUsername,
|
||||
compact = false,
|
||||
}: {
|
||||
message: Message;
|
||||
avatarUrl: string | null;
|
||||
onAvatarClick?: () => void;
|
||||
onSaveAsPage?: (message: Message) => void;
|
||||
saveDisabled?: boolean;
|
||||
publishUsername?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const text = getDisplayText(message);
|
||||
const saveActions = getMessageSaveActions(text, publishUsername);
|
||||
const thinking = getThinking(message);
|
||||
const isUser = message.role === 'user';
|
||||
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>
|
||||
)}
|
||||
{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 && (
|
||||
<a
|
||||
className="msg-open-preview"
|
||||
href={saveActions.previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
打开预览
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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 && (
|
||||
<a
|
||||
className="msg-open-preview"
|
||||
href={saveActions.previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
打开预览
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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,
|
||||
publishUsername,
|
||||
compact = false,
|
||||
}: {
|
||||
messages: Message[];
|
||||
streaming: boolean;
|
||||
onAvatarClick?: () => void;
|
||||
onSaveAsPage?: (message: Message) => void;
|
||||
publishUsername?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { avatarUrl } = useUserAvatar();
|
||||
|
||||
return (
|
||||
<div className="message-list">
|
||||
{messages.length === 0 && (
|
||||
<div className={`empty-state${compact ? ' empty-state-compact' : ''}`}>
|
||||
<TKMindAvatar />
|
||||
<h2>TKMind</h2>
|
||||
<p>{compact ? '继续和空间里的 Agent 对话' : '发送消息即可在本机执行 Agent 任务'}</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((message, index) => {
|
||||
const prev = index > 0 ? messages[index - 1] : undefined;
|
||||
const showTime = shouldShowTimestamp(message.created, prev?.created);
|
||||
return (
|
||||
<div key={message.id ?? `msg-${index}`} className="msg-block">
|
||||
{showTime && <TimeDivider timestamp={message.created} />}
|
||||
<MessageRow
|
||||
message={message}
|
||||
avatarUrl={avatarUrl}
|
||||
onAvatarClick={onAvatarClick}
|
||||
onSaveAsPage={onSaveAsPage}
|
||||
saveDisabled={streaming}
|
||||
publishUsername={publishUsername}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user