fix: address ds-review-bot v6/v7 findings on the image-input assembly

- resolveLlmRoute: reuse the yml pi-ai row for providers it already routes
  (DUPLICATE_ADAPTER boot failure) and detect an unset model by origin, not
  by comparison against one deployment default; covered by a new spec.
- LlmService.resolveModelInfoFor preserves (and validates) modality
  metadata, arming the host image preflight for exact-route resolution.
- session.selectModel refuses a text-only target once the session log
  carries an image on any replayed route; an accepted switch would strand
  every later turn with no in-product recovery.
- The composer no longer gates image intake on the handshake activeModel
  snapshot (wrong authority for a per-session decision); the host preflight
  plus the error strip own capability, deployment limits stay client-side.
- InputHub shell teardown releases the scope's draft images (File objects
  and object URLs leaked for the page lifetime).
- session.prompt image parts carry optional alt into the durable block;
  ImageBlock documents assistant-side rendering as forward compatibility.
- Assembled built-client lane apps/web/tests/image-display.snapshot.ts pins
  the history galleries over the authorized attachment route, the lightbox,
  and the composer paste rail; the attachment rail is an accessible group.
- Docs: validateImage on the seam page, fixture byte metadata matches its
  PNG, and the Agent Note claims now match the shipped coverage.
This commit is contained in:
creatixchu
2026-07-29 18:56:40 +08:00
parent 22e48c1953
commit adce3b833d
21 changed files with 526 additions and 36 deletions

View File

@@ -106,7 +106,7 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten
mediaType: item.part.mediaType,
...item.part.name === undefined ? {} : { name: item.part.name },
})
return { type: 'image', attachment }
return { type: 'image', attachment, ...item.part.alt === undefined ? {} : { alt: item.part.alt } }
}))
}
@@ -127,6 +127,32 @@ function imageInContent(content: unknown, attachmentId: string): ImageAttachment
return undefined
}
/** True when any block (nested tool-result content included) is an image block. */
function contentHasImage(content: unknown): boolean {
if (!Array.isArray(content)) return false
for (const value of content) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
const block = value as { type?: unknown; content?: unknown }
if (block.type === 'image') return true
if (block.type === 'tool-result' && contentHasImage(block.content)) return true
}
return false
}
/**
* True when the session log already carries image content on any route a
* model request replays (message content, wrapped messages, streamed blocks).
* The log is immutable, so a true here is permanent for the session's life.
*/
function sessionHasImage(events: readonly SessionEvent[]): boolean {
return events.some((event) => {
const data = event.data as { content?: unknown; message?: { content?: unknown }; chunk?: { type?: unknown; block?: unknown } }
if (contentHasImage(data.content)) return true
if (data.message !== undefined && contentHasImage(data.message.content)) return true
return event.type === 'assistant/chunk' && data.chunk?.type === 'block-end' && contentHasImage([data.chunk.block])
})
}
function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined {
for (const event of events) {
const data = event.data as { content?: unknown; chunk?: { type?: unknown; block?: unknown } }
@@ -1111,6 +1137,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
? {}
: { reasoningEffort: ReasoningEffortId(reasoningEffort) },
})
// An image-bearing log replays into every later request, and both
// wire routes reject image content on text-only models — accepting
// this selection would strand the session (every turn fails, no
// in-product recovery). Refuse at the selection boundary instead.
if (sessionHasImage(found.agent.session.events)) {
const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model)
if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) {
return err(request, {
code: 'model-unavailable',
message: `Model "${resolved.model}" does not accept image input, but this session's history already contains images; select an image-capable model.`,
details: { provider, model },
})
}
}
const selected: AgentLlmTarget = {
provider: resolved.provider,
model: resolved.model,