Merge remote-tracking branch 'origin/worktree/web-multimodal-image-input' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml
#	apps/web/tests/code-mode-fixture.snapshot.ts
#	apps/web/tests/image-display.snapshot.ts
#	docs/architecture.i18n.yaml
#	docs/module-graph.md
#	packages/client/test-runtime/tests/runtime.spec.tsx
#	packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
#	packages/client/ui-conversation/tests/terminal-card.spec.tsx
#	packages/client/ui-trajectory/src/client/layout.ts
This commit is contained in:
creatixchu
2026-07-30 10:46:50 +08:00
38 changed files with 75 additions and 444 deletions
@@ -191,9 +191,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. */
@@ -46,13 +46,9 @@ export interface IConversation {
/**
* Create runtime-only draft attachments and preview URLs.
* @param files - browser-owned image files.
* @param current - images already present in the composer.
* @returns ordered descriptors for the input state.
*/
createDraftImages(
files: readonly File[],
current?: readonly ComposerAttachment[],
): readonly ComposerAttachment[]
createDraftImages(files: readonly File[]): readonly ComposerAttachment[]
/**
* Resolve ordered draft ids to runtime-owned attachments.
* @param ids - ordered composer attachment ids.
@@ -171,7 +167,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)
@@ -181,14 +176,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 = new BrowserDraftAttachment(file)
this.draftAttachments.set(attachment.id, attachment)
@@ -324,40 +315,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
// Deployment-wide limits only. Model capability is deliberately NOT
// checked here: the handshake's activeModel is the host default, not the
// session's current target (session.selectModel never refreshes it), so a
// client-side modality gate refuses sessions the host would accept and
// vice versa. The host preflight on session.prompt is the authority; its
// rejection renders through the composer error strip.
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) }, [])