feat(mindspace): 0630004 空间 UI、聊天连接、微信分享与 Agent 能力

含 MindSpace 三列布局与统计修复、聊天加载态与连接降级、平台页脚标记与 og:site_name 微信卡片、勾选资料删除 Agent 接口及内部话术过滤。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-30 09:30:51 +08:00
parent b1b8d3afc6
commit 722b18326f
53 changed files with 2450 additions and 313 deletions
+7
View File
@@ -0,0 +1,7 @@
export function ChatLoadingSpinner({ className = '' }: { className?: string }) {
return (
<span className={`chat-loading-spinner${className ? ` ${className}` : ''}`} aria-hidden="true">
<span />
</span>
);
}
+13 -4
View File
@@ -9,6 +9,7 @@ import {
} from '../utils/imageUpload';
import { AvatarPicker } from './AvatarPicker';
import { ChatSkillPicker } from './ChatSkillPicker';
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
import { DesignSkillPanel } from './DesignSkillPanel';
import { MessageList } from './MessageList';
import { PageSaveDialog } from './PageSaveDialog';
@@ -292,13 +293,15 @@ export function ChatPanel({
const placeholder = offlineBlocked
? '网络断开,恢复连接后可继续输入'
: chatState === 'connecting'
: compact
? '随时召唤你的小助手吧'
: randomPrompt;
const connectStatusText =
chatState === 'connecting'
? '正在连接会话…'
: chatState === 'waiting'
? '消息已收到,正在连接后台…'
: compact
? '随时召唤你的小助手吧'
: randomPrompt;
: null;
const mergeVoiceText = (spoken: string) => {
const base = voiceBaseRef.current.trimEnd();
@@ -609,6 +612,12 @@ export function ChatPanel({
)}
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
{connectStatusText && (
<div className="chat-connect-status" role="status" aria-live="polite">
<ChatLoadingSpinner />
<span>{connectStatusText}</span>
</div>
)}
{voiceNotice && (
<div className="voice-notice" role="status">
{voiceNotice}
+6 -1
View File
@@ -8,6 +8,7 @@ import { BalanceRing } from './BalanceRing';
import { HistorySidebar } from './HistorySidebar';
import { TKMindAvatar } from './TKMindAvatar';
import { ChatPanel } from './ChatPanel';
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
import { WechatBindPrompt } from './WechatBindPrompt';
import { WechatAccountButton } from './WechatAccountButton';
import { ChatHeaderMoreMenu } from './ChatHeaderMoreMenu';
@@ -90,6 +91,7 @@ export function ChatView({
setSidebarOpen(false);
};
const isConnectingTitle = !session && chatState !== 'idle';
const sessionTitle = session
? getSessionDisplayName(session)
: chatState === 'idle'
@@ -145,7 +147,10 @@ export function ChatView({
<TKMindAvatar size="sm" className="header-brand-avatar" />
<div>
<div className="header-title">{user?.displayName ?? 'TKMind'}</div>
<div className="header-sub">{sessionTitle}</div>
<div className="header-sub">
{isConnectingTitle ? <ChatLoadingSpinner /> : null}
<span>{sessionTitle}</span>
</div>
</div>
</div>
<div className="header-actions header-actions-desktop">
+3 -3
View File
@@ -1,7 +1,7 @@
import { useRef, useState } from 'react';
import { useUserAvatar } from '../hooks/useUserAvatar';
import type { Message } from '../types';
import { getDisplayText, getImageUrls, getRenderableImageUrls, getThinking } from '../utils/message';
import { getDisplayText, getImageUrls, getRenderableImageUrls, getThinking, shouldShowChatMessage } from '../utils/message';
import { getMessageSaveActions } from '../utils/messageSave';
import { renderMarkdown } from '../utils/markdown';
import { filterText } from '../utils/wordFilter';
@@ -365,8 +365,8 @@ export function MessageList({
return (
<div className="message-list">
{messages.length === 0 && <ChatWelcomePanel compact={compact} />}
{messages.map((message, index) => {
const prev = index > 0 ? messages[index - 1] : undefined;
{messages.filter(shouldShowChatMessage).map((message, index, visibleMessages) => {
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 (
+8 -22
View File
@@ -35,8 +35,7 @@ import { MindSpacePagePreviewPanel } from './MindSpacePagePreviewPanel';
import { MindSpaceModal } from './MindSpaceModal';
import { MindSpaceDeletePlazaOption } from './MindSpaceDeletePlazaOption';
import { MindSpacePublishSuccess } from './MindSpacePublishSuccess';
import { ShareSheet } from './ShareSheet';
import { buildPageSharePayload, type SharePayload } from '../utils/shareChannels';
import { prepareHtmlPageBrandMarkers } from '../../mindspace-page-tag.mjs';
type AccessMode = MindSpacePublishCheck['accessMode'];
@@ -271,7 +270,6 @@ export function MindSpacePageDetail({
const [deletePreview, setDeletePreview] = useState<MindSpacePageDeletePreview | null>(null);
const [deletePreviewLoading, setDeletePreviewLoading] = useState(false);
const [deleteRemoveFromPlaza, setDeleteRemoveFromPlaza] = useState(false);
const [sharePayload, setSharePayload] = useState<SharePayload | null>(null);
const [previewFrame, setPreviewFrame] = useState<HTMLIFrameElement | null>(null);
const [isExportingImage, setIsExportingImage] = useState(false);
const [exportFeedback, setExportFeedback] = useState<string | null>(null);
@@ -315,13 +313,17 @@ export function MindSpacePageDetail({
setError(null);
try {
const next = await getMindSpacePage(pageId);
const pageContent =
next.contentFormat === 'html'
? prepareHtmlPageBrandMarkers(next.content ?? '')
: (next.content ?? '');
setPage(next);
resetDraft({
title: next.title,
summary: next.summary,
content: next.content ?? '',
content: pageContent,
});
setPreviewContent(next.content ?? '');
setPreviewContent(pageContent);
setPreviewRefreshPending(false);
setPreviewKey((value) => value + 1);
setTemplateId(next.templateId);
@@ -773,13 +775,6 @@ export function MindSpacePageDetail({
setPreviewRefreshPending(false);
};
const handleOpenShareSheet = () => {
if (!page) return;
const shareUrl = page.publication ? resolvePublicPageUrl(page.publication.publicUrl) : undefined;
setSharePayload(buildPageSharePayload(page, shareUrl));
setExportFeedback(null);
};
const handleExportPageImage = async () => {
if (!page) return;
setIsExportingImage(true);
@@ -897,9 +892,6 @@ export function MindSpacePageDetail({
</div>
</div>
<div className="mindspace-page-floating-actions">
<button type="button" className="mindspace-page-floating-action" onClick={handleOpenShareSheet}>
</button>
<button
type="button"
className="mindspace-page-floating-action is-secondary"
@@ -1445,16 +1437,10 @@ export function MindSpacePageDetail({
))}
</section>
</section>
{sharePayload ? (
<ShareSheet
payload={sharePayload}
publication={page?.publication}
onClose={() => setSharePayload(null)}
/>
) : null}
{fullscreenPreviewOpen && page && page.contentFormat === 'html' ? (
<MindSpacePageFullscreenPreview
pageId={page.id}
pageTitle={title || page.title}
title={title}
content={content}
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { rewriteMindSpacePageDownloadLinks } from '../api/client';
import { bindIframeDocumentHeightSync, measureDocumentHeight } from '../utils/iframeContentHeight';
import {
buildEditablePreviewDocument,
@@ -6,6 +7,7 @@ import {
} from '../utils/mindspaceVisualEditor';
export function MindSpacePageEditablePreviewFrame({
pageId,
title,
content,
reloadKey,
@@ -18,6 +20,7 @@ export function MindSpacePageEditablePreviewFrame({
onFrameReady,
onContentChange,
}: {
pageId: string;
title: string;
content: string;
reloadKey: number;
@@ -34,6 +37,7 @@ export function MindSpacePageEditablePreviewFrame({
const frameClassName =
className ?? (compact ? 'page-save-mini-page-frame' : 'mindspace-page-preview-frame');
const [previewFailed, setPreviewFailed] = useState(false);
const [displayContent, setDisplayContent] = useState(content);
const [srcdoc, setSrcdoc] = useState(() => buildEditablePreviewDocument(content));
const [height, setHeight] = useState(minHeight);
const iframeContentRef = useRef(content);
@@ -41,19 +45,30 @@ export function MindSpacePageEditablePreviewFrame({
const syncHeightRef = useRef<(() => void) | null>(null);
useEffect(() => {
iframeContentRef.current = content;
setSrcdoc(buildEditablePreviewDocument(content));
setPreviewFailed(false);
setHeight(minHeight);
}, [reloadKey]);
let cancelled = false;
const timer = window.setTimeout(() => {
void rewriteMindSpacePageDownloadLinks(pageId, content)
.then((html) => {
if (cancelled) return;
setDisplayContent(html);
})
.catch(() => {
if (cancelled) return;
setDisplayContent(content);
});
}, 120);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [pageId, content, reloadKey]);
useEffect(() => {
if (content === iframeContentRef.current) return;
iframeContentRef.current = content;
setSrcdoc(buildEditablePreviewDocument(content));
iframeContentRef.current = displayContent;
setSrcdoc(buildEditablePreviewDocument(displayContent));
setPreviewFailed(false);
setHeight(minHeight);
}, [content, minHeight]);
}, [displayContent, reloadKey, minHeight]);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
@@ -3,6 +3,7 @@ import { MindSpacePageEditablePreviewFrame } from './MindSpacePageEditablePrevie
import type { MindSpacePreviewChangeHighlight } from './MindSpacePageDraftPreviewFrame';
export function MindSpacePageFullscreenPreview({
pageId,
pageTitle,
title,
content,
@@ -17,6 +18,7 @@ export function MindSpacePageFullscreenPreview({
refreshPending = false,
onContentChange,
}: {
pageId: string;
pageTitle: string;
title: string;
content: string;
@@ -101,6 +103,7 @@ export function MindSpacePageFullscreenPreview({
<div className="mindspace-page-fullscreen-preview-body">
<MindSpacePageEditablePreviewFrame
pageId={pageId}
title={title}
content={content}
reloadKey={reloadKey}
@@ -170,6 +170,7 @@ export function MindSpacePagePreviewPanel({
</p>
<div className="page-save-mini-page">
<MindSpacePageEditablePreviewFrame
pageId={pageId}
title={title}
content={content}
reloadKey={reloadKey}
+30 -33
View File
@@ -2928,20 +2928,30 @@ export function MindSpaceView({
</>
)}
{previewAsset && previewAssetFrame && (
{previewAsset && (isImageAsset(previewAsset) || previewAssetFrame) && (
<div className="mindspace-asset-preview-backdrop" role="presentation">
<section className="mindspace-asset-preview-panel" role="dialog" aria-modal="true">
<div className="mindspace-asset-preview-bar">
<strong></strong>
<strong>{previewAsset.displayName || '文件预览'}</strong>
<button type="button" onClick={() => setPreviewAssetId(null)}>
</button>
</div>
<iframe
title="资产预览"
src={previewAssetFrame.src}
{...(previewAssetFrame.sandbox ? { sandbox: previewAssetFrame.sandbox } : {})}
/>
{isImageAsset(previewAsset) ? (
<img
className="mindspace-asset-preview-image"
src={buildAssetImageUrl(previewAsset)}
alt={previewAsset.displayName}
/>
) : (
previewAssetFrame && (
<iframe
title="资产预览"
src={previewAssetFrame.src}
{...(previewAssetFrame.sandbox ? { sandbox: previewAssetFrame.sandbox } : {})}
/>
)
)}
</section>
</div>
)}
@@ -2957,33 +2967,13 @@ export function MindSpaceView({
</div>
<span></span>
</div>
<div className="mindspace-grid">
{space.categories
.filter((category) => ['oa', 'public'].includes(category.code))
.map((category) => (
<article
className={`mindspace-card mindspace-card-${category.code}`}
key={category.id}
>
<div className="mindspace-card-top">
<span className="mindspace-card-code">{category.code.toUpperCase()}</span>
<span>{category.itemCount} </span>
</div>
<h3>{category.name}</h3>
<p>{CATEGORY_DESCRIPTIONS[category.code]}</p>
{CATEGORY_ACTIONS[category.code] && (
<button type="button" onClick={() => void openCategory(category)}>
{CATEGORY_ACTIONS[category.code]}
</button>
)}
</article>
))}
<div className="mindspace-grid-secondary">
<div className="mindspace-category-layout">
<div className="mindspace-grid-zones">
{space.categories
.filter((category) => category.code === 'draft')
.filter((category) => ['oa', 'public', 'archive'].includes(category.code))
.map((category) => (
<article
className={`mindspace-card mindspace-card-${category.code}`}
className={`mindspace-card mindspace-card-compact mindspace-card-${category.code}`}
key={category.id}
>
<div className="mindspace-card-top">
@@ -2999,11 +2989,13 @@ export function MindSpaceView({
)}
</article>
))}
</div>
<div className="mindspace-grid-secondary">
{space.categories
.filter((category) => category.code === 'archive')
.filter((category) => category.code === 'draft')
.map((category) => (
<article
className={`mindspace-card mindspace-card-${category.code}`}
className={`mindspace-card mindspace-card-compact mindspace-card-${category.code}`}
key={category.id}
>
<div className="mindspace-card-top">
@@ -3012,6 +3004,11 @@ export function MindSpaceView({
</div>
<h3>{category.name}</h3>
<p>{CATEGORY_DESCRIPTIONS[category.code]}</p>
{CATEGORY_ACTIONS[category.code] && (
<button type="button" onClick={() => void openCategory(category)}>
{CATEGORY_ACTIONS[category.code]}
</button>
)}
</article>
))}
</div>
+2 -1
View File
@@ -155,6 +155,7 @@ export function PageSaveDialog({
categoryCode,
selectedLinkIndex,
replacePageId,
saveAsNew: duplicateResolved === 'new',
});
if (result.kind === 'page') {
setSaveNotice(replacePageId ? '已替换原有页面' : `已保存到${CATEGORY_LABELS[categoryCode]}`);
@@ -196,7 +197,7 @@ export function PageSaveDialog({
{!analysisLoading && showDuplicatePrompt && (
<div className="page-save-duplicate">
<p>{existingPage!.title}</p>
<p>{existingPage!.title}</p>
<div className="page-save-duplicate-actions">
<button
type="button"
+3
View File
@@ -51,6 +51,7 @@ export function SpaceChatPanel({
uploadChatImage,
retryConnect,
} = chat;
const { capabilities, grantedSkills } = mainChat;
useEffect(() => {
if (!open) return;
@@ -134,6 +135,8 @@ export function SpaceChatPanel({
chatState={chatState}
pendingTool={pendingTool}
session={session}
capabilities={capabilities ?? undefined}
grantedSkills={grantedSkills}
onSubmit={(text, imageUrls, previewImageUrls) =>
chatBridge
? void submit(text, context, imageUrls, previewImageUrls)