feat(web): whole-page image drop, projected intake limits, and thumbnail tiling

Second alignment step for #2248: document-level drag intake behind the new
DropOverlay atom, lightbox close icon and shared dialog mask, DeepSeek Chat
thumbnail rules (single 240px long edge with ratio clamp, 64px tiles, merged
consecutive assistant images), image limits raised to 20/10MiB/100MiB and
published to clients as the imageLimits projection, whole-batch intake
pre-check with product-copy banners, and attachment-error reasons mapped to
localized copy.
This commit is contained in:
creatixchu
2026-08-12 12:31:52 +08:00
parent 4a8ee7674a
commit 2c9036b102
46 changed files with 999 additions and 171 deletions

View File

@@ -313,9 +313,9 @@ export function apply(ctx: Context): void {
return null
} catch (error: unknown) {
if (error instanceof UnsupportedImageMediaTypeError) {
return t('image.unsupportedType', {
type: error.mediaType || t('image.unknownType'),
})
// Positive copy: the supported list is fixed in imageMediaType,
// and naming it beats echoing the rejected MIME type back.
return t('image.unsupportedType')
}
return error instanceof Error ? error.message : String(error)
}

View File

@@ -10,6 +10,7 @@
// turn's transcript tail. Think / tool-head-only nodes stay chrome-free.
import { memo, useMemo } from 'react'
import type { ReactNode } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -48,34 +49,56 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|| interrupted === true
|| blocks.some(block => block.kind !== 'tool-call')
if (!hasVisible) return null
const rendered: ReactNode[] = []
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
if (block === undefined) continue
switch (block.kind) {
case 'text':
rendered.push(
<MarkdownText
key={i}
text={block.text}
streaming={streaming}
codeLabels={codeLabels}
fileMentions={mentions}
/>,
)
break
case 'reasoning':
rendered.push(<ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />)
break
case 'image': {
// Consecutive image blocks share one gallery so several images tile
// into rows instead of each opening a one-image group of its own.
const group = [block]
while (i + 1 < blocks.length) {
const next = blocks[i + 1]
if (next === undefined || next.kind !== 'image') break
group.push(next)
i += 1
}
rendered.push(<ImageGallery key={i} images={group} load={imageLoader} align="start" labels={messageImageLabels(t)} />)
break
}
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call':
break
default:
rendered.push(
<JsonBlock
key={i}
label={t('message.unknownBlock')}
payload={block.block}
truncatedLabel={total => t('json.truncated', { total })}
/>,
)
}
}
return (
<div className={css.root} data-streaming={streaming || undefined}>
<div className={css.body}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return (
<MarkdownText
key={i}
text={block.text}
streaming={streaming}
codeLabels={codeLabels}
fileMentions={mentions}
/>
)
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" labels={messageImageLabels(t)} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return (
<JsonBlock
key={i}
label={t('message.unknownBlock')}
payload={block.block}
truncatedLabel={total => t('json.truncated', { total })}
/>
)
}
})}
{rendered}
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
</div>
</div>

View File

