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.
@@ -27,18 +27,24 @@ function svg(paths: JSX.Element, size: number | undefined, className: string | u
27
27
  );
28
28
  }
29
29
 
30
- /** 品牌:lucide file-code-corner。 */
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(附件按钮;官方为 fill 路径自带描边轮廓,不得再叠 stroke)。 */
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:文档+代码括号,fill
276
- * currentColor,与 primitives Icon*Outline16 同构——接受 size,命令
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 width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} aria-hidden="true">
282
- <path
283
- fillRule="evenodd"
284
- clipRule="evenodd"
285
- d="M12.25 2.83422C11.7896 2.75598 11.162 2.75005 10.0298 2.75005C8.11311 2.75005 6.75075 2.75163 5.71785 2.88987C4.70596 3.0253 4.12453 3.27933 3.7019 3.70195C3.27869 4.12516 3.02502 4.70481 2.88976 5.7109C2.75159 6.73856 2.75 8.09323 2.75 10.0001V14.0001C2.75 15.9069 2.75159 17.2615 2.88976 18.2892C3.02502 19.2953 3.27869 19.8749 3.7019 20.2981C4.12511 20.7214 4.70476 20.975 5.71085 21.1103C6.73851 21.2485 8.09318 21.2501 10 21.2501H14C15.9068 21.2501 17.2615 21.2485 18.2892 21.1103C19.2952 20.975 19.8749 20.7214 20.2981 20.2981C20.7213 19.8749 20.975 19.2953 21.1102 18.2892C21.2484 17.2615 21.25 15.9069 21.25 14.0001V13.5629C21.25 12.0269 21.2392 11.2988 21.0762 10.7501H17.9463C16.8135 10.7501 15.8877 10.7501 15.1569 10.6518C14.3929 10.5491 13.7306 10.3268 13.2019 9.79815C12.6732 9.26945 12.4509 8.60712 12.3482 7.84317C12.25 7.1123 12.25 6.18657 12.25 5.05374V2.83422ZM13.75 3.6095V5.00005C13.75 6.19976 13.7516 7.0241 13.8348 7.64329C13.9152 8.24091 14.059 8.53395 14.2626 8.73749C14.4661 8.94103 14.7591 9.08486 15.3568 9.16521C15.976 9.24846 16.8003 9.25005 18 9.25005H20.0195C19.723 8.9625 19.3432 8.61797 18.85 8.17407L14.8912 4.61117C14.4058 4.17433 14.0446 3.85187 13.75 3.6095ZM10.1755 1.25002C11.5601 1.24965 12.4546 1.24942 13.2779 1.56535C14.1012 1.88129 14.7632 2.47735 15.7873 3.39955C15.8226 3.43139 15.8584 3.46361 15.8947 3.49623L19.8534 7.05912C19.8956 7.09705 19.9372 7.1345 19.9783 7.17149C21.162 8.23614 21.9274 8.92458 22.3391 9.84902C22.7508 10.7734 22.7505 11.8029 22.75 13.3949C22.75 13.4502 22.75 13.5062 22.75 13.5629V14.0565C22.75 15.8942 22.75 17.3499 22.5969 18.4891C22.4392 19.6615 22.1071 20.6104 21.3588 21.3588C20.6104 22.1072 19.6614 22.4393 18.489 22.5969C17.3498 22.7501 15.8942 22.7501 14.0564 22.7501H9.94359C8.10583 22.7501 6.65019 22.7501 5.51098 22.5969C4.33856 22.4393 3.38961 22.1072 2.64124 21.3588C1.89288 20.6104 1.56076 19.6615 1.40314 18.4891C1.24997 17.3499 1.24998 15.8942 1.25 14.0565V9.94363C1.24998 8.10587 1.24997 6.65024 1.40314 5.51103C1.56076 4.33861 1.89288 3.38966 2.64124 2.64129C3.39019 1.89235 4.34232 1.56059 5.51887 1.40313C6.66283 1.25002 8.1257 1.25003 9.97352 1.25005L10.0298 1.25005C10.0789 1.25005 10.1275 1.25004 10.1755 1.25002Z"
286
- fill="currentColor"
287
- />
288
- <path
289
- fillRule="evenodd"
290
- clipRule="evenodd"
291
- d="M10.2633 13.2978C10.6512 13.4432 10.8477 13.8756 10.7022 14.2634L9.20225 18.2634C9.05681 18.6512 8.6245 18.8477 8.23666 18.7023C7.84882 18.5569 7.65231 18.1245 7.79775 17.7367L9.29775 13.7367C9.44319 13.3489 9.8755 13.1524 10.2633 13.2978ZM7.53033 13.4697C7.82322 13.7626 7.82322 14.2375 7.53033 14.5304L7.06066 15L7.53033 15.4697C7.82322 15.7626 7.82322 16.2375 7.53033 16.5304C7.23744 16.8233 6.76256 16.8233 6.46967 16.5304L5.46967 15.5304C5.17678 15.2375 5.17678 14.7626 5.46967 14.4697L6.46967 13.4697C6.76256 13.1768 7.23744 13.1768 7.53033 13.4697ZM10.9697 15.4697C11.2626 15.1768 11.7374 15.1768 12.0303 15.4697L13.0303 16.4697C13.3232 16.7626 13.3232 17.2375 13.0303 17.5304L12.0303 18.5304C11.7374 18.8233 11.2626 18.8233 10.9697 18.5304C10.6768 18.2375 10.6768 17.7626 10.9697 17.4697L11.4393 17L10.9697 16.5304C10.6768 16.2375 10.6768 15.7626 10.9697 15.4697Z"
292
- fill="currentColor"
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
  }
