feat(mindspace): 0630004 空间 UI、聊天连接、微信分享与 Agent 能力
含 MindSpace 三列布局与统计修复、聊天加载态与连接降级、平台页脚标记与 og:site_name 微信卡片、勾选资料删除 Agent 接口及内部话术过滤。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+70
-29
@@ -61,6 +61,7 @@ import { normalizeConversationMessages, normalizeUserMessageForApi } from '../ut
|
||||
|
||||
const API = '/api';
|
||||
const DEFAULT_API_TIMEOUT_MS = 20_000;
|
||||
const AGENT_CONNECT_TIMEOUT_MS = 60_000;
|
||||
const AGENT_RUNS_PATH = '/agent/runs';
|
||||
|
||||
export type AgentRun = {
|
||||
@@ -184,10 +185,14 @@ function notifyUnauthorized() {
|
||||
unauthorizedHandler();
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
async function fetchWithTimeout(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
timeoutMs = DEFAULT_API_TIMEOUT_MS,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const upstreamSignal = init?.signal;
|
||||
const timeout = window.setTimeout(() => controller.abort(), DEFAULT_API_TIMEOUT_MS);
|
||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const abortFromUpstream = () => controller.abort();
|
||||
if (upstreamSignal) {
|
||||
@@ -267,16 +272,24 @@ function sanitizeSessionEvent(event: SessionEvent): SessionEvent {
|
||||
return { ...event, error: sanitizeUserFacingErrorMessage(event.error) };
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
async function apiFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
options?: { timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetchWithTimeout(`${API}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers,
|
||||
res = await fetchWithTimeout(
|
||||
`${API}${path}`,
|
||||
{
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
},
|
||||
});
|
||||
options?.timeoutMs,
|
||||
);
|
||||
} catch (err) {
|
||||
throw new ApiError(0, formatNetworkError(err));
|
||||
}
|
||||
@@ -313,10 +326,14 @@ async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export async function startSession(): Promise<Session> {
|
||||
return apiFetch<Session>('/agent/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
return apiFetch<Session>(
|
||||
'/agent/start',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
{ timeoutMs: AGENT_CONNECT_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
export async function bootstrapProjectMemory(
|
||||
@@ -929,6 +946,7 @@ export async function saveChatMessageAsPage(input: {
|
||||
selectedLinkIndex?: number;
|
||||
acknowledgedFindingIds?: string[];
|
||||
replacePageId?: string;
|
||||
saveAsNew?: boolean;
|
||||
}): Promise<ChatSaveResult> {
|
||||
const result = await apiFetch<{ data: ChatSaveResult }>(
|
||||
'/mindspace/v1/pages/save-from-chat',
|
||||
@@ -945,6 +963,7 @@ export async function saveChatMessageAsPage(input: {
|
||||
acknowledged_finding_ids: input.acknowledgedFindingIds,
|
||||
page_type: input.templateId === 'report' ? 'report' : 'article',
|
||||
replace_page_id: input.replacePageId,
|
||||
save_as_new: input.saveAsNew ?? false,
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -1018,6 +1037,20 @@ export async function updateMindSpacePage(
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function rewriteMindSpacePageDownloadLinks(
|
||||
pageId: string,
|
||||
content: string,
|
||||
): Promise<string> {
|
||||
const result = await apiFetch<{ data: { html: string } }>(
|
||||
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/rewrite-download-links`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
},
|
||||
);
|
||||
return result.data.html;
|
||||
}
|
||||
|
||||
export async function fetchMindSpacePageDraftPreview(
|
||||
pageId: string,
|
||||
input: {
|
||||
@@ -2112,14 +2145,18 @@ export async function resumeSession(
|
||||
sessionId: string,
|
||||
options?: { skipReconcile?: boolean },
|
||||
): Promise<Session> {
|
||||
const result = await apiFetch<{ session: Session }>('/agent/resume', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
load_model_and_extensions: true,
|
||||
...(options?.skipReconcile ? { skip_reconcile: true } : {}),
|
||||
}),
|
||||
});
|
||||
const result = await apiFetch<{ session: Session }>(
|
||||
'/agent/resume',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
load_model_and_extensions: true,
|
||||
...(options?.skipReconcile ? { skip_reconcile: true } : {}),
|
||||
}),
|
||||
},
|
||||
{ timeoutMs: AGENT_CONNECT_TIMEOUT_MS },
|
||||
);
|
||||
return result.session;
|
||||
}
|
||||
|
||||
@@ -2211,14 +2248,18 @@ export async function createAgentRun(
|
||||
requestId: string,
|
||||
userMessage: Message,
|
||||
): Promise<AgentRun> {
|
||||
const result = await apiFetch<{ run: AgentRun }>(AGENT_RUNS_PATH, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
request_id: requestId,
|
||||
user_message: normalizeUserMessageForApi(userMessage),
|
||||
}),
|
||||
});
|
||||
const result = await apiFetch<{ run: AgentRun }>(
|
||||
AGENT_RUNS_PATH,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
request_id: requestId,
|
||||
user_message: normalizeUserMessageForApi(userMessage),
|
||||
}),
|
||||
},
|
||||
{ timeoutMs: AGENT_CONNECT_TIMEOUT_MS },
|
||||
);
|
||||
return result.run;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function ChatLoadingSpinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<span className={`chat-loading-spinner${className ? ` ${className}` : ''}`} aria-hidden="true">
|
||||
<span />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
+92
-14
@@ -116,6 +116,61 @@ function isAmbiguousReplySubmitError(err: unknown) {
|
||||
return err.status === 0 || err.status === 409 || err.status >= 500;
|
||||
}
|
||||
|
||||
const SESSION_LIST_RETRY_ATTEMPTS = 3;
|
||||
const SESSION_LIST_RETRY_DELAY_MS = 450;
|
||||
|
||||
function isTransientSessionListError(err: unknown) {
|
||||
return err instanceof ApiError && (err.status === 0 || err.status >= 502);
|
||||
}
|
||||
|
||||
function sessionListFailureMessage(err: unknown, action: 'refresh' | 'load-more') {
|
||||
const fallback =
|
||||
action === 'refresh' ? '历史列表暂时无法加载,请稍后重试' : '暂时无法加载更多历史,请稍后重试';
|
||||
if (!(err instanceof ApiError)) return fallback;
|
||||
if (err.status === 0) {
|
||||
return action === 'refresh'
|
||||
? '无法连接后端,历史列表未刷新(聊天仍可使用)'
|
||||
: '无法连接后端,暂时无法加载更多历史';
|
||||
}
|
||||
return err.message?.trim() || fallback;
|
||||
}
|
||||
|
||||
async function fetchSessionListWithRetry(
|
||||
options?: Parameters<typeof listSessions>[0],
|
||||
): Promise<Awaited<ReturnType<typeof listSessions>>> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < SESSION_LIST_RETRY_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
return await listSessions(options);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (!isTransientSessionListError(err) || attempt === SESSION_LIST_RETRY_ATTEMPTS - 1) break;
|
||||
await new Promise((resolve) => window.setTimeout(resolve, SESSION_LIST_RETRY_DELAY_MS * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
function isTransientConnectError(err: unknown) {
|
||||
if (!(err instanceof ApiError)) return false;
|
||||
if (err.status === 0 || err.status >= 502) return true;
|
||||
return /超时|timeout/i.test(err.message);
|
||||
}
|
||||
|
||||
async function withTransientConnectRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (!isTransientConnectError(err) || attempt === attempts - 1) break;
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 600 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
export function useTKMindChat(
|
||||
user?: PortalUser | null,
|
||||
onUserUpdate?: (user: PortalUser) => void,
|
||||
@@ -437,7 +492,7 @@ export function useTKMindChat(
|
||||
async (options?: { preserveExisting?: boolean; query?: string }): Promise<SessionSummary[]> => {
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
const { items, page } = await listSessions({
|
||||
const { items, page } = await fetchSessionListWithRetry({
|
||||
limit: appConfig.sessionPageSize,
|
||||
offset: 0,
|
||||
query: options?.query ?? sessionSearchQueryRef.current,
|
||||
@@ -456,8 +511,8 @@ export function useTKMindChat(
|
||||
setSessions(merged);
|
||||
return items;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 0) {
|
||||
setError('网络不可用,无法刷新历史列表');
|
||||
if (err instanceof ApiError || err instanceof Error) {
|
||||
setNotice(sessionListFailureMessage(err, 'refresh'));
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
@@ -471,7 +526,7 @@ export function useTKMindChat(
|
||||
if (sessionsLoading || sessionsLoadingMore || !sessionsHasMore) return;
|
||||
setSessionsLoadingMore(true);
|
||||
try {
|
||||
const { items, page } = await listSessions({
|
||||
const { items, page } = await fetchSessionListWithRetry({
|
||||
limit: appConfig.sessionPageSize,
|
||||
offset: sessionsOffsetRef.current,
|
||||
query: sessionSearchQueryRef.current,
|
||||
@@ -483,8 +538,8 @@ export function useTKMindChat(
|
||||
setSessionsHasMore(merged.length < total);
|
||||
setSessions(merged);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 0) {
|
||||
setError('网络不可用,无法继续加载历史列表');
|
||||
if (isTransientSessionListError(err) || err instanceof ApiError) {
|
||||
setNotice(sessionListFailureMessage(err, 'load-more'));
|
||||
}
|
||||
} finally {
|
||||
setSessionsLoadingMore(false);
|
||||
@@ -808,7 +863,12 @@ export function useTKMindChat(
|
||||
const connectSession = useCallback(
|
||||
async (
|
||||
sessionId: string,
|
||||
options?: { showLoading?: boolean; skipResume?: boolean; seedSession?: Session },
|
||||
options?: {
|
||||
showLoading?: boolean;
|
||||
skipResume?: boolean;
|
||||
seedSession?: Session;
|
||||
skipReconcile?: boolean;
|
||||
},
|
||||
) => {
|
||||
const showLoading = options?.showLoading !== false;
|
||||
const token = ++connectTokenRef.current;
|
||||
@@ -825,20 +885,22 @@ export function useTKMindChat(
|
||||
|
||||
const resumed = options?.skipResume
|
||||
? (options.seedSession ?? null)
|
||||
: await resumeSession(sessionId);
|
||||
: await withTransientConnectRetry(() =>
|
||||
resumeSession(sessionId, {
|
||||
skipReconcile: options?.skipReconcile ?? false,
|
||||
}),
|
||||
);
|
||||
if (token !== connectTokenRef.current) return;
|
||||
|
||||
const knownSession = sessionsRef.current.find((s) => s.id === sessionId);
|
||||
const hints = knownSession
|
||||
? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at }
|
||||
: undefined;
|
||||
const { session: detail, messages: history, page } = await loadSessionDetail(
|
||||
sessionId,
|
||||
hints,
|
||||
{
|
||||
const { session: detail, messages: history, page } = await withTransientConnectRetry(() =>
|
||||
loadSessionDetail(sessionId, hints, {
|
||||
before: 0,
|
||||
limit: appConfig.sessionMessagePageSize,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (token !== connectTokenRef.current) return;
|
||||
|
||||
@@ -957,7 +1019,10 @@ export function useTKMindChat(
|
||||
if (cancelled) return;
|
||||
if (restorableSessionId) {
|
||||
try {
|
||||
await connectSessionRef.current(restorableSessionId, { showLoading: false });
|
||||
await connectSessionRef.current(restorableSessionId, {
|
||||
showLoading: false,
|
||||
skipReconcile: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) {
|
||||
clearStoredSessionId(userRef.current?.id);
|
||||
@@ -970,6 +1035,17 @@ export function useTKMindChat(
|
||||
setMessageHistoryTotal(0);
|
||||
setMessages([]);
|
||||
setChatState('idle');
|
||||
} else if (isTransientConnectError(err)) {
|
||||
clearStoredSessionId(userRef.current?.id);
|
||||
setSession(null);
|
||||
messageHistoryLoadedCountRef.current = 0;
|
||||
messageHistoryTotalRef.current = 0;
|
||||
messageHistoryHasMoreRef.current = false;
|
||||
setMessageHistoryHasMore(false);
|
||||
setMessageHistoryTotal(0);
|
||||
setMessages([]);
|
||||
setChatState('idle');
|
||||
setNotice('上次会话恢复超时,已为你准备新对话,可直接发送消息');
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
@@ -1311,6 +1387,8 @@ export function useTKMindChat(
|
||||
userMemoryLoading,
|
||||
canUseProjectMemory,
|
||||
canUseLongTermMemory,
|
||||
capabilities,
|
||||
grantedSkills,
|
||||
submit,
|
||||
stop,
|
||||
approveTool,
|
||||
|
||||
+141
-16
@@ -668,6 +668,9 @@ body,
|
||||
}
|
||||
|
||||
.header-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
@@ -676,6 +679,43 @@ body,
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-loading-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 14px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.chat-loading-spinner > span {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
animation: chat-loading-spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes chat-loading-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-connect-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ghost-btn,
|
||||
.send-btn,
|
||||
.danger-btn,
|
||||
@@ -1219,6 +1259,7 @@ body,
|
||||
border: 1px solid rgba(238, 176, 78, 0.3);
|
||||
border-radius: 26px;
|
||||
color: #18211d;
|
||||
color-scheme: light;
|
||||
background:
|
||||
radial-gradient(circle at 100% 0, rgba(47, 111, 87, 0.16), transparent 20rem),
|
||||
#f8f3e8;
|
||||
@@ -1313,6 +1354,11 @@ body,
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.page-save-actions button:not(.page-save-primary) {
|
||||
color: #52605a;
|
||||
background: rgba(24, 33, 29, 0.08);
|
||||
}
|
||||
|
||||
.page-save-primary {
|
||||
color: #fffaf0;
|
||||
background: #2f6f57;
|
||||
@@ -1586,6 +1632,7 @@ body,
|
||||
border-radius: 10px;
|
||||
border: 1px solid #c0cbc4;
|
||||
background: #fff;
|
||||
color: #18211d;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
@@ -5656,24 +5703,51 @@ body,
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.mindspace-category-layout {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mindspace-grid-zones {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mindspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mindspace-card {
|
||||
min-height: auto;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(24, 33, 29, 0.12);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 252, 244, 0.82);
|
||||
.mindspace-card-compact {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.mindspace-card-compact h3 {
|
||||
margin: 8px 0 4px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.mindspace-card-compact p {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.mindspace-card-compact button {
|
||||
margin-top: 8px;
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mindspace-card-compact .mindspace-card-top {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mindspace-grid-secondary {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
@@ -5684,6 +5758,14 @@ body,
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.mindspace-card {
|
||||
min-height: auto;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(24, 33, 29, 0.12);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 252, 244, 0.82);
|
||||
}
|
||||
|
||||
.mindspace-agent-jobs-module {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -7353,7 +7435,8 @@ body,
|
||||
background: #f8f3e8;
|
||||
}
|
||||
|
||||
.mindspace-asset-preview-panel iframe {
|
||||
.mindspace-asset-preview-panel iframe,
|
||||
.mindspace-asset-preview-panel .mindspace-asset-preview-image {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
@@ -7362,6 +7445,11 @@ body,
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mindspace-asset-preview-panel .mindspace-asset-preview-image {
|
||||
object-fit: contain;
|
||||
background: #0b100e;
|
||||
}
|
||||
|
||||
.mindspace-asset-preview-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -8154,12 +8242,50 @@ body,
|
||||
font-size: clamp(28px, 4vw, 44px);
|
||||
}
|
||||
|
||||
.mindspace-category-layout {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.mindspace-grid-zones {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mindspace-grid {
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.mindspace-card {
|
||||
.mindspace-card.mindspace-card-compact,
|
||||
.mindspace-grid-zones .mindspace-card,
|
||||
.mindspace-grid-secondary .mindspace-card-compact {
|
||||
grid-column: auto;
|
||||
min-height: auto;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.mindspace-card-compact h3 {
|
||||
margin: 8px 0 4px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.mindspace-card-compact p {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.mindspace-card-compact button {
|
||||
margin-top: 8px;
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mindspace-card-compact .mindspace-card-top {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mindspace-card:not(.mindspace-card-compact) {
|
||||
grid-column: span 4;
|
||||
min-height: 220px;
|
||||
padding: 24px;
|
||||
@@ -8167,14 +8293,13 @@ body,
|
||||
}
|
||||
|
||||
.mindspace-grid-secondary {
|
||||
grid-column: span 12;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.mindspace-grid-secondary .mindspace-card,
|
||||
.mindspace-grid-secondary .mindspace-agent-jobs-module {
|
||||
min-height: 180px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.mindspace-agent-jobs-module {
|
||||
@@ -8187,12 +8312,12 @@ body,
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.mindspace-card h3 {
|
||||
.mindspace-card:not(.mindspace-card-compact) h3 {
|
||||
margin: 30px 0 8px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.mindspace-card button {
|
||||
.mindspace-card:not(.mindspace-card-compact) button {
|
||||
margin-top: 8px;
|
||||
padding: 9px 14px;
|
||||
}
|
||||
|
||||
+13
-3
@@ -1,7 +1,7 @@
|
||||
import type { Message, MessageContent } from '../types';
|
||||
import { mergeMessageContent } from '../../message-stream.mjs';
|
||||
import { mergeConversationSnapshot as mergeConversationSnapshotCore } from '../../chat-finish-sync.mjs';
|
||||
import { deriveUserFacingText } from '../../conversation-display.mjs';
|
||||
import { deriveUserFacingText, deriveAssistantFacingText } from '../../conversation-display.mjs';
|
||||
import { stripUserAddressPrefix } from './userAddress';
|
||||
|
||||
const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/;
|
||||
@@ -182,12 +182,22 @@ export function getDisplayText(message: Message): string {
|
||||
return stripImageUrlLines(deriveUserFacingText(raw));
|
||||
}
|
||||
if ('displayText' in message.metadata) {
|
||||
return stripImageUrlLines(message.metadata.displayText ?? '');
|
||||
return stripImageUrlLines(deriveAssistantFacingText(message.metadata.displayText ?? ''));
|
||||
}
|
||||
const systemText = getSystemNotificationText(message);
|
||||
if (systemText) return systemText;
|
||||
const visible = getVisibleText(message);
|
||||
return visible;
|
||||
return stripImageUrlLines(deriveAssistantFacingText(visible));
|
||||
}
|
||||
|
||||
export function shouldShowChatMessage(message: Message): boolean {
|
||||
if (message.role === 'user') return true;
|
||||
if (getDisplayText(message).trim()) return true;
|
||||
if (getThinking(message)) return true;
|
||||
if (getSystemNotificationText(message)) return true;
|
||||
return message.content.some(
|
||||
(item) => item.type === 'toolRequest' || item.type === 'toolResponse' || item.type === 'actionRequired',
|
||||
);
|
||||
}
|
||||
|
||||
export function pushMessage(messages: Message[], incoming: Message): Message[] {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { prepareHtmlPageBrandMarkers } from '../../mindspace-page-tag.mjs';
|
||||
|
||||
export const MINDSPACE_PAGE_CONTENT_MESSAGE = 'mindspace:page-content';
|
||||
|
||||
const EDITOR_STYLE = `<style id="mindspace-visual-editor-style">
|
||||
@@ -218,6 +220,23 @@ const EDITOR_STYLE = `<style id="mindspace-visual-editor-style">
|
||||
background: rgba(24, 33, 29, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mindspace-editor-legend[data-collapsed="true"] {
|
||||
width: auto;
|
||||
max-width: min(280px, calc(100vw - 24px));
|
||||
gap: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mindspace-editor-legend[data-collapsed="true"] .mindspace-editor-legend-body {
|
||||
display: none;
|
||||
}
|
||||
.mindspace-editor-legend[data-collapsed="true"] .mindspace-editor-legend-title::after {
|
||||
content: ' · 点击展开';
|
||||
font-weight: 400;
|
||||
color: #6d7771;
|
||||
}
|
||||
[data-mindspace-editing="true"] [data-mindspace-page-tag] {
|
||||
display: none !important;
|
||||
}
|
||||
</style>`;
|
||||
|
||||
const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
||||
@@ -226,9 +245,14 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
||||
var TEXT_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,figcaption,blockquote,span,a,label,small,strong,em,b,i,u,sub,sup,cite,time,address,dt,dd,button,.sub,.tagline,[class*="title"],[class*="subtitle"],[class*="desc"],[class*="text"]';
|
||||
var BLOCK_CHILD_SELECTOR = 'div,section,article,header,footer,main,p,h1,h2,h3,h4,h5,h6,ul,ol,table,form,video,audio,iframe,svg,canvas';
|
||||
var MEDIA_BLOCK_SELECTOR = 'img,video,iframe,svg,canvas,table,ul,ol,form';
|
||||
var PAGE_TAG_SELECTOR = '[data-mindspace-page-tag]';
|
||||
var popover = null;
|
||||
var emitTimer = null;
|
||||
|
||||
function isProtectedNode(el) {
|
||||
return !!(el && el.closest && el.closest(PAGE_TAG_SELECTOR));
|
||||
}
|
||||
|
||||
function debounceEmit() {
|
||||
if (emitTimer) window.clearTimeout(emitTimer);
|
||||
emitTimer = window.setTimeout(emitChange, 350);
|
||||
@@ -479,6 +503,7 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
||||
var nodes = document.querySelectorAll('body, main, section, article, header, footer, div');
|
||||
nodes.forEach(function (el) {
|
||||
if (el.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
|
||||
if (isProtectedNode(el)) return;
|
||||
if (el.isContentEditable) return;
|
||||
var style = window.getComputedStyle(el);
|
||||
var cls = String(el.className || '');
|
||||
@@ -497,18 +522,22 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
||||
function markMediaTargets() {
|
||||
document.querySelectorAll('img').forEach(function (img) {
|
||||
if (img.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
|
||||
if (isProtectedNode(img)) return;
|
||||
markEditable(img, 'media', '点击换图片');
|
||||
});
|
||||
document.querySelectorAll('video').forEach(function (video) {
|
||||
if (video.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
|
||||
if (isProtectedNode(video)) return;
|
||||
markEditable(video, 'media', '点击换视频');
|
||||
});
|
||||
document.querySelectorAll('audio').forEach(function (audio) {
|
||||
if (audio.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
|
||||
if (isProtectedNode(audio)) return;
|
||||
markEditable(audio, 'media', '点击换音频');
|
||||
});
|
||||
document.querySelectorAll('iframe').forEach(function (iframe) {
|
||||
if (iframe.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
|
||||
if (isProtectedNode(iframe)) return;
|
||||
iframe.setAttribute('data-mindspace-media-target', 'true');
|
||||
markEditable(iframe, 'media', '点击换嵌入');
|
||||
});
|
||||
@@ -517,6 +546,7 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
||||
function markLinkTargets() {
|
||||
document.querySelectorAll('a[href]').forEach(function (anchor) {
|
||||
if (anchor.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
|
||||
if (isProtectedNode(anchor)) return;
|
||||
anchor.setAttribute('data-mindspace-link-target', 'true');
|
||||
markEditable(anchor, 'link', '双击改链接');
|
||||
});
|
||||
@@ -525,6 +555,7 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
||||
function enableTextEditing() {
|
||||
document.querySelectorAll(TEXT_SELECTOR).forEach(function (el) {
|
||||
if (el.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
|
||||
if (isProtectedNode(el)) return;
|
||||
if (el.querySelector && el.querySelector(MEDIA_BLOCK_SELECTOR)) return;
|
||||
if (el.matches('a[data-mindspace-link-target="true"]')) return;
|
||||
var textParent = el.closest('[data-mindspace-edit-kind="text"]');
|
||||
@@ -536,6 +567,7 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
||||
|
||||
document.querySelectorAll('div').forEach(function (el) {
|
||||
if (el.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
|
||||
if (isProtectedNode(el)) return;
|
||||
if (el.hasAttribute('contenteditable')) return;
|
||||
if (el.querySelector(BLOCK_CHILD_SELECTOR)) return;
|
||||
if (el.querySelector(MEDIA_BLOCK_SELECTOR)) return;
|
||||
@@ -550,16 +582,19 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
||||
var legend = document.createElement('div');
|
||||
legend.className = 'mindspace-editor-legend';
|
||||
legend.innerHTML =
|
||||
'<strong>编辑模式 · 可修改区域</strong>' +
|
||||
'<strong class="mindspace-editor-legend-title">编辑模式 · 可修改区域</strong>' +
|
||||
'<div class="mindspace-editor-legend-body">' +
|
||||
'<div class="mindspace-editor-legend-items">' +
|
||||
'<div class="mindspace-editor-legend-item"><span class="mindspace-editor-legend-swatch" data-kind="text"></span><span>蓝色虚线 = 文字,直接点击修改</span></div>' +
|
||||
'<div class="mindspace-editor-legend-item"><span class="mindspace-editor-legend-swatch" data-kind="media"></span><span>橙色虚线 = 图片/音视频,点击替换</span></div>' +
|
||||
'<div class="mindspace-editor-legend-item"><span class="mindspace-editor-legend-swatch" data-kind="link"></span><span>紫色虚线 = 链接,双击改地址</span></div>' +
|
||||
'<div class="mindspace-editor-legend-item"><span class="mindspace-editor-legend-swatch" data-kind="background"></span><span>绿色虚线 = 背景区域,点击换背景</span></div>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="mindspace-editor-legend-toggle" data-action="toggle-highlights">隐藏标记</button>';
|
||||
'<button type="button" class="mindspace-editor-legend-toggle" data-action="toggle-highlights">隐藏标记</button>' +
|
||||
'</div>';
|
||||
document.body.appendChild(legend);
|
||||
legend.querySelector('[data-action="toggle-highlights"]').addEventListener('click', function () {
|
||||
legend.querySelector('[data-action="toggle-highlights"]').addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
var hidden = document.body.getAttribute('data-mindspace-highlights') === 'off';
|
||||
if (hidden) {
|
||||
document.body.removeAttribute('data-mindspace-highlights');
|
||||
@@ -569,12 +604,21 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
||||
this.textContent = '显示标记';
|
||||
}
|
||||
});
|
||||
legend.addEventListener('click', function () {
|
||||
if (legend.getAttribute('data-collapsed') === 'true') {
|
||||
legend.removeAttribute('data-collapsed');
|
||||
}
|
||||
});
|
||||
window.setTimeout(function () {
|
||||
legend.setAttribute('data-collapsed', 'true');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function onClick(event) {
|
||||
var target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (target.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
|
||||
if (isProtectedNode(target)) return;
|
||||
|
||||
if (target.tagName === 'IMG') {
|
||||
event.preventDefault();
|
||||
@@ -601,6 +645,7 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
||||
function onDblClick(event) {
|
||||
var target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (isProtectedNode(target)) return;
|
||||
var anchor = target.closest('a[data-mindspace-link-target="true"]');
|
||||
if (!anchor) return;
|
||||
event.preventDefault();
|
||||
@@ -644,7 +689,7 @@ export function stripPreviewEditCspMeta(html: string): string {
|
||||
}
|
||||
|
||||
export function buildEditablePreviewDocument(html: string): string {
|
||||
const source = String(html ?? '').trim();
|
||||
const source = prepareHtmlPageBrandMarkers(String(html ?? '').trim());
|
||||
const cleaned = stripPreviewEditCspMeta(source)
|
||||
.replace(/<style id="mindspace-visual-editor-style">[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<script id="mindspace-visual-editor-script">[\s\S]*?<\/script>/gi, '');
|
||||
|
||||
Reference in New Issue
Block a user