dsh-codinput 0.3.7 → 0.3.9
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 +59 -13
- package/NOTICE +18 -3
- package/README.en.md +11 -7
- package/README.md +11 -7
- package/TESTING.md +26 -5
- package/lib/client.js +66 -56
- package/package.json +2 -2
- package/src/client/components/MenuSurface.tsx +64 -12
- package/src/client/components/ModelMenu.tsx +5 -17
- package/src/client/components/PermissionMenu.tsx +8 -3
- package/src/client/components/StatsRow.tsx +199 -78
- package/src/client/components/UserMessageMarkdown.tsx +115 -30
- package/src/client/i18n.ts +13 -9
- package/src/client/index.tsx +6 -1
- package/src/client/settings.tsx +8 -3
- package/src/client/styles.ts +40 -24
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-codinput",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "DSH
|
|
3
|
+
"version": "0.3.9",
|
|
4
|
+
"description": "DSH 插件:把聊天输入框变成代码编辑器一样的输入风格(CodeMirror 6 接管 conversation.composer.bar),web 与桌面端通用。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Witherwithwinter",
|
|
7
7
|
"icon": "./assets/icon.png",
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* 更长的子页(模型/推理强度列表)时卡片保持小菜单的 top 向下生长,
|
|
10
10
|
* 表现为「向下弹出」且列表伸出视口外够不着。
|
|
11
11
|
*
|
|
12
|
+
* 锚点按元素实时读取:早期实现把 getBoundingClientRect 快照塞进 state,
|
|
13
|
+
* 重算时读同一份快照,scroll/resize 监听等于空转——布局一变菜单就停在打开
|
|
14
|
+
* 瞬间的位置(表现为「菜单飘到屏幕角落」)。现在每次 place 都读实时 rect,
|
|
15
|
+
* 并在打开期间逐帧比对锚点位置,兜住「不触发 scroll/resize 的重排」。
|
|
16
|
+
*
|
|
12
17
|
* 滚动复刻官方两级结构:卡片 overflow:hidden +
|
|
13
18
|
* max-height: min(360px, 100vh - 96px),列表在 .dci-viewport 内滚,
|
|
14
19
|
* 细滚动条由卡片注入的 --dsh-scrollbar-thumb(l2) 变量着色(官方
|
|
@@ -16,10 +21,17 @@
|
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
23
|
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
|
24
|
+
import { createPortal } from 'react-dom';
|
|
19
25
|
|
|
20
26
|
export interface MenuSurfaceProps {
|
|
21
|
-
/**
|
|
22
|
-
anchor:
|
|
27
|
+
/** 触发器元素:每次重算都读实时 rect,锚点移动后菜单跟随。 */
|
|
28
|
+
anchor: HTMLElement | null;
|
|
29
|
+
/**
|
|
30
|
+
* 打开瞬间的锚点矩形。点击本身可能触发布局变化(输入框失焦、卡片换型),
|
|
31
|
+
* layout effect 时读到的 rect 已不是用户点下去时看到的那个——首次定位优先
|
|
32
|
+
* 用它,保证菜单位置与点击时的按钮重合。
|
|
33
|
+
*/
|
|
34
|
+
initialRect?: { left: number; top: number; right: number; bottom: number } | null;
|
|
23
35
|
onClose(): void;
|
|
24
36
|
children: ReactNode;
|
|
25
37
|
/** 右对齐锚点(trailing 控件用)。 */
|
|
@@ -32,23 +44,32 @@ export interface MenuSurfaceProps {
|
|
|
32
44
|
/** 官方 place() 的视口留距。 */
|
|
33
45
|
const MARGIN = 12;
|
|
34
46
|
|
|
47
|
+
/** 锚点位移阈值(px):小于它视为亚像素抖动,不重排菜单。 */
|
|
48
|
+
const SHIFT_THRESHOLD = 1;
|
|
49
|
+
|
|
35
50
|
export function MenuSurface(props: MenuSurfaceProps): JSX.Element {
|
|
36
|
-
const { anchor, onClose, children, alignEnd, minWidth = 218, side = 'top' } = props;
|
|
51
|
+
const { anchor, initialRect, onClose, children, alignEnd, minWidth = 218, side = 'top' } = props;
|
|
52
|
+
const firstPlaceRef = useRef(true);
|
|
37
53
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
38
54
|
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
|
|
39
55
|
|
|
40
56
|
useLayoutEffect(() => {
|
|
41
57
|
const el = ref.current;
|
|
42
|
-
if (!el) return;
|
|
58
|
+
if (!el || !anchor) return;
|
|
43
59
|
const place = (): void => {
|
|
44
60
|
// 尺寸取 offset*(官方同款):不受自身 top/left 变更反馈影响。
|
|
45
61
|
const w = el.offsetWidth;
|
|
46
62
|
const h = el.offsetHeight;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
|
|
63
|
+
// 锚点尚未布局(或被隐藏)时不落位:宁可保持隐藏,也不写进一组
|
|
64
|
+
// 没有视口约束的坐标(否则菜单会停在视口外,看起来「飘走」)。
|
|
65
|
+
const rect = (firstPlaceRef.current && initialRect) || anchor.getBoundingClientRect();
|
|
66
|
+
if (rect.right - rect.left === 0 || rect.bottom - rect.top === 0 || w === 0 || h === 0) return;
|
|
67
|
+
firstPlaceRef.current = false;
|
|
68
|
+
let x = alignEnd ? rect.right - w : rect.left;
|
|
69
|
+
let y = side === 'top' ? rect.top - 8 - h : rect.bottom + 4;
|
|
70
|
+
x = Math.min(Math.max(x, MARGIN), window.innerWidth - w - MARGIN);
|
|
71
|
+
y = Math.min(Math.max(y, MARGIN), window.innerHeight - h - MARGIN);
|
|
72
|
+
setPos((prev) => (prev && Math.abs(prev.left - x) < 0.5 && Math.abs(prev.top - y) < 0.5 ? prev : { left: x, top: y }));
|
|
52
73
|
};
|
|
53
74
|
place();
|
|
54
75
|
// 内容换页/目录热更/滚动/视口变化都重定位——官方 place 的三路监听。
|
|
@@ -56,7 +77,29 @@ export function MenuSurface(props: MenuSurfaceProps): JSX.Element {
|
|
|
56
77
|
window.addEventListener('resize', place);
|
|
57
78
|
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null;
|
|
58
79
|
observer?.observe(el);
|
|
80
|
+
observer?.observe(anchor);
|
|
81
|
+
|
|
82
|
+
// 布局重排可能既不产生 scroll 也不改窗口尺寸(输入框换型、分屏切换、
|
|
83
|
+
// 会话区从空到有内容),上面的监听全都收不到信号。用低频轮询兜住这类
|
|
84
|
+
// 变化:只有锚点位移超过阈值才重排——布局微抖时不跟随,否则菜单会跟着
|
|
85
|
+
// 一帧帧地跳(表现为「来回跳」)。
|
|
86
|
+
const start = anchor.getBoundingClientRect();
|
|
87
|
+
let lastLeft = start.left;
|
|
88
|
+
let lastTop = start.top;
|
|
89
|
+
let raf = 0;
|
|
90
|
+
const tick = (): void => {
|
|
91
|
+
const r = anchor.getBoundingClientRect();
|
|
92
|
+
if (Math.abs(r.left - lastLeft) > SHIFT_THRESHOLD || Math.abs(r.top - lastTop) > SHIFT_THRESHOLD) {
|
|
93
|
+
lastLeft = r.left;
|
|
94
|
+
lastTop = r.top;
|
|
95
|
+
place();
|
|
96
|
+
}
|
|
97
|
+
raf = requestAnimationFrame(tick);
|
|
98
|
+
};
|
|
99
|
+
raf = requestAnimationFrame(tick);
|
|
100
|
+
|
|
59
101
|
return () => {
|
|
102
|
+
cancelAnimationFrame(raf);
|
|
60
103
|
observer?.disconnect();
|
|
61
104
|
window.removeEventListener('scroll', place, true);
|
|
62
105
|
window.removeEventListener('resize', place);
|
|
@@ -66,15 +109,23 @@ export function MenuSurface(props: MenuSurfaceProps): JSX.Element {
|
|
|
66
109
|
useEffect(() => {
|
|
67
110
|
const onDown = (event: PointerEvent): void => {
|
|
68
111
|
if (ref.current && event.target instanceof Node && ref.current.contains(event.target)) return;
|
|
112
|
+
// 落在触发器上时不要在这里关:pointerdown 先于 click,先关掉会让触发器
|
|
113
|
+
// 自己的 onClick 读到 open=false 又开回来,表现为「点一次收不起来」。
|
|
114
|
+
// 开合交给触发器的 onClick 统一 toggle。
|
|
115
|
+
if (anchor && event.target instanceof Node && anchor.contains(event.target)) return;
|
|
69
116
|
onClose();
|
|
70
117
|
};
|
|
71
118
|
window.addEventListener('pointerdown', onDown, true);
|
|
72
119
|
return () => window.removeEventListener('pointerdown', onDown, true);
|
|
73
|
-
}, [onClose]);
|
|
120
|
+
}, [anchor, onClose]);
|
|
74
121
|
|
|
75
122
|
// 未定位的首帧隐藏但仍参与布局(官方 unplaced card 同款)——place() 在
|
|
76
123
|
// layout effect 里同步测得尺寸后即落位,无闪烁。
|
|
77
|
-
|
|
124
|
+
// 必须 portal 到 body:祖先链上的 transform / filter / backdrop-filter /
|
|
125
|
+
// contain 会把 position:fixed 的包含块从视口换成该祖先,left/top 便按它的
|
|
126
|
+
// 坐标系解释,菜单整体偏移到别处(实测能偏出 400px+,看起来「飘到角落」)。
|
|
127
|
+
// 官方数据行面板同样走 createPortal(document.body)。
|
|
128
|
+
return createPortal(
|
|
78
129
|
<div
|
|
79
130
|
ref={ref}
|
|
80
131
|
className="dci-menu"
|
|
@@ -88,7 +139,8 @@ export function MenuSurface(props: MenuSurfaceProps): JSX.Element {
|
|
|
88
139
|
}}
|
|
89
140
|
>
|
|
90
141
|
<div className="dci-viewport">{children}</div>
|
|
91
|
-
</div
|
|
142
|
+
</div>,
|
|
143
|
+
document.body,
|
|
92
144
|
);
|
|
93
145
|
}
|
|
94
146
|
|
|
@@ -76,7 +76,8 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
76
76
|
const [open, setOpen] = useState(false);
|
|
77
77
|
const [view, setView] = useState<MenuView>('root');
|
|
78
78
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
79
|
-
const [anchor, setAnchor] = useState<
|
|
79
|
+
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
|
80
|
+
const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
|
80
81
|
|
|
81
82
|
// 服务缺席(installServices 未注入到)时禁用;svcEpoch 轮询兜底见 CodinputSurface。
|
|
82
83
|
const directory = getSessionDirectory(sessionId);
|
|
@@ -89,23 +90,13 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
89
90
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
90
91
|
}, [open]);
|
|
91
92
|
|
|
92
|
-
useEffect(() => {
|
|
93
|
-
if (!open) return;
|
|
94
|
-
const onDown = (event: PointerEvent): void => {
|
|
95
|
-
if (refContains(rootMenuRef.current, event.target)) return;
|
|
96
|
-
close();
|
|
97
|
-
};
|
|
98
|
-
window.addEventListener('pointerdown', onDown, true);
|
|
99
|
-
return () => window.removeEventListener('pointerdown', onDown, true);
|
|
100
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
101
|
-
}, [open]);
|
|
102
|
-
|
|
103
93
|
const rootMenuRef = useRef<HTMLDivElement | null>(null);
|
|
104
94
|
|
|
105
95
|
const openMenu = (): void => {
|
|
106
96
|
const el = triggerRef.current;
|
|
107
97
|
if (!el) return;
|
|
108
|
-
setAnchor(el
|
|
98
|
+
setAnchor(el);
|
|
99
|
+
setAnchorRect(el.getBoundingClientRect());
|
|
109
100
|
setView('root');
|
|
110
101
|
setOpen(true);
|
|
111
102
|
};
|
|
@@ -150,9 +141,6 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
150
141
|
}
|
|
151
142
|
};
|
|
152
143
|
|
|
153
|
-
const refContains = (el: Element | null, target: EventTarget | null): boolean =>
|
|
154
|
-
!!el && target instanceof Node && el.contains(target);
|
|
155
|
-
|
|
156
144
|
return (
|
|
157
145
|
<div style={{ position: 'relative', minWidth: 0 }}>
|
|
158
146
|
<button
|
|
@@ -179,7 +167,7 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
179
167
|
|
|
180
168
|
{open && anchor ? (
|
|
181
169
|
<div ref={rootMenuRef}>
|
|
182
|
-
<MenuSurface anchor={anchor} onClose={close} alignEnd={true} minWidth={240}>
|
|
170
|
+
<MenuSurface anchor={anchor} initialRect={anchorRect} onClose={close} alignEnd={true} minWidth={240}>
|
|
183
171
|
{view === 'root' ? (
|
|
184
172
|
<>
|
|
185
173
|
<button type="button" className="dci-cell" onClick={() => setView('models')}>
|
|
@@ -58,7 +58,8 @@ export function PermissionMenu(props: PermissionMenuProps): JSX.Element | null {
|
|
|
58
58
|
const [confirmation, setConfirmation] = useState<string | null>(null);
|
|
59
59
|
const [acknowledged, setAcknowledged] = useState(false);
|
|
60
60
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
61
|
-
const [anchor, setAnchor] = useState<
|
|
61
|
+
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
|
62
|
+
const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
|
62
63
|
|
|
63
64
|
// locked/无值时收起(官方 useEffect 同语义)。
|
|
64
65
|
useEffect(() => {
|
|
@@ -130,7 +131,11 @@ export function PermissionMenu(props: PermissionMenuProps): JSX.Element | null {
|
|
|
130
131
|
disabled={disabled || busy}
|
|
131
132
|
onMouseDown={(e) => e.preventDefault()}
|
|
132
133
|
onClick={() => {
|
|
133
|
-
if (!open)
|
|
134
|
+
if (!open) {
|
|
135
|
+
const el = triggerRef.current ?? null;
|
|
136
|
+
setAnchor(el);
|
|
137
|
+
setAnchorRect(el ? el.getBoundingClientRect() : null);
|
|
138
|
+
}
|
|
134
139
|
setOpen(!open);
|
|
135
140
|
}}
|
|
136
141
|
>
|
|
@@ -146,7 +151,7 @@ export function PermissionMenu(props: PermissionMenuProps): JSX.Element | null {
|
|
|
146
151
|
</button>
|
|
147
152
|
|
|
148
153
|
{open && anchor ? (
|
|
149
|
-
<MenuSurface anchor={anchor} onClose={close}>
|
|
154
|
+
<MenuSurface anchor={anchor} initialRect={anchorRect} onClose={close}>
|
|
150
155
|
{items.map((item) => {
|
|
151
156
|
const active = item.id === currentValue;
|
|
152
157
|
return (
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/** 数据行(卡片下方):官方 composer dock 复刻——两枚胶囊(仪表盘「轮/步 · tok/s」、
|
|
2
2
|
* 数据库「累计 tok · 缓存命中」)+ 上下文小圈(0.1.7-rc.2 起官方把它挪进本行,圈+百分比
|
|
3
|
-
* 药丸),点击弹出官方同构详情面板(会话统计 / Token 用量 /
|
|
4
|
-
*
|
|
3
|
+
* 药丸),点击弹出官方同构详情面板(会话统计 / Token 用量 / 上下文分段)。面板照官方
|
|
4
|
+
* ContextMeter / useStatDialog 机制:portal 到 body + fixed,经 useAnchoredPanel(官方
|
|
5
|
+
* useAnchoredPosition 插件侧移植)按锚点视口矩形定位(side top / align start、gap 8、
|
|
6
|
+
* 视口 margin 12 夹取,滚动(捕获)/resize/面板尺寸变化重算);每枚面板一个
|
|
7
|
+
* useStatDialogSeat(锚点/面板 ref + 定位 + 点外与 Escape 关闭),三枚共用 StatsRow 的
|
|
8
|
+
* 单一展开态互斥(与官方 chat StatsPills 的 openPill 同语义)。 */
|
|
5
9
|
|
|
6
|
-
import { useEffect, useRef, useState } from 'react';
|
|
10
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
11
|
+
import { createPortal } from 'react-dom';
|
|
7
12
|
import { useSessionStats, formatTokens, formatTokensPerSecond, formatCacheHitPercent, formatDuration, type SessionStats } from '../stats';
|
|
8
13
|
import type { UseProjection } from '../host-types';
|
|
9
14
|
import { t } from '../i18n';
|
|
@@ -37,16 +42,114 @@ export function DatabaseIcon({ size = 14 }: { size?: number }): JSX.Element {
|
|
|
37
42
|
);
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
/**
|
|
45
|
+
/** 官方 useAnchoredPosition 的插件侧移植(primitives 不可 import):fixed 面板按锚点
|
|
46
|
+
* 视口矩形定位(side top / align start:面板左缘对齐锚点左缘、底距锚点顶 gap),
|
|
47
|
+
* 两侧按 margin 夹取(顶边用官方 overlayTopMargin:帧顶 clearance+20 与 margin 取大),
|
|
48
|
+
* 滚动(捕获相位)/resize/面板自身尺寸变化重算。返回 null = 尚未量得(面板隐藏)。 */
|
|
49
|
+
function useAnchoredPanel(
|
|
50
|
+
open: boolean,
|
|
51
|
+
anchorRef: React.RefObject<HTMLElement | null>,
|
|
52
|
+
panelRef: React.RefObject<HTMLElement | null>,
|
|
53
|
+
gap: number,
|
|
54
|
+
margin: number,
|
|
55
|
+
): { left: number; top: number } | null {
|
|
56
|
+
const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
if (!open) {
|
|
59
|
+
setPosition(null);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// 官方 overlayTopMargin:帧顶 clearance(data-fullscreen 归零)+20,与 margin 取大。
|
|
63
|
+
const topMargin = (): number => {
|
|
64
|
+
const clearance = Number.parseFloat(
|
|
65
|
+
getComputedStyle(document.documentElement).getPropertyValue('--dsh-frame-top-clearance'),
|
|
66
|
+
);
|
|
67
|
+
if (Number.isNaN(clearance)) return margin;
|
|
68
|
+
return Math.max(margin, (document.documentElement.hasAttribute('data-fullscreen') ? 0 : clearance) + 20);
|
|
69
|
+
};
|
|
70
|
+
// 与官方 place() 同构:rect 存在就落位,宽/高为 0 时只跳过对应轴的夹取。
|
|
71
|
+
const place = (): void => {
|
|
72
|
+
const rect = anchorRef.current?.getBoundingClientRect();
|
|
73
|
+
if (rect === undefined) return;
|
|
74
|
+
const panel = panelRef.current;
|
|
75
|
+
const width = panel?.offsetWidth ?? 0;
|
|
76
|
+
const height = panel?.offsetHeight ?? 0;
|
|
77
|
+
let left = rect.left;
|
|
78
|
+
let top = rect.top - gap - height;
|
|
79
|
+
if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin);
|
|
80
|
+
if (height > 0) top = Math.min(Math.max(top, topMargin()), window.innerHeight - height - margin);
|
|
81
|
+
setPosition((prev) => (prev && prev.left === left && prev.top === top ? prev : { left, top }));
|
|
82
|
+
};
|
|
83
|
+
place();
|
|
84
|
+
window.addEventListener('scroll', place, true);
|
|
85
|
+
window.addEventListener('resize', place);
|
|
86
|
+
const panel = panelRef.current;
|
|
87
|
+
let observer: ResizeObserver | null = null;
|
|
88
|
+
if (typeof ResizeObserver !== 'undefined' && panel !== null) {
|
|
89
|
+
observer = new ResizeObserver(place);
|
|
90
|
+
observer.observe(panel);
|
|
91
|
+
}
|
|
92
|
+
return () => {
|
|
93
|
+
observer?.disconnect();
|
|
94
|
+
window.removeEventListener('scroll', place, true);
|
|
95
|
+
window.removeEventListener('resize', place);
|
|
96
|
+
};
|
|
97
|
+
}, [open, anchorRef, panelRef, gap, margin]);
|
|
98
|
+
return position;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 官方 useStatDialog 的插件侧等价 seat(primitives/chat 的 useStatDialog 不可 import):
|
|
102
|
+
* 每枚面板自持锚点/面板 ref + 定位,并在打开期间挂「点外关闭 + Escape 关闭」(官方语义:
|
|
103
|
+
* 锚点或 portal 面板内的 pointerdown 算「内」;Escape 走 document 冒泡)。父级用受控
|
|
104
|
+
* open 维持三枚面板互斥,与官方 chat StatsPills 的 openPill 同构。 */
|
|
105
|
+
interface StatDialogSeat {
|
|
106
|
+
rootRef: React.MutableRefObject<HTMLSpanElement | null>;
|
|
107
|
+
panelRef: React.MutableRefObject<HTMLDivElement | null>;
|
|
108
|
+
position: { left: number; top: number } | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function useStatDialogSeat(open: boolean, onClose: () => void): StatDialogSeat {
|
|
112
|
+
const rootRef = useRef<HTMLSpanElement | null>(null);
|
|
113
|
+
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
114
|
+
const position = useAnchoredPanel(open, rootRef, panelRef, 8, 12);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!open) return;
|
|
117
|
+
const onDown = (event: PointerEvent): void => {
|
|
118
|
+
if (!(event.target instanceof Node)) return;
|
|
119
|
+
if (rootRef.current?.contains(event.target) === true) return;
|
|
120
|
+
if (panelRef.current?.contains(event.target) === true) return;
|
|
121
|
+
onClose();
|
|
122
|
+
};
|
|
123
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
124
|
+
if (event.key === 'Escape') onClose();
|
|
125
|
+
};
|
|
126
|
+
document.addEventListener('pointerdown', onDown);
|
|
127
|
+
document.addEventListener('keydown', onKeyDown);
|
|
128
|
+
return () => {
|
|
129
|
+
document.removeEventListener('pointerdown', onDown);
|
|
130
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
131
|
+
};
|
|
132
|
+
}, [open, onClose]);
|
|
133
|
+
return { rootRef, panelRef, position };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 面板定位样式(未量得前官方同款:隐藏在原点)。 */
|
|
137
|
+
function panelStyle(position: { left: number; top: number } | null): React.CSSProperties {
|
|
138
|
+
return position ?? { visibility: 'hidden', left: 0, top: 0 };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 官方详情面板(bRhRbq:标题行 + 发丝线 + dt/dd 栅格)。portal 到 body,fixed 定位。 */
|
|
41
142
|
function StatPanel(props: {
|
|
42
143
|
label: string;
|
|
43
144
|
icon: JSX.Element;
|
|
44
145
|
titleValue?: string;
|
|
45
146
|
rows: readonly [string, string][];
|
|
147
|
+
panelRef: React.MutableRefObject<HTMLDivElement | null>;
|
|
148
|
+
position: { left: number; top: number } | null;
|
|
46
149
|
}): JSX.Element {
|
|
47
|
-
const { label, icon, titleValue, rows } = props;
|
|
48
|
-
return (
|
|
49
|
-
<div className="dci-stat-panel" role="dialog" aria-label={label}>
|
|
150
|
+
const { label, icon, titleValue, rows, panelRef, position } = props;
|
|
151
|
+
return createPortal(
|
|
152
|
+
<div className="dci-stat-panel" role="dialog" aria-label={label} ref={panelRef} style={panelStyle(position)}>
|
|
50
153
|
<div className="dci-stat-panelTitle">
|
|
51
154
|
<span className="dci-stat-panelLabel">
|
|
52
155
|
{icon}
|
|
@@ -63,7 +166,8 @@ function StatPanel(props: {
|
|
|
63
166
|
</div>
|
|
64
167
|
))}
|
|
65
168
|
</dl>
|
|
66
|
-
</div
|
|
169
|
+
</div>,
|
|
170
|
+
document.body,
|
|
67
171
|
);
|
|
68
172
|
}
|
|
69
173
|
|
|
@@ -76,8 +180,9 @@ function exactCount(value: number | undefined): string {
|
|
|
76
180
|
}
|
|
77
181
|
|
|
78
182
|
/** 官方 TimePill:轮/步 + · tok/s(decodeMs>0 才出),面板 = 模型/工具用时、TTFT 均值、TPS。 */
|
|
79
|
-
function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }): JSX.Element {
|
|
80
|
-
const { stats, open, onToggle } = props;
|
|
183
|
+
function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void; onClose(): void }): JSX.Element {
|
|
184
|
+
const { stats, open, onToggle, onClose } = props;
|
|
185
|
+
const { rootRef, panelRef, position } = useStatDialogSeat(open, onClose);
|
|
81
186
|
const roundsPart = t('stats.turns', { turns: stats.turns ?? 0, steps: stats.steps ?? 0 });
|
|
82
187
|
const tps =
|
|
83
188
|
(stats.decodeMs ?? 0) > 0 ? formatTokensPerSecond((stats.decodeTokens ?? 0) / ((stats.decodeMs ?? 1) / 1000)) : null;
|
|
@@ -97,7 +202,7 @@ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }):
|
|
|
97
202
|
// 官方:无任何计时数据时胶囊不可点(无面板可看)。
|
|
98
203
|
if (!hasTimings) {
|
|
99
204
|
return (
|
|
100
|
-
<span className="dci-stat-anchor">
|
|
205
|
+
<span className="dci-stat-anchor" ref={rootRef}>
|
|
101
206
|
<span className="dci-stat-pill">
|
|
102
207
|
<GaugeIcon />
|
|
103
208
|
{label}
|
|
@@ -106,7 +211,7 @@ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }):
|
|
|
106
211
|
);
|
|
107
212
|
}
|
|
108
213
|
return (
|
|
109
|
-
<span className="dci-stat-anchor">
|
|
214
|
+
<span className="dci-stat-anchor" ref={rootRef}>
|
|
110
215
|
<button
|
|
111
216
|
type="button"
|
|
112
217
|
className="dci-stat-pill"
|
|
@@ -122,6 +227,8 @@ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }):
|
|
|
122
227
|
<StatPanel
|
|
123
228
|
label={t("stats.session")}
|
|
124
229
|
icon={<GaugeIcon />}
|
|
230
|
+
panelRef={panelRef}
|
|
231
|
+
position={position}
|
|
125
232
|
rows={[
|
|
126
233
|
[t('stats.modelTime'), (stats.llmMs ?? 0) > 0 ? formatDuration(stats.llmMs!) : '—'],
|
|
127
234
|
[t('stats.toolTime'), (stats.toolMs ?? 0) > 0 ? formatDuration(stats.toolMs!) : '—'],
|
|
@@ -140,8 +247,9 @@ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }):
|
|
|
140
247
|
}
|
|
141
248
|
|
|
142
249
|
/** 官方 UsagePill:总量 · 缓存命中(缓存命中用官方百分比精度规则)。 */
|
|
143
|
-
function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void }): JSX.Element | null {
|
|
144
|
-
const { stats, open, onToggle } = props;
|
|
250
|
+
function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void; onClose(): void }): JSX.Element | null {
|
|
251
|
+
const { stats, open, onToggle, onClose } = props;
|
|
252
|
+
const { rootRef, panelRef, position } = useStatDialogSeat(open, onClose);
|
|
145
253
|
if (stats.totalTokens === undefined) return null;
|
|
146
254
|
const total = stats.totalTokens;
|
|
147
255
|
const cacheHitText =
|
|
@@ -151,7 +259,7 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
|
|
|
151
259
|
(stats.cacheReadTokens ?? 0) + (stats.cacheWriteTokens ?? 0) + (stats.uncachedInputTokens ?? 0),
|
|
152
260
|
);
|
|
153
261
|
return (
|
|
154
|
-
<span className="dci-stat-anchor">
|
|
262
|
+
<span className="dci-stat-anchor" ref={rootRef}>
|
|
155
263
|
<button
|
|
156
264
|
type="button"
|
|
157
265
|
className="dci-stat-pill"
|
|
@@ -180,6 +288,8 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
|
|
|
180
288
|
label={t("stats.usage")}
|
|
181
289
|
icon={<DatabaseIcon />}
|
|
182
290
|
titleValue={exactCount(total)}
|
|
291
|
+
panelRef={panelRef}
|
|
292
|
+
position={position}
|
|
183
293
|
rows={[
|
|
184
294
|
[t('stats.cacheHitLabel'), cacheHitText !== null ? `${cacheHitText}%` : '—'],
|
|
185
295
|
[t('stats.uncached'), exactCount(stats.uncachedInputTokens)],
|
|
@@ -198,40 +308,40 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
|
|
|
198
308
|
export function StatsRow(props: StatsRowProps): JSX.Element | null {
|
|
199
309
|
const { useProjection, sessionId, chatNodes } = props;
|
|
200
310
|
// 唯一展开:三枚(轮步/用量/上下文小圈)共用一个 open 态——任意一枚展开
|
|
201
|
-
//
|
|
311
|
+
// 即收起其余(官方 chat StatsPills 的 openPill 同语义:详情面板同时只存在一个)。
|
|
202
312
|
const [open, setOpen] = useState<'rounds' | 'tokens' | 'gauge' | null>(null);
|
|
203
|
-
const
|
|
313
|
+
const close = useCallback(() => setOpen(null), []);
|
|
314
|
+
const toggle = useCallback((key: 'rounds' | 'tokens' | 'gauge') => {
|
|
315
|
+
setOpen((current) => (current === key ? null : key));
|
|
316
|
+
}, []);
|
|
204
317
|
const stats = useSessionStats(useProjection, sessionId, chatNodes);
|
|
205
318
|
|
|
206
|
-
// 点外面关闭(面板内/胶囊上不关)。
|
|
207
|
-
useEffect(() => {
|
|
208
|
-
if (open === null) return;
|
|
209
|
-
const onDown = (event: PointerEvent): void => {
|
|
210
|
-
if (rootRef.current && event.target instanceof Node && rootRef.current.contains(event.target)) return;
|
|
211
|
-
setOpen(null);
|
|
212
|
-
};
|
|
213
|
-
window.addEventListener('pointerdown', onDown, true);
|
|
214
|
-
return () => window.removeEventListener('pointerdown', onDown, true);
|
|
215
|
-
}, [open]);
|
|
216
|
-
|
|
217
319
|
// 官方 rc.2:上下文小圈独立于 stats 胶囊渲染(无 stats 也要出圈)。
|
|
218
320
|
const pressure = useProjection
|
|
219
321
|
? (useProjection('contextPressure') as PressureSnapshot | undefined)
|
|
220
322
|
: undefined;
|
|
221
323
|
const hasGauge = pressure !== undefined && !!pressure.contextWindow;
|
|
324
|
+
// 面板打开期间其数据源消失(切换会话 / 投影短暂缺席)时自动收起,避免恢复后「自己重开」。
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
if (open === null) return;
|
|
327
|
+
const valid = open === 'gauge' ? hasGauge : stats !== null && stats !== undefined;
|
|
328
|
+
if (!valid) setOpen(null);
|
|
329
|
+
}, [open, hasGauge, stats]);
|
|
330
|
+
|
|
222
331
|
if (!stats && !hasGauge) return null;
|
|
223
332
|
return (
|
|
224
|
-
<div className="dci-stat-root" data-composer-stats="codinput"
|
|
333
|
+
<div className="dci-stat-root" data-composer-stats="codinput">
|
|
225
334
|
{stats ? (
|
|
226
335
|
<>
|
|
227
|
-
<Pill stats={stats} open={open === 'rounds'} onToggle={() =>
|
|
228
|
-
<TokenPill stats={stats} open={open === 'tokens'} onToggle={() =>
|
|
336
|
+
<Pill stats={stats} open={open === 'rounds'} onToggle={() => toggle('rounds')} onClose={close} />
|
|
337
|
+
<TokenPill stats={stats} open={open === 'tokens'} onToggle={() => toggle('tokens')} onClose={close} />
|
|
229
338
|
</>
|
|
230
339
|
) : null}
|
|
231
340
|
<ContextGauge
|
|
232
341
|
useProjection={useProjection}
|
|
233
342
|
open={open === 'gauge'}
|
|
234
|
-
onToggle={() =>
|
|
343
|
+
onToggle={() => toggle('gauge')}
|
|
344
|
+
onClose={close}
|
|
235
345
|
/>
|
|
236
346
|
</div>
|
|
237
347
|
);
|
|
@@ -259,14 +369,16 @@ function shortTokens(n: number): string {
|
|
|
259
369
|
|
|
260
370
|
/** 上下文占用小圈(官方 JObwrW 复刻):双环仪表 + 百分比,点击展开分段面板。
|
|
261
371
|
* 0.1.7-rc.2 起官方渲染在 dock 行 stats 胶囊之后(原先在工具行 trailing)。
|
|
262
|
-
* 展开态由 StatsRow
|
|
372
|
+
* 展开态由 StatsRow 持有(三枚详情面板唯一展开);面板 portal 到 body +
|
|
373
|
+
* useAnchoredPanel 定位(官方 ContextMeter:side top / align start / gap 8 / margin 12)。 */
|
|
263
374
|
export function ContextGauge(props: {
|
|
264
375
|
useProjection: UseProjection | undefined;
|
|
265
376
|
open: boolean;
|
|
266
377
|
onToggle(): void;
|
|
378
|
+
onClose(): void;
|
|
267
379
|
}): JSX.Element | null {
|
|
268
|
-
const { useProjection, open, onToggle } = props;
|
|
269
|
-
const rootRef
|
|
380
|
+
const { useProjection, open, onToggle, onClose } = props;
|
|
381
|
+
const { rootRef, panelRef, position } = useStatDialogSeat(open, onClose);
|
|
270
382
|
const pressure = useProjection
|
|
271
383
|
? (useProjection('contextPressure') as PressureSnapshot | undefined)
|
|
272
384
|
: undefined;
|
|
@@ -307,49 +419,58 @@ export function ContextGauge(props: {
|
|
|
307
419
|
</svg>
|
|
308
420
|
<span>{Math.round(pct)}%</span>
|
|
309
421
|
</button>
|
|
310
|
-
{open
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
422
|
+
{open
|
|
423
|
+
? createPortal(
|
|
424
|
+
<div
|
|
425
|
+
className="dci-gauge-panel"
|
|
426
|
+
role="dialog"
|
|
427
|
+
aria-label={t("tool.contextTitle")}
|
|
428
|
+
ref={panelRef}
|
|
429
|
+
style={panelStyle(position)}
|
|
430
|
+
>
|
|
431
|
+
<div className="dci-gauge-header">
|
|
432
|
+
<span className="dci-gauge-headline">{t("tool.contextTitle")}</span>
|
|
433
|
+
<span className="dci-gauge-percent">{Math.round(pct)}%</span>
|
|
434
|
+
<span className="dci-gauge-figures">
|
|
435
|
+
~{shortTokens(usedTokens)} / {shortTokens(windowTokens)}
|
|
436
|
+
</span>
|
|
437
|
+
</div>
|
|
438
|
+
<div className="dci-gauge-bar">
|
|
439
|
+
{segments.map(([cls, tokens]) => (
|
|
440
|
+
<div
|
|
441
|
+
key={cls}
|
|
442
|
+
className={`dci-gauge-segment ${cls}`}
|
|
443
|
+
style={{ width: `${(tokens / windowTokens) * 100}%` }}
|
|
444
|
+
/>
|
|
445
|
+
))}
|
|
446
|
+
</div>
|
|
447
|
+
<dl className="dci-gauge-rows">
|
|
448
|
+
<div className="dci-gauge-row">
|
|
449
|
+
<dt>
|
|
450
|
+
<span className={`dci-gauge-swatch dci-gauge-colorSystem`} aria-hidden="true" />
|
|
451
|
+
{t("stats.prompt")}
|
|
452
|
+
</dt>
|
|
453
|
+
<dd>~{shortTokens(breakdown?.systemTokens ?? 0)}</dd>
|
|
454
|
+
</div>
|
|
455
|
+
<div className="dci-gauge-row">
|
|
456
|
+
<dt>
|
|
457
|
+
<span className={`dci-gauge-swatch dci-gauge-colorTools`} aria-hidden="true" />
|
|
458
|
+
{t("stats.tools")}
|
|
459
|
+
</dt>
|
|
460
|
+
<dd>~{shortTokens(breakdown?.toolsTokens ?? 0)}</dd>
|
|
461
|
+
</div>
|
|
462
|
+
<div className="dci-gauge-row">
|
|
463
|
+
<dt>
|
|
464
|
+
<span className={`dci-gauge-swatch dci-gauge-colorMessages`} aria-hidden="true" />
|
|
465
|
+
{t("stats.messages")}
|
|
466
|
+
</dt>
|
|
467
|
+
<dd>~{shortTokens(breakdown?.messageTokens ?? 0)}</dd>
|
|
468
|
+
</div>
|
|
469
|
+
</dl>
|
|
470
|
+
</div>,
|
|
471
|
+
document.body,
|
|
472
|
+
)
|
|
473
|
+
: null}
|
|
353
474
|
</span>
|
|
354
475
|
);
|
|
355
476
|
}
|