Merge remote-tracking branch 'refs/remotes/origin/worktree/web-multimodal-image-input' into worktree/pr555-simplify
# Conflicts: # .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml # .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md # .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md # apps/cli/README.i18n.yaml # apps/cli/README.md # apps/cli/README.zh.md # apps/cli/src/app-cli-entry.ts # packages/client/connection/src/client/fixture.ts # packages/client/ui-conversation/src/client/service.ts # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/host.schema.ts # packages/host/apiproxy/src/api/host.ts # packages/host/apiproxy/tests/fetch-carrier.spec.ts
This commit is contained in:
@@ -196,9 +196,9 @@ export function apply(ctx: Context): void {
|
||||
const shell = inputHub.shell(sessionId)
|
||||
return {
|
||||
keyboard: shell,
|
||||
addImages: (files, current) => {
|
||||
addImages: (files) => {
|
||||
try {
|
||||
const images = conversation.createDraftImages(files, current)
|
||||
const images = conversation.createDraftImages(files)
|
||||
shell.addImages(images.map(image => image.id))
|
||||
return null
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -273,7 +273,7 @@ export interface ComposerBarInjected {
|
||||
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
|
||||
keyboard: ComposerKeyboard
|
||||
/** Create browser previews and append their ids to the session input state. */
|
||||
addImages: (files: readonly File[], current: readonly ComposerAttachment[]) => string | null
|
||||
addImages: (files: readonly File[]) => string | null
|
||||
/** Release one browser preview and remove its id from the session input state. */
|
||||
removeImage: (id: string) => void
|
||||
/** Resolve ordered input-state ids to browser-owned draft attachments. */
|
||||
|
||||
@@ -123,7 +123,6 @@ export class ConversationService extends Service implements IConversation {
|
||||
mode: 'queue' | 'steer',
|
||||
images: readonly File[],
|
||||
): Promise<void> {
|
||||
this.validateImages(images, [])
|
||||
const uploaded = await this.serializeImages(images)
|
||||
const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
|
||||
const result = await session.prompt(content, mode)
|
||||
@@ -133,14 +132,10 @@ export class ConversationService extends Service implements IConversation {
|
||||
/**
|
||||
* Create runtime-only draft attachments and their object URLs.
|
||||
* @param files - browser-owned image files.
|
||||
* @param current - images already present in the same composer.
|
||||
* @returns ordered attachment descriptors whose ids may enter the input state.
|
||||
*/
|
||||
createDraftImages(
|
||||
files: readonly File[],
|
||||
current: readonly ComposerAttachment[] = [],
|
||||
): readonly ComposerAttachment[] {
|
||||
this.validateImages(files, current)
|
||||
createDraftImages(files: readonly File[]): readonly ComposerAttachment[] {
|
||||
for (const file of files) imageMediaType(file.type)
|
||||
return files.map((file) => {
|
||||
const attachment = browserDraftAttachment(file)
|
||||
this.draftAttachments.set(attachment.id, attachment)
|
||||
@@ -276,36 +271,6 @@ export class ConversationService extends Service implements IConversation {
|
||||
return sessions
|
||||
}
|
||||
|
||||
/** Apply host-advertised fast-path checks before any object URL or base64 allocation. */
|
||||
private validateImages(
|
||||
files: readonly File[],
|
||||
current: readonly ComposerAttachment[],
|
||||
): 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 upload limits.
|
||||
const description = this.requireSessions().hostDescription()
|
||||
const limits = description?.imageLimits
|
||||
const all = [...current.map(attachment => attachment.file), ...files]
|
||||
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)) {
|
||||
throw new Error(`当前部署不支持 ${mediaType} 图片`)
|
||||
}
|
||||
if (limits !== undefined && file.size > limits.maxImageBytes) {
|
||||
throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`)
|
||||
}
|
||||
totalBytes += file.size
|
||||
}
|
||||
if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) {
|
||||
throw new Error('图片总大小超过单条消息限制')
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert browser files to the prompt wire's canonical base64 image parts. */
|
||||
private serializeImages(images: readonly File[]): Promise<Parameters<SessionFace['prompt']>[0]> {
|
||||
return Promise.all(images.map(async file => ({
|
||||
|
||||
@@ -234,7 +234,7 @@ export function InputBar({
|
||||
.map(item => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
if (files.length > 0) {
|
||||
setDropError(addImages(files, attachments))
|
||||
setDropError(addImages(files))
|
||||
}
|
||||
const text = e.clipboardData.getData('text/plain')
|
||||
if (text === '') {
|
||||
@@ -290,7 +290,7 @@ export function InputBar({
|
||||
if (locked || machineBusy) return
|
||||
const dropped = [...event.dataTransfer.files]
|
||||
if (dropped.length === 0) return
|
||||
setDropError(addImages(dropped, attachments))
|
||||
setDropError(addImages(dropped))
|
||||
}
|
||||
|
||||
const closePreview = useCallback(() => { setPreview(null) }, [])
|
||||
|
||||
Reference in New Issue
Block a user