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
+206
View File
@@ -0,0 +1,206 @@
export function slugFromPageTitle(title, pageId) {
const ascii = String(title ?? '')
.normalize('NFKC')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, pageId ? 54 : 64);
const base = ascii || 'page';
if (pageId) {
return `${base}-${String(pageId).replace(/-/g, '').slice(0, 8)}`.slice(0, 64);
}
return base;
}
async function resolveExistingPage({
userId,
sessionId,
messageId,
analysis,
mindSpacePages,
}) {
const byMessage = await mindSpacePages
.findPageBySourceMessage(userId, sessionId, messageId)
.catch(() => null);
if (byMessage) return byMessage;
if (analysis.contentMode === 'static_html' && analysis.relativePath) {
const byPath = await mindSpacePages
.findPageByRelativePath(userId, analysis.relativePath)
.catch(() => null);
if (byPath) return byPath;
}
return null;
}
async function buildPageInput({ bundle, body = {} }) {
const { source, analysis, resolvedHtml } = bundle;
let pageInput = {
title: body.title,
summary: body.summary,
templateId: body.template_id ?? 'editorial',
pageType: body.page_type,
categoryCode: 'draft',
};
if (analysis.contentMode === 'static_html') {
if (!resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
pageInput = {
...pageInput,
title: body.title || resolvedHtml.suggestedTitle,
summary: body.summary || resolvedHtml.suggestedSummary,
content: resolvedHtml.content,
contentFormat: 'html',
pageType: 'html',
};
} else {
pageInput = {
...pageInput,
title: body.title || 'AI 创作',
content: source.content,
contentFormat: 'markdown',
};
}
return pageInput;
}
export async function ensureChatPageForPlaza({
userId,
bundle,
mindSpacePages,
ensureWorkspaceHtmlThumbnail,
publishDir,
body = {},
skipThumbnail = false,
}) {
const { source, analysis, resolvedHtml } = bundle;
const sessionId = body.session_id ?? body.sessionId;
const messageId = body.message_id ?? body.messageId;
const snapshot = {
session_name: source.session.name,
message_created: source.message.created,
role: source.message.role,
content_mode: analysis.contentMode,
public_url: analysis.previewUrl,
relative_path: analysis.relativePath ?? null,
};
const pageInput = await buildPageInput({ bundle, body });
const existingPage = await resolveExistingPage({
userId,
sessionId,
messageId,
analysis,
mindSpacePages,
});
if (
!skipThumbnail &&
analysis.contentMode === 'static_html' &&
resolvedHtml?.content &&
analysis.relativePath &&
ensureWorkspaceHtmlThumbnail &&
publishDir
) {
await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, {
title: pageInput.title,
subtitle: pageInput.summary,
}).catch(() => {});
}
if (existingPage) {
return mindSpacePages.updatePage(userId, existingPage.id, {
...pageInput,
expectedVersion: existingPage.versionNo,
});
}
return mindSpacePages.createFromChat(userId, pageInput, {
sessionId,
messageId,
snapshot,
});
}
export async function ensurePagePublicationForPlaza({ userId, page, mindSpacePublications }) {
const existing = await mindSpacePublications.getCurrent(userId, page.id);
if (existing?.id) return existing;
const preferredSlug = slugFromPageTitle(page.title, page.id);
return mindSpacePublications.publish(userId, page.id, {
pageVersionId: page.currentVersionId,
accessMode: 'public',
urlSlug: preferredSlug,
autoAcknowledgeFindings: true,
});
}
export async function ensurePlazaPostForPublication({
userId,
publication,
plazaPosts,
categorySlug = 'other',
}) {
const post = await plazaPosts.publishPostForPublication(
userId,
{
publication_id: publication.id,
category_slug: categorySlug,
cover_url: '',
allow_comment: true,
},
{ forcePublished: true, deferPostPublishedHooks: true },
);
return { post };
}
export async function quickPlazaFromChat({
user,
h5Root,
bundle,
body,
mindSpacePages,
mindSpacePublications,
plazaPosts,
publishDir,
}) {
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' });
}
const page = await ensureChatPageForPlaza({
userId: user.id,
bundle,
mindSpacePages,
publishDir,
body,
skipThumbnail: true,
});
const publication = await ensurePagePublicationForPlaza({
userId: user.id,
page,
mindSpacePublications,
});
const { post } = await ensurePlazaPostForPublication({
userId: user.id,
publication,
plazaPosts,
});
return {
pageId: page.id,
publicationId: publication.id,
publicUrl: publication.publicUrl ?? publication.public_url ?? null,
post: {
id: post.id,
status: post.status,
},
};
}
+11
View File
@@ -0,0 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { slugFromPageTitle } from './mindspace-chat-plaza.mjs';
test('slugFromPageTitle falls back to page when title has no ascii', () => {
assert.equal(slugFromPageTitle('春江花月夜', '1c99b83b-0454-474f-a5d2-129d34506a32'), 'page-1c99b83b');
});
test('slugFromPageTitle keeps ascii slug and page suffix', () => {
assert.equal(slugFromPageTitle('Travel Guide 2026', 'abcd-1234-5678-9012'), 'travel-guide-2026-abcd1234');
});
+3 -1
View File
@@ -581,7 +581,9 @@ export function createPublicationService(pool, options = {}) {
findings: result.findings,
});
}
const acknowledged = new Set(input.acknowledgedFindingIds ?? []);
const acknowledged = input.autoAcknowledgeFindings
? new Set(result.findings.map((finding) => finding.id))
: new Set(input.acknowledgedFindingIds ?? []);
const missingAcknowledgements = result.findings.filter(
(finding) => !finding.blocking && !acknowledged.has(finding.id),
);
+1
View File
@@ -123,6 +123,7 @@ export function createPlazaSeoService(
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: list.join('\n'),
signal: AbortSignal.timeout(5_000),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
+84 -9
View File
@@ -122,6 +122,7 @@ import {
normalizePublicHtmlRelativePath,
syncPublicHtmlAfterFinish,
} from './mindspace-public-finish-sync.mjs';
import { quickPlazaFromChat } from './mindspace-chat-plaza.mjs';
import { extractCoverSignals, generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
import {
extractSharePreviewMeta,
@@ -2908,7 +2909,8 @@ function messageText(message) {
.trim();
}
async function resolveOwnedAssistantMessage(userId, sessionId, messageId) {
async function resolveOwnedAssistantMessage(user, sessionId, messageId) {
const userId = user?.id;
if (!sessionId || !messageId) {
throw Object.assign(new Error('缺少来源会话或消息'), {
code: 'invalid_page_input',
@@ -2917,13 +2919,52 @@ async function resolveOwnedAssistantMessage(userId, sessionId, messageId) {
if (!(await userAuth.ownsSession(userId, sessionId))) {
throw Object.assign(new Error('来源会话不存在'), { code: 'source_message_not_found' });
}
const upstream = await tkmindProxy.apiFetch(`/sessions/${encodeURIComponent(sessionId)}`, {
method: 'GET',
});
if (!upstream.ok) {
throw Object.assign(new Error('无法读取来源会话'), { code: 'source_message_not_found' });
let session = null;
if (sessionSnapshotService?.isEnabled()) {
const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null);
if (snapshot?.messages?.length) {
session = {
...snapshot.session,
conversation: sanitizeSessionConversationPublicHtmlLinks(snapshot.messages, user),
};
if (authPool) {
session = await repairSessionConversationFromDb(authPool, session, sessionId, userId);
}
}
}
const session = await upstream.json();
if (!session) {
const target = await tkmindProxy.resolveTarget(sessionId);
let upstream;
try {
upstream = await tkmindProxy.apiFetchTo(
target,
`/sessions/${encodeURIComponent(sessionId)}`,
{ method: 'GET', signal: AbortSignal.timeout(15_000) },
);
} catch (error) {
throw Object.assign(
new Error(
error?.name === 'TimeoutError' || error?.name === 'AbortError'
? '读取来源会话超时,请稍后重试'
: '无法读取来源会话',
),
{ code: 'source_message_not_found' },
);
}
if (!upstream.ok) {
throw Object.assign(new Error('无法读取来源会话'), { code: 'source_message_not_found' });
}
session = await upstream.json();
if (Array.isArray(session.conversation)) {
session.conversation = sanitizeSessionConversationPublicHtmlLinks(session.conversation, user);
}
if (authPool) {
session = await repairSessionConversationFromDb(authPool, session, sessionId, userId);
}
}
const message = (session.conversation ?? []).find((item) => item.id === messageId);
if (!message) {
throw Object.assign(new Error('来源消息不存在'), { code: 'source_message_not_found' });
@@ -2963,7 +3004,7 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) {
const previewTitle = String(input.previewTitle ?? input.preview_title ?? '').trim();
const previewSummary = String(input.previewSummary ?? input.preview_summary ?? '').trim();
const source = await resolveOwnedAssistantMessage(user.id, sessionId, messageId);
const source = await resolveOwnedAssistantMessage(user, sessionId, messageId);
let { analysis, resolvedHtml } = await resolveChatSaveAnalysis({
content: source.content,
userId: user.id,
@@ -3294,6 +3335,40 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
}
});
api.post('/mindspace/v1/pages/quick-plaza-from-chat', async (req, res) => {
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用');
}
const startedAt = Date.now();
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
const result = await quickPlazaFromChat({
user: req.currentUser,
h5Root: __dirname,
bundle,
body: req.body ?? {},
mindSpacePages,
mindSpacePublications,
plazaPosts,
publishDir,
});
console.info('[quick-plaza] ok', {
ms: Date.now() - startedAt,
pageId: result.pageId,
publicationId: result.publicationId,
postId: result.post?.id,
});
return sendData(res, req, result, 201);
} catch (error) {
console.error('[quick-plaza] error:', { ms: Date.now() - startedAt, error });
if (error?.code && mapPlazaError(error) !== 500) {
return plazaRouteError(res, req, error);
}
return mindSpaceError(res, req, error);
}
});
async function handleChatSaveDocx(req, res) {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
@@ -3348,7 +3423,7 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
throw Object.assign(new Error('无效的保存目标'), { code: 'invalid_category_code' });
}
const source = await resolveOwnedAssistantMessage(
req.currentUser.id,
req.currentUser,
req.body?.session_id,
req.body?.message_id,
);
+30
View File
@@ -64,6 +64,7 @@ import type { AgentRunCreateOptions, AgentRunValidation } from '../utils/agentRu
const API = '/api';
const DEFAULT_API_TIMEOUT_MS = 20_000;
const AGENT_CONNECT_TIMEOUT_MS = 60_000;
const QUICK_PLAZA_TIMEOUT_MS = 120_000;
const AGENT_RUNS_PATH = '/agent/runs';
export type AgentRun = {
@@ -1086,6 +1087,35 @@ export async function quickShareFromChat(input: {
return result.data;
}
export async function quickPlazaFromChat(input: {
sessionId: string;
messageId: string;
selectedLinkIndex?: number;
}, signal?: AbortSignal): Promise<{
pageId: string;
publicationId: string;
publicUrl: string | null;
post: PlazaPostBrief;
}> {
const result = await apiFetch<{
data: {
pageId: string;
publicationId: string;
publicUrl: string | null;
post: PlazaPostBrief;
};
}>('/mindspace/v1/pages/quick-plaza-from-chat', {
method: 'POST',
body: JSON.stringify({
session_id: input.sessionId,
message_id: input.messageId,
selected_link_index: input.selectedLinkIndex ?? 0,
}),
signal,
}, { timeoutMs: QUICK_PLAZA_TIMEOUT_MS });
return result.data;
}
export async function downloadChatMessageDocx(input: {
sessionId: string;
messageId: string;
+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}
+70 -52
View File
@@ -1014,45 +1014,57 @@ export function useTKMindChat(
setPendingTool(null);
activeRequestId.current = null;
const knownSession = sessionsRef.current.find((s) => s.id === sessionId);
const hints = knownSession
? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at }
: undefined;
const detailPromise = withTransientConnectRetry(() =>
loadSessionDetail(sessionId, hints, {
before: 0,
limit: appConfig.sessionMessagePageSize,
}),
);
const resumedPromise =
options?.skipResume || isDirectChatSessionId(sessionId)
? Promise.resolve(options.seedSession ?? null)
: withTransientConnectRetry(() =>
resumeSession(sessionId, {
skipReconcile: options?.skipReconcile ?? false,
}),
);
const { session: detail, messages: history, page } = await detailPromise;
if (token !== connectTokenRef.current) return;
let completed = false;
try {
const knownSession = sessionsRef.current.find((s) => s.id === sessionId);
const hints = knownSession
? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at }
: undefined;
const detailPromise = withTransientConnectRetry(() =>
loadSessionDetail(sessionId, hints, {
before: 0,
limit: appConfig.sessionMessagePageSize,
}),
);
const resumedPromise =
options?.skipResume || isDirectChatSessionId(sessionId)
? Promise.resolve(options.seedSession ?? null)
: withTransientConnectRetry(() =>
resumeSession(sessionId, {
skipReconcile: options?.skipReconcile ?? false,
}),
);
const { session: detail, messages: history, page } = await detailPromise;
if (token !== connectTokenRef.current) return;
writeStoredSessionId(userRef.current?.id, sessionId);
setSession((current) => (current?.id === sessionId ? { ...detail, ...current, id: sessionId } : { ...detail, id: sessionId }));
messagesRef.current = history;
messageHistoryLoadedCountRef.current = history.length;
messageHistoryTotalRef.current = Math.max(Number(page.total ?? history.length), history.length);
messageHistoryHasMoreRef.current = history.length < messageHistoryTotalRef.current;
setMessageHistoryHasMore(messageHistoryHasMoreRef.current);
setMessageHistoryTotal(messageHistoryTotalRef.current);
setMessages(history);
const resumed = await resumedPromise;
if (token !== connectTokenRef.current) return;
setSession({ ...detail, ...(resumed ?? {}), id: sessionId });
setChatState('idle');
setSessions((prev) =>
prependUnique(prev, toSessionSummary({ ...detail, ...(resumed ?? {}), id: sessionId })),
);
writeStoredSessionId(userRef.current?.id, sessionId);
setSession((current) =>
current?.id === sessionId ? { ...detail, ...current, id: sessionId } : { ...detail, id: sessionId },
);
messagesRef.current = history;
messageHistoryLoadedCountRef.current = history.length;
messageHistoryTotalRef.current = Math.max(Number(page.total ?? history.length), history.length);
messageHistoryHasMoreRef.current = history.length < messageHistoryTotalRef.current;
setMessageHistoryHasMore(messageHistoryHasMoreRef.current);
setMessageHistoryTotal(messageHistoryTotalRef.current);
setMessages(history);
const resumed = await resumedPromise;
if (token !== connectTokenRef.current) return;
setSession({ ...detail, ...(resumed ?? {}), id: sessionId });
setChatState('idle');
completed = true;
setSessions((prev) =>
prependUnique(prev, toSessionSummary({ ...detail, ...(resumed ?? {}), id: sessionId })),
);
subscribeToSession(sessionId);
subscribeToSession(sessionId);
} finally {
if (!completed && token === connectTokenRef.current) {
setChatState((current) =>
current === 'connecting' || current === 'loading' ? 'idle' : current,
);
}
}
},
[clearActiveRequestMissingTimer, subscribeToSession],
);
@@ -1528,22 +1540,23 @@ export function useTKMindChat(
setPendingTool(null);
setChatState('connecting');
if (
previousSessionId &&
previousSession &&
shouldShowNewChatTitle(toSessionSummary(previousSession))
) {
try {
await deleteChatSession(previousSessionId);
if (token === connectTokenRef.current) {
setSessions((prev) => prev.filter((item) => item.id !== previousSessionId));
}
} catch {
// Keep the abandoned empty session in history if cleanup fails.
}
}
let completed = false;
try {
if (
previousSessionId &&
previousSession &&
shouldShowNewChatTitle(toSessionSummary(previousSession))
) {
try {
await deleteChatSession(previousSessionId);
if (token === connectTokenRef.current) {
setSessions((prev) => prev.filter((item) => item.id !== previousSessionId));
}
} catch {
// Keep the abandoned empty session in history if cleanup fails.
}
}
const started = await startSession();
if (token !== connectTokenRef.current) return;
@@ -1560,12 +1573,17 @@ export function useTKMindChat(
if (token !== connectTokenRef.current) return;
subscribeToSession(nextSession.id);
setChatState('idle');
completed = true;
void refreshSessions();
} catch (err) {
if (token !== connectTokenRef.current) return;
setSession(null);
setChatState('idle');
setError(err instanceof Error ? err.message : String(err));
} finally {
if (!completed && token === connectTokenRef.current) {
setChatState((current) => (current === 'connecting' ? 'idle' : current));
}
}
}, [
clearActiveRequestMissingTimer,
+113
View File
@@ -1377,6 +1377,12 @@ body,
gap: 6px;
}
.msg-save-page.icon-plaza:hover,
.msg-save-page.icon-plaza.is-done {
color: #2f6f57;
border-color: rgba(47, 111, 87, 0.55);
}
.msg-save-page,
.msg-public-share-link {
display: inline-flex;
@@ -1442,6 +1448,107 @@ body,
backdrop-filter: blur(10px);
}
.chat-plaza-publish-backdrop {
position: fixed;
z-index: 1250;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
background: rgba(7, 12, 10, 0.72);
backdrop-filter: blur(10px);
}
.chat-plaza-publish-panel {
width: min(360px, 100%);
padding: 28px 24px;
border: 1px solid rgba(238, 176, 78, 0.28);
border-radius: 22px;
color: #18211d;
background:
radial-gradient(circle at 100% 0, rgba(47, 111, 87, 0.14), transparent 16rem),
#f8f3e8;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.38);
}
.chat-plaza-publish-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text-align: center;
}
.chat-plaza-publish-body h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.chat-plaza-publish-body p {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: rgba(24, 33, 29, 0.72);
}
.chat-plaza-publish-spinner {
width: 28px;
height: 28px;
margin-bottom: 4px;
}
.chat-plaza-publish-icon {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 999px;
font-size: 22px;
font-weight: 700;
line-height: 1;
}
.chat-plaza-publish-icon-success {
color: #2f6f57;
background: rgba(47, 111, 87, 0.14);
}
.chat-plaza-publish-icon-warning {
color: #b7791f;
background: rgba(238, 176, 78, 0.18);
}
.chat-plaza-publish-icon-error {
color: #b42318;
background: rgba(220, 38, 38, 0.12);
}
.chat-plaza-publish-link {
margin-top: 4px;
color: #2f6f57;
font-size: 14px;
text-decoration: underline;
text-underline-offset: 2px;
}
.chat-plaza-publish-close-btn {
margin-top: 8px;
padding: 8px 18px;
border: 1px solid rgba(24, 33, 29, 0.14);
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
color: #18211d;
cursor: pointer;
font: inherit;
font-size: 14px;
}
.chat-plaza-publish-close-btn:hover {
border-color: rgba(47, 111, 87, 0.35);
color: #2f6f57;
}
.page-save-panel {
display: flex;
flex-direction: column;
@@ -9880,6 +9987,12 @@ body,
color: var(--ms-soft);
}
.space-chat-panel .msg-save-page.icon-plaza:hover,
.space-chat-panel .msg-save-page.icon-plaza.is-done {
color: var(--ms-green-deep);
border-color: rgba(47, 111, 87, 0.45);
}
.space-chat-panel .chat-skill-menu {
border-color: rgba(24, 33, 29, 0.12);
background: rgba(255, 252, 244, 0.98);