fix: preserve chat order and published page scripts

This commit is contained in:
john
2026-07-15 14:05:09 +08:00
parent b3337c9643
commit 933466c8ab
5 changed files with 97 additions and 8 deletions
+35 -2
View File
@@ -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;
}
/**
+28
View File
@@ -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'],
);
});
+7 -1
View File
@@ -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 = /<script\b(?![^>]*\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) {
+16
View File
@@ -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: '<html><head><meta http-equiv="Content-Security-Policy" content="script-src \'none\'"></head><body><script>window.ready=1</script></body></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({
+11 -5
View File
@@ -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];