From 3318c04490a981c7ba7929c80f009474921c2c0b Mon Sep 17 00:00:00 2001 From: john Date: Sun, 5 Jul 2026 19:28:36 +0800 Subject: [PATCH] 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 --- mindspace-chat-plaza.mjs | 206 +++++++++++++++++++++++ mindspace-chat-plaza.test.mjs | 11 ++ mindspace-publications.mjs | 4 +- plaza-seo.mjs | 1 + server.mjs | 93 +++++++++- src/api/client.ts | 30 ++++ src/components/ChatPanel.tsx | 19 +++ src/components/ChatPlazaPublishModal.tsx | 155 +++++++++++++++++ src/components/MessageList.tsx | 152 ++++++++--------- src/hooks/useTKMindChat.ts | 122 ++++++++------ src/index.css | 113 +++++++++++++ 11 files changed, 766 insertions(+), 140 deletions(-) create mode 100644 mindspace-chat-plaza.mjs create mode 100644 mindspace-chat-plaza.test.mjs create mode 100644 src/components/ChatPlazaPublishModal.tsx diff --git a/mindspace-chat-plaza.mjs b/mindspace-chat-plaza.mjs new file mode 100644 index 0000000..cdc9e11 --- /dev/null +++ b/mindspace-chat-plaza.mjs @@ -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, + }, + }; +} diff --git a/mindspace-chat-plaza.test.mjs b/mindspace-chat-plaza.test.mjs new file mode 100644 index 0000000..dcccced --- /dev/null +++ b/mindspace-chat-plaza.test.mjs @@ -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'); +}); diff --git a/mindspace-publications.mjs b/mindspace-publications.mjs index 3045e1b..3cf8388 100644 --- a/mindspace-publications.mjs +++ b/mindspace-publications.mjs @@ -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), ); diff --git a/plaza-seo.mjs b/plaza-seo.mjs index 0b3f162..2f980f5 100644 --- a/plaza-seo.mjs +++ b/plaza-seo.mjs @@ -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) { diff --git a/server.mjs b/server.mjs index d8a6d6d..97c5040 100644 --- a/server.mjs +++ b/server.mjs @@ -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, ); diff --git a/src/api/client.ts b/src/api/client.ts index 90ec497..6bb1cb2 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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; diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 069e515..eabdf0e 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -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(null); const [sharePreviewSource, setSharePreviewSource] = useState(null); + const [plazaPublishSource, setPlazaPublishSource] = useState(null); const [voiceNotice, setVoiceNotice] = useState(null); const [forceDeepReasoning, setForceDeepReasoning] = useState(false); const [pendingImages, setPendingImages] = useState([]); @@ -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 && ( + + )} +