feat(chat): add one-click Plaza publish for HTML page messages.

Speed up the quick-plaza path with session snapshot reads, publish timeouts, and a modal fix so parent re-renders no longer abort in-flight requests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-05 19:28:36 +08:00
parent 53eb0014ba
commit 3318c04490
11 changed files with 766 additions and 140 deletions
+19
View File
@@ -16,6 +16,7 @@ import { AvatarPicker } from './AvatarPicker';
import { ChatSkillPicker } from './ChatSkillPicker';
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
import { ChatPlazaPublishModal } from './ChatPlazaPublishModal';
import { MessageList } from './MessageList';
import { PageSaveDialog } from './PageSaveDialog';
import { VoiceInputButton } from './VoiceInputButton';
@@ -166,6 +167,7 @@ export function ChatPanel({
const [voiceRecording, setVoiceRecording] = useState(false);
const [pageSource, setPageSource] = useState<Message | null>(null);
const [sharePreviewSource, setSharePreviewSource] = useState<Message | null>(null);
const [plazaPublishSource, setPlazaPublishSource] = useState<Message | null>(null);
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
const [forceDeepReasoning, setForceDeepReasoning] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
@@ -629,6 +631,14 @@ export function ChatPanel({
setPageSource(message);
};
const openPlazaPublish = (message: Message) => {
setPlazaPublishSource(message);
};
const closePlazaPublish = useCallback(() => {
setPlazaPublishSource(null);
}, []);
const downloadLongImage = (_message: Message, publicUrl: string) => {
triggerUrlDownload(appendLongImageDownloadParam(publicUrl));
};
@@ -665,6 +675,7 @@ export function ChatPanel({
streaming={chatState === 'streaming'}
onAvatarClick={compact ? undefined : openAvatarPicker}
onSaveAsPage={openSaveActions}
onShareToPlaza={openPlazaPublish}
onDownloadLongImage={downloadLongImage}
onDownloadDocx={(message) => void downloadDocx(message)}
publishUserId={user?.id}
@@ -721,6 +732,14 @@ export function ChatPanel({
/>
)}
{plazaPublishSource?.id && session?.id && (
<ChatPlazaPublishModal
sessionId={session.id}
messageId={plazaPublishSource.id}
onClose={closePlazaPublish}
/>
)}
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
{connectStatusText && (
<div className="chat-connect-status" role="status" aria-live="polite">
+155
View File
@@ -0,0 +1,155 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ApiError, quickPlazaFromChat } from '../api/client';
import { resolvePlazaPostUrl } from '../utils/publicUrl';
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
type PlazaPublishPhase =
| { kind: 'loading' }
| { kind: 'success'; plazaUrl: string }
| { kind: 'already'; plazaUrl: string; message: string }
| { kind: 'error'; message: string };
function resolveAlreadyPublishedMessage(err: ApiError) {
const postId = String(err.details?.post_id ?? err.details?.postId ?? '').trim();
const plazaUrl = postId ? resolvePlazaPostUrl(postId) : '';
return {
message: '该内容已发布到 Plaza,请先在广场删除后再试',
plazaUrl,
};
}
export function ChatPlazaPublishModal({
sessionId,
messageId,
onClose,
}: {
sessionId: string;
messageId: string;
onClose: () => void;
}) {
const [phase, setPhase] = useState<PlazaPublishPhase>({ kind: 'loading' });
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
if (closeTimer.current) window.clearTimeout(closeTimer.current);
};
}, []);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || phase.kind === 'loading') return;
onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, phase.kind]);
useEffect(() => {
const controller = new AbortController();
void quickPlazaFromChat({ sessionId, messageId }, controller.signal)
.then((result) => {
if (controller.signal.aborted) return;
const plazaUrl = resolvePlazaPostUrl(result.post.id);
setPhase({ kind: 'success', plazaUrl });
closeTimer.current = window.setTimeout(() => onCloseRef.current(), 2200);
})
.catch((err) => {
if (controller.signal.aborted) return;
if (err instanceof ApiError && err.code === 'ALREADY_PUBLISHED') {
const resolved = resolveAlreadyPublishedMessage(err);
setPhase({
kind: 'already',
message: resolved.message,
plazaUrl: resolved.plazaUrl,
});
return;
}
setPhase({
kind: 'error',
message: err instanceof Error ? err.message : '发布到 Plaza 失败,请重试',
});
});
return () => controller.abort();
}, [sessionId, messageId]);
const canClose = phase.kind !== 'loading';
return createPortal(
<div
className="chat-plaza-publish-backdrop"
role="presentation"
onClick={canClose ? onClose : undefined}
>
<div
className="chat-plaza-publish-panel"
role="dialog"
aria-modal="true"
aria-labelledby="chat-plaza-publish-title"
aria-busy={phase.kind === 'loading'}
onClick={(event) => event.stopPropagation()}
>
{phase.kind === 'loading' && (
<div className="chat-plaza-publish-body">
<ChatLoadingSpinner className="chat-plaza-publish-spinner" />
<h3 id="chat-plaza-publish-title"></h3>
<p> Plaza 广</p>
</div>
)}
{phase.kind === 'success' && (
<div className="chat-plaza-publish-body chat-plaza-publish-body-success">
<div className="chat-plaza-publish-icon chat-plaza-publish-icon-success" aria-hidden="true">
</div>
<h3 id="chat-plaza-publish-title"></h3>
<p> Plaza 广</p>
<a href={phase.plazaUrl} target="_blank" rel="noreferrer" className="chat-plaza-publish-link">
Plaza
</a>
</div>
)}
{phase.kind === 'already' && (
<div className="chat-plaza-publish-body chat-plaza-publish-body-warning">
<div className="chat-plaza-publish-icon chat-plaza-publish-icon-warning" aria-hidden="true">
!
</div>
<h3 id="chat-plaza-publish-title"></h3>
<p>{phase.message}</p>
{phase.plazaUrl ? (
<a href={phase.plazaUrl} target="_blank" rel="noreferrer" className="chat-plaza-publish-link">
</a>
) : null}
<button type="button" className="chat-plaza-publish-close-btn" onClick={onClose}>
</button>
</div>
)}
{phase.kind === 'error' && (
<div className="chat-plaza-publish-body chat-plaza-publish-body-error">
<div className="chat-plaza-publish-icon chat-plaza-publish-icon-error" aria-hidden="true">
×
</div>
<h3 id="chat-plaza-publish-title"></h3>
<p>{phase.message}</p>
<button type="button" className="chat-plaza-publish-close-btn" onClick={onClose}>
</button>
</div>
)}
</div>
</div>,
document.body,
);
}
+74 -78
View File
@@ -83,6 +83,22 @@ function DocumentIcon() {
);
}
function PlazaIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" className="icon-plaza">
<rect x="4" y="4" width="7" height="7" rx="1.6" stroke="currentColor" strokeWidth="1.7" />
<rect x="13" y="4" width="7" height="7" rx="1.6" stroke="currentColor" strokeWidth="1.7" />
<rect x="4" y="13" width="7" height="7" rx="1.6" stroke="currentColor" strokeWidth="1.7" />
<path
d="M13 16.5h7M16.5 13v7"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
);
}
function LinkIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -240,6 +256,7 @@ function MessageRow({
avatarUrl,
onAvatarClick,
onSaveAsPage,
onShareToPlaza,
onDownloadLongImage,
onDownloadDocx,
saveDisabled,
@@ -253,6 +270,7 @@ function MessageRow({
avatarUrl: string | null;
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
onShareToPlaza?: (message: Message) => void;
onDownloadLongImage?: (message: Message, publicUrl: string) => void;
onDownloadDocx?: (message: Message) => void;
saveDisabled?: boolean;
@@ -286,8 +304,59 @@ function MessageRow({
const hasAssistantActions =
!isUser && Boolean(message.id && onSaveAsPage && copyText);
const hasPageDownloadActions = hasAssistantActions && saveActions.kind === 'page' && Boolean(saveActions.previewUrl);
const hasPlazaAction = hasPageDownloadActions && Boolean(onShareToPlaza);
const showActionsToggle = compact && Boolean(copyText);
const renderPageActions = () => (
<div className="msg-page-actions">
<button
type="button"
className="msg-save-page"
disabled={saveDisabled}
onClick={() => onSaveAsPage!(message)}
aria-label={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
title={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
>
<PageIcon />
</button>
{saveActions.previewUrl && <PublicLinkCopyButton url={saveActions.previewUrl} />}
{hasPageDownloadActions && (
<>
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadLongImage?.(message, saveActions.previewUrl!)}
aria-label="下载图片"
title="下载图片"
>
<ImageIcon />
</button>
{hasPlazaAction && (
<button
type="button"
className="msg-save-page icon-plaza"
disabled={saveDisabled}
onClick={() => onShareToPlaza!(message)}
aria-label="发布到 Plaza"
title="发布到 Plaza"
>
<PlazaIcon />
</button>
)}
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadDocx?.(message)}
aria-label="保存文档"
title="保存文档"
>
<DocumentIcon />
</button>
</>
)}
</div>
);
return (
<div className={`msg-row ${isUser ? 'msg-row-user' : 'msg-row-assistant'}`}>
{!isUser && <TKMindAvatar />}
@@ -344,89 +413,13 @@ function MessageRow({
{copyText && !compact && (
<div className="msg-actions">
<CopyButton text={copyText} />
{hasAssistantActions && (
<div className="msg-page-actions">
<button
type="button"
className="msg-save-page"
disabled={saveDisabled}
onClick={() => onSaveAsPage!(message)}
aria-label={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
title={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
>
<PageIcon />
</button>
{saveActions.previewUrl && (
<PublicLinkCopyButton url={saveActions.previewUrl} />
)}
{hasPageDownloadActions && (
<>
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadLongImage?.(message, saveActions.previewUrl!)}
aria-label="下载图片"
title="下载图片"
>
<ImageIcon />
</button>
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadDocx?.(message)}
aria-label="保存文档"
title="保存文档"
>
<DocumentIcon />
</button>
</>
)}
</div>
)}
{hasAssistantActions && renderPageActions()}
</div>
)}
{copyText && compact && actionsOpen && (
<div className="msg-actions msg-actions-compact">
<CopyButton text={copyText} />
{hasAssistantActions && (
<div className="msg-page-actions">
<button
type="button"
className="msg-save-page"
disabled={saveDisabled}
onClick={() => onSaveAsPage!(message)}
aria-label={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
title={saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
>
<PageIcon />
</button>
{saveActions.previewUrl && (
<PublicLinkCopyButton url={saveActions.previewUrl} />
)}
{hasPageDownloadActions && (
<>
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadLongImage?.(message, saveActions.previewUrl!)}
aria-label="下载图片"
title="下载图片"
>
<ImageIcon />
</button>
<button
type="button"
className="msg-save-page"
onClick={() => onDownloadDocx?.(message)}
aria-label="保存文档"
title="保存文档"
>
<DocumentIcon />
</button>
</>
)}
</div>
)}
{hasAssistantActions && renderPageActions()}
</div>
)}
</div>
@@ -448,6 +441,7 @@ export function MessageList({
streaming,
onAvatarClick,
onSaveAsPage,
onShareToPlaza,
onDownloadLongImage,
onDownloadDocx,
publishUserId,
@@ -458,6 +452,7 @@ export function MessageList({
streaming: boolean;
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
onShareToPlaza?: (message: Message) => void;
onDownloadLongImage?: (message: Message, publicUrl: string) => void;
onDownloadDocx?: (message: Message) => void;
publishUserId?: string;
@@ -496,6 +491,7 @@ export function MessageList({
avatarUrl={avatarUrl}
onAvatarClick={onAvatarClick}
onSaveAsPage={onSaveAsPage}
onShareToPlaza={onShareToPlaza}
onDownloadLongImage={onDownloadLongImage}
onDownloadDocx={onDownloadDocx}
saveDisabled={streaming}