@@ -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
  });
@@ -6,13 +6,17 @@
6
6
  *
7
7
  * 唯一输入组件约定:body **挂载**期间经 sideinput 总线声明承载本会话
8
8
  * 输入(形态 = side),主输入遮蔽条目随之隐藏——收起侧栏不让位(可见性
9
- * 只用于判断「需不需要右上角小球唤出」)。标签关闭(body 卸载)后撤销
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
- // 承载声明挂在 body 生命周期上(挂载即承载,与可见性无关);可见性与
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>
@@ -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
- // 编辑面归属最早声明者(owner),其余 body 只出提示,杜绝双编辑面。
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, { ...prev, visible: true, expanded: claim.expanded });
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
- * 只有最早声明者渲染编辑面。`token === 0`(尚未声明)一律返回 true——避免
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 { carried: state !== undefined, collapsed: state !== undefined && state.expanded === false };
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(() => {
@@ -1,8 +1,8 @@
1
1
  /**
2
- * 数据行数据源(卡片下方):会话统计投影探测 + 归一化。
3
- * 官方投影键(实测确认):tokenUsage(cacheRead/cacheWrite/output/
4
- * uncachedInput)、contextPressure(contextWindow/pressureTokens/
5
- * projectedTokens);轮/步按别名键探测。tok/s 为输出令牌的实时采样速率。
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
- /** 实时输出速率(tok/s),运行期间采样更新,空闲时保留最后一次。 */
22
+ /** 输出速度(tok/s)= decodeTokens ÷ (decodeMs/1000);decodeMs>0 才有。 */
23
23
  tokensPerSecond?: number;
24
24
  totalTokens?: number;
25
- cacheHitPct?: number;
25
+ /** 官方 formatCacheHitPercent 文本("88"/"99.9"/"100")。 */
26
+ cacheHitText?: string;
26
27
  /** 上下文占用百分比(projectedTokens ?? pressureTokens,除以 contextWindow)。 */
27
28
  contextPct?: number;
28
- /** 会话统计面板:模型用时(流式活跃累计毫秒)与首 token 延迟。 */
29
- modelMs?: number;
29
+ /** 会话统计面板:模型用时 / 工具调用用时 / TTFT 均值(官方格式化)。 */
30
+ llmMs?: number;
31
+ toolMs?: number;
30
32
  ttftMs?: number;
31
- /** Token 用量面板:原始分段。 */
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 readTurnsSteps(raw: unknown): { turns?: number; steps?: number } {
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 turns = num(r['turns']) ?? num(r['turnCount']);
81
- const steps = num(r['steps']) ?? num(r['stepCount']);
82
- return { turns, steps };
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
- /** 实时速率与用时采样:模块级(按 sessionId 记录上次输出令牌数与时刻)。 */
86
- const rateSamples = new Map<string, { tokens: number; time: number; rate?: number; modelMs: number }>();
87
-
88
- export function sampleTokensPerSecond(
89
- sessionId: string | undefined,
90
- outputTokens: number | undefined,
91
- ): { rate?: number; modelMs?: number; ttftMs?: number } {
92
- if (!sessionId || outputTokens === undefined) return {};
93
- const now = Date.now();
94
- const last = rateSamples.get(sessionId);
95
- let rate: number | undefined = last?.rate;
96
- let modelMs = last?.modelMs ?? 0;
97
- let ttftMs: number | undefined;
98
- if (!last) {
99
- // 首个样本:无从计算速率;当前值即初始输出。
100
- rateSamples.set(sessionId, { tokens: outputTokens, time: now, modelMs: 0 });
101
- return {};
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
- const dt = now - last.time;
104
- if (outputTokens > last.tokens && dt > 0) {
105
- rate = ((outputTokens - last.tokens) / dt) * 1000;
106
- modelMs += dt;
107
- // 空闲较久后恢复增长:这段等待近似 TTFT。
108
- if (dt > 600) ttftMs = dt;
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
- rateSamples.set(sessionId, { tokens: outputTokens, time: now, rate, modelMs });
111
- return { rate, modelMs, ttftMs };
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
- const { turns, steps } = readTurnsSteps(k2) ?? {};
128
- const alt = readTurnsSteps(k3) ?? {};
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 (turns !== undefined || steps !== undefined) Object.assign(stats, { turns, steps });
133
- else if (alt.turns !== undefined || alt.steps !== undefined) Object.assign(stats, alt);
134
- else if (alt2.turns !== undefined || alt2.steps !== undefined) Object.assign(stats, alt2);
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) stats.cacheHitPct = ((usage.cacheReadTokens ?? 0) / inputTotal) * 100;
149
- const sample = sampleTokensPerSecond(sessionId, usage.outputTokens);
150
- if (sample.rate !== undefined && sample.rate > 0) stats.tokensPerSecond = sample.rate;
151
- if (sample.modelMs !== undefined && sample.modelMs > 0) stats.modelMs = sample.modelMs;
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
- if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M tok`;
165
- if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens % 1_000 === 0 ? 0 : 1)}K tok`;
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
  }