feat(web): turn speed metrics and composer context meter
Assistant footers and the stats line gain TTFT/tok-per-second readings folded from step timings; context occupancy moves off the stats line onto a composer ring whose panel shows a heuristic system/tools/messages breakdown from the new token-meter contextBreakdown session projection.
This commit is contained in:
@@ -16,6 +16,8 @@ import type {
|
||||
} from '../sessions/conversation-context.ts'
|
||||
import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts'
|
||||
import { PartialAccumulator } from '../sessions/partial.ts'
|
||||
import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts'
|
||||
import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts'
|
||||
|
||||
interface CallIndexEntry {
|
||||
name: string
|
||||
@@ -30,11 +32,6 @@ interface FoldedContext {
|
||||
originSeq?: number
|
||||
}
|
||||
|
||||
interface AssistantStepMetadata {
|
||||
stepStartTime: number | null
|
||||
firstTokenTime: number | null
|
||||
}
|
||||
|
||||
/** Immutable conversation projections derived only from the history source. */
|
||||
export interface ConversationHistoryProjection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
@@ -45,10 +42,6 @@ export interface ConversationHistoryProjection {
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
// Trajectory owns surface-window reconstruction so its immutable ledger does
|
||||
// not depend on Chat's live fold adapter or Session's mutable state.
|
||||
/* jscpd:ignore-start */
|
||||
@@ -72,18 +65,6 @@ function contextOriginKind(event: SessionEvent | undefined): ConversationContext
|
||||
return 'rewrite'
|
||||
}
|
||||
|
||||
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return chunk.argumentsDelta !== '' || chunk.name !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
|
||||
const replay: SessionEvent[] = []
|
||||
const surface = new SurfaceManager(replay)
|
||||
@@ -362,6 +343,7 @@ export function projectConversationHistory(
|
||||
contextGeneration++
|
||||
if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt)
|
||||
}
|
||||
indexAssistantStepTiming(assistantSteps, event)
|
||||
if (event.type === 'request/header') {
|
||||
activeRequestConfig = event.data.header.config
|
||||
activePrompt = {
|
||||
@@ -370,30 +352,10 @@ export function projectConversationHistory(
|
||||
tools: event.data.header.tools ?? [],
|
||||
}
|
||||
promptsByContext.set(contextGeneration, activePrompt)
|
||||
} else if (event.type === 'step/start') {
|
||||
assistantSteps.set(
|
||||
assistantStepKey(event.data.turn, event.data.step),
|
||||
{ stepStartTime: event.time, firstTokenTime: null },
|
||||
)
|
||||
} else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
|
||||
const key = assistantStepKey(event.data.turn, event.data.step)
|
||||
const current = assistantSteps.get(key) ?? {
|
||||
stepStartTime: null,
|
||||
firstTokenTime: null,
|
||||
}
|
||||
if (current.firstTokenTime === null) {
|
||||
assistantSteps.set(key, { ...current, firstTokenTime: event.time })
|
||||
}
|
||||
} else if (event.type === 'assistant/message') {
|
||||
assistantTimings.set(
|
||||
event.seq,
|
||||
{
|
||||
...(assistantSteps.get(assistantStepKey(event.data.turn, event.data.step)) ?? {
|
||||
stepStartTime: null,
|
||||
firstTokenTime: null,
|
||||
}),
|
||||
completedTime: event.time,
|
||||
},
|
||||
settledAssistantTiming(assistantSteps, event.data.turn, event.data.step, event.time),
|
||||
)
|
||||
if (activeRequestConfig !== undefined) {
|
||||
assistantRequestConfigs.set(event.seq, activeRequestConfig)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Shared assistant step-timing fold: both transcript projections (the live
|
||||
// window adapter and the trajectory history fold) derive AssistantTiming from
|
||||
// the same step/start -> first token delta -> assistant/message sequence, so
|
||||
// the derivation lives once here instead of drifting per projection.
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { AssistantTiming } from './conversation.ts'
|
||||
|
||||
/** Pre-finalize timing boundaries for one assistant step (start + first token). */
|
||||
export interface AssistantStepMetadata {
|
||||
stepStartTime: number | null
|
||||
firstTokenTime: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite map key for one assistant step.
|
||||
* @param turn - turn number from the event payload.
|
||||
* @param step - step number from the event payload.
|
||||
* @returns collision-free `turn`/`step` key (NUL separator).
|
||||
*/
|
||||
export function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a chunk carries visible model output (first-token boundary). Empty
|
||||
* deltas (heartbeats, empty tool-call frames) do not count as a first token.
|
||||
* @param chunk - the assistant/chunk payload.
|
||||
* @returns true when the chunk contains a non-empty text/reasoning/tool delta.
|
||||
*/
|
||||
export function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return chunk.argumentsDelta !== '' || chunk.name !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one event into the per-step timing index: step/start opens the entry,
|
||||
* the first non-empty token delta stamps first-token time once. Other event
|
||||
* types are no-ops.
|
||||
* @param steps - the mutable per-step index, keyed by {@link assistantStepKey}.
|
||||
* @param event - the raw window event.
|
||||
*/
|
||||
export function indexAssistantStepTiming(steps: Map<string, AssistantStepMetadata>, event: SessionEvent): void {
|
||||
if (event.type === 'step/start') {
|
||||
steps.set(
|
||||
assistantStepKey(event.data.turn, event.data.step),
|
||||
{ stepStartTime: event.time, firstTokenTime: null },
|
||||
)
|
||||
} else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
|
||||
const key = assistantStepKey(event.data.turn, event.data.step)
|
||||
const current = steps.get(key) ?? { stepStartTime: null, firstTokenTime: null }
|
||||
if (current.firstTokenTime === null) {
|
||||
steps.set(key, { ...current, firstTokenTime: event.time })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle one finalized assistant message's timing from its step entry; a step
|
||||
* whose start or first token fell outside the window yields null boundaries.
|
||||
* @param steps - the per-step index built by {@link indexAssistantStepTiming}.
|
||||
* @param turn - the assistant/message turn number.
|
||||
* @param step - the assistant/message step number.
|
||||
* @param completedTime - the assistant/message event timestamp (epoch ms).
|
||||
* @returns the node-ready timing record.
|
||||
*/
|
||||
export function settledAssistantTiming(
|
||||
steps: ReadonlyMap<string, AssistantStepMetadata>,
|
||||
turn: number,
|
||||
step: number,
|
||||
completedTime: number,
|
||||
): AssistantTiming {
|
||||
return {
|
||||
...(steps.get(assistantStepKey(turn, step)) ?? { stepStartTime: null, firstTokenTime: null }),
|
||||
completedTime,
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpo
|
||||
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
import type { AssistantStepMetadata } from './assistant-timing.ts'
|
||||
import { indexAssistantStepTiming, settledAssistantTiming } from './assistant-timing.ts'
|
||||
|
||||
/**
|
||||
* The compaction seam's checkpoint plugin, pinned to the seam's own declaration
|
||||
@@ -50,6 +52,7 @@ function materializeNode(
|
||||
event: SessionEvent,
|
||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
@@ -71,6 +74,7 @@ function materializeNode(
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
|
||||
timing: settledAssistantTiming(stepTimings, event.data.turn, event.data.step, event.time),
|
||||
}
|
||||
case 'steering/message':
|
||||
return {
|
||||
@@ -177,6 +181,8 @@ export class TranscriptAdapter {
|
||||
/** Transcript nodes in log order; copy-on-write so a published array never mutates. */
|
||||
private projected: ConversationNode[] = []
|
||||
private callIdx = new Map<string, CallIndexEntry>()
|
||||
/** Per-step timing boundaries (step/start + first token delta), consumed when the step's assistant/message materializes. */
|
||||
private stepTimings = new Map<string, AssistantStepMetadata>()
|
||||
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
|
||||
private resultViews = new Map<number, ToolResultView>()
|
||||
/**
|
||||
@@ -207,6 +213,7 @@ export class TranscriptAdapter {
|
||||
this.callIdx = new Map()
|
||||
this.resultViews.clear()
|
||||
this.commandIdx = new Map()
|
||||
this.stepTimings = new Map()
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
@@ -214,6 +221,7 @@ export class TranscriptAdapter {
|
||||
this.eventIndex.set(event.seq, event)
|
||||
this.indexCall(event, views?.[i])
|
||||
this.indexCommand(event)
|
||||
indexAssistantStepTiming(this.stepTimings, event)
|
||||
}
|
||||
// Indexes first, then project: a tool/result materializes against the
|
||||
// complete call index, and a checkpoint against the complete event index.
|
||||
@@ -236,6 +244,7 @@ export class TranscriptAdapter {
|
||||
append(event: SessionEvent, view?: ToolEventView): void {
|
||||
this.eventIndex.set(event.seq, event)
|
||||
this.indexCall(event, view)
|
||||
indexAssistantStepTiming(this.stepTimings, event)
|
||||
if (this.indexCommand(event)) this.rev++
|
||||
if (!isTranscriptEvent(event)) return
|
||||
this.projected = [...this.projected, this.materialize(event)]
|
||||
@@ -274,7 +283,7 @@ export class TranscriptAdapter {
|
||||
private materialize(event: SessionEvent): ConversationNode {
|
||||
return isCompactCheckpoint(event)
|
||||
? materializeCompaction(event, this.eventIndex)
|
||||
: materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null)
|
||||
: materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null, this.stepTimings)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -415,4 +415,48 @@ describe('TranscriptAdapter', () => {
|
||||
expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('assistant timing', () => {
|
||||
const base = 1_700_000_000_000
|
||||
|
||||
it('derives step timing across a window rebuild (start + first token + completion)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.turnStart(0, 0),
|
||||
ev.user(1, '问'),
|
||||
ev.stepStart(2, 0),
|
||||
ev.chunkStart(3, 0),
|
||||
ev.chunkText(4, 0, '答'),
|
||||
ev.chunkText(5, 0, '案'),
|
||||
ev.assistant(6, 0, '答案'),
|
||||
ev.turnEnd(7, 0),
|
||||
])
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 },
|
||||
})
|
||||
})
|
||||
|
||||
it('derives the same timing on the live append path, first token winning once', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.user(0, '问')])
|
||||
adapter.append(ev.stepStart(1, 0))
|
||||
adapter.append(ev.chunkText(2, 0, '首'))
|
||||
adapter.append(ev.chunkText(3, 0, '次'))
|
||||
adapter.append(ev.assistant(4, 0, '首次'))
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 },
|
||||
})
|
||||
})
|
||||
|
||||
it('soft-falls to null boundaries when the step opening fell outside the window', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.assistant(100, 0, '被切窗的答案')])
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: 7d3a4b5fe07cc8858c2f2059e6f65b6e27602b4d
|
||||
README.zh.md: d93af91381157bb4e8e4b6a14ad00edecd505246
|
||||
README.md: 737a53da1fa4541f81d89b41945cacd2b415ef4a
|
||||
README.zh.md: 800d2b3e762d410100c8c9eb64bfbcdb22cda62a
|
||||
|
||||
@@ -46,7 +46,7 @@ Per-session UI state for selection and the active view lives in the declared cha
|
||||
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
|
||||
|
||||
The chat stats line takes its token accounting from two generic token-meter projections read through the standard-kit `useProjection`: `tokenUsage` for full-log billing (billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total) and `contextPressure` for context occupancy. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. A deployment without token-meter drops the token groups, and occupancy stays hidden until both provider pressure and route capacity are known. Occupancy is deliberately an approximation — its numerator and capacity are independent last-wins projection fields, not one atomic request observation ([rationale](../../llm/token-meter/README.md)). The inline stats row remains the sole context UI; the model selector has no circle or accessory.
|
||||
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a `TTFT avg … · … tok/s` group; a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy moved off the row onto the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both provider pressure and route capacity are known, that click-opens a panel pairing the provider-exact `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection — the exact and heuristic vocabularies deliberately do not reconcile. Occupancy is deliberately an approximation — its numerator and capacity are independent last-wins projection fields, not one atomic request observation ([rationale](../../llm/token-meter/README.md)).
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
|
||||
|
||||
@@ -61,7 +61,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced.
|
||||
- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
|
||||
- **Stats-line durations and speeds cover the in-window flow only** — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
|
||||
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)).
|
||||
- **Sent user messages cannot be edited** — user bubbles retain clock, copy, and branch; branch stays disabled unless a completed turn's transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)).
|
||||
|
||||
@@ -46,7 +46,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
|
||||
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
|
||||
|
||||
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的两个通用 token-meter 投影:`tokenUsage` 提供完整日志计费用量(计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量),`contextPressure` 提供上下文占用率。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。未组合 token-meter 的部署会整组省略 token 分组;只有提供方压力与路由容量都已知时才显示占用率。占用率是刻意为之的近似值:它的分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测([原理](../../llm/token-meter/README.md))。行内统计行仍是唯一的上下文 UI;模型选择器不增加圆环或附属控件。
|
||||
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到 `TTFT avg … · … tok/s` 分组;缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率从统计行移到了 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当提供方压力与路由容量都已知时才渲染;点击弹出的面板把提供方精确的「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列——精确口径与启发式口径刻意不做对账。占用率是刻意为之的近似值:它的分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测([原理](../../llm/token-meter/README.md))。
|
||||
|
||||
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
|
||||
|
||||
@@ -61,7 +61,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。
|
||||
- **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
|
||||
- **统计行的耗时与速率只覆盖窗口内消息流**:LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
|
||||
- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。
|
||||
- **已发送的 user 消息无法编辑**:user 气泡保留时钟、复制和分支;除非已完成轮次的 transcript 结束于该 user 消息,否则分支保持禁用。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。
|
||||
|
||||
@@ -30,6 +30,10 @@ export interface AssistantMarkdownProps {
|
||||
/** Turn wall time in ms for the IconActions run-time label; omitted when the
|
||||
* turn's triggering input is outside the loaded window. */
|
||||
runMs?: number | undefined
|
||||
/** Turn first-step TTFT in ms for the IconActions label; omitted when unrecorded. */
|
||||
ttftMs?: number | undefined
|
||||
/** Turn decode throughput for the IconActions label; omitted when unrecorded. */
|
||||
tokensPerSecond?: number | undefined
|
||||
/** Event sequence used as the fork boundary; omitted while streaming. */
|
||||
seq?: number | undefined
|
||||
/** Fork the session through this finalized message's completed turn when eligible. */
|
||||
@@ -82,7 +86,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
|
||||
}
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, time, runMs, seq, onFork, forkUnavailable, t,
|
||||
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, t,
|
||||
}: AssistantMarkdownProps) {
|
||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||
// per render would rebuild MarkdownText's component table every chunk.
|
||||
@@ -125,6 +129,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
text={copyText(blocks)}
|
||||
time={time}
|
||||
runMs={runMs}
|
||||
ttftMs={ttftMs}
|
||||
tokensPerSecond={tokensPerSecond}
|
||||
clock="end"
|
||||
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
|
||||
branchUnavailable={forkUnavailable}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
|
||||
import { formatRunDuration } from './message-chrome.ts'
|
||||
import { deriveTurnMetrics } from './turn-metrics.ts'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
const FOLLOW_THRESHOLD = 24
|
||||
@@ -362,6 +363,7 @@ export function ChatView({
|
||||
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
|
||||
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
|
||||
const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings])
|
||||
const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes])
|
||||
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const columnRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -599,6 +601,9 @@ export function ChatView({
|
||||
const node: ConversationNode = item.node
|
||||
if (node.kind === 'assistant') {
|
||||
const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined
|
||||
// Metrics gate on the settled in-window timing: turn/start loaded means
|
||||
// every step of the turn is loaded, so first-step TTFT is genuine.
|
||||
const metrics = timing?.endTime === undefined ? undefined : turnMetrics.get(node.turn)
|
||||
return (
|
||||
<AssistantMarkdown
|
||||
blocks={node.blocks}
|
||||
@@ -608,6 +613,8 @@ export function ChatView({
|
||||
runMs={timing?.endTime === undefined
|
||||
? undefined
|
||||
: Math.max(0, timing.endTime - timing.startTime)}
|
||||
ttftMs={metrics?.ttftMs}
|
||||
tokensPerSecond={metrics?.tokensPerSecond}
|
||||
seq={node.seq}
|
||||
onFork={forkAt}
|
||||
forkUnavailable={!branchSeqs.has(node.seq)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { formatMessageClock, formatRunDuration } from './message-chrome.ts'
|
||||
import { formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond } from './message-chrome.ts'
|
||||
import { useCalendarDay } from './use-calendar-day.ts'
|
||||
import css from './MessageIconActions.module.css'
|
||||
|
||||
@@ -17,6 +17,10 @@ export interface MessageIconActionsProps {
|
||||
time?: number | undefined
|
||||
/** Turn wall time in ms, appended to the clock as `· Ran for 15s`; omitted when the turn's start is unknown. */
|
||||
runMs?: number | undefined
|
||||
/** Turn first-step TTFT in ms, appended as `· TTFT 1.2s`; omitted when unrecorded. */
|
||||
ttftMs?: number | undefined
|
||||
/** Turn decode throughput, appended as `· 34 tok/s`; omitted when unrecorded. */
|
||||
tokensPerSecond?: number | undefined
|
||||
/** Clock before icons (user) or after (assistant). */
|
||||
clock: 'start' | 'end'
|
||||
/** Fork the session at this message; omission hides the branch action. */
|
||||
@@ -37,7 +41,7 @@ export interface MessageIconActionsProps {
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, runMs, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
|
||||
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const reasonId = useId()
|
||||
@@ -76,6 +80,18 @@ export function MessageIconActions({
|
||||
{t('message.ranFor', { duration: formatRunDuration(runMs, t) })}
|
||||
</>
|
||||
)}
|
||||
{ttftMs !== undefined && (
|
||||
<>
|
||||
<span className={css.runTimeDot} aria-hidden>·</span>
|
||||
{t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })}
|
||||
</>
|
||||
)}
|
||||
{tokensPerSecond !== undefined && (
|
||||
<>
|
||||
<span className={css.runTimeDot} aria-hidden>·</span>
|
||||
{t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
|
||||
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
|
||||
|
||||
import { Fragment, memo, useMemo } from 'react'
|
||||
import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import { formatTokensPerSecond } from './message-chrome.ts'
|
||||
import { assistantStepReading } from './turn-metrics.ts'
|
||||
import css from './StatsLine.module.css'
|
||||
|
||||
interface WindowStats {
|
||||
@@ -15,6 +18,14 @@ interface WindowStats {
|
||||
llmMs: number
|
||||
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
|
||||
toolMs: number
|
||||
/** Summed first-token latency over `ttftSteps`; 0 when no step records it. */
|
||||
ttftMs: number
|
||||
/** Steps carrying a recorded TTFT. */
|
||||
ttftSteps: number
|
||||
/** Summed decode wall time over steps that also report output tokens. */
|
||||
decodeMs: number
|
||||
/** Summed output tokens over the same decode-timed steps. */
|
||||
decodeTokens: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,6 +43,10 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
|
||||
let steps = 0
|
||||
let llmMs = 0
|
||||
let toolMs = 0
|
||||
let ttftMs = 0
|
||||
let ttftSteps = 0
|
||||
let decodeMs = 0
|
||||
let decodeTokens = 0
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'tool-result') {
|
||||
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
|
||||
@@ -43,8 +58,17 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
|
||||
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
|
||||
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
|
||||
}
|
||||
const reading = assistantStepReading(node)
|
||||
if (reading.ttftMs !== null) {
|
||||
ttftMs += reading.ttftMs
|
||||
ttftSteps += 1
|
||||
}
|
||||
if (reading.decodeMs !== null && reading.outputTokens !== null) {
|
||||
decodeMs += reading.decodeMs
|
||||
decodeTokens += reading.outputTokens
|
||||
}
|
||||
}
|
||||
return { turns: turns.size, steps, llmMs, toolMs }
|
||||
return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,13 +108,18 @@ export function cacheHitPercent(usage: TokenUsageProjection): number | null {
|
||||
: Math.round(usage.cacheReadTokens / denominator * 100)
|
||||
}
|
||||
|
||||
/** Sum the three disjoint prompt-side billing buckets. */
|
||||
function billedInputTokens(usage: TokenUsageProjection): number {
|
||||
/**
|
||||
* Sum the three disjoint prompt-side billing buckets.
|
||||
* @param usage - the session's token-usage projection value.
|
||||
* @returns billed input tokens.
|
||||
*/
|
||||
export function billedInputTokens(usage: TokenUsageProjection): number {
|
||||
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
|
||||
}
|
||||
|
||||
interface ContextOccupancy {
|
||||
percent: number
|
||||
pressureTokens: number
|
||||
contextWindow: number
|
||||
}
|
||||
|
||||
@@ -100,7 +129,7 @@ interface ContextOccupancy {
|
||||
* fields, so this is a reference figure rather than an exact measurement of one
|
||||
* request (see the token-meter README).
|
||||
* @param pressure - the session's context-pressure projection value.
|
||||
* @returns occupancy and its denominator, or null until both values are known.
|
||||
* @returns occupancy with its numerator and denominator, or null until both values are known.
|
||||
*/
|
||||
export function contextOccupancy(
|
||||
pressure: ContextPressureProjection | undefined,
|
||||
@@ -108,6 +137,7 @@ export function contextOccupancy(
|
||||
if (pressure?.pressureTokens === undefined || pressure.contextWindow === undefined) return null
|
||||
return {
|
||||
percent: Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100)),
|
||||
pressureTokens: pressure.pressureTokens,
|
||||
contextWindow: pressure.contextWindow,
|
||||
}
|
||||
}
|
||||
@@ -121,7 +151,6 @@ export interface StatsLineProps {
|
||||
export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const usage = useProjection('tokenUsage')
|
||||
const pressure = useProjection('contextPressure')
|
||||
const stats = useMemo(() => deriveStats(nodes), [nodes])
|
||||
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
|
||||
const groups: string[] = []
|
||||
@@ -131,11 +160,14 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection }:
|
||||
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
|
||||
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
|
||||
if (durations.length > 0) groups.push(durations.join(' · '))
|
||||
// Window-scoped like the wall times above: averages describe loaded steps.
|
||||
const speeds: string[] = []
|
||||
if (stats.ttftSteps > 0) speeds.push(`TTFT avg ${formatDuration(stats.ttftMs / stats.ttftSteps)}`)
|
||||
if (stats.decodeMs > 0) speeds.push(`${formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000))} tok/s`)
|
||||
if (speeds.length > 0) groups.push(speeds.join(' · '))
|
||||
}
|
||||
const context = contextOccupancy(pressure)
|
||||
if (context !== null) {
|
||||
groups.push(`Context ${context.percent}% of ${formatTokens(context.contextWindow)}`)
|
||||
}
|
||||
// Context occupancy deliberately lives on the composer's ContextMeter ring,
|
||||
// not here — one home per fact.
|
||||
// Billing rides the durable projection, so these survive paging and
|
||||
// compaction. Suppress the empty projection on a brand-new session.
|
||||
if (usage !== undefined
|
||||
@@ -147,15 +179,31 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection }:
|
||||
+ ` · Output ${formatTokens(usage.outputTokens)} tok`,
|
||||
)
|
||||
}
|
||||
const line = groups.join(' | ')
|
||||
// The row elides with ellipsis when overlong; a delayed hover tooltip carries
|
||||
// the full line, enabled only while content is actually clipped.
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const [truncated, setTruncated] = useState(false)
|
||||
useLayoutEffect(() => {
|
||||
const el = rootRef.current
|
||||
if (el === null) return
|
||||
const measure = () => { setTruncated(el.scrollWidth > el.clientWidth) }
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
return () => { observer.disconnect() }
|
||||
}, [line])
|
||||
if (groups.length === 0) return null
|
||||
return (
|
||||
<div className={css.root}>
|
||||
{groups.map((group, i) => (
|
||||
<Fragment key={group}>
|
||||
{i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>}
|
||||
<span>{group}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<Tooltip label={line} side="top" delayMs={500} disabled={!truncated}>
|
||||
<div ref={rootRef} className={css.root}>
|
||||
{groups.map((group, i) => (
|
||||
<Fragment key={group}>
|
||||
{i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>}
|
||||
<span>{group}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -48,6 +48,27 @@ export function formatRunDuration(ms: number, t: RunDurationTranslate): string {
|
||||
: t('duration.seconds', { seconds })
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-turn latency figure: one decimal under ten seconds, whole seconds
|
||||
* beyond. Unit-less so the locale template owns the second suffix.
|
||||
* @param ms - Latency in milliseconds (negatives clamp to zero).
|
||||
* @returns Display number in seconds without unit.
|
||||
*/
|
||||
export function formatLatencySeconds(ms: number): string {
|
||||
const s = Math.max(0, ms) / 1000
|
||||
return s < 10 ? String(Math.round(s * 10) / 10) : String(Math.round(s))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode-throughput figure: whole tokens from ten up, one decimal below.
|
||||
* @param tps - Tokens per second.
|
||||
* @returns Display number without unit.
|
||||
*/
|
||||
export function formatTokensPerSecond(tps: number): string {
|
||||
const clamped = Math.max(0, tps)
|
||||
return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact local timestamp for message IconActions. Same calendar day →
|
||||
* `HH:mm`; earlier this year → the `clock.md` date template + clock; other
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Latency/throughput folds shared by the settled turn footer and StatsLine.
|
||||
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Latency and decode-throughput readings for one turn's footer. */
|
||||
export interface TurnMetrics {
|
||||
/** First-step TTFT in ms; absent when that step carries no recorded timing. */
|
||||
ttftMs?: number
|
||||
/** Decode throughput over steps carrying both timing and provider usage. */
|
||||
tokensPerSecond?: number
|
||||
}
|
||||
|
||||
/** One assistant step's derivable latency facts; null marks an unrecorded part. */
|
||||
export interface StepReading {
|
||||
/** step/start → first token delta, in ms. */
|
||||
ttftMs: number | null
|
||||
/** First token delta → final message, in ms. */
|
||||
decodeMs: number | null
|
||||
/** Provider-reported completion tokens. */
|
||||
outputTokens: number | null
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
outputTokens?: number
|
||||
}
|
||||
|
||||
type AssistantNode = Extract<ConversationSnapshot['nodes'][number], { kind: 'assistant' }>
|
||||
|
||||
function usageOutputTokens(usage: unknown): number | null {
|
||||
if (typeof usage !== 'object' || usage === null) return null
|
||||
const value = (usage as UsageLike).outputTokens
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one assistant node's TTFT, decode wall time, and output tokens.
|
||||
* @param node - A settled assistant node.
|
||||
* @returns Per-part readings with `null` for unrecorded values.
|
||||
*/
|
||||
export function assistantStepReading(node: AssistantNode): StepReading {
|
||||
const timing = node.timing
|
||||
const ttftMs = timing !== undefined && timing.stepStartTime !== null && timing.firstTokenTime !== null
|
||||
? Math.max(0, timing.firstTokenTime - timing.stepStartTime)
|
||||
: null
|
||||
const decodeMs = timing !== undefined && timing.firstTokenTime !== null
|
||||
? Math.max(0, timing.completedTime - timing.firstTokenTime)
|
||||
: null
|
||||
return { ttftMs, decodeMs, outputTokens: usageOutputTokens(node.usage) }
|
||||
}
|
||||
|
||||
interface TurnFold {
|
||||
firstStep: number
|
||||
firstStepTtftMs: number | null
|
||||
decodeMs: number
|
||||
outputTokens: number
|
||||
sampled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold assistant nodes into per-turn footer metrics.
|
||||
*
|
||||
* TTFT is the turn's lowest-step reading — the user-perceived wait before
|
||||
* output appeared — so it is only meaningful when the turn's start is inside
|
||||
* the loaded window (the caller gates on `turnTimings`, which shares that
|
||||
* window). Throughput divides summed output tokens by summed decode wall time,
|
||||
* counting only steps that carry both.
|
||||
* @param nodes - Snapshot nodes of the loaded window.
|
||||
* @returns Turn number → available metrics; turns with none are absent.
|
||||
*/
|
||||
export function deriveTurnMetrics(nodes: ConversationSnapshot['nodes']): Map<number, TurnMetrics> {
|
||||
const folds = new Map<number, TurnFold>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'assistant') continue
|
||||
const reading = assistantStepReading(node)
|
||||
let fold = folds.get(node.turn)
|
||||
if (fold === undefined) {
|
||||
fold = { firstStep: node.step, firstStepTtftMs: reading.ttftMs, decodeMs: 0, outputTokens: 0, sampled: false }
|
||||
folds.set(node.turn, fold)
|
||||
} else if (node.step < fold.firstStep) {
|
||||
fold.firstStep = node.step
|
||||
fold.firstStepTtftMs = reading.ttftMs
|
||||
}
|
||||
if (reading.decodeMs !== null && reading.outputTokens !== null) {
|
||||
fold.decodeMs += reading.decodeMs
|
||||
fold.outputTokens += reading.outputTokens
|
||||
fold.sampled = true
|
||||
}
|
||||
}
|
||||
const metrics = new Map<number, TurnMetrics>()
|
||||
for (const [turn, fold] of folds) {
|
||||
const entry: TurnMetrics = {}
|
||||
if (fold.firstStepTtftMs !== null) entry.ttftMs = fold.firstStepTtftMs
|
||||
if (fold.sampled && fold.decodeMs > 0) entry.tokensPerSecond = fold.outputTokens / (fold.decodeMs / 1000)
|
||||
if (entry.ttftMs !== undefined || entry.tokensPerSecond !== undefined) metrics.set(turn, entry)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
@@ -23,6 +23,11 @@ export const zh = {
|
||||
'input.stop': '停止生成',
|
||||
'input.send': '发送消息',
|
||||
'input.accessMode': '访问模式,当前:{name}',
|
||||
'context.aria': '上下文已用 {percent}%',
|
||||
'context.used': '上下文已用',
|
||||
'context.system': '系统提示词',
|
||||
'context.tools': '工具',
|
||||
'context.messages': '对话消息',
|
||||
'settings.enter.title': '繁忙时 Enter 键行为',
|
||||
'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为',
|
||||
'settings.enter.queue': '排队发送',
|
||||
@@ -71,6 +76,8 @@ export const zh = {
|
||||
'message.retry.failure': '失败原因:',
|
||||
'message.turnError': '本轮运行失败',
|
||||
'message.ranFor': '用时 {duration}',
|
||||
'message.ttft': '首 token {seconds}秒',
|
||||
'message.tokensPerSecond': '{tps} tok/s',
|
||||
'duration.seconds': '{seconds}秒',
|
||||
'duration.minutes': '{minutes}分{seconds}秒',
|
||||
'command.running': '执行中…',
|
||||
@@ -136,6 +143,11 @@ export const en = {
|
||||
'input.stop': 'Stop generating',
|
||||
'input.send': 'Send message',
|
||||
'input.accessMode': 'Access mode, current: {name}',
|
||||
'context.aria': '{percent}% of context used',
|
||||
'context.used': 'of context used',
|
||||
'context.system': 'System prompt',
|
||||
'context.tools': 'Tools',
|
||||
'context.messages': 'Messages',
|
||||
'settings.enter.title': 'Enter behavior while busy',
|
||||
'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior',
|
||||
'settings.enter.queue': 'Queue',
|
||||
@@ -184,6 +196,8 @@ export const en = {
|
||||
'message.retry.failure': 'Failure reason: ',
|
||||
'message.turnError': 'This turn failed',
|
||||
'message.ranFor': 'Ran for {duration}',
|
||||
'message.ttft': 'TTFT {seconds}s',
|
||||
'message.tokensPerSecond': '{tps} tok/s',
|
||||
'duration.seconds': '{seconds}s',
|
||||
'duration.minutes': '{minutes}m {seconds}s',
|
||||
'command.running': 'Running…',
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/* Context-occupancy ring beside the send button plus its click-open breakdown
|
||||
panel (menu surface: r12, inverted hairline, shadow-lv3). */
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Same 28px circular hit target family as the composer's attach button. */
|
||||
.trigger {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.track {
|
||||
fill: none;
|
||||
stroke: var(--dsw-alias-border-l3);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.fill {
|
||||
fill: none;
|
||||
stroke: var(--dsw-alias-label-tertiary);
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
box-sizing: border-box;
|
||||
width: 264px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--dsw-alias-border-inverted);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-menu);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.figures {
|
||||
margin-left: auto;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.percent {
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.headline {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
margin: 10px 0 12px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.segment {
|
||||
flex: none;
|
||||
min-width: 2px;
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
background: var(--meter-tint, var(--dsw-alias-label-tertiary));
|
||||
}
|
||||
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--meter-tint);
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.colorSystem {
|
||||
--meter-tint: var(--dsw-static-neutral-bluish-400);
|
||||
}
|
||||
|
||||
.colorTools {
|
||||
/* The design platform ships no purple static token; violet-400 literal. */
|
||||
--meter-tint: rgb(167, 139, 250);
|
||||
}
|
||||
|
||||
.colorMessages {
|
||||
--meter-tint: var(--dsw-static-blue-450);
|
||||
}
|
||||
|
||||
.rows {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.row dt {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.row dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/** Composer context-occupancy meter: a ring beside the send button fed by the
|
||||
* `contextPressure` projection, with a click-open panel of the heuristic
|
||||
* `contextBreakdown` composition (system prompt, tools, conversation).
|
||||
* Renders nothing until a provider reports both pressure and a route capacity
|
||||
* (same gate as the stats row used). */
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the `contextPressure` / `contextBreakdown` projection key merges.
|
||||
import type {} from '@deepseek-ai/dsh-token-meter/client'
|
||||
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import { contextOccupancy, formatTokens } from '../chat/StatsLine.tsx'
|
||||
import css from './ContextMeter.module.css'
|
||||
|
||||
/** Ring geometry: 14px viewBox, 2px stroke. */
|
||||
const RADIUS = 5.5
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
||||
|
||||
/** Panel legend rows, in bar-segment order; each color class carries the shared swatch/segment tint. */
|
||||
const ROWS = [
|
||||
{ key: 'systemTokens', label: 'context.system', color: css.colorSystem },
|
||||
{ key: 'toolsTokens', label: 'context.tools', color: css.colorTools },
|
||||
{ key: 'messageTokens', label: 'context.messages', color: css.colorMessages },
|
||||
] as const
|
||||
|
||||
export interface ContextMeterProps {
|
||||
useProjection: UseProjection
|
||||
/** The owning bar's locale seat, passed down as a plain prop. */
|
||||
t: ComposerBarProps['t']
|
||||
}
|
||||
|
||||
export function ContextMeter({ useProjection, t }: ContextMeterProps) {
|
||||
const pressure = useProjection('contextPressure')
|
||||
const breakdown = useProjection('contextBreakdown')
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLSpanElement | null>(null)
|
||||
|
||||
// Outside click / Escape close, one document listener while open (Menu's pattern).
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onPointerDown = (e: PointerEvent): void => {
|
||||
if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return
|
||||
setOpen(false)
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const context = contextOccupancy(pressure)
|
||||
if (context === null) return null
|
||||
const percent = context.percent
|
||||
|
||||
// The bar's overall length stays the provider-exact percent; the heuristic
|
||||
// breakdown only proportions its colored segments.
|
||||
const breakdownTotal = breakdown === undefined
|
||||
? 0
|
||||
: breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens
|
||||
const segments = breakdown === undefined || breakdownTotal === 0
|
||||
? null
|
||||
: ROWS.map(row => ({ key: row.key, color: row.color, share: breakdown[row.key] / breakdownTotal }))
|
||||
|
||||
return (
|
||||
<span ref={rootRef} className={css.root}>
|
||||
<Tooltip label={t('context.aria', { percent })} side="top" delayMs={200} disabled={open}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={t('context.aria', { percent })}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
>
|
||||
<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden>
|
||||
<circle className={css.track} cx="7" cy="7" r={RADIUS} />
|
||||
<circle
|
||||
className={css.fill}
|
||||
cx="7"
|
||||
cy="7"
|
||||
r={RADIUS}
|
||||
strokeDasharray={`${CIRCUMFERENCE * percent / 100} ${CIRCUMFERENCE}`}
|
||||
transform="rotate(-90 7 7)"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{open && (
|
||||
<div className={css.panel} role="dialog" aria-label={t('context.used')}>
|
||||
<div className={css.header}>
|
||||
<span className={css.percent}>{`${percent}%`}</span>
|
||||
<span className={css.headline}>{t('context.used')}</span>
|
||||
<span className={css.figures}>
|
||||
{`~${formatTokens(context.pressureTokens)} / ${formatTokens(context.contextWindow)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.bar}>
|
||||
{segments === null
|
||||
? <div className={css.segment} style={{ width: `${percent}%` }} />
|
||||
: segments.map(segment => (
|
||||
<div
|
||||
key={segment.key}
|
||||
className={`${css.segment} ${segment.color}`}
|
||||
style={{ width: `${percent * segment.share}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{breakdown !== undefined && (
|
||||
<dl className={css.rows}>
|
||||
{ROWS.map(row => (
|
||||
<div key={row.key} className={css.row}>
|
||||
<dt>
|
||||
<span className={`${css.swatch} ${row.color}`} aria-hidden />
|
||||
{t(row.label)}
|
||||
</dt>
|
||||
<dd>{`~${formatTokens(breakdown[row.key])}`}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import { deriveDecorations } from '../input/decorations.ts'
|
||||
import type { DraftDecorations } from '../input/decorations.ts'
|
||||
import { ContextMeter } from './ContextMeter.tsx'
|
||||
import { PermissionSelect } from './PermissionSelect.tsx'
|
||||
import css from './InputBar.module.css'
|
||||
|
||||
@@ -512,6 +513,7 @@ export function InputBar({
|
||||
<div className={css.trailing}>
|
||||
{rightItems}
|
||||
{renderSlot('conversation.input.model', { locked })}
|
||||
<ContextMeter useProjection={useProjection} t={t} />
|
||||
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
|
||||
<Tooltip label={primaryLabel} side="top" delayMs={500}>
|
||||
<button
|
||||
|
||||
@@ -18,9 +18,18 @@ import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// hard acceptance — zero renders during streaming. Bash sample row: ToolRow
|
||||
// chrome (Bash · description) without a row click target.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -12,7 +12,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
@@ -21,7 +21,20 @@ type BashRowProps = Parameters<typeof BashRow>[0]
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: BashRowProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
@@ -66,8 +79,11 @@ describe('deriveStats', () => {
|
||||
expect(stats.turns).toBe(2)
|
||||
expect(stats.steps).toBe(3)
|
||||
// Window-scoped by design: the paged window is not an accounting source, so
|
||||
// the fold exposes no token fields at all (billing rides the projection).
|
||||
expect(Object.keys(stats).sort()).toEqual(['llmMs', 'steps', 'toolMs', 'turns'])
|
||||
// the fold exposes no billing fields (billing rides the projection);
|
||||
// decodeTokens is a throughput input, not a billed total.
|
||||
expect(Object.keys(stats).sort()).toEqual(
|
||||
['decodeMs', 'decodeTokens', 'llmMs', 'steps', 'toolMs', 'ttftMs', 'ttftSteps', 'turns'],
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores tool results with no call time', () => {
|
||||
@@ -97,6 +113,23 @@ describe('deriveStats', () => {
|
||||
expect(stats.llmMs).toBe(2_500)
|
||||
expect(stats.toolMs).toBe(3_000)
|
||||
})
|
||||
|
||||
it('sums ttft per recorded step and decode throughput inputs per usage-carrying step', () => {
|
||||
const sampled: AssistantMessageNode = {
|
||||
...assistant(1, 1, { outputTokens: 40 }),
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
|
||||
}
|
||||
const ttftOnly: AssistantMessageNode = {
|
||||
...assistant(2, 1),
|
||||
timing: { stepStartTime: 5_000, firstTokenTime: 5_400, completedTime: 7_400 },
|
||||
}
|
||||
const stats = deriveStats([sampled, ttftOnly, assistant(3, 2)])
|
||||
expect(stats.ttftMs).toBe(1_200)
|
||||
expect(stats.ttftSteps).toBe(2)
|
||||
// The usage-less step contributes no decode share, keeping the ratio honest.
|
||||
expect(stats.decodeMs).toBe(3_000)
|
||||
expect(stats.decodeTokens).toBe(40)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatters', () => {
|
||||
@@ -142,47 +175,62 @@ describe('StatsLine', () => {
|
||||
expect(emptyView.container.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('keeps durable token and context groups after the visible step window is empty', () => {
|
||||
it('reveals the full line in a delayed hover tooltip only while the row is clipped', () => {
|
||||
vi.useFakeTimers()
|
||||
// jsdom lays nothing out; fake a row narrower than its content.
|
||||
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(800)
|
||||
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(400)
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
fireEvent.mouseEnter(view.container.firstElementChild!)
|
||||
act(() => { vi.advanceTimersByTime(499) })
|
||||
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(view.container.querySelector('[role="tooltip"]')?.textContent)
|
||||
.toBe('1 turns · 1 steps | Cache hit 90% | Input 100 tok · Output 5 tok')
|
||||
})
|
||||
|
||||
it('suppresses the tooltip while the row fits without truncation', () => {
|
||||
vi.useFakeTimers()
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
fireEvent.mouseEnter(view.container.firstElementChild!)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders window latency and throughput beside the wall-time group', () => {
|
||||
const timed: AssistantMessageNode = {
|
||||
...assistant(1, 1, { outputTokens: 60 }),
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
|
||||
}
|
||||
const { source } = makeSource({ nodes: [timed] })
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
expect(view.container.textContent).toContain('LLM 3.8s| TTFT avg 0.8s · 20 tok/s')
|
||||
})
|
||||
|
||||
it('keeps durable token groups after the visible step window is empty', () => {
|
||||
const { source } = makeSource()
|
||||
const view = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
|
||||
})} />)
|
||||
// Context occupancy lives on the composer's ContextMeter ring, not here.
|
||||
expect(view.container.textContent)
|
||||
.toBe('Context 25% of 128K| Cache hit 90%| Input 100 tok · Output 5 tok')
|
||||
.toBe('Cache hit 90%| Input 100 tok · Output 5 tok')
|
||||
})
|
||||
|
||||
it('renders context occupancy only when the projection knows a capacity', () => {
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const withCapacity = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
|
||||
})} />)
|
||||
expect(withCapacity.container.textContent).toContain('Context 25% of 128K')
|
||||
// Pressure without capacity has no denominator: the group drops out.
|
||||
const noCapacity = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
contextPressure: { pressureTokens: 32_000 },
|
||||
})} />)
|
||||
expect(noCapacity.container.textContent).not.toContain('Context')
|
||||
// Capacity arrives before usage in the log; no provider sample means there
|
||||
// is no numerator yet, rather than a synthetic 0%.
|
||||
const noPressure = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
contextPressure: { contextWindow: 128_000 },
|
||||
})} />)
|
||||
expect(noPressure.container.textContent).not.toContain('Context')
|
||||
})
|
||||
|
||||
it('clamps occupancy at 100% when pressure exceeds the recorded capacity', () => {
|
||||
it('computes context occupancy only when both pressure and capacity are known', () => {
|
||||
expect(contextOccupancy({ pressureTokens: 32_000, contextWindow: 128_000 }))
|
||||
.toEqual({ percent: 25, pressureTokens: 32_000, contextWindow: 128_000 })
|
||||
// Pressure without capacity has no denominator; capacity without a provider
|
||||
// sample has no numerator yet, rather than a synthetic 0%.
|
||||
expect(contextOccupancy({ pressureTokens: 32_000 })).toBeNull()
|
||||
expect(contextOccupancy({ contextWindow: 128_000 })).toBeNull()
|
||||
expect(contextOccupancy(undefined)).toBeNull()
|
||||
// Capacity and pressure are independent last-wins fields, so a model switch
|
||||
// can pair a smaller new window with the previous route's larger prompt.
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const view = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
contextPressure: { pressureTokens: 300_000, contextWindow: 128_000 },
|
||||
})} />)
|
||||
expect(view.container.textContent).toContain('Context 100% of 128K')
|
||||
expect(contextOccupancy({ pressureTokens: 300_000, contextWindow: 128_000 })?.percent).toBe(100)
|
||||
})
|
||||
|
||||
it('drops every token group when no projection is composed', () => {
|
||||
|
||||
@@ -540,6 +540,45 @@ describe('ChatView', () => {
|
||||
expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('the settled footer appends first-step ttft and turn decode throughput', () => {
|
||||
const first: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'mid' }],
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
|
||||
usage: { outputTokens: 40 },
|
||||
}
|
||||
const second: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 16, time: 16_000, turn: 1, step: 2, blocks: [{ kind: 'text', text: 'final' }],
|
||||
timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 },
|
||||
usage: { outputTokens: 60 },
|
||||
}
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'hi'), first, second],
|
||||
turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
|
||||
turnEnds: new Map([[1, 20]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// First-step ttft (1.2s) plus 100 tokens over 5s of decode.
|
||||
expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
|
||||
expect(view.getAllByText(/首 token 1\.2秒/)).toHaveLength(1)
|
||||
expect(view.getAllByText(/20 tok\/s/)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('withholds ttft and throughput while the turn is still running', () => {
|
||||
const settled: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }],
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 1_500, completedTime: 2_000 },
|
||||
usage: { outputTokens: 10 },
|
||||
}
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'hi'), settled],
|
||||
turnTimings: new Map([[1, { startTime: 1_000 }]]),
|
||||
turnEnds: new Map(),
|
||||
running: true,
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.queryByText(/首 token|tok\/s/)).toBeNull()
|
||||
})
|
||||
|
||||
it('user and assistant message containers scope the hover-revealed time chrome', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'hi'), assistant(2, 'answer')],
|
||||
|
||||
93
packages/client/ui-conversation/tests/context-meter.spec.tsx
Normal file
93
packages/client/ui-conversation/tests/context-meter.spec.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
// ContextMeter (composer trailing control): occupancy ring gating, the
|
||||
// click-open breakdown panel, and its close gestures.
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { ContextMeter, type ContextMeterProps } from '../src/client/skeleton/ContextMeter.tsx'
|
||||
import css from '../src/client/skeleton/ContextMeter.module.css'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t = makeTranslate(zh, commonZh) as ContextMeterProps['t']
|
||||
|
||||
const BREAKDOWN = { systemTokens: 120, toolsTokens: 21_500, messageTokens: 477_000 }
|
||||
|
||||
const segmentClass = css.segment
|
||||
if (segmentClass === undefined) throw new Error('segment class missing from ContextMeter.module.css')
|
||||
|
||||
/** Stub the projection seat: a key-addressed table of whole values. */
|
||||
function projections(values: Record<string, unknown>): ContextMeterProps['useProjection'] {
|
||||
return (key: string) => values[key]
|
||||
}
|
||||
|
||||
function meter(values: Record<string, unknown>) {
|
||||
return render(<ContextMeter useProjection={projections(values)} t={t} />)
|
||||
}
|
||||
|
||||
describe('ContextMeter', () => {
|
||||
it('renders nothing until both pressure and capacity are known', () => {
|
||||
expect(meter({}).container.textContent).toBe('')
|
||||
expect(meter({ contextPressure: { pressureTokens: 32_000 } }).container.textContent).toBe('')
|
||||
expect(meter({ contextPressure: { contextWindow: 128_000 } }).container.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('shows the occupancy ring and opens the breakdown panel on click', () => {
|
||||
const view = meter({
|
||||
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
|
||||
contextBreakdown: BREAKDOWN,
|
||||
})
|
||||
const trigger = view.getByRole('button', { name: '上下文已用 25%' })
|
||||
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
|
||||
fireEvent.click(trigger)
|
||||
const panel = view.container.querySelector('[role="dialog"]')!
|
||||
expect(panel.textContent).toContain('~32K / 128K')
|
||||
expect(panel.textContent).toContain('25%')
|
||||
expect(panel.textContent).toContain('上下文已用')
|
||||
expect(panel.textContent).toContain('系统提示词~120')
|
||||
expect(panel.textContent).toContain('工具~21.5K')
|
||||
expect(panel.textContent).toContain('对话消息~477K')
|
||||
// The occupancy bar splits into one colored segment per composition row.
|
||||
expect(panel.getElementsByClassName(segmentClass)).toHaveLength(3)
|
||||
// Clicking the trigger again toggles the panel shut.
|
||||
fireEvent.click(trigger)
|
||||
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('omits the composition rows while the contextBreakdown projection is absent', () => {
|
||||
const view = meter({ contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 } })
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
|
||||
const panel = view.container.querySelector('[role="dialog"]')!
|
||||
expect(panel.textContent).toContain('~32K / 128K')
|
||||
expect(panel.textContent).not.toContain('系统提示词')
|
||||
expect(panel.textContent).not.toContain('对话消息')
|
||||
// Without composition shares, the bar falls back to one plain segment.
|
||||
expect(panel.getElementsByClassName(segmentClass)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('closes on outside pointerdown and Escape — but not inside clicks', () => {
|
||||
const view = meter({
|
||||
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
|
||||
contextBreakdown: BREAKDOWN,
|
||||
})
|
||||
const trigger = view.getByRole('button', { name: '上下文已用 25%' })
|
||||
const openPanel = () => {
|
||||
fireEvent.click(trigger)
|
||||
return view.container.querySelector('[role="dialog"]')!
|
||||
}
|
||||
// A pointerdown inside the panel keeps it open; outside closes it.
|
||||
const again = openPanel()
|
||||
fireEvent.pointerDown(again)
|
||||
expect(view.container.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
|
||||
// Escape.
|
||||
openPanel()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -18,7 +18,18 @@ import { zh } from '../src/client/locales.ts'
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
afterEach(cleanup)
|
||||
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
|
||||
154
packages/client/ui-conversation/tests/turn-metrics.spec.ts
Normal file
154
packages/client/ui-conversation/tests/turn-metrics.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// Per-turn latency/throughput fold and the footer figure formatters.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AssistantMessageNode, ConversationNode, UserMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { assistantStepReading, deriveTurnMetrics } from '../src/client/chat/turn-metrics.ts'
|
||||
import { formatLatencySeconds, formatTokensPerSecond } from '../src/client/chat/message-chrome.ts'
|
||||
|
||||
interface StepSpec {
|
||||
seq: number
|
||||
turn: number
|
||||
step: number
|
||||
timing?: AssistantMessageNode['timing']
|
||||
usage?: unknown
|
||||
}
|
||||
|
||||
const assistant = ({ seq, turn, step, timing, usage }: StepSpec): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'text', text: `t${seq}` }],
|
||||
...(timing === undefined ? {} : { timing }),
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
})
|
||||
|
||||
const user = (seq: number): UserMessageNode => ({
|
||||
kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text: 'hi' }] as never, source: null,
|
||||
})
|
||||
|
||||
describe('assistantStepReading', () => {
|
||||
it('derives ttft, decode time, and output tokens from a fully recorded step', () => {
|
||||
const reading = assistantStepReading(assistant({
|
||||
seq: 2, turn: 1, step: 1,
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 6_800 },
|
||||
usage: { outputTokens: 200 },
|
||||
}))
|
||||
expect(reading).toEqual({ ttftMs: 800, decodeMs: 5_000, outputTokens: 200 })
|
||||
})
|
||||
|
||||
it('returns nulls when timing is absent', () => {
|
||||
const reading = assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, usage: { outputTokens: 5 } }))
|
||||
expect(reading).toEqual({ ttftMs: null, decodeMs: null, outputTokens: 5 })
|
||||
})
|
||||
|
||||
it('needs both boundaries for ttft and clamps negative spans to zero', () => {
|
||||
expect(assistantStepReading(assistant({
|
||||
seq: 2, turn: 1, step: 1,
|
||||
timing: { stepStartTime: null, firstTokenTime: 1_800, completedTime: 6_800 },
|
||||
}))).toEqual({ ttftMs: null, decodeMs: 5_000, outputTokens: null })
|
||||
expect(assistantStepReading(assistant({
|
||||
seq: 2, turn: 1, step: 1,
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: null, completedTime: 6_800 },
|
||||
}))).toEqual({ ttftMs: null, decodeMs: null, outputTokens: null })
|
||||
expect(assistantStepReading(assistant({
|
||||
seq: 2, turn: 1, step: 1,
|
||||
timing: { stepStartTime: 2_000, firstTokenTime: 1_500, completedTime: 1_200 },
|
||||
}))).toEqual({ ttftMs: 0, decodeMs: 0, outputTokens: null })
|
||||
})
|
||||
|
||||
it('rejects non-object, missing, and non-finite usage token counts', () => {
|
||||
const timing = { stepStartTime: 1_000, firstTokenTime: 1_500, completedTime: 2_000 }
|
||||
expect(assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, timing, usage: 'weird' })).outputTokens).toBeNull()
|
||||
expect(assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, timing, usage: {} })).outputTokens).toBeNull()
|
||||
const nan = assistant({ seq: 2, turn: 1, step: 1, timing, usage: { outputTokens: Number.NaN } })
|
||||
expect(assistantStepReading(nan).outputTokens).toBeNull()
|
||||
expect(assistantStepReading(assistant({ seq: 2, turn: 1, step: 1, timing, usage: { outputTokens: -3 } })).outputTokens).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveTurnMetrics', () => {
|
||||
it('takes ttft from the lowest step and throughput over all sampled steps', () => {
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1),
|
||||
// Out of step order on purpose: the lowest step owns the ttft slot.
|
||||
assistant({
|
||||
seq: 4, turn: 1, step: 2,
|
||||
timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 },
|
||||
usage: { outputTokens: 60 },
|
||||
}),
|
||||
assistant({
|
||||
seq: 2, turn: 1, step: 1,
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
|
||||
usage: { outputTokens: 40 },
|
||||
}),
|
||||
]
|
||||
// 100 tokens over 5s of decode.
|
||||
expect(deriveTurnMetrics(nodes).get(1)).toEqual({ ttftMs: 1_200, tokensPerSecond: 20 })
|
||||
})
|
||||
|
||||
it('emits ttft without throughput when no step carries usage', () => {
|
||||
const nodes = [assistant({
|
||||
seq: 2, turn: 1, step: 1,
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 1_900, completedTime: 3_000 },
|
||||
})]
|
||||
expect(deriveTurnMetrics(nodes).get(1)).toEqual({ ttftMs: 900 })
|
||||
})
|
||||
|
||||
it('emits throughput without ttft when only a later step is recorded', () => {
|
||||
const nodes = [
|
||||
assistant({ seq: 2, turn: 1, step: 1 }),
|
||||
assistant({
|
||||
seq: 4, turn: 1, step: 2,
|
||||
timing: { stepStartTime: 10_000, firstTokenTime: 10_500, completedTime: 12_500 },
|
||||
usage: { outputTokens: 30 },
|
||||
}),
|
||||
]
|
||||
expect(deriveTurnMetrics(nodes).get(1)).toEqual({ tokensPerSecond: 15 })
|
||||
})
|
||||
|
||||
it('omits turns with no readings and zero-decode throughput', () => {
|
||||
const nodes = [
|
||||
assistant({ seq: 2, turn: 1, step: 1 }),
|
||||
assistant({
|
||||
seq: 4, turn: 2, step: 1,
|
||||
timing: { stepStartTime: null, firstTokenTime: 5_000, completedTime: 5_000 },
|
||||
usage: { outputTokens: 10 },
|
||||
}),
|
||||
]
|
||||
expect(deriveTurnMetrics(nodes).size).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps turns independent and ignores non-assistant nodes', () => {
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1),
|
||||
assistant({
|
||||
seq: 2, turn: 1, step: 1,
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 1_400, completedTime: 2_400 },
|
||||
usage: { outputTokens: 10 },
|
||||
}),
|
||||
user(3),
|
||||
assistant({
|
||||
seq: 4, turn: 2, step: 1,
|
||||
timing: { stepStartTime: 4_000, firstTokenTime: 4_100, completedTime: 6_100 },
|
||||
usage: { outputTokens: 100 },
|
||||
}),
|
||||
]
|
||||
const metrics = deriveTurnMetrics(nodes)
|
||||
expect(metrics.get(1)).toEqual({ ttftMs: 400, tokensPerSecond: 10 })
|
||||
expect(metrics.get(2)).toEqual({ ttftMs: 100, tokensPerSecond: 50 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('footer figure formatters', () => {
|
||||
it('formats latency with one decimal under ten seconds and whole seconds beyond', () => {
|
||||
expect(formatLatencySeconds(840)).toBe('0.8')
|
||||
expect(formatLatencySeconds(1_000)).toBe('1')
|
||||
expect(formatLatencySeconds(9_949)).toBe('9.9')
|
||||
expect(formatLatencySeconds(12_400)).toBe('12')
|
||||
expect(formatLatencySeconds(-5)).toBe('0')
|
||||
})
|
||||
|
||||
it('formats throughput with whole tokens from ten up and one decimal below', () => {
|
||||
expect(formatTokensPerSecond(34.4)).toBe('34')
|
||||
expect(formatTokensPerSecond(9.96)).toBe('10')
|
||||
expect(formatTokensPerSecond(3.14)).toBe('3.1')
|
||||
expect(formatTokensPerSecond(-1)).toBe('0')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user