diff --git a/chat-finish-sync.mjs b/chat-finish-sync.mjs index 3afee3b..4d62c6f 100644 --- a/chat-finish-sync.mjs +++ b/chat-finish-sync.mjs @@ -30,8 +30,41 @@ export function mergeConversationSnapshot(current, incoming) { } const base = Array.isArray(current) ? current : []; const incomingIds = new Set(incoming.map((message) => message?.id).filter(Boolean)); - const localOnly = base.filter((message) => message?.id && !incomingIds.has(message.id)); - return localOnly.length ? [...incoming, ...localOnly] : incoming; + const localOnlyByNextAnchor = new Map(); + + // Keep the regression-guard behaviour of retaining local streamed messages, + // but put them back between the same server messages instead of appending the + // whole local tail. Finish/UpdateConversation snapshots can temporarily omit + // a middle message while Goose is still persisting it. + for (let index = 0; index < base.length; index += 1) { + const message = base[index]; + if (!message?.id || incomingIds.has(message.id)) continue; + + let nextAnchor = null; + for (let cursor = index + 1; cursor < base.length; cursor += 1) { + const candidateId = base[cursor]?.id; + if (candidateId && incomingIds.has(candidateId)) { + nextAnchor = candidateId; + break; + } + } + + const bucket = localOnlyByNextAnchor.get(nextAnchor) ?? []; + bucket.push(message); + localOnlyByNextAnchor.set(nextAnchor, bucket); + } + + if (localOnlyByNextAnchor.size === 0) return incoming; + + const merged = []; + for (const message of incoming) { + const before = localOnlyByNextAnchor.get(message?.id); + if (before?.length) merged.push(...before); + merged.push(message); + } + const trailing = localOnlyByNextAnchor.get(null); + if (trailing?.length) merged.push(...trailing); + return merged; } /** diff --git a/chat-finish-sync.test.mjs b/chat-finish-sync.test.mjs index 4f65be0..4910a8e 100644 --- a/chat-finish-sync.test.mjs +++ b/chat-finish-sync.test.mjs @@ -52,3 +52,31 @@ test('mergeSessionMessagesAfterFinish matches Finish sync merge semantics', () = ['u1', 'a1'], ); }); + +test('mergeConversationSnapshot keeps an omitted middle message between its anchors', () => { + const local = [ + msg('u1', 'user', '第一轮'), + msg('a1', 'assistant', '第一轮回复'), + msg('u2', 'user', '第二轮'), + msg('a2', 'assistant', '第二轮回复'), + ]; + const server = [local[0], local[1], local[3]]; + assert.deepEqual( + mergeConversationSnapshot(local, server).map((message) => message.id), + ['u1', 'a1', 'u2', 'a2'], + ); +}); + +test('mergeConversationSnapshot keeps local messages before the first and after the last server anchor', () => { + const local = [ + msg('u0', 'user', '本地前置'), + msg('u1', 'user', '服务端消息'), + msg('a1', 'assistant', '服务端回复'), + msg('a2', 'assistant', '本地尾部'), + ]; + const server = [local[1], local[2]]; + assert.deepEqual( + mergeConversationSnapshot(local, server).map((message) => message.id), + ['u0', 'u1', 'a1', 'a2'], + ); +}); diff --git a/mindspace-public-delivery.mjs b/mindspace-public-delivery.mjs index ea53b71..52d6f5f 100644 --- a/mindspace-public-delivery.mjs +++ b/mindspace-public-delivery.mjs @@ -5,6 +5,7 @@ import { injectMindSpacePageDataContext } from './mindspace-public-page-context. import { preparePublishedPlatformBrand } from './mindspace-page-tag.mjs'; import { injectPublicImageRetryScript } from './mindspace-public-image-retry.mjs'; import { applyWechatSurveyCompat } from './mindspace-page-data-wechat-survey-compat.mjs'; +import { stripPublicationHtmlCspMeta } from './plaza-embed.mjs'; const INLINE_SCRIPT_PATTERN = /]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi; @@ -64,7 +65,12 @@ export function decorateMindSpacePublishedHtml({ publishedPageCsp, isWechatUserAgent, } = {}) { - let nextHtml = html; + // Preview HTML carries a restrictive inline CSP (often script-src 'none'). + // Published delivery sets the authoritative CSP response header below; keep + // the preview meta out of the delivered document so Page Data and other + // same-origin scripts are governed by that header instead of being blocked + // by a stale preview policy. + let nextHtml = stripPublicationHtmlCspMeta(html); let allowEmbedFrame = false; if (embed) { diff --git a/mindspace-public-delivery.test.mjs b/mindspace-public-delivery.test.mjs index 873c9cb..aeb61e4 100644 --- a/mindspace-public-delivery.test.mjs +++ b/mindspace-public-delivery.test.mjs @@ -137,6 +137,22 @@ test('decorateMindSpacePublishedHtml returns decorated html and csp', () => { assert.equal(options.scriptHashes.length, 4); }); +test('decorateMindSpacePublishedHtml removes preview CSP before published delivery', () => { + const result = decorateMindSpacePublishedHtml({ + html: '', + context: { origin: '', pageUrl: '', pageDirUrl: '', fallbackImageUrl: '' }, + preparePublicationHtmlForEmbed: (value) => value, + injectOgTags: (value) => value, + injectWechatShareBridge: (value) => value, + injectPublicFileShareButton: (value) => ({ html: value, scriptHashes: [] }), + publishedPageCsp: (value) => value, + isWechatUserAgent: () => false, + }); + + assert.doesNotMatch(result.html, /Content-Security-Policy/i); + assert.match(result.html, /window\.ready=1/); +}); + test('decorateMindSpacePublishedHtml forwards isOwner=false to the share button injector', () => { let sharedIsOwner; decorateMindSpacePublishedHtml({ diff --git a/src/utils/message.ts b/src/utils/message.ts index 93a7f42..14f0b9d 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -234,14 +234,20 @@ export function shouldShowChatMessage(message: Message): boolean { } export function pushMessage(messages: Message[], incoming: Message): Message[] { - const last = messages[messages.length - 1]; - if (last?.id && incoming.id && last.id === incoming.id) { + const existingIndex = incoming.id + ? messages.findIndex((message) => message?.id === incoming.id) + : -1; + if (existingIndex >= 0) { + const existing = messages[existingIndex]; return [ - ...messages.slice(0, -1), + ...messages.slice(0, existingIndex), { - ...last, - content: mergeMessageContent(last.content, incoming.content) as MessageContent[], + ...existing, + ...incoming, + metadata: { ...existing.metadata, ...incoming.metadata }, + content: mergeMessageContent(existing.content, incoming.content) as MessageContent[], }, + ...messages.slice(existingIndex + 1), ]; } return [...messages, incoming];