dsh-codinput 0.1.1 → 0.3.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/CHANGELOG.md +48 -0
- package/README.en.md +32 -26
- package/README.md +34 -28
- package/TESTING.md +10 -2
- package/assets/icon-white.svg +11 -0
- package/assets/icon.png +0 -0
- package/assets/icon.svg +11 -1
- package/assets/preview-ball-detail.png +0 -0
- package/assets/preview-float.png +0 -0
- package/assets/preview-normal.png +0 -0
- package/assets/preview-side.png +0 -0
- package/assets/preview-split.png +0 -0
- package/lib/client.js +67 -37
- package/package.json +1 -1
- package/src/client/attachments.ts +34 -0
- package/src/client/command.ts +52 -12
- package/src/client/components/AttachmentRail.tsx +20 -1
- package/src/client/components/CodinputBar.tsx +13 -35
- package/src/client/components/CodinputSurface.tsx +42 -8
- package/src/client/components/ImageLightbox.tsx +61 -0
- package/src/client/components/StatsRow.tsx +76 -43
- package/src/client/components/Toolbar.tsx +36 -42
- package/src/client/components/VoiceActivity.tsx +172 -0
- package/src/client/host-types.ts +24 -1
- package/src/client/i18n.ts +37 -2
- package/src/client/icons.tsx +33 -27
- package/src/client/services.ts +5 -0
- package/src/client/sidebar.tsx +66 -7
- package/src/client/sideinput.ts +33 -7
- package/src/client/stats.ts +156 -53
- package/src/client/styles.ts +37 -7
package/src/client/icons.tsx
CHANGED
|
@@ -27,18 +27,24 @@ function svg(paths: JSX.Element, size: number | undefined, className: string | u
|
|
|
27
27
|
);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* 品牌图形:DSH-Codinput 机型标(代码括号 + 航迹斜杠 + 纸飞机)。
|
|
32
|
+
* 大尺寸原稿见 assets/icon.svg / icon-white.svg;这里是 24 栅格的
|
|
33
|
+
* 小尺寸简化——发卡弯并线、钩脚并入单笔括号,斜杠上收让出机鼻。
|
|
34
|
+
*/
|
|
35
|
+
const BRAND_MARK = (
|
|
36
|
+
<>
|
|
37
|
+
<path d="M6.8 5.8 2.9 11l4 4.4" />
|
|
38
|
+
<path d="M17.2 5.8 21.1 11l-3.9 4.4" />
|
|
39
|
+
<path d="M10.7 9.5 12.5 3.9" />
|
|
40
|
+
<path d="M16.6 9.4 6.9 14l2.9 1.3.1 2.5q.1.8.7.3l1-2.3 2.2 1.4Z" />
|
|
41
|
+
<path d="M9.8 15.3 16.6 9.4" />
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** 品牌:DSH-Codinput 机型标(悬浮小球 / 侧栏唤回球)。 */
|
|
31
46
|
export function IconBrand({ size, className }: IconProps): JSX.Element {
|
|
32
|
-
return svg(
|
|
33
|
-
<>
|
|
34
|
-
<path d="M4 12.15V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-3.35" />
|
|
35
|
-
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
|
36
|
-
<path d="m5 16-3 3 3 3" />
|
|
37
|
-
<path d="m9 22 3-3-3-3" />
|
|
38
|
-
</>,
|
|
39
|
-
size,
|
|
40
|
-
className,
|
|
41
|
-
);
|
|
47
|
+
return svg(BRAND_MARK, size, className);
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
export function IconEdit({ size, className }: IconProps): JSX.Element {
|
|
@@ -167,7 +173,7 @@ export function IconPlusOfficial({ size }: { size?: number }): JSX.Element {
|
|
|
167
173
|
);
|
|
168
174
|
}
|
|
169
175
|
|
|
170
|
-
/** 官方 IconPaperclipOutline16
|
|
176
|
+
/** 官方 IconPaperclipOutline16(slash 添加·文件 补位条目的 glyph;官方为 fill 路径自带描边轮廓)。 */
|
|
171
177
|
export function IconPaperclipOfficial({ size }: { size?: number }): JSX.Element {
|
|
172
178
|
return (
|
|
173
179
|
<svg width={size ?? 14} height={size ?? 14} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
@@ -272,25 +278,25 @@ export function IconChevronOfficial({ size }: { size?: number }): JSX.Element {
|
|
|
272
278
|
}
|
|
273
279
|
|
|
274
280
|
/**
|
|
275
|
-
* /codinput 命令图标(宿主命令菜单 glyph
|
|
276
|
-
* currentColor,与 primitives Icon*Outline16
|
|
281
|
+
* /codinput 命令图标(宿主命令菜单 glyph:品牌机型标,stroke
|
|
282
|
+
* currentColor,与 primitives Icon*Outline16 同尺度——接受 size,命令
|
|
277
283
|
* 菜单里按官方语义渲染为 <icon size={16}/>)。
|
|
278
284
|
*/
|
|
279
285
|
export function IconCodinputCommand16({ size = 16, className }: { size?: number; className?: string }): JSX.Element {
|
|
280
286
|
return (
|
|
281
|
-
<svg
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
287
|
+
<svg
|
|
288
|
+
width={size}
|
|
289
|
+
height={size}
|
|
290
|
+
viewBox="0 0 24 24"
|
|
291
|
+
fill="none"
|
|
292
|
+
stroke="currentColor"
|
|
293
|
+
strokeWidth={2}
|
|
294
|
+
strokeLinecap="round"
|
|
295
|
+
strokeLinejoin="round"
|
|
296
|
+
className={className}
|
|
297
|
+
aria-hidden="true"
|
|
298
|
+
>
|
|
299
|
+
{BRAND_MARK}
|
|
294
300
|
</svg>
|
|
295
301
|
);
|
|
296
302
|
}
|
package/src/client/services.ts
CHANGED
|
@@ -10,11 +10,13 @@ import type {
|
|
|
10
10
|
ModelDirectoriesFace,
|
|
11
11
|
SessionsFace,
|
|
12
12
|
SidebarRightFace,
|
|
13
|
+
SlotsService,
|
|
13
14
|
} from './host-types';
|
|
14
15
|
import { diagError, runGuarded } from './diag';
|
|
15
16
|
import { installI18n } from './i18n';
|
|
16
17
|
|
|
17
18
|
export interface ServiceStash {
|
|
19
|
+
slots?: SlotsService;
|
|
18
20
|
conversation?: ConversationFace;
|
|
19
21
|
sessions?: SessionsFace;
|
|
20
22
|
commandUi?: CommandUiFace;
|
|
@@ -33,8 +35,11 @@ export function installServices(ctx: ClientContext): void {
|
|
|
33
35
|
});
|
|
34
36
|
|
|
35
37
|
// sessions / commandUi:被消费服务自身的 static inject 链由宿主装配。
|
|
38
|
+
// slots 一并取入 stash:语音按钮要检视 conversation.input.activity 槽位
|
|
39
|
+
// 条目(见 components/VoiceActivity)。
|
|
36
40
|
runGuarded('stash.services', () => {
|
|
37
41
|
ctx.inject(['slots', 'sessions', 'commandUi'], (scope: Record<string, unknown>) => {
|
|
42
|
+
stash.slots = scope['slots'] as SlotsService;
|
|
38
43
|
stash.sessions = scope['sessions'] as SessionsFace;
|
|
39
44
|
stash.commandUi = scope['commandUi'] as CommandUiFace;
|
|
40
45
|
});
|
package/src/client/sidebar.tsx
CHANGED
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
*
|
|
7
7
|
* 唯一输入组件约定:body **挂载**期间经 sideinput 总线声明承载本会话
|
|
8
8
|
* 输入(形态 = side),主输入遮蔽条目随之隐藏——收起侧栏不让位(可见性
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* 只用于判断「需不需要右上角小球唤出」)。声明以 prefs.enabled 为前提:
|
|
10
|
+
* 侧边模式下 /codinput 关闭接管即撤销声明、主输入回到官方输入框,标签
|
|
11
|
+
* 内只出提示;重新开启自动恢复。标签关闭(body 卸载)后撤销声明,主输入
|
|
12
|
+
* 原样恢复。
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import { useEffect, useRef, useState } from 'react';
|
|
14
16
|
import type { SessionStandard, SidebarTabInfo, SlotRegisterOptions } from './host-types';
|
|
15
|
-
import { claimSidebarInput, useSidebarCarryOwner, type SidebarCarry } from './sideinput';
|
|
17
|
+
import { claimSidebarInput, subscribeSidebarTabClose, useSidebarCarryOwner, type SidebarCarry } from './sideinput';
|
|
18
|
+
import { loadPrefs, savePrefs, subscribePrefs } from './prefs';
|
|
19
|
+
import { diagError, diagWindow } from './diag';
|
|
16
20
|
import { CodinputSurface } from './components/CodinputSurface';
|
|
17
21
|
import { t, useT } from './i18n';
|
|
18
22
|
|
|
@@ -26,6 +30,10 @@ export function sidebarTabDefinition(): Record<string, unknown> {
|
|
|
26
30
|
kind: SIDEBAR_TAB_KIND,
|
|
27
31
|
priority: 'extension',
|
|
28
32
|
title: () => 'Codinput',
|
|
33
|
+
// 非激活也保持 body 挂载(官方浏览器标签同款开关):承载声明挂在 body
|
|
34
|
+
// 生命周期上,若切到同栏其他标签(如工作区文件)body 被 dockkit 卸载,
|
|
35
|
+
// 声明随之消失、主输入回来——「标签存在即唯一输入入口」就不成立了。
|
|
36
|
+
keepMounted: true,
|
|
29
37
|
guide: [
|
|
30
38
|
{
|
|
31
39
|
order: 0,
|
|
@@ -55,19 +63,62 @@ export function sidebarBodyRegistration(): { options: SlotRegisterOptions; compo
|
|
|
55
63
|
* useTabInfo 内部订阅 dockkit store,展开/激活/浮动变化都会重渲染。
|
|
56
64
|
*/
|
|
57
65
|
export function SidebarCodinputBody(props: SessionStandard & { useTabInfo?: () => SidebarTabInfo }): JSX.Element {
|
|
58
|
-
const { sessionId, useInput, inputActions, useProjection, useTabInfo } = props;
|
|
66
|
+
const { sessionId, useInput, inputActions, useProjection, useSession, useTabInfo } = props;
|
|
59
67
|
const state = useInput((s) => s);
|
|
68
|
+
// 会话运行中:侧栏形态的主按钮同样变停止(官方同款)。
|
|
69
|
+
const running = useSession ? useSession((s: unknown) => (s as { running?: boolean } | undefined)?.running) === true : false;
|
|
60
70
|
useT();
|
|
61
71
|
// 注入 hook 的有无逐渲染稳定(渲染器 useMemo 绑定),条件调用合规。
|
|
62
72
|
const info = useTabInfo ? useTabInfo() : undefined;
|
|
63
73
|
const visible = info?.tab.visible === true;
|
|
64
74
|
const expanded = info?.sidebar.expanded === true;
|
|
65
75
|
|
|
66
|
-
//
|
|
67
|
-
//
|
|
76
|
+
// /codinput 从侧栏退出 → 命令侧广播关闭请求,这里经 tab 域动作面的
|
|
77
|
+
// actions.close() 关掉自己的标签。关闭即卸载 body、撤销承载,主输入按
|
|
78
|
+
// 退出后的 prefs 恢复官方输入框。
|
|
79
|
+
const closeSelf = info?.tab.actions?.close;
|
|
80
|
+
const closeRef = useRef(closeSelf);
|
|
81
|
+
closeRef.current = closeSelf;
|
|
82
|
+
// 诊断:tabId/关闭面是否可得(宿主形状漂移时链路断在哪一目了然)。
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const shell = diagWindow().__dshCodinputShell;
|
|
85
|
+
shell['sidebar.tabId'] = info?.tab.id ?? 'none';
|
|
86
|
+
shell['sidebar.closeFace'] = closeSelf ? 'yes' : 'none';
|
|
87
|
+
}, [info, closeSelf]);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
return subscribeSidebarTabClose((requested) => {
|
|
90
|
+
if (requested !== sessionId || !closeRef.current) return;
|
|
91
|
+
try {
|
|
92
|
+
closeRef.current();
|
|
93
|
+
} catch (error) {
|
|
94
|
+
diagError('sidebar.closeTab', error);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
98
|
+
}, [sessionId]);
|
|
99
|
+
|
|
100
|
+
// 接管开关是承载的前提:侧边模式下输入 /codinput(或设置里关闭)要能
|
|
101
|
+
// 退回官方输入框——body 撤销承载声明(主输入位随之恢复),标签里只出
|
|
102
|
+
// 提示;重新开启后自动恢复侧栏形态。
|
|
103
|
+
const [enabled, setEnabled] = useState(() => loadPrefs().enabled);
|
|
104
|
+
useEffect(() => subscribePrefs(() => setEnabled(loadPrefs().enabled)), []);
|
|
105
|
+
// 打开标签即启用:body 挂载(首次激活/重载后激活/关掉重开)时若接管
|
|
106
|
+
// 未开,直接开启——用户点开这个标签就是要用它。body 在 keepMounted 下
|
|
107
|
+
// 常驻,/codinput 退出后不会重挂,不会把退出顶回去。
|
|
108
|
+
const bootRef = useRef(false);
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (bootRef.current) return;
|
|
111
|
+
bootRef.current = true;
|
|
112
|
+
if (!loadPrefs().enabled) savePrefs({ enabled: true });
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
// 承载声明挂在 body 生命周期上(挂载即承载,与可见性无关),但以
|
|
116
|
+
// enabled 为前提;可见性与侧栏展开态随后续渲染实时上报(小球据此
|
|
117
|
+
// 判断「已收起」)。
|
|
68
118
|
const carryRef = useRef<SidebarCarry | null>(null);
|
|
69
119
|
const [token, setToken] = useState(0);
|
|
70
120
|
useEffect(() => {
|
|
121
|
+
if (!enabled) return;
|
|
71
122
|
const carry = claimSidebarInput(sessionId, { visible, expanded });
|
|
72
123
|
carryRef.current = carry;
|
|
73
124
|
setToken(carry.token);
|
|
@@ -76,7 +127,7 @@ export function SidebarCodinputBody(props: SessionStandard & { useTabInfo?: () =
|
|
|
76
127
|
carry.release();
|
|
77
128
|
};
|
|
78
129
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
79
|
-
}, [sessionId]);
|
|
130
|
+
}, [sessionId, enabled]);
|
|
80
131
|
useEffect(() => {
|
|
81
132
|
carryRef.current?.set({ visible, expanded });
|
|
82
133
|
}, [visible, expanded]);
|
|
@@ -84,6 +135,13 @@ export function SidebarCodinputBody(props: SessionStandard & { useTabInfo?: () =
|
|
|
84
135
|
// 同一会话被复制出第二个 Codinput 标签(分栏)时,只有最早声明的 body
|
|
85
136
|
// 渲染编辑面——输入入口唯一,另一个 body 出提示而不是第二个编辑器。
|
|
86
137
|
const owner = useSidebarCarryOwner(sessionId, token);
|
|
138
|
+
if (!enabled) {
|
|
139
|
+
return (
|
|
140
|
+
<div className="dci-side-notice" role="note">
|
|
141
|
+
{t('side.disabled')}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
87
145
|
if (!owner) {
|
|
88
146
|
return (
|
|
89
147
|
<div className="dci-side-notice" role="note">
|
|
@@ -107,6 +165,7 @@ export function SidebarCodinputBody(props: SessionStandard & { useTabInfo?: () =
|
|
|
107
165
|
state={state}
|
|
108
166
|
actions={inputActions}
|
|
109
167
|
useProjection={useProjection}
|
|
168
|
+
running={running}
|
|
110
169
|
side={true}
|
|
111
170
|
/>
|
|
112
171
|
</div>
|
package/src/client/sideinput.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface SidebarCarryState {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export interface SidebarCarry {
|
|
24
|
-
/** 本次承载的令牌(同会话多 body
|
|
24
|
+
/** 本次承载的令牌(同会话多 body 时,可见者优先、同可见最早者渲染编辑面)。 */
|
|
25
25
|
readonly token: number;
|
|
26
26
|
/** 可见性/展开态变化时更新(同一 body 生命周期内)。 */
|
|
27
27
|
set(state: SidebarCarryState): void;
|
|
@@ -53,11 +53,12 @@ function recompute(): void {
|
|
|
53
53
|
for (const [token, claim] of claims) {
|
|
54
54
|
const prev = next.get(claim.sessionId);
|
|
55
55
|
// 同一会话多 body(分栏里复制出第二个 Codinput 标签)时取「最可见」的一份,
|
|
56
|
-
//
|
|
56
|
+
// 编辑面归属可见者(owner 随 visible 走——否则编辑面会留在看不见的标签里,
|
|
57
|
+
// 正在看的标签只剩提示),同可见时归最早声明者,杜绝双编辑面。
|
|
57
58
|
if (!prev) {
|
|
58
59
|
next.set(claim.sessionId, { visible: claim.visible, expanded: claim.expanded, owner: token });
|
|
59
60
|
} else if (!prev.visible && claim.visible) {
|
|
60
|
-
next.set(claim.sessionId, {
|
|
61
|
+
next.set(claim.sessionId, { visible: true, expanded: claim.expanded, owner: token });
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
if (sameSnapshot(next)) return;
|
|
@@ -93,6 +94,11 @@ export function isSidebarInputCarried(sessionId: string | undefined): boolean {
|
|
|
93
94
|
return sessionId !== undefined && snapshot.has(sessionId);
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
/** 当前被侧栏承载的会话 id 列表(/codinput 退出时广播关闭标签用)。 */
|
|
98
|
+
export function carriedSessionIds(): string[] {
|
|
99
|
+
return [...snapshot.keys()];
|
|
100
|
+
}
|
|
101
|
+
|
|
96
102
|
/** 任一会话被侧栏承载(主遮蔽条目需保持挂载以执行隐藏)。 */
|
|
97
103
|
export function isAnySidebarInputCarried(): boolean {
|
|
98
104
|
return snapshot.size > 0;
|
|
@@ -105,14 +111,30 @@ export function sidebarCarryState(sessionId: string | undefined): SidebarCarrySt
|
|
|
105
111
|
|
|
106
112
|
/**
|
|
107
113
|
* 编辑面归属:同会话被多个 body 承载(分栏里复制出第二个 Codinput 标签)时,
|
|
108
|
-
*
|
|
109
|
-
*
|
|
114
|
+
* 可见者优先,同可见归最早声明者。`token === 0`(尚未声明)一律返回 true——
|
|
115
|
+
* 避免首帧把还没拿到令牌的自己判成非归属者。
|
|
110
116
|
*/
|
|
111
117
|
export function isSidebarCarryOwner(sessionId: string | undefined, token: number): boolean {
|
|
112
118
|
if (token === 0 || sessionId === undefined) return true;
|
|
113
119
|
return snapshot.get(sessionId)?.owner === token;
|
|
114
120
|
}
|
|
115
121
|
|
|
122
|
+
|
|
123
|
+
/** 标签关闭请求总线:/codinput 从侧栏退出时,命令侧请求 body 关掉自己的标签。 */
|
|
124
|
+
const tabCloseListeners = new Set<(sessionId: string) => void>();
|
|
125
|
+
|
|
126
|
+
/** 请求关闭某会话的侧栏 Codinput 标签(无 body 在听时无害)。 */
|
|
127
|
+
export function requestSidebarTabClose(sessionId: string): void {
|
|
128
|
+
for (const fn of tabCloseListeners) fn(sessionId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function subscribeSidebarTabClose(fn: (sessionId: string) => void): () => void {
|
|
132
|
+
tabCloseListeners.add(fn);
|
|
133
|
+
return () => {
|
|
134
|
+
tabCloseListeners.delete(fn);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
116
138
|
/** 归属订阅(body 用):令牌变化、其他 body 挂载/卸载都会重渲染。 */
|
|
117
139
|
export function useSidebarCarryOwner(sessionId: string | undefined, token: number): boolean {
|
|
118
140
|
const [owner, setOwner] = useState(() => isSidebarCarryOwner(sessionId, token));
|
|
@@ -150,12 +172,16 @@ export function useSidebarInputCarried(sessionId: string | undefined): boolean {
|
|
|
150
172
|
|
|
151
173
|
/**
|
|
152
174
|
* 侧栏承载态订阅(小球用):`carried` = 本会话输入在侧栏里,
|
|
153
|
-
* `collapsed` =
|
|
175
|
+
* `collapsed` = 输入面不可见(承载但侧栏已收起 → 输入入口需要小球唤出)。
|
|
176
|
+
* 浮动面板 visible=true——输入就在眼前,即使侧栏恰好收起也不出球。
|
|
154
177
|
*/
|
|
155
178
|
export function useSidebarCarry(sessionId: string | undefined): { carried: boolean; collapsed: boolean } {
|
|
156
179
|
const read = (): { carried: boolean; collapsed: boolean } => {
|
|
157
180
|
const state = sidebarCarryState(sessionId);
|
|
158
|
-
return {
|
|
181
|
+
return {
|
|
182
|
+
carried: state !== undefined,
|
|
183
|
+
collapsed: state !== undefined && state.expanded === false && state.visible === false,
|
|
184
|
+
};
|
|
159
185
|
};
|
|
160
186
|
const [value, setValue] = useState(read);
|
|
161
187
|
useEffect(() => {
|
package/src/client/stats.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* 数据行数据源(卡片下方):逐值复刻官方 StatsPills 的取数与计算——
|
|
3
|
+
* `sessionStats` 投影优先(turns/steps/llm/tool/ttft/decode 全量),缺席时
|
|
4
|
+
* 从 chat legacy 节点按官方 deriveStats 派生;tok/s = decodeTokens ÷
|
|
5
|
+
* decodeMs(会话级持久值,非流式采样),缓存命中/用量分桶同官方公式。
|
|
6
6
|
* 注意:useProjection 是真实 hook,键探测必须是固定次数的顶层调用。
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -19,18 +19,24 @@ const PROJECTION_KEYS = [
|
|
|
19
19
|
export interface SessionStats {
|
|
20
20
|
turns?: number;
|
|
21
21
|
steps?: number;
|
|
22
|
-
/**
|
|
22
|
+
/** 输出速度(tok/s)= decodeTokens ÷ (decodeMs/1000);decodeMs>0 才有。 */
|
|
23
23
|
tokensPerSecond?: number;
|
|
24
24
|
totalTokens?: number;
|
|
25
|
-
|
|
25
|
+
/** 官方 formatCacheHitPercent 文本("88"/"99.9"/"100")。 */
|
|
26
|
+
cacheHitText?: string;
|
|
26
27
|
/** 上下文占用百分比(projectedTokens ?? pressureTokens,除以 contextWindow)。 */
|
|
27
28
|
contextPct?: number;
|
|
28
|
-
/**
|
|
29
|
-
|
|
29
|
+
/** 会话统计面板:模型用时 / 工具调用用时 / TTFT 均值(官方格式化)。 */
|
|
30
|
+
llmMs?: number;
|
|
31
|
+
toolMs?: number;
|
|
30
32
|
ttftMs?: number;
|
|
31
|
-
|
|
33
|
+
ttftSteps?: number;
|
|
34
|
+
decodeMs?: number;
|
|
35
|
+
decodeTokens?: number;
|
|
36
|
+
/** Token 用量面板:原始分桶。 */
|
|
32
37
|
uncachedInputTokens?: number;
|
|
33
38
|
cacheReadTokens?: number;
|
|
39
|
+
cacheWriteTokens?: number;
|
|
34
40
|
outputTokens?: number;
|
|
35
41
|
}
|
|
36
42
|
|
|
@@ -47,6 +53,18 @@ interface ContextPressure {
|
|
|
47
53
|
projectedTokens?: number;
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
/** 官方 sessionStats 投影/deriveStats 的输出形状。 */
|
|
57
|
+
interface ChatStats {
|
|
58
|
+
turns?: number;
|
|
59
|
+
steps?: number;
|
|
60
|
+
llmMs?: number;
|
|
61
|
+
toolMs?: number;
|
|
62
|
+
ttftMs?: number;
|
|
63
|
+
ttftSteps?: number;
|
|
64
|
+
decodeMs?: number;
|
|
65
|
+
decodeTokens?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
function num(value: unknown): number | undefined {
|
|
51
69
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
52
70
|
}
|
|
@@ -74,47 +92,105 @@ function readContextPressure(raw: unknown): ContextPressure | undefined {
|
|
|
74
92
|
return cp.contextWindow !== undefined && cp.pressureTokens !== undefined ? cp : undefined;
|
|
75
93
|
}
|
|
76
94
|
|
|
77
|
-
function
|
|
78
|
-
if (!raw || typeof raw !== 'object') return
|
|
95
|
+
function readSessionStats(raw: unknown): ChatStats | undefined {
|
|
96
|
+
if (!raw || typeof raw !== 'object') return undefined;
|
|
79
97
|
const r = raw as Record<string, unknown>;
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
98
|
+
const stats: ChatStats = {
|
|
99
|
+
turns: num(r['turns']),
|
|
100
|
+
steps: num(r['steps']),
|
|
101
|
+
llmMs: num(r['llmMs']),
|
|
102
|
+
toolMs: num(r['toolMs']),
|
|
103
|
+
ttftMs: num(r['ttftMs']),
|
|
104
|
+
ttftSteps: num(r['ttftSteps']),
|
|
105
|
+
decodeMs: num(r['decodeMs']),
|
|
106
|
+
decodeTokens: num(r['decodeTokens']),
|
|
107
|
+
};
|
|
108
|
+
return Object.values(stats).some((v) => v !== undefined) ? stats : undefined;
|
|
83
109
|
}
|
|
84
110
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
let
|
|
96
|
-
let
|
|
97
|
-
let
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
/**
|
|
112
|
+
* 官方 deriveStats 逐行复刻:assistant 节点计轮/步并带 timing(stepStartTime
|
|
113
|
+
* →firstTokenTime→completedTime)与 usage.outputTokens;tool-result 以
|
|
114
|
+
* callTime 起算工具用时。
|
|
115
|
+
*/
|
|
116
|
+
export function deriveChatStats(nodes: unknown): ChatStats | undefined {
|
|
117
|
+
if (!Array.isArray(nodes)) return undefined;
|
|
118
|
+
const turns = new Set<unknown>();
|
|
119
|
+
let steps = 0;
|
|
120
|
+
let llmMs = 0;
|
|
121
|
+
let toolMs = 0;
|
|
122
|
+
let ttftMs = 0;
|
|
123
|
+
let ttftSteps = 0;
|
|
124
|
+
let decodeMs = 0;
|
|
125
|
+
let decodeTokens = 0;
|
|
126
|
+
for (const raw of nodes) {
|
|
127
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
128
|
+
const node = raw as Record<string, unknown>;
|
|
129
|
+
if (node['kind'] === 'tool-result') {
|
|
130
|
+
const callTime = num(node['callTime']);
|
|
131
|
+
const time = num(node['time']);
|
|
132
|
+
if (callTime !== undefined && time !== undefined) toolMs += Math.max(0, time - callTime);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (node['kind'] !== 'assistant') continue;
|
|
136
|
+
turns.add(node['turn']);
|
|
137
|
+
steps += 1;
|
|
138
|
+
const timing = node['timing'] as Record<string, unknown> | undefined;
|
|
139
|
+
const stepStartTime = num(timing?.['stepStartTime']);
|
|
140
|
+
const firstTokenTime = num(timing?.['firstTokenTime']);
|
|
141
|
+
const completedTime = num(timing?.['completedTime']);
|
|
142
|
+
if (stepStartTime !== undefined && completedTime !== undefined) {
|
|
143
|
+
llmMs += Math.max(0, completedTime - stepStartTime);
|
|
144
|
+
}
|
|
145
|
+
if (stepStartTime !== undefined && firstTokenTime !== undefined) {
|
|
146
|
+
ttftMs += Math.max(0, firstTokenTime - stepStartTime);
|
|
147
|
+
ttftSteps += 1;
|
|
148
|
+
}
|
|
149
|
+
if (firstTokenTime !== undefined && completedTime !== undefined) {
|
|
150
|
+
decodeMs += Math.max(0, completedTime - firstTokenTime);
|
|
151
|
+
}
|
|
152
|
+
const usage = node['usage'] as Record<string, unknown> | undefined;
|
|
153
|
+
const out = num(usage?.['outputTokens']);
|
|
154
|
+
if (out !== undefined && out >= 0) decodeTokens += out;
|
|
102
155
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
156
|
+
if (nodes.length === 0) return undefined;
|
|
157
|
+
return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 官方 formatTokensPerSecond:≥10 取整,<10 保留 1 位小数。 */
|
|
161
|
+
export function formatTokensPerSecond(tps: number): string {
|
|
162
|
+
const clamped = Math.max(0, tps);
|
|
163
|
+
return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 官方 formatCacheHitPercent 语义:整除 100 → "100";整数舍入 <100 → 整数;
|
|
168
|
+
* 否则递增小数位直到舍入仍 <100(99.9 / 99.99 …)。
|
|
169
|
+
*/
|
|
170
|
+
export function formatCacheHitPercent(cacheReadTokens: number, promptTokens: number): string | null {
|
|
171
|
+
if (promptTokens <= 0) return null;
|
|
172
|
+
if (cacheReadTokens >= promptTokens) return '100';
|
|
173
|
+
for (let places = 0; places < 20; places++) {
|
|
174
|
+
const factor = 10 ** places;
|
|
175
|
+
const rounded = Math.round((cacheReadTokens / promptTokens) * 100 * factor) / factor;
|
|
176
|
+
if (rounded < 100) return places === 0 ? String(rounded) : rounded.toFixed(places);
|
|
109
177
|
}
|
|
110
|
-
|
|
111
|
-
|
|
178
|
+
return '99.99';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 官方 formatDuration:<60s 一位小数秒,否则 分+秒。 */
|
|
182
|
+
export function formatDuration(ms: number): string {
|
|
183
|
+
const s = ms / 1000;
|
|
184
|
+
if (s < 60) return `${Math.round(s * 10) / 10}s`;
|
|
185
|
+
const whole = Math.round(s);
|
|
186
|
+
return `${Math.floor(whole / 60)}分${whole % 60}秒`;
|
|
112
187
|
}
|
|
113
188
|
|
|
114
189
|
/** 每个键一个固定位置的 hook 调用——跨渲染的调用次数与顺序恒定。 */
|
|
115
190
|
export function useSessionStats(
|
|
116
191
|
useProjection: UseProjection | undefined,
|
|
117
192
|
sessionId: string | undefined,
|
|
193
|
+
chatNodes: unknown,
|
|
118
194
|
): SessionStats | null {
|
|
119
195
|
const k0 = useProjection ? useProjection(PROJECTION_KEYS[0]) : undefined;
|
|
120
196
|
const k1 = useProjection ? useProjection(PROJECTION_KEYS[1]) : undefined;
|
|
@@ -124,14 +200,25 @@ export function useSessionStats(
|
|
|
124
200
|
|
|
125
201
|
const usage = readTokenUsage(k0);
|
|
126
202
|
const pressure = readContextPressure(k1);
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
const alt2 = readTurnsSteps(k4) ?? {};
|
|
203
|
+
// 官方:sessionStats 投影优先,缺席从 chat legacy 节点派生。
|
|
204
|
+
const chat = readSessionStats(k4) ?? deriveChatStats(chatNodes) ?? readTurnsSteps(k2, k3);
|
|
130
205
|
|
|
131
206
|
const stats: SessionStats = {};
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
207
|
+
if (chat) {
|
|
208
|
+
if (chat.turns !== undefined || chat.steps !== undefined) {
|
|
209
|
+
stats.turns = chat.turns;
|
|
210
|
+
stats.steps = chat.steps;
|
|
211
|
+
}
|
|
212
|
+
if (chat.llmMs !== undefined) stats.llmMs = chat.llmMs;
|
|
213
|
+
if (chat.toolMs !== undefined) stats.toolMs = chat.toolMs;
|
|
214
|
+
if (chat.ttftMs !== undefined) stats.ttftMs = chat.ttftMs;
|
|
215
|
+
if (chat.ttftSteps !== undefined) stats.ttftSteps = chat.ttftSteps;
|
|
216
|
+
if (chat.decodeMs !== undefined) stats.decodeMs = chat.decodeMs;
|
|
217
|
+
if (chat.decodeTokens !== undefined) stats.decodeTokens = chat.decodeTokens;
|
|
218
|
+
if ((chat.decodeMs ?? 0) > 0) {
|
|
219
|
+
stats.tokensPerSecond = (chat.decodeTokens ?? 0) / (chat.decodeMs! / 1000);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
135
222
|
if (usage) {
|
|
136
223
|
const total =
|
|
137
224
|
(usage.cacheReadTokens ?? 0) +
|
|
@@ -142,14 +229,15 @@ export function useSessionStats(
|
|
|
142
229
|
stats.totalTokens = total;
|
|
143
230
|
stats.uncachedInputTokens = usage.uncachedInputTokens;
|
|
144
231
|
stats.cacheReadTokens = usage.cacheReadTokens;
|
|
232
|
+
stats.cacheWriteTokens = usage.cacheWriteTokens;
|
|
145
233
|
stats.outputTokens = usage.outputTokens;
|
|
146
234
|
}
|
|
235
|
+
// 官方:分母 = 三段输入桶(缓存读+写+未缓存输入)。
|
|
147
236
|
const inputTotal = (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) + (usage.uncachedInputTokens ?? 0);
|
|
148
|
-
if (inputTotal > 0)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (sample.ttftMs !== undefined && sample.ttftMs > 0) stats.ttftMs = sample.ttftMs;
|
|
237
|
+
if (inputTotal > 0) {
|
|
238
|
+
const text = formatCacheHitPercent(usage.cacheReadTokens ?? 0, inputTotal);
|
|
239
|
+
if (text !== null) stats.cacheHitText = text;
|
|
240
|
+
}
|
|
153
241
|
}
|
|
154
242
|
const window_ = pressure?.contextWindow;
|
|
155
243
|
// 官方 contextOccupancy:优先 projectedTokens。
|
|
@@ -157,11 +245,26 @@ export function useSessionStats(
|
|
|
157
245
|
if (window_ !== undefined && window_ > 0 && used !== undefined) {
|
|
158
246
|
stats.contextPct = (used / window_) * 100;
|
|
159
247
|
}
|
|
248
|
+
void sessionId;
|
|
160
249
|
return Object.keys(stats).length > 0 ? stats : null;
|
|
161
250
|
}
|
|
162
251
|
|
|
252
|
+
function readTurnsSteps(k2: unknown, k3: unknown): ChatStats | undefined {
|
|
253
|
+
const pick = (raw: unknown): ChatStats | undefined => {
|
|
254
|
+
if (!raw || typeof raw !== 'object') return undefined;
|
|
255
|
+
const r = raw as Record<string, unknown>;
|
|
256
|
+
const turns = num(r['turns']) ?? num(r['turnCount']);
|
|
257
|
+
const steps = num(r['steps']) ?? num(r['stepCount']);
|
|
258
|
+
return turns !== undefined || steps !== undefined ? { turns, steps } : undefined;
|
|
259
|
+
};
|
|
260
|
+
return pick(k2) ?? pick(k3);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 官方 formatTokens:<1e3 原值,否则 K/M(≥100 取整、<100 一位小数)。 */
|
|
163
264
|
export function formatTokens(tokens: number): string {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return `${tokens} tok`;
|
|
265
|
+
const scaled = (candidate: number): string =>
|
|
266
|
+
candidate >= 100 ? String(Math.round(candidate)) : String(Math.round(candidate * 10) / 10);
|
|
267
|
+
if (tokens < 1e3) return `${tokens} tok`;
|
|
268
|
+
if (tokens < 1e6) return `${scaled(tokens / 1e3)}K tok`;
|
|
269
|
+
return `${scaled(tokens / 1e6)}M tok`;
|
|
167
270
|
}
|