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..40bd4b8 --- /dev/null +++ b/mindspace-chat-plaza.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { slugFromPageTitle } from './mindspace-chat-plaza.mjs'; +import { MINDSPACE_SERVER_ADAPTER_BINDINGS } from './mindspace-server-adapter-contract.mjs'; + +test('adapter contract exposes findPageBySourceMessage for quick plaza', () => { + assert.ok(MINDSPACE_SERVER_ADAPTER_BINDINGS.pageService.includes('findPageBySourceMessage')); +}); + +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/mindspace-remote-server-adapter.mjs b/mindspace-remote-server-adapter.mjs index 09c0fbf..92aa6f6 100644 --- a/mindspace-remote-server-adapter.mjs +++ b/mindspace-remote-server-adapter.mjs @@ -27,17 +27,32 @@ function buildRemoteOperationUrl(endpoint, bindingKey, method, operationBasePath return `${base}${normalizedBasePath}/${encodeURIComponent(bindingKey)}/${encodeURIComponent(method)}`; } -async function parseRemoteOperationResponse(response, bindingKey, method) { - if (!response.ok) { - const bodyText = await response.text().catch(() => ''); - throw new Error( +function throwRemoteOperationError(response, bindingKey, method, bodyText, payload) { + const error = new Error( + payload?.message ?? `MindSpace remote adapter ${bindingKey}.${method}() failed with ${response.status}${bodyText ? `: ${bodyText}` : ''}`, - ); + ); + if (payload?.code) error.code = payload.code; + if (payload?.details !== undefined) error.details = payload.details; + throw error; +} + +async function parseRemoteOperationResponse(response, bindingKey, method) { + const bodyText = await response.text().catch(() => ''); + let payload = null; + if (bodyText) { + try { + payload = JSON.parse(bodyText); + } catch { + payload = null; + } + } + if (!response.ok) { + throwRemoteOperationError(response, bindingKey, method, bodyText, payload); } if (response.status === 204) return null; - const text = await response.text(); - if (!text) return null; - return JSON.parse(text); + if (!bodyText) return null; + return payload ?? JSON.parse(bodyText); } async function invokeRemoteOperation({ diff --git a/mindspace-remote-server-adapter.test.mjs b/mindspace-remote-server-adapter.test.mjs index 9105074..1aa6c51 100644 --- a/mindspace-remote-server-adapter.test.mjs +++ b/mindspace-remote-server-adapter.test.mjs @@ -133,3 +133,26 @@ test('createMindSpaceRemoteServerAdapter surfaces remote conversation package er /conversationPackageRegistry\.readManifestForSession\(\) failed with 502: upstream manifest missing/, ); }); + +test('createMindSpaceRemoteServerAdapter preserves publication error codes from rpc payload', async () => { + const adapter = createMindSpaceRemoteServerAdapter({ + endpoint: 'https://mindspace.example.com/', + authToken: 'secret-token', + fetchFn: async () => ({ + ok: false, + status: 404, + async text() { + return JSON.stringify({ + message: '公开页面不存在', + code: 'publication_not_found', + }); + }, + }), + logger: { log() {}, warn() {}, error() {} }, + }); + + await assert.rejects( + () => adapter.publicationService.resolvePublic('john', 'missing-page', null, null, {}), + (error) => error instanceof Error && error.code === 'publication_not_found', + ); +}); diff --git a/mindspace-server-adapter-contract.mjs b/mindspace-server-adapter-contract.mjs index d3783ff..b3200ee 100644 --- a/mindspace-server-adapter-contract.mjs +++ b/mindspace-server-adapter-contract.mjs @@ -30,6 +30,7 @@ export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({ 'deletePage', 'findPageByRelativePath', 'findPageBySourceAsset', + 'findPageBySourceMessage', 'getDeletePreview', 'getPage', 'listPages', diff --git a/mindspace-service/mindspace-rpc-server.mjs b/mindspace-service/mindspace-rpc-server.mjs index b3eb6ae..5968bac 100644 --- a/mindspace-service/mindspace-rpc-server.mjs +++ b/mindspace-service/mindspace-rpc-server.mjs @@ -53,6 +53,36 @@ function json(res, statusCode, payload) { res.end(body); } +function serializeRpcError(error) { + return { + message: error instanceof Error ? error.message : String(error), + code: error?.code ?? 'internal_error', + ...(error?.details !== undefined ? { details: error.details } : {}), + }; +} + +function resolveRpcErrorStatus(error) { + switch (error?.code) { + case 'publication_not_found': + case 'publication_owner_not_found': + case 'page_not_found': + case 'category_not_found': + return 404; + case 'publication_login_required': + return 401; + case 'publication_password_required': + return 403; + case 'invalid_input': + case 'invalid_publish_input': + case 'invalid_state_transition': + case 'slug_conflict': + case 'security_ack_required': + return 400; + default: + return 500; + } +} + export async function createMindSpaceRpcRequestHandler({ adapter, env = process.env, @@ -113,10 +143,11 @@ export async function createMindSpaceRpcRequestHandler({ const result = await service[method](...args); return json(res, 200, result); } catch (error) { - logger.error?.('[MindSpace RPC Error]', error); - return json(res, 500, { - message: error instanceof Error ? error.message : String(error), - }); + const statusCode = resolveRpcErrorStatus(error); + if (statusCode >= 500) { + logger.error?.('[MindSpace RPC Error]', error); + } + return json(res, statusCode, serializeRpcError(error)); } }; } diff --git a/mindspace-service/mindspace-rpc-server.test.mjs b/mindspace-service/mindspace-rpc-server.test.mjs index 6b5bf95..af05657 100644 --- a/mindspace-service/mindspace-rpc-server.test.mjs +++ b/mindspace-service/mindspace-rpc-server.test.mjs @@ -196,3 +196,52 @@ test('rpc invocation revives JSON-serialized buffers before dispatch', async () assert.deepEqual(response.body, { isBuffer: true, size: payload.length }); assert.equal(Buffer.isBuffer(adapter.calls.writeUploadContent[0][2]), true); }); + +test('rpc maps publication_not_found to 404 with error code', async () => { + const adapter = createStubAdapter(); + adapter.publicationService.resolvePublic = async () => { + const error = new Error('公开页面不存在'); + error.code = 'publication_not_found'; + throw error; + }; + const handler = await createMindSpaceRpcRequestHandler({ + adapter, + env: { + MINDSPACE_MEMIND_ROOT: '..', + }, + }); + + const response = await runRequest(handler, { + method: 'POST', + path: '/mindspace/v1/adapter/publicationService/resolvePublic', + body: JSON.stringify({ args: ['john', 'missing-page', null, null, {}] }), + }); + + assert.equal(response.statusCode, 404); + assert.equal(response.body.code, 'publication_not_found'); + assert.match(response.body.message, /公开页面不存在/); +}); + +test('rpc maps publication_login_required to 401 with error code', async () => { + const adapter = createStubAdapter(); + adapter.publicationService.resolvePublic = async () => { + const error = new Error('登录后才能访问此页面'); + error.code = 'publication_login_required'; + throw error; + }; + const handler = await createMindSpaceRpcRequestHandler({ + adapter, + env: { + MINDSPACE_MEMIND_ROOT: '..', + }, + }); + + const response = await runRequest(handler, { + method: 'POST', + path: '/mindspace/v1/adapter/publicationService/resolvePublic', + body: JSON.stringify({ args: ['john', 'login-page', null, null, {}] }), + }); + + assert.equal(response.statusCode, 401); + assert.equal(response.body.code, 'publication_login_required'); +}); 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 && ( + + )} +