@morlay/ui-conversation-message-actions 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,362 +0,0 @@
1
- // Session stats under the composer, split into two icon pills: a gauge pill (turn/step counts + output speed)
2
- // opening the time-and-speed dialog, and a database pill (total tokens + cache hit) opening the token-usage
3
- // dialog. Settled-node identity prevents stream-delta updates from rerendering the row; this row shadows
4
- // upstream ui-chat's `composer.dock` id `stats`(priority −1),`data-composer-stats` is its stable anchor
5
- // (style / test hooks)。
6
-
7
- import { memo, useMemo, useState } from "react";
8
- import { createPortal } from "react-dom";
9
- import {
10
- IconDatabaseOutlineRegular,
11
- IconGaugeOutlineRegular,
12
- } from "@deepseek-ai/dsh-client-ui-primitives";
13
- import type { UseProjection } from "@deepseek-ai/dsh-api-session-controller/client";
14
- import type { SnapshotSelectorHook } from "@deepseek-ai/dsh-client-ui-slots";
15
- // Type-only: merges the sessionStats key into SessionProjectionMap for useProjection.
16
- import type {} from "@deepseek-ai/dsh-session-stats/client";
17
- import type { TokenUsageProjection } from "@deepseek-ai/dsh-token-meter/client";
18
- import type { ChatSnapshot, ChatViewSlotProps } from "@deepseek-ai/dsh-client-ui-chat/client";
19
- import { formatTokensPerSecond } from "../chat-node/message-chrome.ts";
20
- import { assistantStepReading } from "./turn-reading.ts";
21
- import { formatCacheHitPercent, formatExactTokens, formatTokens } from "./token-format.ts";
22
- import { MEASURE_STYLE, useStatDialog } from "./stat-dialog.ts";
23
- import { styling } from "@morlay/dsh-client-ui-primitives/client";
24
- import { styles } from "./StatsPills.styles.ts";
25
- import { styles as dialogStyles } from "./stat-dialog.styles.ts";
26
-
27
- interface WindowStats {
28
- turns: number;
29
- steps: number;
30
- // Summed request wall time (step/start → assistant/message); 0 when no node carries timing.
31
- llmMs: number;
32
- // Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window.
33
- toolMs: number;
34
- // Summed first-token latency over `ttftSteps`; 0 when no step records it.
35
- ttftMs: number;
36
- // Steps carrying a recorded TTFT.
37
- ttftSteps: number;
38
- // Summed decode wall time over steps that also report output tokens.
39
- decodeMs: number;
40
- // Summed output tokens over the same decode-timed steps.
41
- decodeTokens: number;
42
- }
43
-
44
- // Fold assistant and tool-result nodes into window-scoped display totals — the fallback for assemblies
45
- // without the `sessionStats` projection. Every displayed figure otherwise rides that durable whole-log
46
- // projection (token accounting rides `tokenUsage`), because the window is paged and compaction rewrites it;
47
- // field names deliberately mirror the projection's so the two swap wholesale.
48
- export function deriveStats(nodes: ChatSnapshot["legacy"]["nodes"]): WindowStats {
49
- const turns = new Set<number>();
50
- let steps = 0;
51
- let llmMs = 0;
52
- let toolMs = 0;
53
- let ttftMs = 0;
54
- let ttftSteps = 0;
55
- let decodeMs = 0;
56
- let decodeTokens = 0;
57
- for (const node of nodes) {
58
- if (node.kind === "tool-result") {
59
- if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime);
60
- continue;
61
- }
62
- if (node.kind !== "assistant") continue;
63
- turns.add(node.turn);
64
- steps += 1;
65
- if (node.timing !== undefined && node.timing.stepStartTime !== null) {
66
- llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime);
67
- }
68
- const reading = assistantStepReading(node);
69
- if (reading.ttftMs !== null) {
70
- ttftMs += reading.ttftMs;
71
- ttftSteps += 1;
72
- }
73
- if (reading.decodeMs !== null && reading.outputTokens !== null) {
74
- decodeMs += reading.decodeMs;
75
- decodeTokens += reading.outputTokens;
76
- }
77
- }
78
- return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens };
79
- }
80
-
81
- // Compact duration: 45.2s under a minute, 2m42s from there on.
82
- export function formatDuration(ms: number, t: ChatViewSlotProps["t"]): string {
83
- const s = ms / 1_000;
84
- if (s < 60) return t("duration.compactSeconds", { seconds: Math.round(s * 10) / 10 });
85
- const whole = Math.round(s);
86
- return t("duration.compactMinutes", {
87
- minutes: Math.floor(whole / 60),
88
- seconds: whole % 60,
89
- });
90
- }
91
-
92
- // Display-ready cache-hit share of prompt-side input over the whole durable log: integer text while integer
93
- // rounding stays below 100, otherwise the minimum decimal precision that still rounds below 100; a full hit
94
- // returns 100, and no billed input returns null.
95
- export function cacheHitPercent(usage: TokenUsageProjection): string | null {
96
- const denominator = billedInputTokens(usage);
97
- return formatCacheHitPercent(usage.cacheReadTokens, denominator);
98
- }
99
-
100
- // Sum the three disjoint prompt-side billing buckets.
101
- export function billedInputTokens(usage: TokenUsageProjection): number {
102
- return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
103
- }
104
-
105
- // Props: the conversation-snapshot selector plus the projection read seat.
106
- export interface StatsPillsProps {
107
- useChat: SnapshotSelectorHook<ChatSnapshot>;
108
- useProjection: UseProjection;
109
- // The owning dock's locale seat.
110
- t: ChatViewSlotProps["t"];
111
- }
112
-
113
- function exactCount(value: number, t: ChatViewSlotProps["t"]): string {
114
- return t("message.turnUsage.count", { count: formatExactTokens(value, t) });
115
- }
116
-
117
- // External open state one pill's dialog reads and writes (the row's exclusive slot).
118
- type PillDialog = Pick<ReturnType<typeof useStatDialog>, "open" | "setOpen">;
119
-
120
- function TimePill({
121
- stats,
122
- t,
123
- dialog,
124
- }: {
125
- stats: WindowStats;
126
- t: ChatViewSlotProps["t"];
127
- dialog: PillDialog;
128
- }) {
129
- const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog);
130
- const counts = t("stats.counts", { turns: stats.turns, steps: stats.steps });
131
- const tps =
132
- stats.decodeMs > 0
133
- ? t("message.tokensPerSecond", {
134
- tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
135
- })
136
- : null;
137
- const label = (
138
- <span {...styling.props(styles.label)}>
139
- {counts}
140
- {tps !== null && (
141
- <>
142
- <span {...styling.props(styles.sep)} aria-hidden>
143
- ·
144
- </span>
145
- {tps}
146
- </>
147
- )}
148
- </span>
149
- );
150
- // A window without one timed figure has no dialog rows to show, so the pill
151
- // stays a plain reading instead of a button opening an empty dialog.
152
- if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && stats.decodeMs <= 0) {
153
- return (
154
- <span {...styling.props(styles.anchor)}>
155
- <span {...styling.props(styles.pill)}>
156
- <IconGaugeOutlineRegular />
157
- {label}
158
- </span>
159
- </span>
160
- );
161
- }
162
- return (
163
- <span ref={rootRef} {...styling.props(styles.anchor)}>
164
- <button
165
- type="button"
166
- {...styling.props(styles.pill)}
167
- aria-haspopup="dialog"
168
- aria-expanded={open}
169
- aria-label={tps === null ? counts : `${counts} · ${tps}`}
170
- onClick={() => {
171
- setOpen(!open);
172
- }}
173
- >
174
- <IconGaugeOutlineRegular />
175
- {label}
176
- </button>
177
- {open &&
178
- createPortal(
179
- <div
180
- ref={panelRef}
181
- {...styling.props(dialogStyles.panel)}
182
- role="dialog"
183
- aria-label={t("stats.dialog.title")}
184
- style={pos ?? MEASURE_STYLE}
185
- >
186
- <div {...styling.props(dialogStyles.title)}>
187
- <span {...styling.props(dialogStyles.titleLabel)}>
188
- <IconGaugeOutlineRegular />
189
- {t("stats.dialog.title")}
190
- </span>
191
- </div>
192
- <div {...styling.props(dialogStyles.titleRule)} aria-hidden />
193
- <dl {...styling.props(dialogStyles.details)} data-session-stats-details>
194
- {stats.llmMs > 0 && (
195
- <>
196
- <dt>{t("stats.dialog.llmTime")}</dt>
197
- <dd>{formatDuration(stats.llmMs, t)}</dd>
198
- </>
199
- )}
200
- {stats.toolMs > 0 && (
201
- <>
202
- <dt>{t("stats.dialog.toolTime")}</dt>
203
- <dd>{formatDuration(stats.toolMs, t)}</dd>
204
- </>
205
- )}
206
- {stats.ttftSteps > 0 && (
207
- <>
208
- <dt>{t("stats.dialog.ttft")}</dt>
209
- <dd>{formatDuration(stats.ttftMs / stats.ttftSteps, t)}</dd>
210
- </>
211
- )}
212
- {stats.decodeMs > 0 && (
213
- <>
214
- <dt>{t("stats.dialog.speed")}</dt>
215
- <dd>
216
- {t("message.tokensPerSecond", {
217
- tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
218
- })}
219
- </dd>
220
- </>
221
- )}
222
- </dl>
223
- </div>,
224
- document.body,
225
- )}
226
- </span>
227
- );
228
- }
229
-
230
- function UsagePill({
231
- usage,
232
- t,
233
- dialog,
234
- }: {
235
- usage: TokenUsageProjection;
236
- t: ChatViewSlotProps["t"];
237
- dialog: PillDialog;
238
- }) {
239
- const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog);
240
- // Same aggregate as the Turn pill's totalTokens: every prompt-side billing bucket plus output.
241
- const total = billedInputTokens(usage) + usage.outputTokens;
242
- const totalText = t("message.turnUsage.count", { count: formatTokens(total, t) });
243
- const cacheHit = cacheHitPercent(usage);
244
- const cacheHitText = cacheHit !== null ? t("stats.cacheHit", { percent: cacheHit }) : null;
245
- return (
246
- <span ref={rootRef} {...styling.props(styles.anchor)}>
247
- <button
248
- type="button"
249
- {...styling.props(styles.pill)}
250
- aria-haspopup="dialog"
251
- aria-expanded={open}
252
- aria-label={cacheHitText === null ? totalText : `${totalText} · ${cacheHitText}`}
253
- onClick={() => {
254
- setOpen(!open);
255
- }}
256
- >
257
- <IconDatabaseOutlineRegular />
258
- <span {...styling.props(styles.label)}>
259
- {totalText}
260
- {cacheHitText !== null && (
261
- <>
262
- <span {...styling.props(styles.sep)} aria-hidden>
263
- ·
264
- </span>
265
- {cacheHitText}
266
- </>
267
- )}
268
- </span>
269
- </button>
270
- {open &&
271
- createPortal(
272
- <div
273
- ref={panelRef}
274
- {...styling.props(dialogStyles.panel)}
275
- role="dialog"
276
- aria-label={t("stats.dialog.usageTitle")}
277
- style={pos ?? MEASURE_STYLE}
278
- >
279
- <div {...styling.props(dialogStyles.title)}>
280
- <span {...styling.props(dialogStyles.titleLabel)}>
281
- <IconDatabaseOutlineRegular />
282
- {t("stats.dialog.usageTitle")}
283
- </span>
284
- <span {...styling.props(dialogStyles.titleValue)}>{exactCount(total, t)}</span>
285
- </div>
286
- <div {...styling.props(dialogStyles.titleRule)} aria-hidden />
287
- {/* jscpd:ignore-start -- the session-total bucket rows deliberately mirror
288
- TurnUsagePanel's per-turn dl: same skin, different data contract (the
289
- buckets are always present here; per-turn fields are optional). A
290
- session that never wrote cache drops the row, as the per-turn panel
291
- drops its absent fields. */}
292
- <dl {...styling.props(dialogStyles.details)} data-session-stats-usage>
293
- {cacheHit !== null && (
294
- <>
295
- <dt>{t("message.turnUsage.cacheHit")}</dt>
296
- <dd>{`${cacheHit}%`}</dd>
297
- </>
298
- )}
299
- <dt>{t("message.turnUsage.input")}</dt>
300
- <dd>{exactCount(usage.uncachedInputTokens, t)}</dd>
301
- <dt>{t("message.turnUsage.cacheRead")}</dt>
302
- <dd>{exactCount(usage.cacheReadTokens, t)}</dd>
303
- {usage.cacheWriteTokens !== 0 && (
304
- <>
305
- <dt>{t("message.turnUsage.cacheWrite")}</dt>
306
- <dd>{exactCount(usage.cacheWriteTokens, t)}</dd>
307
- </>
308
- )}
309
- <dt>{t("message.turnUsage.output")}</dt>
310
- <dd>{exactCount(usage.outputTokens, t)}</dd>
311
- </dl>
312
- {/* jscpd:ignore-end */}
313
- </div>,
314
- document.body,
315
- )}
316
- </span>
317
- );
318
- }
319
-
320
- export const StatsPills = memo(function StatsPills({ useChat, useProjection, t }: StatsPillsProps) {
321
- const settledNodes = useChat((s) => s.legacy.nodes);
322
- const usage = useProjection("tokenUsage");
323
- // One exclusive slot for both dialogs: opening either pill closes the other.
324
- const [openPill, setOpenPill] = useState<"time" | "usage" | null>(null);
325
- // Every figure rides the durable sessionStats projection, so paging and compaction cannot change any of
326
- // them; an assembly without the unit falls back to the window-scoped fold wholesale (same field names).
327
- const projected = useProjection("sessionStats");
328
- const stats = useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes]);
329
- // Gated on actual token activity: a session whose steps all settled without
330
- // billing (e.g. every request failed) shows its counts without a usage pill.
331
- const hasTokens = usage !== undefined && (billedInputTokens(usage) > 0 || usage.outputTokens > 0);
332
- if (stats.steps === 0 && !hasTokens) return null;
333
- // data-composer-stats: 这一行的稳定锚点(样式 / 测试定位用)。
334
- return (
335
- <div {...styling.props(styles.root)} data-composer-stats>
336
- {stats.steps > 0 && (
337
- <TimePill
338
- stats={stats}
339
- t={t}
340
- dialog={{
341
- open: openPill === "time",
342
- setOpen: (open) => {
343
- setOpenPill(open ? "time" : null);
344
- },
345
- }}
346
- />
347
- )}
348
- {hasTokens && (
349
- <UsagePill
350
- usage={usage}
351
- t={t}
352
- dialog={{
353
- open: openPill === "usage",
354
- setOpen: (open) => {
355
- setOpenPill(open ? "usage" : null);
356
- },
357
- }}
358
- />
359
- )}
360
- </div>
361
- );
362
- });
@@ -1,17 +0,0 @@
1
- import type { Context } from "@deepseek-ai/cordis";
2
- import type {} from "@deepseek-ai/dsh-client-ui-chat/client";
3
- import type {} from "@morlay/dsh-client-ui-conversation/client";
4
- import { StatsPills } from "./StatsPills.tsx";
5
-
6
- // 官方 ui-chat 的字典命名空间:文案沿用上游(`stats.*` / `message.turnUsage.*`)。
7
- const NS = "chat";
8
-
9
- // 覆盖注册:官方 ui-chat 在 `conversation.composer.dock` 注册 id `stats`(优先级 0),
10
- // 我们以 priority -1 同 id 重新注册——最低优先级渲染,shadow 官方那一格。
11
- // 覆盖是 shadow 不是删除:官方 stats 行仍在注册表里。
12
- export function registerComposerStats(slots: Context["slots"]): () => void {
13
- return slots.register(
14
- { name: "conversation.composer.dock", id: "stats", order: 0, priority: -1, locale: NS },
15
- StatsPills,
16
- );
17
- }
@@ -1,70 +0,0 @@
1
- import type { CSSProps } from "@morlay/dsh-client-ui-primitives/client";
2
-
3
- export const styles = {
4
- panel: {
5
- position: "fixed",
6
- zIndex: "1100",
7
- boxSizing: "border-box",
8
- width: "max-content",
9
- minWidth: "min(300px, calc(100vw - 24px))",
10
- maxWidth: "min(440px, calc(100vw - 24px))",
11
- padding: "16px",
12
- border: "0",
13
- borderRadius: "12px",
14
- background: "var(--dsw-specific-menu)",
15
- "--dsw-elevation-stroke-color": "var(--dsw-alias-border-l1)",
16
- boxShadow: "var(--dsw-elevation-prominent)",
17
- fontSize: "12px",
18
- lineHeight: "18px",
19
- color: "var(--dsw-alias-label-secondary)",
20
- cursor: "default",
21
- },
22
- title: {
23
- display: "flex",
24
- justifyContent: "space-between",
25
- gap: "16px",
26
- marginBottom: "8px",
27
- color: "var(--dsw-alias-label-primary)",
28
- fontWeight: "500",
29
- },
30
- titleRule: {
31
- marginBottom: "10px",
32
- borderTop: "0.5px solid var(--dsw-alias-border-l2)",
33
- },
34
- titleValue: {
35
- fontVariantNumeric: "tabular-nums",
36
- },
37
- titleLabel: {
38
- display: "inline-flex",
39
- alignItems: "center",
40
- gap: "6px",
41
- minWidth: "0",
42
- "& svg": {
43
- width: "14px",
44
- height: "14px",
45
- flex: "none",
46
- },
47
- },
48
- details: {
49
- display: "grid",
50
- gridTemplateColumns: "minmax(76px, auto) minmax(0, 1fr)",
51
- gap: "6px 16px",
52
- margin: "0",
53
- color: "var(--dsw-alias-label-tertiary)",
54
- "& dt": {
55
- minWidth: "0",
56
- margin: "0",
57
- },
58
- "& dd": {
59
- minWidth: "0",
60
- margin: "0",
61
- color: "var(--dsw-alias-label-secondary)",
62
- fontVariantNumeric: "tabular-nums",
63
- textAlign: "right",
64
- },
65
- },
66
- reasoning: {
67
- color: "var(--dsw-alias-label-tertiary)",
68
- whiteSpace: "nowrap",
69
- },
70
- } satisfies Record<string, CSSProps>;
@@ -1,52 +0,0 @@
1
- import { useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from "react";
2
- import {
3
- useAnchoredPosition,
4
- useDismissOnOutsidePointer,
5
- } from "@deepseek-ai/dsh-client-ui-primitives";
6
-
7
- const PANEL_MARGIN = 12;
8
-
9
- const PANEL_GAP = 8;
10
-
11
- export const MEASURE_STYLE: CSSProperties = { visibility: "hidden", left: 0, top: 0 };
12
-
13
- export interface StatDialogSeat {
14
- open: boolean;
15
- setOpen: (open: boolean) => void;
16
- rootRef: MutableRefObject<HTMLSpanElement | null>;
17
- panelRef: MutableRefObject<HTMLDivElement | null>;
18
- pos: CSSProperties | null;
19
- }
20
-
21
- export function useStatDialog(
22
- controlled?: Pick<StatDialogSeat, "open" | "setOpen">,
23
- ): StatDialogSeat {
24
- const [ownOpen, setOwnOpen] = useState(false);
25
- const open = controlled?.open ?? ownOpen;
26
- const setOpen = controlled?.setOpen ?? setOwnOpen;
27
- const rootRef = useRef<HTMLSpanElement | null>(null);
28
- const panelRef = useRef<HTMLDivElement | null>(null);
29
-
30
- const pos = useAnchoredPosition({
31
- open,
32
- anchorRef: rootRef,
33
- panelRef,
34
- side: "top",
35
- gap: PANEL_GAP,
36
- margin: PANEL_MARGIN,
37
- });
38
-
39
- useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef);
40
- useEffect(() => {
41
- if (!open) return;
42
- const onKeyDown = (e: KeyboardEvent): void => {
43
- if (e.key === "Escape") setOpen(false);
44
- };
45
- document.addEventListener("keydown", onKeyDown);
46
- return () => {
47
- document.removeEventListener("keydown", onKeyDown);
48
- };
49
- }, [open, setOpen]);
50
-
51
- return { open, setOpen, rootRef, panelRef, pos };
52
- }
@@ -1,84 +0,0 @@
1
- // Token 展示口径:`formatCacheHitPercent` 的越界分支是**主动修的缺陷**(上游只在未命中侧为 0 时返回 "100",
2
- // cacheRead 超出 prompt 时展开循环的缩放间隙恒为负 → 同步死循环冻结主线程),这里保持修好的口径。
3
- import type { ChatViewSlotProps } from "@deepseek-ai/dsh-client-ui-chat/client";
4
-
5
- export function formatTokens(value: number, t: ChatViewSlotProps["t"]): string {
6
- const scaled = (candidate: number): string =>
7
- candidate >= 100 ? String(Math.round(candidate)) : String(Math.round(candidate * 10) / 10);
8
- if (value < 1_000) return String(value);
9
- if (value < 1_000_000) return t("number.thousand", { value: scaled(value / 1_000) });
10
- return t("number.million", { value: scaled(value / 1_000_000) });
11
- }
12
-
13
- export function formatExactTokens(value: number, t: ChatViewSlotProps["t"]): string {
14
- const digits = String(value);
15
- const groups: string[] = [];
16
- for (let end = digits.length; end > 0; end -= 3) {
17
- groups.unshift(digits.slice(Math.max(0, end - 3), end));
18
- }
19
- return groups.join(t("number.groupSeparator"));
20
- }
21
-
22
- function roundedPercentUnits(
23
- cacheReadTokens: number,
24
- denominator: number,
25
- decimalPlaces: 0 | 1,
26
- ): number {
27
- const unitsPerPercent = decimalPlaces === 0 ? 1 : 10;
28
- const scale = unitsPerPercent * 100;
29
- const doubledScale = scale * 2;
30
- const denominatorQuotient = Math.floor(denominator / doubledScale);
31
- const denominatorRemainder = denominator % doubledScale;
32
- let lower = 0;
33
- let upper = scale;
34
- while (lower < upper) {
35
- const candidate = Math.floor((lower + upper + 1) / 2);
36
- const factor = candidate * 2 - 1;
37
- const threshold =
38
- factor * denominatorQuotient + Math.ceil((factor * denominatorRemainder) / doubledScale);
39
- if (cacheReadTokens >= threshold) lower = candidate;
40
- else upper = candidate - 1;
41
- }
42
- return lower;
43
- }
44
-
45
- function displayPercentUnits(units: number, decimalPlaces: 0 | 1): string {
46
- if (decimalPlaces === 0) return String(units);
47
- const whole = Math.floor(units / 10);
48
- const tenths = units % 10;
49
- return tenths === 0 ? String(whole) : `${whole}.${tenths}`;
50
- }
51
-
52
- export function formatCacheHitPercent(
53
- cacheReadTokens: number,
54
- promptTokens: number,
55
- decimalPlaces: 0 | 1 = 0,
56
- ): string | null {
57
- if (promptTokens === 0) return null;
58
- const missedInputTokens = promptTokens - cacheReadTokens;
59
- // 未命中侧为 0,或为负(cacheRead 超出 prompt 的越界输入)时都按全命中返回。
60
- if (missedInputTokens <= 0) return "100";
61
-
62
- const roundedUnits = roundedPercentUnits(cacheReadTokens, promptTokens, decimalPlaces);
63
- const fullHitUnits = decimalPlaces === 0 ? 100 : 1_000;
64
- if (roundedUnits < fullHitUnits) return displayPercentUnits(roundedUnits, decimalPlaces);
65
-
66
- let distinguishingPlaces = 1;
67
- let scaledDoubleGap = missedInputTokens * 200;
68
- const denominatorTens = Math.floor(promptTokens / 10);
69
- while (scaledDoubleGap <= denominatorTens) {
70
- scaledDoubleGap *= 10;
71
- distinguishingPlaces += 1;
72
- }
73
- const denominatorOnes = promptTokens % 10;
74
- let roundedLoss = 5;
75
- for (let loss = 1; loss < 5; loss += 1) {
76
- const factor = loss * 2 + 1;
77
- const threshold = factor * denominatorTens + Math.floor((factor * denominatorOnes) / 10);
78
- if (scaledDoubleGap <= threshold) {
79
- roundedLoss = loss;
80
- break;
81
- }
82
- }
83
- return `99.${"9".repeat(distinguishingPlaces - 1)}${10 - roundedLoss}`;
84
- }
@@ -1,35 +0,0 @@
1
- // 窗口折叠(无 `sessionStats` 投影时的兜底)读一条 assistant 步的时序 / 用量。
2
- // 从上游 ui-chat 的 `contract/turn-metrics.ts` 取出统计行真正用到的那一半,
3
- // 只服务 composer 统计行。
4
- import type { AssistantMessageNode } from "@deepseek-ai/dsh-client-ui-chat/client";
5
-
6
- export interface StepReading {
7
- ttftMs: number | null;
8
-
9
- decodeMs: number | null;
10
-
11
- outputTokens: number | null;
12
- }
13
-
14
- interface UsageLike {
15
- outputTokens?: number;
16
- }
17
-
18
- function usageOutputTokens(usage: unknown): number | null {
19
- if (typeof usage !== "object" || usage === null) return null;
20
- const value = (usage as UsageLike).outputTokens;
21
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
22
- }
23
-
24
- export function assistantStepReading(node: AssistantMessageNode): StepReading {
25
- const timing = node.timing;
26
- const ttftMs =
27
- timing !== undefined && timing.stepStartTime !== null && timing.firstTokenTime !== null
28
- ? Math.max(0, timing.firstTokenTime - timing.stepStartTime)
29
- : null;
30
- const decodeMs =
31
- timing !== undefined && timing.firstTokenTime !== null
32
- ? Math.max(0, timing.completedTime - timing.firstTokenTime)
33
- : null;
34
- return { ttftMs, decodeMs, outputTokens: usageOutputTokens(node.usage) };
35
- }