fix(client): address conversation assembly review feedback

This commit is contained in:
imccyu
2026-08-09 17:28:42 +08:00
parent 8723a0e15f
commit 990f700d3c
20 changed files with 73 additions and 49 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 911bca28dcfb31b1d8ef9ea5458a0d2017d8b31d
README.zh.md: 25574421bbcc3992188e378a49a69acf25744af7
README.md: 3570e814609af0bc4e7d048a55205af756fae843
README.zh.md: e37f867c72d83b47924e87261e6ee5ff8b374a6f

View File

@@ -20,7 +20,7 @@ Logged non-user messages render as a default-collapsed disclosure whose header n
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
The chat view keeps Tool placement but delegates Tool presentation. It passes each ordered root call through `conversation.chat.tool`, and the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle fills the whole-Tool seat with [`ui-tool`](../ui-tool/README.md), which selects Runtime-projected Code Dispatch children and owns root/child composition, per-name dispatch, generic rendering, and render-intent cards; the details seat alone retains a raw-result fallback when that renderer is absent.
The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent.
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments.
@@ -40,7 +40,7 @@ The chat stats line takes its token accounting from the generic token-meter `tok
`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` export surface contains only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` contract — an absent service leaves the prose inert.
A finished turn materializes one ordered `turn-tail` Conversation Node. Its engine-owned `TurnLocation` supplies the closing Assistant and Turn data; the renderer places the `conversation.chat.turnTail` chain before that node's IconActions and dispatches `TurnTailOwnerProps` containing the Turn, closing seq, and `openFile`. This package owns only the hole; `@deepseek-ai/dsh-client-ui-deliverables` accumulates mutation-tool `locations` into Turn data and owns the produced-files row, chip cap, and copy, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert.
## Model Experience

View File

@@ -18,7 +18,7 @@
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
聊天视图保留 Tool 的消息流位置,但委托其展示。它通过 `conversation.chat.tool` 传递每个已排序的 root call;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 由 [`ui-tool`](../ui-tool/README.md) 填充整体 Tool 席位,并由后者选择 Runtime 已投影的 Code Dispatch 子调用,负责 root/child 编排、按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。
聊天视图保留 Tool 的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.md),由后者渲染 Runtime 已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。
@@ -40,7 +40,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
`src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/`、`chat/`、`input/`、`queue/` 和 `settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。
完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` 约定——service 缺席时正文保持死文本。
完成的一轮会物化一个有序的 `turn-tail` Conversation Node。它由引擎维护的 `TurnLocation` 提供收尾 Assistant 和 Turn data;renderer 在该 Node 的 IconActions 之前渲染 `conversation.chat.turnTail` chain,并派发包含 Turn、收尾 seq 和 `openFile` 的 `TurnTailOwnerProps`。本包只拥有空位;`@deepseek-ai/dsh-client-ui-deliverables` 把改写工具的 `locations` 累积到 Turn data,并拥有产物行、chip 上限和文案,因此把该插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。
## 模型体验

View File

@@ -56,11 +56,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*/
'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
/**
* The chat view's turn-tail chain: rendered between a closing assistant
* message's body and its IconActions footer, once per turn (the render
* site elects the closing seq). Entries derive a match from the owner
* currency before mounting, so presentation components never mount only
* to return null; an all-declined chain renders nothing.
* The completed Turn Node's extension chain, rendered before that Node's
* IconActions. Entries derive a match from the engine-owned Turn and
* closing seq before mounting, so presentation components never mount
* only to return null; an all-declined chain renders nothing.
*/
'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
/** Selected Tool call output inside the details panel. */

View File

@@ -7,7 +7,7 @@ import {
emptyAssistantBlock, isAppendSurfaceEvent, isTokenDelta, toAssistantBlock, toAssistantBlocks,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { AssistantChatData } from '../contract/chat-nodes.ts'
import { chatNode } from './common.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
@@ -169,7 +169,7 @@ function finalNode(
if (boundary === undefined || !hasInterruptionEvidence(blocks)) return undefined
return {
kind: 'assistant',
seq: boundary.seq - 0.9,
seq: boundary.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedAssistant,
time: boundary.time,
turn: state.turn,
step: state.step,

View File

@@ -154,6 +154,9 @@ const EMPTY_CONTRIBUTION: LegacyContribution = {
function legacyContribution(raw: ChatConversationViewNode): LegacyContribution {
const node = raw as ChatNode
// Content-free settled Assistants remain in the finalized compatibility
// stream so StatsLine preserves its pre-assembly step counts; hidden running
// attempts have no final Node to contribute.
if (raw.visibility !== 'visible' && node.kind !== 'assistant-step') return EMPTY_CONTRIBUTION
switch (node.kind) {
case 'user':
@@ -221,7 +224,7 @@ function sameContribution(left: LegacyContribution | undefined, right: LegacyCon
&& sameReferences(left.nodes, right.nodes)
}
/** Incremental compatibility projection retained solely for unmigrated Trajectory consumers. */
/** Incremental compatibility projection for StatsLine and legacy top-level snapshot fields. */
class LegacySliceBuilder {
private readonly contributions = new Map<string, LegacyContribution>()
private readonly finalizedContributions = new Map<string, LegacyContribution>()

View File

@@ -5,6 +5,16 @@ import type {
ChatNode, ChatNodeDataMap, ChatNodeKind,
} from '../contract/chat-nodes.ts'
/**
* Relative positions in one durable event's seq neighborhood: interrupted
* Assistant, its follow-up Nodes, then follow-ups to an ordinary final.
*/
export const CHAT_SYNTHETIC_SEQ_OFFSETS = {
interruptedAssistant: -0.9,
interruptedFollowup: -0.8,
finalizedFollowup: 0.1,
} as const
/**
* Resolve one Context's best currently loaded event Location.
* @param context - assembled business Context.

View File

@@ -5,7 +5,7 @@ import type {
} from '@deepseek-ai/dsh-client-runtime/client'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolChatData } from '../contract/chat-nodes.ts'
import { chatNode } from './common.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
@@ -190,7 +190,7 @@ function projectBlock(
? sameReferences(block.subCalls, children) ? block : { ...block, subCalls: children }
: {
kind: 'tool-result',
seq: interruptedAt.seq - 0.8,
seq: interruptedAt.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup,
time: interruptedAt.time,
callId: block.callId,
call: { name: block.name, argsRaw: block.argsRaw },

View File

@@ -7,7 +7,7 @@ import type {
AssistantChatData, FinalAssistantChatData, TurnTailChatData,
} from '../contract/chat-nodes.ts'
import { deriveTurnMetrics } from '../chat/turn-metrics.ts'
import { chatNode } from './common.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
@@ -85,7 +85,9 @@ function closingAnchor(context: ConversationNodeContext<TurnTailState>): number
}
if (event.type === 'assistant/message') {
steps.set(coordinates.step, { streamedText: false, finalized: true })
if (hasTextAssistant(event)) anchor = event.seq + 0.1
if (hasTextAssistant(event)) {
anchor = event.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup
}
continue
}
if ((event.type as string) === 'llm/retry') {
@@ -93,7 +95,7 @@ function closingAnchor(context: ConversationNodeContext<TurnTailState>): number
continue
}
if (event.type === 'step/end' && previous.streamedText && !previous.finalized) {
anchor = event.seq - 0.8
anchor = event.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup
}
}
return anchor