feat(web): 提交回显在 Chat 流尾即时渲染

ChatView 渲染 pendingSubmissions 为用户气泡,按 rpcId 对正式节点与队列行做
渲染期去重,替换原子无闪烁;新增回显跟随滚动;MessageImage/ImageGallery 增加
本地预览 arm,回显图片直接显示 object URL。
This commit is contained in:
creatixchu
2026-08-26 11:49:56 +08:00
parent 390dad6138
commit cf47b7e059
7 changed files with 183 additions and 35 deletions
@@ -7,6 +7,18 @@ import css from './MessageImage.module.css'
/** Loads a session-authorized durable image URL. */
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
/** One gallery entry: a durable admitted reference, or a submission echo's local preview. */
export type MessageImageSpec =
| { readonly attachment: ImageAttachmentRef }
| {
readonly preview: {
readonly url: string
readonly name?: string
readonly width?: number
readonly height?: number
}
}
/** Message-image strings the owner resolves from its own locale namespace. */
export interface MessageImageLabels {
/** Fallback display name for an unnamed image. */
@@ -28,11 +40,13 @@ export interface MessageImageLabels {
* `object-fit: cover` — and never upscaled past the image's natural size. The
* crop anchor keeps the top of very tall images and the left of very wide
* ones, where the informative content usually starts. */
function singleFit(attachment: ImageAttachmentRef): { width: number; height: number; objectPosition: string } {
const natural = attachment.width / attachment.height
function singleFit(
dimensions: { readonly width: number; readonly height: number },
): { width: number; height: number; objectPosition: string } {
const natural = dimensions.width / dimensions.height
const ratio = Math.min(4, Math.max(0.25, natural))
const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 }
const scale = Math.min(1, attachment.width / box.width, attachment.height / box.height)
const scale = Math.min(1, dimensions.width / box.width, dimensions.height / box.height)
return {
width: Math.max(1, Math.round(box.width * scale)),
height: Math.max(1, Math.round(box.height * scale)),
@@ -40,24 +54,35 @@ function singleFit(attachment: ImageAttachmentRef): { width: number; height: num
}
}
/** Intrinsic dimensions of one gallery entry; a preview's stay unknown until its intake probe resolved. */
function dimensionsOf(image: MessageImageSpec): { readonly width: number; readonly height: number } | undefined {
if ('attachment' in image) return image.attachment
return image.preview.width !== undefined && image.preview.height !== undefined
? { width: image.preview.width, height: image.preview.height }
: undefined
}
/**
* Compact history renderer with retryable loading and click-to-open original
* preview. A lone image renders at its `singleFit` size; an image among
* several renders as a fixed 64px square tile.
* several renders as a fixed 64px square tile. The preview arm displays its
* local URL directly — no loader round-trip, no failure/retry surface.
*
* @param props.attachment - the durable image reference to load and bound.
* @param props.load - session-authorized URL loader.
* @param props.image - the durable reference to load, or the local preview to display.
* @param props.load - session-authorized URL loader for the durable arm.
* @param props.variant - `single` for a message's lone image, `tile` otherwise.
* @param props.labels - resolved strings (tooltip, loading, retry, lightbox).
* @returns the bounded thumbnail button, or the retry control on failure.
*/
export function MessageImage({ attachment, load, variant, labels }: {
attachment: ImageAttachmentRef
export function MessageImage({ image, load, variant, labels }: {
image: MessageImageSpec
load: ImageLoader
variant: 'single' | 'tile'
labels: MessageImageLabels
}) {
const [src, setSrc] = useState<string | null>(null)
const preview = 'preview' in image ? image.preview : undefined
const attachment = 'attachment' in image ? image.attachment : undefined
const [loaded, setLoaded] = useState<string | null>(null)
const [error, setError] = useState(false)
const [open, setOpen] = useState(false)
// Retry re-arms the one load effect below, so every attempt — first load or
@@ -65,20 +90,30 @@ export function MessageImage({ attachment, load, variant, labels }: {
const [attempt, setAttempt] = useState(0)
const request = useCallback(() => { setAttempt(a => a + 1) }, [])
const close = useCallback(() => { setOpen(false) }, [])
const dimensions = useMemo(() => dimensionsOf(image), [image])
const fit = useMemo(
() => (variant === 'single' ? singleFit(attachment) : undefined),
[attachment, variant],
() => {
if (variant !== 'single') return undefined
// A preview whose intake probe has not resolved sizes as a square crop;
// the durable replacement restores the exact fit.
return dimensions === undefined
? { width: 240, height: 240, objectPosition: 'center' }
: singleFit(dimensions)
},
[dimensions, variant],
)
useEffect(() => {
if (attachment === undefined) return
let live = true
setError(false)
setSrc(null)
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
setLoaded(null)
void load(attachment).then((url) => { if (live) setLoaded(url) }).catch(() => { if (live) setError(true) })
return () => { live = false }
}, [attachment, load, attempt])
const label = attachment.name ?? labels.image
const src = preview?.url ?? loaded
const label = (preview?.name ?? attachment?.name) ?? labels.image
if (error) return <button type="button" className={css.error} data-variant={variant} onClick={request}>{labels.loadFailed}</button>
return (
<>
@@ -103,7 +138,7 @@ export function MessageImage({ attachment, load, variant, labels }: {
/** Wrapping image group shared by user and assistant history: a lone image
* renders large, several render as 64px square tiles (DeepSeek Chat rule). */
export function ImageGallery({ images, load, align, labels }: {
images: readonly { attachment: ImageAttachmentRef }[]
images: readonly MessageImageSpec[]
load: ImageLoader
align: 'start' | 'end'
labels: MessageImageLabels
@@ -113,7 +148,13 @@ export function ImageGallery({ images, load, align, labels }: {
return (
<div className={css.gallery} data-align={align}>
{images.map((image, index) => (
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} variant={variant} labels={labels} />
<MessageImage
key={`${'attachment' in image ? image.attachment.attachmentId : image.preview.url}:${index}`}
image={image}
load={load}
variant={variant}
labels={labels}
/>
))}
</div>
)
@@ -49,7 +49,7 @@ const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector(
describe('MessageImage', () => {
it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
expect(frame.getAttribute('style')).toContain('width: 240px')
expect(frame.getAttribute('style')).toContain('height: 120px')
@@ -64,7 +64,7 @@ describe('MessageImage', () => {
it('ignores a click while the thumbnail is still loading', () => {
const load = vi.fn(() => new Promise<string>(() => {}))
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
expect(view.getByText('图片加载中…')).toBeTruthy()
fireEvent.click(frame)
@@ -74,7 +74,7 @@ describe('MessageImage', () => {
it('falls back to the image label for an unnamed attachment', async () => {
const { name: _named, ...unnamed } = attachment
const load = vi.fn().mockResolvedValue('blob:unnamed')
const view = render(<MessageImage attachment={unnamed} load={load} variant="single" labels={labels} />)
const view = render(<MessageImage image={{ attachment: unnamed }} load={load} variant="single" labels={labels} />)
await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
})
@@ -84,7 +84,7 @@ describe('MessageImage', () => {
.mockRejectedValueOnce(new Error('offline'))
.mockRejectedValueOnce(new Error('still offline'))
.mockResolvedValueOnce('blob:retry')
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retry)
const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
@@ -96,7 +96,7 @@ describe('MessageImage', () => {
it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => {
const load = vi.fn().mockResolvedValue('blob:ratio')
const tall = render(
<MessageImage attachment={{ ...attachment, width: 100, height: 2000 }} load={load} variant="single" labels={labels} />,
<MessageImage image={{ attachment: { ...attachment, width: 100, height: 2000 } }} load={load} variant="single" labels={labels} />,
)
const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' })
expect(tallFrame.getAttribute('style')).toContain('width: 60px')
@@ -105,7 +105,7 @@ describe('MessageImage', () => {
expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top')
tall.unmount()
const wide = render(
<MessageImage attachment={{ ...attachment, width: 4000, height: 100 }} load={load} variant="single" labels={labels} />,
<MessageImage image={{ attachment: { ...attachment, width: 4000, height: 100 } }} load={load} variant="single" labels={labels} />,
)
const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' })
expect(wideFrame.getAttribute('style')).toContain('width: 240px')
@@ -114,7 +114,7 @@ describe('MessageImage', () => {
expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center')
wide.unmount()
const small = render(
<MessageImage attachment={{ ...attachment, width: 100, height: 100 }} load={load} variant="single" labels={labels} />,
<MessageImage image={{ attachment: { ...attachment, width: 100, height: 100 } }} load={load} variant="single" labels={labels} />,
)
const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' })
expect(smallFrame.getAttribute('style')).toContain('width: 100px')
@@ -123,7 +123,7 @@ describe('MessageImage', () => {
it('renders a tile at the fixed square without inline sizing', () => {
const load = vi.fn(() => new Promise<string>(() => {}))
const view = render(<MessageImage attachment={attachment} load={load} variant="tile" labels={labels} />)
const view = render(<MessageImage image={{ attachment }} load={load} variant="tile" labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
expect(frame.getAttribute('data-variant')).toBe('tile')
expect(frame.getAttribute('style')).toBeNull()
@@ -131,7 +131,7 @@ describe('MessageImage', () => {
it('keeps the tile variant on the failed-load retry control', async () => {
const load = vi.fn().mockRejectedValue(new Error('offline'))
const view = render(<MessageImage attachment={attachment} load={load} variant="tile" labels={labels} />)
const view = render(<MessageImage image={{ attachment }} load={load} variant="tile" labels={labels} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
expect(retry.getAttribute('data-variant')).toBe('tile')
})
@@ -139,13 +139,13 @@ describe('MessageImage', () => {
it('ignores a load settling after unmount', async () => {
let resolve: ((url: string) => void) | undefined
const load = vi.fn(() => new Promise<string>((r) => { resolve = r }))
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
view.unmount()
resolve?.('blob:late')
await Promise.resolve()
let reject: ((error: Error) => void) | undefined
const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
const second = render(<MessageImage attachment={attachment} load={failing} variant="single" labels={labels} />)
const second = render(<MessageImage image={{ attachment }} load={failing} variant="single" labels={labels} />)
second.unmount()
reject?.(new Error('late failure'))
await Promise.resolve()
@@ -7,7 +7,8 @@ import type {
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { PendingSteeringBubble } from './MessageItem.tsx'
import type { ChatSnapshot } from '../contract/snapshot.ts'
import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx'
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
import { formatRunDuration } from './message-chrome.ts'
import css from './ChatView.module.css'
@@ -96,6 +97,33 @@ function isFolderOpenPath(path: string): boolean {
return path === '.'
}
/**
* Prompt-RPC identities already rendered by durable material: user/steering
* node sources plus queue occurrences. A submission echo whose identity
* appears here is hidden in the same render, so the echo→durable swap is
* atomic — no duplicate, no gap — regardless of when the echo leaves the
* session snapshot.
*/
function observedRpcIds(
order: readonly string[],
nodes: ChatSnapshot['nodes'],
queue: readonly { readonly rpcId?: string }[],
): ReadonlySet<string> {
const observed = new Set<string>()
for (const key of order) {
const node = nodes.get(key)
if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering')) continue
const source = (node.data as { readonly source?: unknown }).source as
| { readonly kind?: unknown; readonly rpcId?: unknown }
| undefined
if (source?.kind === 'user' && typeof source.rpcId === 'string') observed.add(source.rpcId)
}
for (const item of queue) {
if (item.rpcId !== undefined) observed.add(item.rpcId)
}
return observed
}
function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null {
let latest: number | null = null
for (const turn of timeline.turns.values()) {
@@ -202,6 +230,15 @@ export function ChatView({
() => inbox.filter(item => item.placement === 'steering'),
[inbox],
)
const pendingSubmissions = useSession(s => s.pendingSubmissions)
// Submission echoes still awaiting their durable counterpart. `order` is the
// recompute trigger: durable user material always arrives as an append, and
// every append replaces the order array.
const visibleSubmissions = useMemo(() => {
if (pendingSubmissions.length === 0) return pendingSubmissions
const observed = observedRpcIds(order, nodeStore, inbox)
return pendingSubmissions.filter(submission => !observed.has(submission.requestId))
}, [pendingSubmissions, order, nodeStore, inbox])
const renderMessageImages = useCallback<RenderMessageImages>(
owner => renderSlot('conversation.message.images', { ...owner, loadImage }),
[loadImage, renderSlot],
@@ -221,6 +258,7 @@ export function ChatView({
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
const lastSteeringIdRef = useRef<string | null>(null)
const lastSubmissionIdRef = useRef<string | null>(null)
/** Flow tip signature — follow-scroll only when this moves, never on a
* scroll-driven at-bottom chrome re-render (which would snap inertial
* scrolls the rest of the way to the floor). */
@@ -231,7 +269,8 @@ export function ChatView({
const lastKey = order.at(-1) ?? null
const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey)
const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null
const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}`
const lastSubmissionId = visibleSubmissions[visibleSubmissions.length - 1]?.requestId ?? null
const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}:${lastSubmissionId ?? ''}`
const toBottom = (el: HTMLElement): void => {
anchorRef.current = null
@@ -270,6 +309,7 @@ export function ChatView({
firstSeqRef.current = firstSeq
lastKeyRef.current = lastKey
lastSteeringIdRef.current = lastSteeringId
lastSubmissionIdRef.current = lastSubmissionId
followSigRef.current = followSig
return
}
@@ -286,6 +326,7 @@ export function ChatView({
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastKey
lastSteeringIdRef.current = lastSteeringId
lastSubmissionIdRef.current = lastSubmissionId
followSigRef.current = followSig
return
}
@@ -294,13 +335,15 @@ export function ChatView({
// (send lives in the composer, so arrival is detected here, not armed there).
const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user'
const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current
const appendedSubmission = lastSubmissionId !== null && lastSubmissionId !== lastSubmissionIdRef.current
const tipMoved = followSigRef.current !== followSig
lastKeyRef.current = lastKey
lastSteeringIdRef.current = lastSteeringId
lastSubmissionIdRef.current = lastSubmissionId
followSigRef.current = followSig
// Follow new flow content while pinned; do NOT re-pin on every render
// merely because atBottomRef is true (scroll threshold → setState → snap).
if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el)
if (appendedUser || appendedSteering || appendedSubmission || (tipMoved && atBottomRef.current)) toBottom(el)
})
const onScrollRef = useRef(() => {})
@@ -451,6 +494,14 @@ export function ChatView({
t={t}
/>
))}
{visibleSubmissions.map(submission => (
<PendingSubmissionBubble
key={submission.requestId}
submission={submission}
renderMessageImages={renderMessageImages}
t={t}
/>
))}
</div>
{!atBottom && (
<div className={css.toBottomSlot}>
@@ -1,5 +1,7 @@
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client'
import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { JsonBlock, MessageText, ReferenceIcon, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts'
@@ -214,7 +216,7 @@ function projectUserText(text: string, sessionLabels: readonly string[]): ReactN
/** Right-aligned bubble shared by user and steering rows. */
function UserStyleBubble({
content, renderMessageImages, actions, pending = false, referenceLabels = [], t,
content, renderMessageImages, actions, pending = false, referenceLabels = [], previewImages, t,
}: {
content: readonly unknown[]
renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
@@ -224,9 +226,12 @@ function UserStyleBubble({
pending?: boolean
/** Exact session mention labels associated by the adjacent recall node. */
referenceLabels?: readonly string[]
/** Local submission-echo previews replacing the content-derived image group. */
previewImages?: readonly MessageImageSource[]
t: ChatViewSlotProps['t']
}): ReactNode {
const { text, images, rest } = contentParts(content)
const { text, images: contentImages, rest } = contentParts(content)
const images = previewImages ?? contentImages
const truncated = (total: number): string => t('json.truncated', { total })
const showBubble = text !== '' || rest.length > 0
return (
@@ -277,6 +282,53 @@ export function PendingSteeringBubble({ content, renderMessageImages, t }: {
)
}
/**
* Render one local submission echo with the exact visual language of the
* durable user node that replaces it: draft text plus object-URL previews,
* visible from the submit click until the durable `user/message` (or its
* queue occurrence) renders.
* @param props - the session snapshot's pending submission and render seats.
* @returns the echoed user bubble.
*/
export function PendingSubmissionBubble({ submission, renderMessageImages, t }: {
submission: PendingSubmission
renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
t: ChatViewSlotProps['t']
}): ReactNode {
const content = useMemo(
() => (submission.text === '' ? [] : [{ type: 'text', text: submission.text }]),
[submission.text],
)
const previewImages = useMemo<readonly MessageImageSource[]>(
() => submission.images.map(image => ({
preview: {
url: image.previewUrl,
...(image.name === undefined ? {} : { name: image.name }),
...(image.width === undefined ? {} : { width: image.width }),
...(image.height === undefined ? {} : { height: image.height }),
},
})),
[submission.images],
)
return (
<UserStyleBubble
content={content}
previewImages={previewImages}
renderMessageImages={renderMessageImages}
t={t}
actions={text => (
<MessageIconActions
text={text}
time={submission.time}
clock="start"
className={css.actions}
t={t}
/>
)}
/>
)
}
/** User and admitted-steering keyed Chat renderer. */
export const UserMessageNodeView = memo(function UserMessageNodeView({
node, renderMessageImages, t,
@@ -51,6 +51,7 @@ function sessionSnapshot(overrides: Partial<SessionSnapshot> = {}): SessionSnaps
return {
sessionId: SID,
queue: [],
pendingSubmissions: [],
running: false,
removed: false,
openState: 'open',
@@ -57,6 +57,7 @@ function sessionSnapshot(): SessionSnapshot {
return {
sessionId: SID,
queue: [],
pendingSubmissions: [],
running: false,
removed: false,
openState: 'open',
@@ -29,9 +29,11 @@ function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages {
calls.push(owner)
return (
<div data-testid="message-images" data-align={owner.align} data-count={owner.images.length}>
{owner.images.map(({ attachment: image }, index) => (
<span key={`${image.attachmentId}:${String(index)}`}>{image.name}</span>
))}
{owner.images.map((entry, index) => {
if (!('attachment' in entry)) throw new Error('assistant flow images are always durable references')
const image = entry.attachment
return <span key={`${image.attachmentId}:${String(index)}`}>{image.name}</span>
})}
</div>
)
}