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:
Tianyi Cui
2026-07-30 01:16:58 +08:00
97 changed files with 1375 additions and 447 deletions
@@ -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) }, [])