feat(web): retry transient model requests

This commit is contained in:
Yichen Jiang
2026-07-26 14:09:31 +08:00
parent 84be7cc622
commit a430207427
32 changed files with 791 additions and 46 deletions
@@ -45,6 +45,16 @@ type RenderToolRow = ChatViewSlotProps['renderSlot']
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null {
if (!running) return null
for (let index = nodes.length - 1; index >= 0; index -= 1) {
const node = nodes[index]!
if (node.kind === 'model-retry') return node.seq
if (node.kind === 'assistant' || node.kind === 'user') return null
}
return null
}
/** One tool call row (result or running): dispatches through the keyed
* toolview slot with the owner payload; unregistered tools fall back to
* GenericToolCard at this render site. */
@@ -115,6 +125,7 @@ function StreamingTail({ useSession, onGrow }: {
/** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
const nodes = useSession((s) => s.nodes)
const running = useSession((s) => s.running)
const runningCalls = useSession((s) => s.runningCalls)
const pending = useSession((s) => s.pending)
const openState = useSession((s) => s.openState)
@@ -124,6 +135,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
const selectedCallId = useStore((s) => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
@@ -220,7 +232,13 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} />
return (
<MessageItem
key={item.key}
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
/>
)
}
return (
@@ -32,3 +32,103 @@
.contextRow {
padding: 2px 0;
}
.retryRow {
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
}
.retrySummary {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 2px 0;
gap: 7px;
border-radius: 3px;
color: inherit;
cursor: pointer;
list-style: none;
user-select: none;
}
.retrySummary::-webkit-details-marker {
display: none;
}
.retrySummary::after {
width: 6px;
height: 6px;
border-right: 1.5px solid currentcolor;
border-bottom: 1.5px solid currentcolor;
content: '';
opacity: 0.8;
transform: rotate(-45deg);
transition: transform 120ms ease;
}
.retrySummary:hover {
color: var(--dsw-alias-label-secondary);
}
.retrySummary:focus-visible {
outline: 1.5px solid var(--dsw-alias-button-info-fill);
outline-offset: 2px;
}
.retryText {
color: inherit;
}
.retryRow[data-active] .retryText {
background:
linear-gradient(
90deg,
var(--dsw-alias-label-tertiary) 0%,
var(--dsw-alias-label-tertiary) 40%,
var(--dsw-alias-label-secondary) 50%,
var(--dsw-alias-label-tertiary) 60%,
var(--dsw-alias-label-tertiary) 100%
);
background-position: 100% 50%;
background-size: 200% 100%;
background-clip: text;
color: transparent;
animation: retry-shimmer 1.6s ease-in-out infinite;
}
.retryRow[open] .retrySummary::after {
transform: rotate(45deg);
}
.retryDetails {
display: grid;
gap: 2px;
margin-top: 3px;
padding-left: 14px;
overflow-wrap: anywhere;
font-size: 12px;
line-height: 18px;
}
.retryDetailLabel {
color: var(--dsw-alias-label-secondary);
}
@keyframes retry-shimmer {
from {
background-position: 100% 50%;
}
to {
background-position: 0 50%;
}
}
@media (prefers-reduced-motion: reduce) {
.retryRow[data-active] .retryText {
background: none;
color: inherit;
animation: none;
}
}
@@ -1,17 +1,18 @@
// MessageItem: the four simple node kinds — user bubble (right-aligned),
// steering (badged bubble), context injection and unknown-surface JSON rows.
// MessageItem: simple chat nodes — user bubble (right-aligned), steering
// (badged bubble), context injection, retry disclosure and unknown JSON rows.
// Props are frozen node slices off the snapshot cache; memo holds across
// streaming because unchanged nodes keep their references.
import { memo } from 'react'
import { memo, useEffect, useState } from 'react'
import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
ContextMessageNode, ModelRetryNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './MessageItem.module.css'
export interface MessageItemProps {
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | ModelRetryNode | UnknownSurfaceNode
retryActive?: boolean
}
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
@@ -25,7 +26,56 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
return { text: texts.join(''), rest }
}
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
function retrySeconds(milliseconds: number): number {
return Math.max(1, Math.ceil(milliseconds / 1_000))
}
interface RetryCountdown {
deadline: number
seconds: number
}
function ModelRetryItem({ node, active }: { node: ModelRetryNode; active: boolean }) {
const deadline = node.time + node.delayMs
const scheduledSeconds = retrySeconds(node.delayMs)
const [countdown, setCountdown] = useState<RetryCountdown>(() => ({
deadline,
seconds: retrySeconds(deadline - Date.now()),
}))
const remainingSeconds = countdown.deadline === deadline
? countdown.seconds
: retrySeconds(deadline - Date.now())
useEffect(() => {
if (!active || retrySeconds(deadline - Date.now()) === 1) return
const timer = window.setInterval(() => {
const next = retrySeconds(deadline - Date.now())
setCountdown(current => (
current.deadline === deadline && current.seconds === next
? current
: { deadline, seconds: next }
))
if (next === 1) window.clearInterval(timer)
}, 250)
return () => { window.clearInterval(timer) }
}, [active, deadline])
return (
<details className={css.retryRow} data-active={active || undefined}>
<summary className={css.retrySummary}>
<span className={css.retryText} role="status">
{active ? '正在重试' : '已重试'}{node.retry}/{node.maxRetries} · {active ? remainingSeconds : scheduledSeconds}s
</span>
</summary>
<div className={css.retryDetails}>
<div><span className={css.retryDetailLabel}></span>{Math.round(node.delayMs)}ms</div>
<div><span className={css.retryDetailLabel}></span>{node.failure.message}</div>
</div>
</details>
)
}
export const MessageItem = memo(function MessageItem({ node, retryActive = false }: MessageItemProps) {
switch (node.kind) {
case 'user':
case 'steering': {
@@ -46,6 +96,8 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
<JsonBlock label="上下文注入" payload={{ content: node.content, meta: node.meta }} />
</div>
)
case 'model-retry':
return <ModelRetryItem node={node} active={retryActive} />
default:
return (
<div className={css.contextRow}>
@@ -1,7 +1,8 @@
/**
* Chat flow derivation: ConversationSnapshot nodes -> render items. Tool
* results group into consecutive-run tool groups (figma step-summary flow,
* VERTICAL gap10) alternating with narration; everything else passes through.
* VERTICAL gap10) alternating with narration. Consecutive retry notices from
* one turn reuse the first notice's row while projecting the latest attempt.
* Item identity keys are stable across snapshots so the list parent can
* subscribe to keys only while rows subscribe to content.
*/
@@ -15,7 +16,7 @@ export type ChatFlowItem =
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes (surface order).
* @returns flow items; consecutive tool-results merged into one group keyed by the first seq.
* @returns flow items; consecutive tool results and same-turn retry notices reuse their first key.
*/
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
const items: ChatFlowItem[] = []
@@ -28,6 +29,18 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
} else {
group.push(node)
}
} else if (node.kind === 'model-retry') {
group = null
const previous = items[items.length - 1]
if (
previous?.kind === 'node'
&& previous.node.kind === 'model-retry'
&& previous.node.turn === node.turn
) {
items[items.length - 1] = { ...previous, node }
} else {
items.push({ kind: 'node', key: `n${node.seq}`, node })
}
} else {
group = null
items.push({ kind: 'node', key: `n${node.seq}`, node })