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:
John
2026-06-15 15:04:43 -07:00
commit 2e14873f2d
272 changed files with 64133 additions and 0 deletions
+320
View File
@@ -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>
);
}