@@ -3,11 +3,55 @@
* application state; owners resolve every string). */
import type {
AttachmentRailLabels, ImageLightboxLabels, MessageImageLabels,
AttachmentRailLabels, DropOverlayLabels, ImageLightboxLabels, MessageImageLabels,
} from '@deepseek-ai/dsh-client-ui-attachment'
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationKey } from './locales.ts'
/**
* Byte count as user-facing megabytes (`10MB`, `2.5MB`).
* @param bytes - the byte count.
* @returns the rounded megabyte text.
*/
export function imageSizeText(bytes: number): string {
const mb = bytes / (1024 * 1024)
return `${Number.isInteger(mb) ? String(mb) : mb.toFixed(1)}MB`
}
/**
* Product copy for a host attachment rejection (the `attachment-error`
* `details.reason`). User-solvable reasons name the limit and the way out;
* reasons the user cannot act on fold into one send-failed line carrying the
* reason code for a bug report.
* @param t - the conversation-namespace translate.
* @param reason - the wire `details.reason` code.
* @param limits - projected limits interpolated into count/size copy, when known.
* @returns the banner text.
*/
export function attachmentErrorText(
t: Translate<ConversationKey>,
reason: string,
limits?: ImageAttachmentLimits,
): string {
switch (reason) {
case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported')
case 'SUBAGENT_IMAGE_UNSUPPORTED': return t('image.subagentUnsupported')
case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels')
case 'TOO_MANY_IMAGES':
if (limits !== undefined) return t('image.tooMany', { count: limits.maxImagesPerMessage })
break
case 'IMAGE_TOO_LARGE':
if (limits !== undefined) return t('image.fileTooLarge', { size: imageSizeText(limits.maxImageBytes) })
break
case 'IMAGES_TOO_LARGE':
if (limits !== undefined) return t('image.totalTooLarge', { size: imageSizeText(limits.maxMessageImageBytes) })
break
default: break
}
return t('image.sendFailed', { reason })
}
/**
* Resolve the original-image lightbox strings.
* @param t - the conversation-namespace translate.
@@ -33,6 +77,25 @@ export function messageImageLabels(t: Translate<ConversationKey>): MessageImageL
}
}
/**
* Resolve the full-page drop overlay strings.
* @param t - the conversation-namespace translate.
* @param accepting - whether drops are currently accepted.
* @param limits - per-message limits for the desc line, when known.
* @returns the overlay title, with the limits desc while accepting.
*/
export function dropOverlayLabels(
t: Translate<ConversationKey>,
accepting: boolean,
limits?: { count: number; size: string },
): DropOverlayLabels {
if (!accepting) return { title: t('image.dropBlocked') }
return {
title: t('image.dropTitle'),
desc: limits === undefined ? undefined : t('image.dropDesc', { count: limits.count, size: limits.size }),
}
}
/**
* Resolve the composer draft-image rail strings.
* @param t - the conversation-namespace translate.

View File

@@ -25,7 +25,9 @@ export const zh = {
'input.send': '发送消息',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
'input.accessMode': '访问模式,当前:{name}',
'image.dropHint': '松开以添加图片',
'image.dropTitle': '图片拖动到此处即可添加',
'image.dropDesc': '最多 {count} 张,每张 {size}',
'image.dropBlocked': '当前无法添加图片',
'image.pending': '待发送图片',
'image.openOriginal': '查看原图',
'image.openOriginalLabel': '{label},点击查看原图',
@@ -39,8 +41,14 @@ export const zh = {
'image.preview': '原图预览',
'image.closePreview': '关闭原图预览',
'image.serviceUnavailable': '图片读取服务不可用',
'image.unsupportedType': '不支持的图片格式:{type}',
'image.unknownType': '未知格式',
'image.unsupportedType': '仅支持 PNG、JPG、WebP、GIF 格式的图片',
'image.tooMany': '一条消息最多添加 {count} 张图片',
'image.fileTooLarge': '单张图片不能超过 {size}',
'image.totalTooLarge': '图片总大小超过 {size},请移除部分图片',
'image.tooManyPixels': '图片分辨率过大,请压缩后重试',
'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型',
'image.subagentUnsupported': '子智能体会话暂不支持图片',
'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试',
'context.aria': '上下文已用 {percent}',
'context.used': '上下文已用',
'context.system': '系统提示词',
@@ -184,7 +192,9 @@ export const en = {
'input.send': 'Send message',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
'input.accessMode': 'Access mode, current: {name}',
'image.dropHint': 'Drop to add images',
'image.dropTitle': 'Drag images here to add them',
'image.dropDesc': 'Up to {count} images, {size} each',
'image.dropBlocked': 'Images cannot be added right now',
'image.pending': 'Pending images',
'image.openOriginal': 'View original',
'image.openOriginalLabel': '{label}, click to view original',
@@ -198,8 +208,14 @@ export const en = {
'image.preview': 'Original image preview',
'image.closePreview': 'Close original image preview',
'image.serviceUnavailable': 'Image loading service unavailable',
'image.unsupportedType': 'Unsupported image format: {type}',
'image.unknownType': 'unknown format',
'image.unsupportedType': 'Only PNG, JPG, WebP, and GIF images are supported',
'image.tooMany': 'A message can include up to {count} images',
'image.fileTooLarge': 'Each image must be smaller than {size}',
'image.totalTooLarge': 'Images exceed {size} in total; remove some and try again',
'image.tooManyPixels': 'Image resolution is too high; compress it and try again',
'image.modelUnsupported': 'The current model does not support images; switch to a model that does',
'image.subagentUnsupported': 'Subagent sessions do not support images yet',
'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again',
'context.aria': '{percent} of context used',
'context.used': 'of context used',
'context.system': 'System prompt',

View File

@@ -115,25 +115,6 @@
background: var(--dsw-alias-state-business-primary);
}
.dragActive {
border-color: var(--dsw-alias-state-business-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2);
}
.dropHint {
position: absolute;
z-index: 2;
inset: 4px;
display: grid;
place-items: center;
border-radius: 16px;
background: color-mix(in srgb, var(--dsw-specific-input-major) 88%, var(--dsw-alias-state-business-primary));
color: var(--dsw-alias-state-business-primary);
font-size: 14px;
font-weight: 600;
pointer-events: none;
}
.accessory {
display: flex;
align-items: center;

View File

@@ -7,23 +7,28 @@
* (running/removed/promptError) are self-selected via useSession. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import {
IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { AttachmentRail, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment'
import { AttachmentRail, DropOverlay, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment'
import type { AttachmentRailItem } from '@deepseek-ai/dsh-client-ui-attachment'
// Type-only: the `plan` projection key merge (the TodoDock posture — the
// composer reads a host-computed value; the domain owns the key).
import type {} from '@deepseek-ai/dsh-plan-mode/client'
// Type-only: the `goal` projection key merge (hint disambiguation).
import type {} from '@deepseek-ai/dsh-goal/client'
// The `imageLimits` projection key merge (intake pre-check) arrives with the
// wire types: apiproxy's sessions contract declares it, and client-runtime's
// api-remotes import already places it in every client program.
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import type { DraftDecorations } from '../input/decorations.ts'
import { attachmentRailLabels, lightboxLabels } from '../image-labels.ts'
import {
attachmentErrorText, attachmentRailLabels, dropOverlayLabels, imageSizeText, lightboxLabels,
} from '../image-labels.ts'
import { ContextMeter } from './ContextMeter.tsx'
import { PermissionSelect } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
@@ -80,14 +85,22 @@ export function InputBar({
setToast({ seq: toastSeq.current, text })
}, [])
const dismissToast = useCallback(() => { setToast(null) }, [])
// The deployment's image-intake limits (absent while no attachment service
// is composed — the pre-check below then defers entirely to the host).
const imageLimits = useProjection('imageLimits')
// Prompt failures are ordinary failures (no create/attach transaction exists
// anymore): the toast announces promptError, the draft stays in the machine,
// and the user resubmits. A remount over a session whose machine still holds
// an unresolved promptError deliberately re-announces it once — the failure
// is still pending, and a transient banner is its only surface.
// is still pending, and a transient banner is its only surface. Attachment
// rejections show product copy keyed by the wire reason; other codes are
// developer-facing and keep the raw message plus code.
useEffect(() => {
if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`)
}, [promptError, showToast])
if (promptError === null) return
showToast(promptError.error.code === 'attachment-error'
? attachmentErrorText(t, promptError.error.details.reason, imageLimits)
: `${promptError.error.message} (${promptError.error.code})`)
}, [promptError, showToast, t, imageLimits])
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const cardRef = useRef<HTMLDivElement | null>(null)
const dragDepthRef = useRef(0)
@@ -384,10 +397,7 @@ export function InputBar({
.filter(item => item.kind === 'file')
.map(item => item.getAsFile())
.filter((file): file is File => file !== null)
if (files.length > 0 && addImages !== undefined) {
const rejected = addImages(files)
if (rejected !== null) showToast(rejected)
}
if (files.length > 0) intakeImages(files)
const text = e.clipboardData.getData('text/plain')
if (text === '') {
if (files.length > 0) e.preventDefault()
@@ -406,38 +416,88 @@ export function InputBar({
keyboard.track(keyboard.snapshot.draft, caret)
}
const onDragEnter = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
if (locked || machineBusy || addImages === undefined) return
dragDepthRef.current += 1
setDragActive(true)
}
// Intake pre-check (DeepSeek Chat semantics): an addition that would break
// a projected limit is refused as a whole batch, announced immediately, and
// never enters the rail — no more submit-time failure rolling the rail
// back. The host enforces the same limits at submit for callers that bypass
// this composer.
const intakeImages = useCallback((files: readonly File[]): void => {
if (addImages === undefined || files.length === 0) return
const rejected = ((): string | null => {
if (imageLimits !== undefined) {
if (attachments.length + files.length > imageLimits.maxImagesPerMessage) {
return t('image.tooMany', { count: imageLimits.maxImagesPerMessage })
}
if (files.some(file => file.size > imageLimits.maxImageBytes)) {
return t('image.fileTooLarge', { size: imageSizeText(imageLimits.maxImageBytes) })
}
const total = attachments.reduce((sum, attachment) => sum + attachment.file.size, 0)
+ files.reduce((sum, file) => sum + file.size, 0)
if (total > imageLimits.maxMessageImageBytes) {
return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) })
}
}
return addImages(files)
})()
if (rejected !== null) showToast(rejected)
}, [addImages, attachments, imageLimits, showToast, t])
const onDragOver = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
event.dataTransfer.dropEffect = locked || machineBusy || addImages === undefined ? 'none' : 'copy'
}
const onDragLeave = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files') || locked || machineBusy) return
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setDragActive(false)
}
const onDrop = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
dragDepthRef.current = 0
setDragActive(false)
if (locked || machineBusy || addImages === undefined) return
const dropped = [...event.dataTransfer.files]
if (dropped.length > 0) {
const rejected = addImages(dropped)
if (rejected !== null) showToast(rejected)
// Whole-page file-drop intake (DeepSeek Chat behavior): the listeners live
// on the document so a drop anywhere over the window adds images, not only
// over the composer card. Safe as document-level state: the composer-bar
// slot is `kind: 'single'`, so at most one bar is mounted to bind these.
// Text drags carry no 'Files' type and pass through untouched, keeping the
// native drop-text-into-textarea path. The overlay layer itself is
// pointer-inert, so it never disturbs the enter/leave count.
const canAcceptDrop = !locked && !machineBusy && addImages !== undefined
useEffect(() => {
const hasFiles = (event: globalThis.DragEvent): boolean =>
event.dataTransfer?.types.includes('Files') ?? false
const reset = (): void => {
dragDepthRef.current = 0
setDragActive(false)
}
}
const onDragEnter = (event: globalThis.DragEvent): void => {
if (!hasFiles(event)) return
event.preventDefault()
dragDepthRef.current += 1
setDragActive(true)
}
const onDragOver = (event: globalThis.DragEvent): void => {
if (!hasFiles(event) || event.dataTransfer === null) return
event.preventDefault()
event.dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none'
}
const onDragLeave = (event: globalThis.DragEvent): void => {
if (!hasFiles(event)) return
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setDragActive(false)
// Leaving through the viewport edge does not balance the count on every
// engine; a page-root leave at the border means the drag left the window.
const leavingViewport = event.clientX <= 0 || event.clientY <= 0
|| event.clientX >= window.innerWidth || event.clientY >= window.innerHeight
if ((event.target === document.documentElement || event.target === document.body) && leavingViewport) reset()
}
const onDrop = (event: globalThis.DragEvent): void => {
if (!hasFiles(event)) return
event.preventDefault()
reset()
if (!canAcceptDrop) return
intakeImages([...(event.dataTransfer?.files ?? [])])
}
document.addEventListener('dragenter', onDragEnter)
document.addEventListener('dragover', onDragOver)
document.addEventListener('dragleave', onDragLeave)
document.addEventListener('drop', onDrop)
window.addEventListener('dragend', reset)
return () => {
document.removeEventListener('dragenter', onDragEnter)
document.removeEventListener('dragover', onDragOver)
document.removeEventListener('dragleave', onDragLeave)
document.removeEventListener('drop', onDrop)
window.removeEventListener('dragend', reset)
}
}, [canAcceptDrop, intakeImages])
const closePreview = useCallback(() => { setPreview(null) }, [])
@@ -573,6 +633,15 @@ export function InputBar({
return (
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
{dragActive && (
<DropOverlay
disabled={!canAcceptDrop}
labels={dropOverlayLabels(t, canAcceptDrop, imageLimits === undefined ? undefined : {
count: imageLimits.maxImagesPerMessage,
size: imageSizeText(imageLimits.maxImageBytes),
})}
/>
)}
{toast !== null && (
<Toast
key={toast.seq}
@@ -594,16 +663,11 @@ export function InputBar({
click's reopen (close-then-open flickers the chip's open echo). */}
<div
ref={cardRef}
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger, dragActive && css.dragActive)}
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger)}
data-composer-card
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
onPointerDown={workspaceTrigger ? (e) => { e.stopPropagation() } : undefined}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{dragActive && <div className={css.dropHint} role="status">{t('image.dropHint')}</div>}
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{railItems.length > 0 && (