fix: preserve multi-image prompt batches
This commit is contained in:
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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('图片总大小超过单条消息限制')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user