Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui

This commit is contained in:
Chinesezjc
2026-08-12 16:39:15 +08:00
176 changed files with 3088 additions and 458 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,60 @@ 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.
// Keyed by the group's FIRST block index: a streaming append that
// extends the group then only grows `images` instead of remounting
// the gallery under a shifted key.
const start = i
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={start} 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,60 @@
* 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')
// Undecodable bytes or a declared type its bytes contradict: solvable by
// replacing or re-exporting the file, so it reads as a format problem.
case 'INVALID_IMAGE':
case 'IMAGE_TYPE_MISMATCH':
return t('image.unsupportedType')
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 +82,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,94 @@ 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) {
// Format precedes limits (DeepSeek Chat's filter order): a batch with
// a non-image must announce the format problem, not a count or size
// it could never pass anyway — addImages rejects it authoritatively.
if (files.some(file => !(imageLimits.mediaTypes as readonly string[]).includes(file.type))) {
return addImages(files)
}
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 +639,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 +669,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 && (