@morlay/ui-conversation-message-actions 0.0.17 → 0.0.19-alpha.0
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.
- package/README.md +29 -98
- package/dist/client.cjs +1380 -0
- package/dist/client.d.cts +6 -0
- package/dist/client.d.mts +6 -0
- package/dist/index.d.cts +98 -0
- package/dist/index.d.mts +3 -10
- package/dist/index.mjs +460 -2
- package/dist/invariant.d.cts +7 -0
- package/dist/{plan-BSa7k9HO.d.mts → plan-CT_6Y1fD.d.cts} +11 -9
- package/dist/plan-Cm9hCord.d.mts +101 -0
- package/dist/plan.d.cts +2 -0
- package/dist/plan.d.mts +2 -2
- package/dist/plan.mjs +145 -11
- package/dist/shared-LK3nImWW.mjs +55 -0
- package/dist/testing.d.cts +3242 -0
- package/dist/testing.d.mts +25 -19
- package/dist/testing.mjs +2140 -3939
- package/package.json +35 -32
- package/src/client/chat-node/MessageIconActions.styles.ts +76 -0
- package/src/client/chat-node/MessageIconActions.tsx +30 -11
- package/src/client/chat-node/MessageItem.styles.ts +247 -0
- package/src/client/chat-node/MessageItem.tsx +32 -39
- package/src/client/chat-node/message-chrome.ts +0 -2
- package/src/client/chat-node/register.ts +3 -4
- package/src/client/chat-node/use-calendar-day.ts +0 -4
- package/src/client/composer-stats/StatsPills.styles.ts +57 -0
- package/src/client/composer-stats/StatsPills.tsx +384 -0
- package/src/client/composer-stats/register.ts +17 -0
- package/src/client/composer-stats/stat-dialog.styles.ts +70 -0
- package/src/client/composer-stats/stat-dialog.ts +52 -0
- package/src/client/composer-stats/token-format.ts +85 -0
- package/src/client/composer-stats/turn-reading.ts +35 -0
- package/src/client/controller.ts +32 -114
- package/src/client/index.ts +8 -24
- package/src/index.ts +102 -111
- package/src/plan.ts +189 -24
- package/src/shared.ts +1 -1
- package/src/testing.ts +1 -5
- package/src/types.ts +1 -1
- package/dist/client.js +0 -806
- package/dist/src-B4HwXsob.mjs +0 -499
- package/src/client/chat-node/MessageIconActions.module.css +0 -86
- package/src/client/chat-node/MessageItem.module.css +0 -290
- package/src/client/css-modules.d.ts +0 -4
- package/src/client/import-action.tsx +0 -74
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
// MessageItem: the user / admitted-steering chat node renderer.
|
|
2
|
-
//
|
|
2
|
+
// 官方 `@deepseek-ai/dsh-client-ui-chat` 内置全部 chat-node 渲染器;本项目仅**替换**
|
|
3
3
|
// `user`/`steering` 两个 key(keyed slot reuse 即替换),在消息动作行上提供
|
|
4
|
-
// edit / retry
|
|
4
|
+
// edit / retry(上游无此能力)。其余 key 由官方内置渲染器处理。
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
markdownLabels,
|
|
8
|
+
ReferenceMarkdown,
|
|
9
|
+
styling,
|
|
10
|
+
type ReferenceActions,
|
|
11
|
+
} from "@morlay/dsh-client-ui-primitives/client";
|
|
12
|
+
import { memo, useMemo, useState } from "react";
|
|
7
13
|
import type { ReactNode } from "react";
|
|
8
14
|
import type { InjectFace } from "@deepseek-ai/dsh-client-ui-slots";
|
|
9
15
|
import { Button, Modal } from "@deepseek-ai/dsh-client-ui-primitives";
|
|
10
16
|
import type { UserMessageNode } from "@deepseek-ai/dsh-client-ui-chat/client";
|
|
11
17
|
import { JsonBlock } from "@deepseek-ai/dsh-client-ui-primitives";
|
|
12
18
|
import type { ChatNodeViewProps, ChatViewSlotProps } from "@deepseek-ai/dsh-client-ui-chat/client";
|
|
13
|
-
import type { RenderMessageImages } from "@
|
|
19
|
+
import type { RenderMessageImages } from "@morlay/dsh-client-ui-conversation/client";
|
|
14
20
|
import { MessageIconActions } from "./MessageIconActions.tsx";
|
|
15
|
-
import
|
|
21
|
+
import { styles } from "./MessageItem.styles.ts";
|
|
16
22
|
import type { EditableMessageBlock } from "../../shared.ts";
|
|
17
23
|
import type { SessionEditorFace } from "../controller.ts";
|
|
18
24
|
|
|
@@ -33,56 +39,37 @@ function contentParts(content: readonly unknown[]): {
|
|
|
33
39
|
images.push({ attachment: (b as UserImage).attachment });
|
|
34
40
|
} else rest.push(block);
|
|
35
41
|
}
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function projectUserText(text: string): ReactNode {
|
|
40
|
-
const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g;
|
|
41
|
-
const parts: ReactNode[] = [];
|
|
42
|
-
let cursor = 0;
|
|
43
|
-
let m: RegExpExecArray | null;
|
|
44
|
-
while ((m = re.exec(text)) !== null) {
|
|
45
|
-
const tokenStart = m.index + (m[1]?.length ?? 0);
|
|
46
|
-
const label = m[2] ?? "";
|
|
47
|
-
if (tokenStart > cursor) parts.push(<span key={cursor}>{text.slice(cursor, tokenStart)}</span>);
|
|
48
|
-
parts.push(
|
|
49
|
-
<span
|
|
50
|
-
key={tokenStart}
|
|
51
|
-
className={css.refChip}
|
|
52
|
-
data-ref-chip={label.startsWith("@") ? "subagent" : "skill"}
|
|
53
|
-
>
|
|
54
|
-
{label}
|
|
55
|
-
</span>,
|
|
56
|
-
);
|
|
57
|
-
cursor = tokenStart + label.length;
|
|
58
|
-
}
|
|
59
|
-
if (parts.length === 0) return <span>{text}</span>;
|
|
60
|
-
if (cursor < text.length) parts.push(<span key={cursor}>{text.slice(cursor)}</span>);
|
|
61
|
-
return <>{parts}</>;
|
|
42
|
+
// 多块消息按空行拼回 raw markdown(新式提交就是单块原文)。
|
|
43
|
+
return { text: texts.join("\n\n"), images, rest };
|
|
62
44
|
}
|
|
63
45
|
|
|
64
46
|
function UserStyleBubble({
|
|
65
47
|
content,
|
|
66
48
|
renderMessageImages,
|
|
67
49
|
actions,
|
|
50
|
+
references,
|
|
68
51
|
t,
|
|
69
52
|
}: {
|
|
70
53
|
content: readonly unknown[];
|
|
71
54
|
renderMessageImages: RenderMessageImages;
|
|
72
55
|
|
|
73
56
|
actions?: (text: string) => ReactNode;
|
|
57
|
+
// 引用 chip 的点击目标(owner props;缺省时 chip 照常渲染但点击无效果)。
|
|
58
|
+
references?: ReferenceActions | undefined;
|
|
74
59
|
t: ChatViewSlotProps["t"];
|
|
75
60
|
}): ReactNode {
|
|
76
61
|
const { text, images, rest } = contentParts(content);
|
|
77
62
|
const truncated = (total: number): string => t("json.truncated", { total });
|
|
63
|
+
// labels 按 locale revision 稳定(官方 MarkdownText 在它上面 memo 渲染缓存)。
|
|
64
|
+
const labels = useMemo(() => markdownLabels(t), [t]);
|
|
78
65
|
const showBubble = text !== "" || rest.length > 0;
|
|
79
66
|
return (
|
|
80
|
-
<div
|
|
81
|
-
<div
|
|
67
|
+
<div {...styling.props(styles.userRow)} data-time-hover-root>
|
|
68
|
+
<div {...styling.props(styles.userStack)}>
|
|
82
69
|
{renderMessageImages({ images, align: "end" })}
|
|
83
70
|
{showBubble && (
|
|
84
|
-
<div
|
|
85
|
-
{
|
|
71
|
+
<div {...styling.props(styles.bubble)}>
|
|
72
|
+
<ReferenceMarkdown text={text} labels={labels} actions={references} />
|
|
86
73
|
{rest.map((block, i) => (
|
|
87
74
|
<JsonBlock
|
|
88
75
|
key={i}
|
|
@@ -103,9 +90,15 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
|
|
|
103
90
|
node,
|
|
104
91
|
renderMessageImages,
|
|
105
92
|
t,
|
|
93
|
+
openFile,
|
|
94
|
+
openSkill,
|
|
106
95
|
recall,
|
|
107
96
|
retry,
|
|
108
|
-
}: ChatNodeViewProps<"user" | "steering"> &
|
|
97
|
+
}: ChatNodeViewProps<"user" | "steering"> & {
|
|
98
|
+
// keyed 渲染器由 owner props 展开调用:引用 chip 的点击目标从这里来。
|
|
99
|
+
openFile: (path: string) => void;
|
|
100
|
+
openSkill: (name: string) => void;
|
|
101
|
+
} & InjectFace<SessionEditorFace>) {
|
|
109
102
|
const data = node.data;
|
|
110
103
|
const [confirmingRecall, setConfirmingRecall] = useState<EditableMessageBlock | null>(null);
|
|
111
104
|
const [confirmingRetry, setConfirmingRetry] = useState(false);
|
|
@@ -153,7 +146,7 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
|
|
|
153
146
|
closeLabel="关闭"
|
|
154
147
|
description="将撤回该消息及其之后的对话内容到输入框,请修改后重新发送。"
|
|
155
148
|
footer={
|
|
156
|
-
<div
|
|
149
|
+
<div {...styling.props(styles.confirmActions)}>
|
|
157
150
|
<Button variant="outline" onClick={() => setConfirmingRecall(null)}>
|
|
158
151
|
取消
|
|
159
152
|
</Button>
|
|
@@ -179,7 +172,7 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
|
|
|
179
172
|
closeLabel="关闭"
|
|
180
173
|
description={`将重新生成第 ${turn} 轮的回复,并抛弃该回合之后的内容。`}
|
|
181
174
|
footer={
|
|
182
|
-
<div
|
|
175
|
+
<div {...styling.props(styles.confirmActions)}>
|
|
183
176
|
<Button variant="outline" onClick={() => setConfirmingRetry(false)}>
|
|
184
177
|
取消
|
|
185
178
|
</Button>
|
|
@@ -199,13 +192,13 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
|
|
|
199
192
|
<UserStyleBubble
|
|
200
193
|
content={data.content}
|
|
201
194
|
renderMessageImages={renderMessageImages}
|
|
195
|
+
references={{ openFile, openSkill }}
|
|
202
196
|
t={t}
|
|
203
197
|
actions={(text) => (
|
|
204
198
|
<MessageIconActions
|
|
205
199
|
text={text}
|
|
206
200
|
time={data.time}
|
|
207
201
|
clock="start"
|
|
208
|
-
className={css.actions}
|
|
209
202
|
t={t}
|
|
210
203
|
onEdit={onEdit}
|
|
211
204
|
onRetry={onRetry}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Context } from "@deepseek-ai/cordis";
|
|
2
2
|
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
3
|
-
import "@
|
|
4
|
-
import "@deepseek-ai/dsh-client-ui-chat/client";
|
|
3
|
+
import "@morlay/dsh-client-ui-conversation/client";
|
|
4
|
+
import type {} from "@deepseek-ai/dsh-client-ui-chat/client";
|
|
5
5
|
import type {} from "@deepseek-ai/dsh-client-ui-renderer/client";
|
|
6
6
|
import { UserMessageNodeView } from "./MessageItem.tsx";
|
|
7
7
|
import type { SessionEditorController } from "../controller.ts";
|
|
@@ -21,8 +21,7 @@ export function registerChatNodeRenderers(
|
|
|
21
21
|
name: "conversation.chat.node",
|
|
22
22
|
key,
|
|
23
23
|
locale: NS,
|
|
24
|
-
|
|
25
|
-
// 渲染器以最低优先级渲染,shadow 上游默认(priority 0)注册。
|
|
24
|
+
|
|
26
25
|
priority: -1,
|
|
27
26
|
inject: injectFace,
|
|
28
27
|
} as never,
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// Component-local calendar-day tick: memoized message rows keep stable props
|
|
2
|
-
// across midnight, so the IconActions clock needs a local day seat that
|
|
3
|
-
// re-fires at the next local midnight without reaching for framework hooks.
|
|
4
|
-
|
|
5
1
|
import { useEffect, useState } from "react";
|
|
6
2
|
import { msUntilNextLocalMidnight, startOfLocalDay } from "./message-chrome.ts";
|
|
7
3
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CSSProps } from "@morlay/dsh-client-ui-primitives/client";
|
|
2
|
+
|
|
3
|
+
export const styles = {
|
|
4
|
+
// 上游 `StatsPills.module.css` 的 `.root` 原样:统计行在 composer dock 里是 shrink-wrap 的
|
|
5
|
+
// 一个条目,与同为 dock 子项的上下文占用按钮并排——行内间距由 dock 的 `gap: 12px` 统一,
|
|
6
|
+
// 统计自己不再撑满整行(撑满会把占用按钮挤到行尾,两块之间的间距就跟行内间距对不上)。
|
|
7
|
+
root: {
|
|
8
|
+
display: "flex",
|
|
9
|
+
justifyContent: "center",
|
|
10
|
+
gap: "12px",
|
|
11
|
+
minWidth: "0",
|
|
12
|
+
maxWidth: "100%",
|
|
13
|
+
boxSizing: "border-box",
|
|
14
|
+
fontSize: "var(--dsh-content-font-size-secondary, 13px)",
|
|
15
|
+
lineHeight: "calc(20px + var(--dsh-content-font-delta-secondary, 0px))",
|
|
16
|
+
},
|
|
17
|
+
anchor: {
|
|
18
|
+
display: "inline-flex",
|
|
19
|
+
minWidth: "0",
|
|
20
|
+
},
|
|
21
|
+
pill: {
|
|
22
|
+
cursor: "pointer",
|
|
23
|
+
|
|
24
|
+
"&:hover, &[aria-expanded='true']": {
|
|
25
|
+
background: "var(--dsw-alias-interactive-bg-hover)",
|
|
26
|
+
color: "var(--dsw-alias-label-secondary)",
|
|
27
|
+
},
|
|
28
|
+
display: "inline-flex",
|
|
29
|
+
alignItems: "center",
|
|
30
|
+
gap: "6px",
|
|
31
|
+
boxSizing: "border-box",
|
|
32
|
+
maxWidth: "100%",
|
|
33
|
+
padding: "1px 8px",
|
|
34
|
+
border: "none",
|
|
35
|
+
borderRadius: "24px",
|
|
36
|
+
background: "transparent",
|
|
37
|
+
color: "var(--dsw-alias-label-tertiary)",
|
|
38
|
+
font: "inherit",
|
|
39
|
+
fontVariantNumeric: "tabular-nums",
|
|
40
|
+
lineHeight: "inherit",
|
|
41
|
+
whiteSpace: "nowrap",
|
|
42
|
+
"& svg": {
|
|
43
|
+
width: "14px",
|
|
44
|
+
height: "14px",
|
|
45
|
+
flex: "none",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
label: {
|
|
49
|
+
minWidth: "0",
|
|
50
|
+
overflow: "hidden",
|
|
51
|
+
textOverflow: "ellipsis",
|
|
52
|
+
},
|
|
53
|
+
sep: {
|
|
54
|
+
color: "var(--dsw-alias-separator-primary)",
|
|
55
|
+
margin: "0 6px",
|
|
56
|
+
},
|
|
57
|
+
} satisfies Record<string, CSSProps>;
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// Session stats under the composer, split into two icon pills: a gauge pill
|
|
2
|
+
// (turn/step counts + output speed) opening the time-and-speed dialog, and a
|
|
3
|
+
// database pill (total tokens + cache hit) opening the token-usage dialog.
|
|
4
|
+
// Settled-node identity prevents stream-delta updates from rerendering the row.
|
|
5
|
+
// Carried from upstream ui-chat (composer.dock id 'stats', priority -1 shadows it)
|
|
6
|
+
// so the fixed token-format (out-of-range cacheRead no longer hangs) stays wired;
|
|
7
|
+
// data-composer-stats pairs with ui-conversation's InputBar `:has()` bottom clearance.
|
|
8
|
+
|
|
9
|
+
import { memo, useMemo, useState } from "react";
|
|
10
|
+
import { createPortal } from "react-dom";
|
|
11
|
+
import { IconDatabaseOutline16, IconGaugeOutline16 } from "@deepseek-ai/dsh-client-ui-primitives";
|
|
12
|
+
import type { UseProjection } from "@deepseek-ai/dsh-api-session-controller/client";
|
|
13
|
+
import type { SnapshotSelectorHook } from "@deepseek-ai/dsh-client-ui-slots";
|
|
14
|
+
// Type-only: merges the sessionStats key into SessionProjectionMap for useProjection.
|
|
15
|
+
import type {} from "@deepseek-ai/dsh-session-stats/client";
|
|
16
|
+
import type { TokenUsageProjection } from "@deepseek-ai/dsh-token-meter/client";
|
|
17
|
+
import type { ChatSnapshot, ChatViewSlotProps } from "@deepseek-ai/dsh-client-ui-chat/client";
|
|
18
|
+
import { formatTokensPerSecond } from "../chat-node/message-chrome.ts";
|
|
19
|
+
import { assistantStepReading } from "./turn-reading.ts";
|
|
20
|
+
import { formatCacheHitPercent, formatExactTokens, formatTokens } from "./token-format.ts";
|
|
21
|
+
import { MEASURE_STYLE, useStatDialog } from "./stat-dialog.ts";
|
|
22
|
+
import { styling } from "@morlay/dsh-client-ui-primitives/client";
|
|
23
|
+
import { styles } from "./StatsPills.styles.ts";
|
|
24
|
+
import { styles as dialogStyles } from "./stat-dialog.styles.ts";
|
|
25
|
+
|
|
26
|
+
interface WindowStats {
|
|
27
|
+
turns: number;
|
|
28
|
+
steps: number;
|
|
29
|
+
/** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
|
|
30
|
+
llmMs: number;
|
|
31
|
+
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
|
|
32
|
+
toolMs: number;
|
|
33
|
+
/** Summed first-token latency over `ttftSteps`; 0 when no step records it. */
|
|
34
|
+
ttftMs: number;
|
|
35
|
+
/** Steps carrying a recorded TTFT. */
|
|
36
|
+
ttftSteps: number;
|
|
37
|
+
/** Summed decode wall time over steps that also report output tokens. */
|
|
38
|
+
decodeMs: number;
|
|
39
|
+
/** Summed output tokens over the same decode-timed steps. */
|
|
40
|
+
decodeTokens: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fold assistant and tool-result nodes into window-scoped display totals —
|
|
45
|
+
* the FALLBACK for assemblies without the `sessionStats` projection.
|
|
46
|
+
*
|
|
47
|
+
* Every displayed figure rides that durable whole-log projection (and token
|
|
48
|
+
* accounting rides `tokenUsage`) because the window is paged and compaction
|
|
49
|
+
* rewrites it; this fold answers "what is on screen" only when no projection
|
|
50
|
+
* value is served. Its field names deliberately mirror the projection's so
|
|
51
|
+
* the two swap wholesale.
|
|
52
|
+
* @param nodes - snapshot nodes.
|
|
53
|
+
* @returns fallback counts and summed wall times.
|
|
54
|
+
*/
|
|
55
|
+
export function deriveStats(nodes: ChatSnapshot["legacy"]["nodes"]): WindowStats {
|
|
56
|
+
const turns = new Set<number>();
|
|
57
|
+
let steps = 0;
|
|
58
|
+
let llmMs = 0;
|
|
59
|
+
let toolMs = 0;
|
|
60
|
+
let ttftMs = 0;
|
|
61
|
+
let ttftSteps = 0;
|
|
62
|
+
let decodeMs = 0;
|
|
63
|
+
let decodeTokens = 0;
|
|
64
|
+
for (const node of nodes) {
|
|
65
|
+
if (node.kind === "tool-result") {
|
|
66
|
+
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (node.kind !== "assistant") continue;
|
|
70
|
+
turns.add(node.turn);
|
|
71
|
+
steps += 1;
|
|
72
|
+
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
|
|
73
|
+
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime);
|
|
74
|
+
}
|
|
75
|
+
const reading = assistantStepReading(node);
|
|
76
|
+
if (reading.ttftMs !== null) {
|
|
77
|
+
ttftMs += reading.ttftMs;
|
|
78
|
+
ttftSteps += 1;
|
|
79
|
+
}
|
|
80
|
+
if (reading.decodeMs !== null && reading.outputTokens !== null) {
|
|
81
|
+
decodeMs += reading.decodeMs;
|
|
82
|
+
decodeTokens += reading.outputTokens;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Compact duration: 45.2s under a minute, 2m42s from there on.
|
|
90
|
+
* @param ms - duration in milliseconds.
|
|
91
|
+
* @returns display string.
|
|
92
|
+
*/
|
|
93
|
+
export function formatDuration(ms: number, t: ChatViewSlotProps["t"]): string {
|
|
94
|
+
const s = ms / 1_000;
|
|
95
|
+
if (s < 60) return t("duration.compactSeconds", { seconds: Math.round(s * 10) / 10 });
|
|
96
|
+
const whole = Math.round(s);
|
|
97
|
+
return t("duration.compactMinutes", {
|
|
98
|
+
minutes: Math.floor(whole / 60),
|
|
99
|
+
seconds: whole % 60,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Display-ready cache-hit share of prompt-side input over the whole durable log.
|
|
105
|
+
* @param usage - the session's token-usage projection value.
|
|
106
|
+
* @returns integer text when integer rounding stays below 100, otherwise the
|
|
107
|
+
* minimum decimal precision that still rounds below 100; a full hit returns
|
|
108
|
+
* 100, and no billed input returns null.
|
|
109
|
+
*/
|
|
110
|
+
export function cacheHitPercent(usage: TokenUsageProjection): string | null {
|
|
111
|
+
const denominator = billedInputTokens(usage);
|
|
112
|
+
return formatCacheHitPercent(usage.cacheReadTokens, denominator);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Sum the three disjoint prompt-side billing buckets.
|
|
117
|
+
* @param usage - the session's token-usage projection value.
|
|
118
|
+
* @returns billed input tokens.
|
|
119
|
+
*/
|
|
120
|
+
export function billedInputTokens(usage: TokenUsageProjection): number {
|
|
121
|
+
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Props: the conversation-snapshot selector plus the projection read seat. */
|
|
125
|
+
export interface StatsPillsProps {
|
|
126
|
+
useChat: SnapshotSelectorHook<ChatSnapshot>;
|
|
127
|
+
useProjection: UseProjection;
|
|
128
|
+
/** The owning dock's locale seat. */
|
|
129
|
+
t: ChatViewSlotProps["t"];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function exactCount(value: number, t: ChatViewSlotProps["t"]): string {
|
|
133
|
+
return t("message.turnUsage.count", { count: formatExactTokens(value, t) });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** External open state one pill's dialog reads and writes (the row's exclusive slot). */
|
|
137
|
+
type PillDialog = Pick<ReturnType<typeof useStatDialog>, "open" | "setOpen">;
|
|
138
|
+
|
|
139
|
+
function TimePill({
|
|
140
|
+
stats,
|
|
141
|
+
t,
|
|
142
|
+
dialog,
|
|
143
|
+
}: {
|
|
144
|
+
stats: WindowStats;
|
|
145
|
+
t: ChatViewSlotProps["t"];
|
|
146
|
+
dialog: PillDialog;
|
|
147
|
+
}) {
|
|
148
|
+
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog);
|
|
149
|
+
const counts = t("stats.counts", { turns: stats.turns, steps: stats.steps });
|
|
150
|
+
const tps =
|
|
151
|
+
stats.decodeMs > 0
|
|
152
|
+
? t("message.tokensPerSecond", {
|
|
153
|
+
tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
|
|
154
|
+
})
|
|
155
|
+
: null;
|
|
156
|
+
const label = (
|
|
157
|
+
<span {...styling.props(styles.label)}>
|
|
158
|
+
{counts}
|
|
159
|
+
{tps !== null && (
|
|
160
|
+
<>
|
|
161
|
+
<span {...styling.props(styles.sep)} aria-hidden>
|
|
162
|
+
·
|
|
163
|
+
</span>
|
|
164
|
+
{tps}
|
|
165
|
+
</>
|
|
166
|
+
)}
|
|
167
|
+
</span>
|
|
168
|
+
);
|
|
169
|
+
// A window without one timed figure has no dialog rows to show, so the pill
|
|
170
|
+
// stays a plain reading instead of a button opening an empty dialog.
|
|
171
|
+
if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && stats.decodeMs <= 0) {
|
|
172
|
+
return (
|
|
173
|
+
<span {...styling.props(styles.anchor)}>
|
|
174
|
+
<span {...styling.props(styles.pill)}>
|
|
175
|
+
<IconGaugeOutline16 />
|
|
176
|
+
{label}
|
|
177
|
+
</span>
|
|
178
|
+
</span>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return (
|
|
182
|
+
<span ref={rootRef} {...styling.props(styles.anchor)}>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
{...styling.props(styles.pill)}
|
|
186
|
+
aria-haspopup="dialog"
|
|
187
|
+
aria-expanded={open}
|
|
188
|
+
aria-label={tps === null ? counts : `${counts} · ${tps}`}
|
|
189
|
+
onClick={() => {
|
|
190
|
+
setOpen(!open);
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
<IconGaugeOutline16 />
|
|
194
|
+
{label}
|
|
195
|
+
</button>
|
|
196
|
+
{open &&
|
|
197
|
+
createPortal(
|
|
198
|
+
<div
|
|
199
|
+
ref={panelRef}
|
|
200
|
+
{...styling.props(dialogStyles.panel)}
|
|
201
|
+
role="dialog"
|
|
202
|
+
aria-label={t("stats.dialog.title")}
|
|
203
|
+
style={pos ?? MEASURE_STYLE}
|
|
204
|
+
>
|
|
205
|
+
<div {...styling.props(dialogStyles.title)}>
|
|
206
|
+
<span {...styling.props(dialogStyles.titleLabel)}>
|
|
207
|
+
<IconGaugeOutline16 />
|
|
208
|
+
{t("stats.dialog.title")}
|
|
209
|
+
</span>
|
|
210
|
+
</div>
|
|
211
|
+
<div {...styling.props(dialogStyles.titleRule)} aria-hidden />
|
|
212
|
+
<dl {...styling.props(dialogStyles.details)} data-session-stats-details>
|
|
213
|
+
{stats.llmMs > 0 && (
|
|
214
|
+
<>
|
|
215
|
+
<dt>{t("stats.dialog.llmTime")}</dt>
|
|
216
|
+
<dd>{formatDuration(stats.llmMs, t)}</dd>
|
|
217
|
+
</>
|
|
218
|
+
)}
|
|
219
|
+
{stats.toolMs > 0 && (
|
|
220
|
+
<>
|
|
221
|
+
<dt>{t("stats.dialog.toolTime")}</dt>
|
|
222
|
+
<dd>{formatDuration(stats.toolMs, t)}</dd>
|
|
223
|
+
</>
|
|
224
|
+
)}
|
|
225
|
+
{stats.ttftSteps > 0 && (
|
|
226
|
+
<>
|
|
227
|
+
<dt>{t("stats.dialog.ttft")}</dt>
|
|
228
|
+
<dd>{formatDuration(stats.ttftMs / stats.ttftSteps, t)}</dd>
|
|
229
|
+
</>
|
|
230
|
+
)}
|
|
231
|
+
{stats.decodeMs > 0 && (
|
|
232
|
+
<>
|
|
233
|
+
<dt>{t("stats.dialog.speed")}</dt>
|
|
234
|
+
<dd>
|
|
235
|
+
{t("message.tokensPerSecond", {
|
|
236
|
+
tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
|
|
237
|
+
})}
|
|
238
|
+
</dd>
|
|
239
|
+
</>
|
|
240
|
+
)}
|
|
241
|
+
</dl>
|
|
242
|
+
</div>,
|
|
243
|
+
document.body,
|
|
244
|
+
)}
|
|
245
|
+
</span>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function UsagePill({
|
|
250
|
+
usage,
|
|
251
|
+
t,
|
|
252
|
+
dialog,
|
|
253
|
+
}: {
|
|
254
|
+
usage: TokenUsageProjection;
|
|
255
|
+
t: ChatViewSlotProps["t"];
|
|
256
|
+
dialog: PillDialog;
|
|
257
|
+
}) {
|
|
258
|
+
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog);
|
|
259
|
+
// Same aggregate as the Turn pill's totalTokens: every prompt-side billing bucket plus output.
|
|
260
|
+
const total = billedInputTokens(usage) + usage.outputTokens;
|
|
261
|
+
const totalText = t("message.turnUsage.count", { count: formatTokens(total, t) });
|
|
262
|
+
const cacheHit = cacheHitPercent(usage);
|
|
263
|
+
const cacheHitText = cacheHit !== null ? t("stats.cacheHit", { percent: cacheHit }) : null;
|
|
264
|
+
return (
|
|
265
|
+
<span ref={rootRef} {...styling.props(styles.anchor)}>
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
{...styling.props(styles.pill)}
|
|
269
|
+
aria-haspopup="dialog"
|
|
270
|
+
aria-expanded={open}
|
|
271
|
+
aria-label={cacheHitText === null ? totalText : `${totalText} · ${cacheHitText}`}
|
|
272
|
+
onClick={() => {
|
|
273
|
+
setOpen(!open);
|
|
274
|
+
}}
|
|
275
|
+
>
|
|
276
|
+
<IconDatabaseOutline16 />
|
|
277
|
+
<span {...styling.props(styles.label)}>
|
|
278
|
+
{totalText}
|
|
279
|
+
{cacheHitText !== null && (
|
|
280
|
+
<>
|
|
281
|
+
<span {...styling.props(styles.sep)} aria-hidden>
|
|
282
|
+
·
|
|
283
|
+
</span>
|
|
284
|
+
{cacheHitText}
|
|
285
|
+
</>
|
|
286
|
+
)}
|
|
287
|
+
</span>
|
|
288
|
+
</button>
|
|
289
|
+
{open &&
|
|
290
|
+
createPortal(
|
|
291
|
+
<div
|
|
292
|
+
ref={panelRef}
|
|
293
|
+
{...styling.props(dialogStyles.panel)}
|
|
294
|
+
role="dialog"
|
|
295
|
+
aria-label={t("stats.dialog.usageTitle")}
|
|
296
|
+
style={pos ?? MEASURE_STYLE}
|
|
297
|
+
>
|
|
298
|
+
<div {...styling.props(dialogStyles.title)}>
|
|
299
|
+
<span {...styling.props(dialogStyles.titleLabel)}>
|
|
300
|
+
<IconDatabaseOutline16 />
|
|
301
|
+
{t("stats.dialog.usageTitle")}
|
|
302
|
+
</span>
|
|
303
|
+
<span {...styling.props(dialogStyles.titleValue)}>{exactCount(total, t)}</span>
|
|
304
|
+
</div>
|
|
305
|
+
<div {...styling.props(dialogStyles.titleRule)} aria-hidden />
|
|
306
|
+
{/* jscpd:ignore-start -- the session-total bucket rows deliberately mirror
|
|
307
|
+
TurnUsagePanel's per-turn dl: same skin, different data contract (the
|
|
308
|
+
buckets are always present here; per-turn fields are optional). A
|
|
309
|
+
session that never wrote cache drops the row, as the per-turn panel
|
|
310
|
+
drops its absent fields. */}
|
|
311
|
+
<dl {...styling.props(dialogStyles.details)} data-session-stats-usage>
|
|
312
|
+
{cacheHit !== null && (
|
|
313
|
+
<>
|
|
314
|
+
<dt>{t("message.turnUsage.cacheHit")}</dt>
|
|
315
|
+
<dd>{`${cacheHit}%`}</dd>
|
|
316
|
+
</>
|
|
317
|
+
)}
|
|
318
|
+
<dt>{t("message.turnUsage.input")}</dt>
|
|
319
|
+
<dd>{exactCount(usage.uncachedInputTokens, t)}</dd>
|
|
320
|
+
<dt>{t("message.turnUsage.cacheRead")}</dt>
|
|
321
|
+
<dd>{exactCount(usage.cacheReadTokens, t)}</dd>
|
|
322
|
+
{usage.cacheWriteTokens !== 0 && (
|
|
323
|
+
<>
|
|
324
|
+
<dt>{t("message.turnUsage.cacheWrite")}</dt>
|
|
325
|
+
<dd>{exactCount(usage.cacheWriteTokens, t)}</dd>
|
|
326
|
+
</>
|
|
327
|
+
)}
|
|
328
|
+
<dt>{t("message.turnUsage.output")}</dt>
|
|
329
|
+
<dd>{exactCount(usage.outputTokens, t)}</dd>
|
|
330
|
+
</dl>
|
|
331
|
+
{/* jscpd:ignore-end */}
|
|
332
|
+
</div>,
|
|
333
|
+
document.body,
|
|
334
|
+
)}
|
|
335
|
+
</span>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export const StatsPills = memo(function StatsPills({ useChat, useProjection, t }: StatsPillsProps) {
|
|
340
|
+
const settledNodes = useChat((s) => s.legacy.nodes);
|
|
341
|
+
const usage = useProjection("tokenUsage");
|
|
342
|
+
// One exclusive slot for both dialogs: opening either pill closes the other.
|
|
343
|
+
const [openPill, setOpenPill] = useState<"time" | "usage" | null>(null);
|
|
344
|
+
// Every figure rides the durable sessionStats projection, so paging and
|
|
345
|
+
// compaction cannot change any of them; an assembly without the unit falls
|
|
346
|
+
// back to the window-scoped fold wholesale (same field names), paid only
|
|
347
|
+
// while no projection value is served.
|
|
348
|
+
const projected = useProjection("sessionStats");
|
|
349
|
+
const stats = useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes]);
|
|
350
|
+
// Gated on actual token activity: a session whose steps all settled without
|
|
351
|
+
// billing (e.g. every request failed) shows its counts without a usage pill.
|
|
352
|
+
const hasTokens = usage !== undefined && (billedInputTokens(usage) > 0 || usage.outputTokens > 0);
|
|
353
|
+
if (stats.steps === 0 && !hasTokens) return null;
|
|
354
|
+
// data-composer-stats: InputBar's `.root:has([data-composer-stats])` rule
|
|
355
|
+
// tightens the composer's bottom clearance only while this row renders.
|
|
356
|
+
return (
|
|
357
|
+
<div {...styling.props(styles.root)} data-composer-stats>
|
|
358
|
+
{stats.steps > 0 && (
|
|
359
|
+
<TimePill
|
|
360
|
+
stats={stats}
|
|
361
|
+
t={t}
|
|
362
|
+
dialog={{
|
|
363
|
+
open: openPill === "time",
|
|
364
|
+
setOpen: (open) => {
|
|
365
|
+
setOpenPill(open ? "time" : null);
|
|
366
|
+
},
|
|
367
|
+
}}
|
|
368
|
+
/>
|
|
369
|
+
)}
|
|
370
|
+
{hasTokens && (
|
|
371
|
+
<UsagePill
|
|
372
|
+
usage={usage}
|
|
373
|
+
t={t}
|
|
374
|
+
dialog={{
|
|
375
|
+
open: openPill === "usage",
|
|
376
|
+
setOpen: (open) => {
|
|
377
|
+
setOpenPill(open ? "usage" : null);
|
|
378
|
+
},
|
|
379
|
+
}}
|
|
380
|
+
/>
|
|
381
|
+
)}
|
|
382
|
+
</div>
|
|
383
|
+
);
|
|
384
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
}
|