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
+70 -29
View File
@@ -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;
}
+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)
+92 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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[] {
+49 -4
View File
@@ -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, '');