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
@@ -28,6 +28,7 @@ interface ConversationAttachmentFace {
mode: 'queue' | 'steer',
imageIds: readonly string[],
): Promise<void>
releaseDraftImage(id: string): void
}
/** Session-addressed input facade registry (InputService face + composer-layer extras). */
@@ -84,8 +85,13 @@ export class InputHub implements InputService {
]
return () => {
for (const off of offs) off()
// Draft attachments die with the scope: the shell only holds ids, so
// the service-owned File objects and object URLs must be released
// here or they leak for the page lifetime.
const drafts = shell.snapshot.imageIds
shell.dispose()
this.shells.delete(id)
for (const imageId of drafts) this.conversation().releaseDraftImage(imageId)
}
}, 'conversation.input: session shell')
return shell
@@ -330,11 +330,13 @@ export class ConversationService extends Service implements IConversation {
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 modalities = description?.activeModel?.inputModalities
if (modalities !== undefined && !modalities.includes('image')) {
throw new Error('当前模型不支持图片输入')
}
const limits = description?.imageLimits
const all = [...current.map(attachment => attachment.file), ...files]
if (limits !== undefined && all.length > limits.maxImagesPerMessage) {
@@ -385,7 +385,7 @@ export function InputBar({
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{attachments.length > 0 && (
<div className={css.attachments} aria-label="待发送图片">
<div className={css.attachments} role="group" aria-label="待发送图片">
{attachments.map(attachment => (
<div key={attachment.id} className={css.attachment}>
<button