diff --git a/chat-image-turn-scope.mjs b/chat-image-turn-scope.mjs index 30e0baf..0c03846 100644 --- a/chat-image-turn-scope.mjs +++ b/chat-image-turn-scope.mjs @@ -122,11 +122,10 @@ export function detachCurrentTurnImagesForTextProvider(message, canonicalImageUr } /** - * Remove image attachments from a persisted user message so later turns cannot reuse them. - * UI displayText / previewImageUrls are preserved for chat history rendering. + * Remove image attachments from a persisted message so later turns cannot reuse them. */ -export function scrubUserMessageImageAttachments(message) { - if (!message || message.role !== 'user') return { message, changed: false }; +function scrubPersistedImageAttachments(message) { + if (!message) return { message, changed: false }; const metadata = message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata) @@ -153,15 +152,13 @@ export function scrubUserMessageImageAttachments(message) { return null; } if (item?.type !== 'text' || typeof item.text !== 'string') return item; - const nextText = stripAgentImageText(item.text); + const nextText = message.role === 'user' ? stripAgentImageText(item.text) : item.text; if (nextText === item.text) return item; contentChanged = true; return nextText ? { ...item, text: nextText } : null; }).filter(Boolean) : message.content; - const displayText = - typeof metadata.displayText === 'string' ? metadata.displayText : null; const changed = hadImageMetadata || contentChanged; if (!changed) return { message, changed: false }; @@ -170,12 +167,20 @@ export function scrubUserMessageImageAttachments(message) { ...message, content, metadata, - ...(displayText != null ? {} : {}), }, changed: true, }; } +/** + * Remove image attachments from a persisted user message so later turns cannot reuse them. + * UI displayText / previewImageUrls are preserved for chat history rendering. + */ +export function scrubUserMessageImageAttachments(message) { + if (!message || message.role !== 'user') return { message, changed: false }; + return scrubPersistedImageAttachments(message); +} + export function messageContentHasImageUrl(content) { if (!Array.isArray(content)) return false; return content.some((item) => item?.type === 'image_url' && item?.image_url?.url); @@ -203,9 +208,8 @@ export function scrubConversationHistoricalImageAttachments(conversation, active let changed = false; const nextConversation = conversation.map((message) => { - if (message?.role !== 'user') return message; if (String(message?.id ?? '').trim() === activeId) return message; - const scrubbed = scrubUserMessageImageAttachments(message); + const scrubbed = scrubPersistedImageAttachments(message); if (scrubbed.changed) changed = true; return scrubbed.message; }); diff --git a/chat-image-turn-scope.test.mjs b/chat-image-turn-scope.test.mjs index db6ca5d..1084653 100644 --- a/chat-image-turn-scope.test.mjs +++ b/chat-image-turn-scope.test.mjs @@ -103,6 +103,30 @@ test('scrubConversationHistoricalImageAttachments keeps only active turn attachm ]); }); +test('scrubConversationHistoricalImageAttachments removes assistant image_url parts', () => { + const { conversation, changed } = scrubConversationHistoricalImageAttachments( + [ + { + id: 'assistant-old', + role: 'assistant', + content: [ + { type: 'text', text: '已识别报告' }, + { type: 'image_url', image_url: { url: 'https://example.com/report.png' } }, + ], + }, + { + id: 'user-new', + role: 'user', + content: [{ type: 'text', text: '解读报告,生成页面' }], + }, + ], + 'user-new', + ); + + assert.equal(changed, true); + assert.equal(conversation[0].content.some((item) => item.type === 'image_url'), false); +}); + test('buildCurrentTurnImageScopeNote states one independent topic per upload', () => { const note = buildCurrentTurnImageScopeNote([ { diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index b723567..fdc7b93 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -2009,6 +2009,17 @@ export function createTkmindProxy({ error.code = 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED'; throw error; } + if ( + requireHistoricalImageIsolation + && imageIsolation.hasImageUrlContent + && !imageIsolation.updated + ) { + const error = new Error( + `historical_image_session_update_unsupported:${imageIsolation.status ?? 'image_url_content_present'}`, + ); + error.code = 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED'; + throw error; + } } let finalUserMessage = userMessage; if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) { diff --git a/tkmind-proxy.test.mjs b/tkmind-proxy.test.mjs index ba745eb..b555213 100644 --- a/tkmind-proxy.test.mjs +++ b/tkmind-proxy.test.mjs @@ -1569,7 +1569,7 @@ test('submitSessionReplyForUser rotates when assistant history still has image_u }, { requireHistoricalImageIsolation: true }, ), - /historical_image_session_update_unsupported:image_url_content_present/, + /historical_image_session_update_unsupported:(405|image_url_content_present)/, ); assert.equal(replyBodies.length, 0); }, { diff --git a/wechat-mp.mjs b/wechat-mp.mjs index ab52b0f..2ca2c13 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -1094,6 +1094,25 @@ export function isWechatHistoricalImageSessionError(message) { ); } +export const WECHAT_IMAGE_STORED_ACK_TEXT = + '已收到图片。请直接告诉我接下来要做什么,例如:解读报告并生成页面。'; + +export function prepareWechatIntentForHistoricalImageRetry(intent) { + if (!intent || typeof intent !== 'object') return intent; + const imageUrl = String(intent.media?.publicUrl ?? '').trim(); + const agentText = String(intent.agentText ?? '').trim(); + if (imageUrl && !agentText.includes(imageUrl)) { + intent.agentText = agentText + ? `${agentText}\n\n[附件图片]: ${imageUrl}` + : `[附件图片]: ${imageUrl}`; + } + delete intent.media; + delete intent.attachment; + delete intent.recentMediaItems; + delete intent.recentMediaBatchId; + return intent; +} + export function isRecoverableWechatAgentSessionError(message) { const normalized = String(message ?? '').trim(); if (!normalized) return false; @@ -3157,6 +3176,9 @@ export function createWechatMpService({ sessionId = route.sessionId; await ensureSessionProvider(sessionId); await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession }); + if (historicalImageError) { + prepareWechatIntentForHistoricalImageRetry(intent); + } const retryId = crypto.randomUUID(); const retryStartedAt = Date.now(); const retryPageContinuation = sessionPageContinuation && !historicalImageError; @@ -3683,6 +3705,22 @@ export function createWechatMpService({ }; intent.agentText = `[图片1]: ${persisted.publicUrl}`; rememberRecentMedia(inbound.fromUserName, intent); + await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); + if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') { + await userAuth.finishWechatMpMessage({ + appId: config.appId, + openid: inbound.fromUserName, + msgId: inbound.msgId, + status: 'done', + agentSessionId: null, + }); + } + return { + ok: true, + status: 200, + contentType: 'application/xml; charset=utf-8', + body: await buildPassiveReplyBody(WECHAT_IMAGE_STORED_ACK_TEXT), + }; } catch (error) { await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash }); return { diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 0a31bce..969c499 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -14,6 +14,8 @@ import { isRecoverableWechatAgentSessionError, isWechatAgentApiErrorText, isWechatHistoricalImageSessionError, + WECHAT_IMAGE_STORED_ACK_TEXT, + prepareWechatIntentForHistoricalImageRetry, sanitizeWechatAgentOutboundText, loadWechatMpConfig, maybeAttachPublishedHtmlLink, @@ -3498,6 +3500,19 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history', assert.equal(isRecoverableWechatAgentSessionError('network timeout'), false); }); +test('prepareWechatIntentForHistoricalImageRetry keeps image url in text only', () => { + const intent = prepareWechatIntentForHistoricalImageRetry({ + agentText: '解读报告,生成页面', + media: { publicUrl: 'https://example.com/report.png' }, + recentMediaItems: [{ media: { publicUrl: 'https://example.com/report.png' } }], + recentMediaBatchId: 'batch-1', + }); + assert.match(intent.agentText, /https:\/\/example\.com\/report\.png/); + assert.equal(intent.media, undefined); + assert.equal(intent.recentMediaItems, undefined); + assert.equal(intent.recentMediaBatchId, undefined); +}); + test('wechat mp rotates and retries when historical image isolation is unsupported', async () => { const token = 'token'; const timestamp = '1710000000'; @@ -4917,7 +4932,7 @@ test('wechat mp wildcard media access persists image and routes image url into a const originalRandomUuid = crypto.randomUUID; crypto.randomUUID = () => 'req-image'; try { - const result = await service.handleInboundMessage( + const imageResult = await service.handleInboundMessage( inboundXml({ msgType: 'image', content: '', @@ -4929,9 +4944,15 @@ test('wechat mp wildcard media access persists image and routes image url into a signature: signatureFor(token, timestamp, nonce), }, ); - assert.equal(result.status, 200); - assert.match(result.body, //); - await result.task; + assert.equal(imageResult.status, 200); + assert.match(imageResult.body ?? '', /已收到图片/); + assert.equal(prompts.length, 0); + + const followupResult = await service.handleInboundMessage( + inboundXml({ msgType: 'text', content: '请分析刚才图片' }), + { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, + ); + await followupResult.task; } finally { crypto.randomUUID = originalRandomUuid; fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { @@ -4941,17 +4962,13 @@ test('wechat mp wildcard media access persists image and routes image url into a } assert.equal(prompts.length, 1); - assert.match(prompts[0], /【微信服务号图片消息】/); - assert.match( - prompts[0], - /\[图片1\]: https:\/\/example\.com\/MindSpace\/test-user-image\/public\/wechat-mp\//, - ); + assert.match(prompts[0], /请分析刚才图片/); assert.equal(metadataCalls.length, 1); assert.equal(metadataCalls[0].source, 'wechat_mp'); - assert.equal(metadataCalls[0].msgType, 'image'); + assert.equal(metadataCalls[0].msgType, 'text'); assert.equal(metadataCalls[0].imageUrls.length, 1); assert.match(metadataCalls[0].imageUrls[0], /\/public\/wechat-mp\//); - assert.equal(detailCalls.length, 1); + assert.equal(detailCalls.length, 2); assert.match(detailCalls[0].mediaPublicUrl, /\/wechat-mp\//); }); @@ -5014,7 +5031,7 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () => }); try { - const result = await service.handleInboundMessage( + const imageResult = await service.handleInboundMessage( inboundXml({ msgType: 'image', content: '', @@ -5022,7 +5039,14 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () => }), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, ); - await result.task; + assert.match(imageResult.body ?? '', /已收到图片/); + assert.equal(submitCalls.length, 0); + + const followupResult = await service.handleInboundMessage( + inboundXml({ msgType: 'text', content: '请分析刚才图片' }), + { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, + ); + await followupResult.task; } finally { fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true }); } @@ -5030,6 +5054,7 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () => assert.equal(submitCalls.length, 1); assert.equal(submitCalls[0].userId, testUserId); assert.equal(submitCalls[0].sessionId, 'session-1'); + assert.equal(submitCalls[0].options?.requireHistoricalImageIsolation, true); assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 1); assert.match(submitCalls[0].userMessage.metadata.imageUrls[0], /\/public\/wechat-mp\//); }); @@ -5337,8 +5362,6 @@ test('wechat mp serializes image and follow-up text and reattaches recent image' const nonce = 'nonce'; const testUserId = 'test-user-image-followup'; const submitCalls = []; - let eventCall = 0; - let releaseFirst = null; const service = createBoundWechatService({ token, config: { @@ -5351,25 +5374,6 @@ test('wechat mp serializes image and follow-up text and reattaches recent image' }, sessionApiFetch: async (_sessionId, pathname) => { if (pathname === '/sessions/session-1/events') { - eventCall += 1; - if (eventCall === 1) { - return new Response( - new ReadableStream({ - start(controller) { - releaseFirst = () => { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"Message","message":{"id":"assistant-image","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"图片已识别。"}]}}\n\n' + - 'data: {"type":"Finish"}\n\n', - ), - ); - controller.close(); - }; - }, - }), - { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, - ); - } return new Response( [ 'data: {"type":"Message","message":{"id":"assistant-followup","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已结合刚才图片分析主题。"}]}}\n\n', @@ -5419,28 +5423,21 @@ test('wechat mp serializes image and follow-up text and reattaches recent image' }), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, ); - while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(submitCalls.length, 0); + assert.match(imageResult.body ?? '', new RegExp(WECHAT_IMAGE_STORED_ACK_TEXT.slice(0, 8))); const followupResult = await service.handleInboundMessage( inboundXml({ msgType: 'text', content: '请根据刚才图片分析主题' }), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, ); - await new Promise((resolve) => setTimeout(resolve, 0)); - assert.equal(submitCalls.length, 1); - - releaseFirst(); - await imageResult.task; await followupResult.task; } finally { fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true }); } - assert.equal(submitCalls.length, 2); - assert.deepEqual( - submitCalls[1].userMessage.metadata.imageUrls, - submitCalls[0].userMessage.metadata.imageUrls, - ); - assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text'); + assert.equal(submitCalls.length, 1); + assert.ok(Array.isArray(submitCalls[0].userMessage.metadata.imageUrls)); + assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text'); }); test('wechat mp reattaches recent image for report interpretation follow-up', async () => { @@ -5449,8 +5446,6 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as const nonce = 'nonce'; const testUserId = 'test-user-report-followup'; const submitCalls = []; - let eventCall = 0; - let releaseFirst = null; const service = createBoundWechatService({ token, config: { @@ -5463,25 +5458,6 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as }, sessionApiFetch: async (_sessionId, pathname) => { if (pathname === '/sessions/session-1/events') { - eventCall += 1; - if (eventCall === 1) { - return new Response( - new ReadableStream({ - start(controller) { - releaseFirst = () => { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"Message","message":{"id":"assistant-image","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"图片已识别。"}]}}\n\n' + - 'data: {"type":"Finish"}\n\n', - ), - ); - controller.close(); - }; - }, - }), - { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, - ); - } return new Response( [ 'data: {"type":"Message","message":{"id":"assistant-followup","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已解读报告。"}]}}\n\n', @@ -5531,29 +5507,22 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as }), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, ); - while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(submitCalls.length, 0); + assert.match(imageResult.body ?? '', /已收到图片/); const followupResult = await service.handleInboundMessage( inboundXml({ msgType: 'text', content: '解读详细报告,做成页面' }), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, ); - await new Promise((resolve) => setTimeout(resolve, 0)); - assert.equal(submitCalls.length, 1); - - releaseFirst(); - await imageResult.task; await followupResult.task; } finally { fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true }); } - assert.equal(submitCalls.length, 2); - assert.deepEqual( - submitCalls[1].userMessage.metadata.imageUrls, - submitCalls[0].userMessage.metadata.imageUrls, - ); - assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text'); - assert.equal(submitCalls[1].userMessage.metadata.displayText, '解读详细报告,做成页面'); + assert.equal(submitCalls.length, 1); + assert.ok(Array.isArray(submitCalls[0].userMessage.metadata.imageUrls)); + assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text'); + assert.equal(submitCalls[0].userMessage.metadata.displayText, '解读详细报告,做成页面'); }); test('wechat mp groups consecutive images for one follow-up and clears the consumed batch', async () => { @@ -5562,8 +5531,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu const nonce = 'nonce'; const testUserId = 'test-user-multi-image-followup'; const submitCalls = []; - let eventCall = 0; - let releaseFirst = null; const service = createBoundWechatService({ token, config: { @@ -5576,25 +5543,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu }, sessionApiFetch: async (_sessionId, pathname) => { if (pathname === '/sessions/session-1/events') { - eventCall += 1; - if (eventCall === 1) { - return new Response( - new ReadableStream({ - start(controller) { - releaseFirst = () => { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"Message","message":{"id":"assistant-first","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"第一张处理完成。"}]}}\n\n' + - 'data: {"type":"Finish"}\n\n', - ), - ); - controller.close(); - }; - }, - }), - { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, - ); - } return new Response( [ 'data: {"type":"Message","message":{"id":"assistant-ok","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"处理完成。"}]}}\n\n', @@ -5648,7 +5596,8 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu }), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, ); - while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(submitCalls.length, 0); + assert.match(firstImageResult.body ?? '', /已收到图片/); const secondImageResult = await service.handleInboundMessage( inboundXml({ @@ -5662,6 +5611,8 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu }), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, ); + assert.equal(submitCalls.length, 0); + assert.match(secondImageResult.body ?? '', /已收到图片/); const followupResult = await service.handleInboundMessage( inboundXml({ @@ -5670,12 +5621,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu }), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce) }, ); - await new Promise((resolve) => setTimeout(resolve, 0)); - assert.equal(submitCalls.length, 1); - - releaseFirst(); - await firstImageResult.task; - await secondImageResult.task; await followupResult.task; const laterResult = await service.handleInboundMessage( @@ -5690,14 +5635,12 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true }); } - assert.equal(submitCalls.length, 4); - assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 1); - assert.equal(submitCalls[1].userMessage.metadata.imageUrls.length, 1); - assert.equal(submitCalls[2].userMessage.metadata.imageUrls.length, 2); - assert.match(submitCalls[2].userMessage.metadata.imageUrls[0], /media-first/); - assert.match(submitCalls[2].userMessage.metadata.imageUrls[1], /media-second/); - assert.equal(submitCalls[2].userMessage.metadata.msgType, 'text'); - assert.equal(submitCalls[3].userMessage.metadata.imageUrls, undefined); + assert.equal(submitCalls.length, 2); + assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 2); + assert.match(submitCalls[0].userMessage.metadata.imageUrls[0], /media-first/); + assert.match(submitCalls[0].userMessage.metadata.imageUrls[1], /media-second/); + assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text'); + assert.equal(submitCalls[1].userMessage.metadata.imageUrls, undefined); }); test('wechat mp service persists Word and Excel files in user public area and reuses H5 attachment metadata', async () => {