Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress
# Conflicts: # docs/core-data-structures/session.i18n.yaml # packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
@@ -330,7 +330,7 @@ export function apply(ctx: Context): void {
|
||||
}, ChatView)
|
||||
|
||||
// Session stats stick with the composer (composer.dock = stats-line family).
|
||||
slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0 }, StatsLine)
|
||||
slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS }, StatsLine)
|
||||
|
||||
// Class-plugin mount (packages/AGENTS.md service form): the service
|
||||
// registers itself as `conversation` and lives on its own child fiber.
|
||||
|
||||
@@ -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()
|
||||
@@ -67,15 +71,36 @@ export function MessageIconActions({
|
||||
}, 1000)
|
||||
})
|
||||
}, [copied, text])
|
||||
// The dot is decorative and stays hidden, but its margins separate the
|
||||
// readings only on screen: without the flanking spaces a reader hears one
|
||||
// run-on string ("Ran for 13sTTFT 0.2s12 tok/s") instead of three facts.
|
||||
const clockEl = time === undefined ? null : (
|
||||
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
|
||||
{formatMessageClock(time, t, day)}
|
||||
{runMs !== undefined && (
|
||||
<>
|
||||
{' '}
|
||||
<span className={css.runTimeDot} aria-hidden>·</span>
|
||||
{' '}
|
||||
{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,14 @@
|
||||
// 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 type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import { formatTokensPerSecond } from './message-chrome.ts'
|
||||
import { assistantStepReading } from './turn-metrics.ts'
|
||||
import css from './StatsLine.module.css'
|
||||
|
||||
interface WindowStats {
|
||||
@@ -15,6 +19,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 +44,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 +59,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,30 +109,41 @@ 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
|
||||
usedTokens: number
|
||||
contextWindow: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate context occupancy, using the TUI's integer rounding and upper
|
||||
* clamp. The numerator and capacity are independent last-wins projection
|
||||
* fields, so this is a reference figure rather than an exact measurement of one
|
||||
* request (see the token-meter README).
|
||||
* clamp. The numerator is `projectedTokens` — the provider sample carried
|
||||
* forward over the surface's movement since — so compaction shows immediately
|
||||
* instead of waiting for the next request to report usage; it falls back to the
|
||||
* bare sample only for a log whose projection predates that field. Numerator
|
||||
* and capacity remain independent last-wins projection 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,
|
||||
): ContextOccupancy | null {
|
||||
if (pressure?.pressureTokens === undefined || pressure.contextWindow === undefined) return null
|
||||
const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens
|
||||
if (usedTokens === undefined || pressure?.contextWindow === undefined) return null
|
||||
return {
|
||||
percent: Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100)),
|
||||
percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)),
|
||||
usedTokens,
|
||||
contextWindow: pressure.contextWindow,
|
||||
}
|
||||
}
|
||||
@@ -116,46 +152,73 @@ export function contextOccupancy(
|
||||
export interface StatsLineProps {
|
||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||
useProjection: UseProjection
|
||||
/** The owning dock's locale seat. */
|
||||
t: ComposerBarProps['t']
|
||||
}
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
|
||||
export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: 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[] = []
|
||||
if (stats.steps > 0) {
|
||||
groups.push(`${stats.turns} turns · ${stats.steps} steps`)
|
||||
groups.push(t('stats.counts', { turns: stats.turns, steps: stats.steps }))
|
||||
const durations: string[] = []
|
||||
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
|
||||
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
|
||||
if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs) }))
|
||||
if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: 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(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps) }))
|
||||
}
|
||||
if (stats.decodeMs > 0) {
|
||||
speeds.push(t('stats.tokensPerSecond', {
|
||||
throughput: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
|
||||
}))
|
||||
}
|
||||
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
|
||||
&& (stats.steps > 0 || billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
|
||||
const cacheHit = cacheHitPercent(usage)
|
||||
if (cacheHit !== null) groups.push(`Cache hit ${cacheHit}%`)
|
||||
groups.push(
|
||||
`Input ${formatTokens(billedInputTokens(usage))} tok`
|
||||
+ ` · Output ${formatTokens(usage.outputTokens)} tok`,
|
||||
)
|
||||
if (cacheHit !== null) groups.push(t('stats.cacheHit', { percent: cacheHit }))
|
||||
groups.push(t('stats.tokens', {
|
||||
input: formatTokens(billedInputTokens(usage)),
|
||||
output: formatTokens(usage.outputTokens),
|
||||
}))
|
||||
}
|
||||
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()
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
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 request-dispatch-to-first-token reading, 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,18 @@ export const zh = {
|
||||
'input.stop': '停止生成',
|
||||
'input.send': '发送消息',
|
||||
'input.accessMode': '访问模式,当前:{name}',
|
||||
'context.aria': '上下文已用 {percent}',
|
||||
'context.used': '上下文已用',
|
||||
'context.system': '系统提示词',
|
||||
'context.tools': '工具',
|
||||
'context.messages': '对话消息',
|
||||
'stats.counts': '{turns} 轮 · {steps} 步',
|
||||
'stats.llm': 'LLM {duration}',
|
||||
'stats.toolCall': '工具调用 {duration}',
|
||||
'stats.ttftAverage': '首 token 平均 {duration}',
|
||||
'stats.tokensPerSecond': '{throughput} tok/s',
|
||||
'stats.cacheHit': '缓存命中 {percent}%',
|
||||
'stats.tokens': '输入 {input} tok · 输出 {output} tok',
|
||||
'settings.enter.title': '繁忙时 Enter 键行为',
|
||||
'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为',
|
||||
'settings.enter.queue': '排队发送',
|
||||
@@ -71,6 +83,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 +150,18 @@ 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',
|
||||
'stats.counts': '{turns} turns · {steps} steps',
|
||||
'stats.llm': 'LLM {duration}',
|
||||
'stats.toolCall': 'Tool call {duration}',
|
||||
'stats.ttftAverage': 'TTFT avg {duration}',
|
||||
'stats.tokensPerSecond': '{throughput} tok/s',
|
||||
'stats.cacheHit': 'Cache hit {percent}%',
|
||||
'stats.tokens': 'Input {input} tok · Output {output} tok',
|
||||
'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 +210,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,147 @@
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* The headline brackets the reading, so the side a locale leaves empty must
|
||||
drop out of the flex row rather than spend a gap. */
|
||||
.headline:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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,153 @@
|
||||
/** 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
|
||||
|
||||
/**
|
||||
* Marker the localized occupancy sentence is split on, so the panel headline
|
||||
* keeps the reading in its own tone while each locale still owns the word
|
||||
* order (`45% of context used` / `上下文已用 45%`).
|
||||
*/
|
||||
const READING_SLOT = '\u0000'
|
||||
|
||||
/** 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)
|
||||
const context = contextOccupancy(pressure)
|
||||
const available = context !== null
|
||||
|
||||
// A model switch can temporarily remove capacity while this component stays
|
||||
// mounted. Close the now-unavailable panel instead of preserving stale UI.
|
||||
useEffect(() => {
|
||||
if (!available && open) setOpen(false)
|
||||
}, [available, open])
|
||||
|
||||
// Outside click / Escape close, one document listener while open (Menu's pattern).
|
||||
useEffect(() => {
|
||||
if (!open || !available) 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)
|
||||
}
|
||||
}, [available, open])
|
||||
|
||||
if (context === null) return null
|
||||
const percent = context.percent
|
||||
const reading = `${percent}%`
|
||||
const [headBefore = '', headAfter = ''] = t('context.aria', { percent: READING_SLOT })
|
||||
.split(READING_SLOT)
|
||||
.map(part => part.trim())
|
||||
|
||||
// The bar's overall length stays the provider-exact percent; the heuristic
|
||||
// breakdown only proportions its colored parts. A zero-width part is dropped
|
||||
// instead of rendered: `.segment`'s min-width keeps a hairline part visible,
|
||||
// which at 0% occupancy would draw a filled bar over an empty context.
|
||||
const breakdownTotal = breakdown === undefined
|
||||
? 0
|
||||
: breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens
|
||||
const parts = breakdown === undefined || breakdownTotal === 0
|
||||
? [{ key: 'total', color: undefined, width: percent }]
|
||||
: ROWS.map(row => ({ key: row.key, color: row.color, width: percent * breakdown[row.key] / breakdownTotal }))
|
||||
const segments = parts.filter(part => part.width > 0)
|
||||
|
||||
return (
|
||||
<span ref={rootRef} className={css.root}>
|
||||
<Tooltip label={t('context.aria', { percent: reading })} side="top" delayMs={200} disabled={open}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={t('context.aria', { percent: reading })}
|
||||
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}>
|
||||
{/* Empty sides collapse through `.headline:empty` so the locale that
|
||||
needs no leading (or trailing) text spends no header gap. */}
|
||||
<span className={css.headline}>{headBefore}</span>
|
||||
<span className={css.percent}>{reading}</span>
|
||||
<span className={css.headline}>{headAfter}</span>
|
||||
<span className={css.figures}>
|
||||
{`~${formatTokens(context.usedTokens)} / ${formatTokens(context.contextWindow)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.bar}>
|
||||
{segments.map(segment => (
|
||||
<div
|
||||
key={segment.key}
|
||||
className={segment.color === undefined ? css.segment : `${css.segment} ${segment.color}`}
|
||||
style={{ width: `${segment.width}%` }}
|
||||
/>
|
||||
))}
|
||||
</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
|
||||
|
||||
Reference in New Issue
Block a user