Sfoglia il codice sorgente

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

ChatView 渲染 pendingSubmissions 为用户气泡,按 rpcId 对正式节点与队列行做
渲染期去重,替换原子无闪烁;新增回显跟随滚动;MessageImage/ImageGallery 增加
本地预览 arm,回显图片直接显示 object URL。
creatixchu 1 mese fa
parent
commit
cf47b7e059

+ 57 - 16
packages/client/ui-attachment/src/MessageImage.tsx

@@ -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>
   )

+ 11 - 11
packages/client/ui-attachment/tests/message-image.client.spec.tsx

@@ -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()

+ 54 - 3
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -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}>

+ 54 - 2
packages/client/ui-chat/src/client/chat/MessageItem.tsx

@@ -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,

+ 1 - 0
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -51,6 +51,7 @@ function sessionSnapshot(overrides: Partial<SessionSnapshot> = {}): SessionSnaps
   return {
     sessionId: SID,
     queue: [],
+    pendingSubmissions: [],
     running: false,
     removed: false,
     openState: 'open',

+ 1 - 0
packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx

@@ -57,6 +57,7 @@ function sessionSnapshot(): SessionSnapshot {
   return {
     sessionId: SID,
     queue: [],
+    pendingSubmissions: [],
     running: false,
     removed: false,
     openState: 'open',

+ 5 - 3
packages/client/ui-chat/tests/image-labels.client.spec.tsx

@@ -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>
     )
   }