fix: preserve multi-image prompt batches

This commit is contained in:
Tianyi Cui
2026-07-30 00:58:35 +08:00
parent 60c8f5ea69
commit 6312e43a11
35 changed files with 336 additions and 149 deletions

View File

@@ -1262,6 +1262,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
model: 'fx-vision',
imageLimits: {
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 10,
maxMessageImageBytes: 20 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
},

View File

@@ -52,7 +52,7 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
for (const entry of trustedHosts) assertTrustedAuthority(entry)
const apiHandler = toFetchHandler(ctx.apiProxy)
const maxRequestBodyBytes = Math.ceil(
ctx.attachments.imageLimits.maxImageBytes * 4 / 3,
ctx.attachments.imageLimits.maxMessageImageBytes * 4 / 3,
) + REQUEST_ENVELOPE_HEADROOM_BYTES
const route: WebRoute = {
kind: 'prefix',

View File

@@ -23,7 +23,7 @@ function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register'
/** Structural attachments fake: the plugin only reads imageLimits for the body cap. */
function fakeAttachments(): AttachmentStore {
return { imageLimits: { maxImageBytes: 5 * 1024 * 1024 } } as AttachmentStore
return { imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 } } as AttachmentStore
}
/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */

View File

@@ -283,11 +283,14 @@ export class ConversationService extends Service implements IConversation {
): void {
if (files.length === 0 && current.length === 0) return
// Model capability is checked only by the host against the session's
// current target; the client owns deployment limits and the one-image UI.
// current target; the client owns deployment upload limits.
const description = this.requireSessions().hostDescription()
const limits = description?.imageLimits
const all = [...current.map(attachment => attachment.file), ...files]
if (all.length > 1) throw new Error('每条消息最多添加 1 张图片')
if (limits !== undefined && all.length > limits.maxImagesPerMessage) {
throw new Error(`每条消息最多添加 ${limits.maxImagesPerMessage} 张图片`)
}
let totalBytes = 0
for (const file of all) {
const mediaType = imageMediaType(file.type)
if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) {
@@ -296,6 +299,10 @@ export class ConversationService extends Service implements IConversation {
if (limits !== undefined && file.size > limits.maxImageBytes) {
throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`)
}
totalBytes += file.size
}
if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) {
throw new Error('图片总大小超过单条消息限制')
}
}

View File

@@ -29,25 +29,6 @@ async function bench() {
}
describe('ConversationService', () => {
it('keeps the browser draft to one image', async () => {
const b = await bench()
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-one')
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
try {
const [first] = b.root.createDraftImages([new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' })])
if (first === undefined) throw new Error('draft attachment missing')
expect(() => b.root.createDraftImages(
[new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' })],
[first],
)).toThrow('每条消息最多添加 1 张图片')
expect(created).toHaveBeenCalledOnce()
} finally {
created.mockRestore()
revoked.mockRestore()
}
await b.runtime.dispose()
})
it('routes operations through the public Session binding', async () => {
const b = await bench()
await b.scoped.send('hello', 'steer')
@@ -68,6 +49,47 @@ describe('ConversationService', () => {
await b.runtime.dispose()
})
it('accepts ordered batches and preflights their advertised count and aggregate limits', async () => {
const b = await bench()
const described = vi.spyOn(b.runtime.sessions, 'hostDescription').mockReturnValue({
version: 'test',
cwd: '/tmp',
imageLimits: {
maxImageBytes: 3,
maxImagesPerMessage: 2,
maxMessageImageBytes: 3,
maxImagePixels: 4,
mediaTypes: ['image/png'],
},
attachedSessions: 1,
})
const created = vi.spyOn(URL, 'createObjectURL')
.mockReturnValueOnce('blob:first')
.mockReturnValueOnce('blob:second')
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
try {
const attachments = b.root.createDraftImages([
new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }),
new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' }),
])
expect(attachments.map(attachment => attachment.file.name)).toEqual(['first.png', 'second.png'])
expect(() => b.root.createDraftImages([
new File([Uint8Array.of(3)], 'third.png', { type: 'image/png' }),
], attachments)).toThrow('每条消息最多添加 2 张图片')
const first = attachments[0]
if (first === undefined) throw new Error('first draft attachment missing')
expect(() => b.root.createDraftImages([
new File([Uint8Array.of(3, 4, 5)], 'large.png', { type: 'image/png' }),
], [first])).toThrow('图片总大小超过单条消息限制')
expect(created).toHaveBeenCalledTimes(2)
} finally {
await b.runtime.dispose()
described.mockRestore()
created.mockRestore()
revoked.mockRestore()
}
})
it('releases draft images when the session scope is disposed', async () => {
const b = await bench()
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1')