dsh-codinput 0.3.8 → 0.4.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 +71 -0
- package/NOTICE +37 -4
- package/README.en.md +28 -13
- package/README.md +28 -13
- package/TESTING.md +96 -9
- package/assets/donate-qr.jpg +0 -0
- package/lib/client.js +2875 -98
- package/package.json +5 -2
- package/src/client/cm.ts +19 -1
- package/src/client/command.ts +39 -8
- package/src/client/components/CodinputSurface.tsx +80 -27
- package/src/client/components/EditorPane.tsx +3 -1
- package/src/client/components/InputSlotSeat.tsx +56 -1
- 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/SlashMenu.tsx +294 -41
- package/src/client/components/StatsRow.tsx +40 -23
- package/src/client/components/Toolbar.tsx +284 -48
- package/src/client/components/UserMessageMarkdown.tsx +158 -115
- package/src/client/components/VoiceActivity.tsx +30 -69
- package/src/client/components/slotEntry.tsx +37 -0
- package/src/client/host-types.ts +11 -3
- package/src/client/hotkey.ts +38 -0
- package/src/client/i18n.ts +18 -4
- package/src/client/index.tsx +9 -0
- package/src/client/markdown/code.ts +281 -0
- package/src/client/markdown/extensions.ts +576 -0
- package/src/client/markdown/heavy-assets.d.ts +11 -0
- package/src/client/markdown/heavy-shared.ts +50 -0
- package/src/client/markdown/heavy.ts +159 -0
- package/src/client/markdown/index.ts +289 -0
- package/src/client/markdown/math.ts +134 -0
- package/src/client/markdown/mermaid.ts +358 -0
- package/src/client/markdown/sanitize.ts +103 -0
- package/src/client/markdown/styles.ts +171 -0
- package/src/client/prefs.ts +206 -23
- package/src/client/settings.tsx +15 -3
- package/src/client/stats.ts +92 -20
- package/src/client/styles.ts +55 -30
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* '/' '@' 候选菜单:官方 MenuView
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
2
|
+
* '/' '@' 候选菜单:官方 MenuView 复刻。两种形态:
|
|
3
|
+
*
|
|
4
|
+
* - normal(主输入):卡片顶锚定的全宽浮层(absolute bottom: calc(100% + 4px),
|
|
5
|
+
* 随卡片等宽,max-height 400 随上方空间收缩)。0.3.9 已验收,几何不动。
|
|
6
|
+
* - dropdown(侧栏/悬浮):portal 到 body 的自适应 fixed 浮层。以 .dci-card 的
|
|
7
|
+
* 实时矩形为不可覆盖区,在卡片上/下/左/右找空位:优先能完整容纳自然高度的
|
|
8
|
+
* 一侧,按可用空间取最大;都放不下时把高度压到该侧空间(min 120 / max 400,
|
|
9
|
+
* 菜单内 .dci-slash-viewport 滚动)。浮层与卡片不重叠——不遮输入区,也不
|
|
10
|
+
* 挤压/重排卡片;打开期间用 rAF 复算(窗口 resize、侧栏宽度、悬浮卡拖动)。
|
|
11
|
+
*
|
|
12
|
+
* crumbs 导航行在滚动区外;组标题按来源本地化(指令/技能/子智能体,未知来源
|
|
13
|
+
* 显示原名),item.section 生成小节标题;pending 空组出骨架屏;拾取/悬停/面包屑/
|
|
14
|
+
* 钻取全部路由回控制器(combobox 语义:aria-activedescendant,焦点不离开编辑器)。
|
|
15
|
+
* 卡片内指针按下不关闭(portal 后按保存的 .dci-card 元素判定,不用 DOM 祖先)。
|
|
10
16
|
*/
|
|
11
17
|
|
|
12
|
-
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
18
|
+
import { Fragment, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type RefObject } from 'react';
|
|
19
|
+
import { createPortal } from 'react-dom';
|
|
13
20
|
import { t, tOptional } from '../i18n';
|
|
14
21
|
import type { InputTriggerController, MenuState } from '../host-types';
|
|
15
22
|
import { useSnapshot } from './useSnapshot';
|
|
@@ -17,12 +24,24 @@ import { IconChevronOfficial, IconFile, IconFolder, IconSearch } from '../icons'
|
|
|
17
24
|
|
|
18
25
|
export interface SlashMenuProps {
|
|
19
26
|
triggers: InputTriggerController;
|
|
20
|
-
/**
|
|
27
|
+
/** true = 侧栏/悬浮形态:portal 到 body 的自适应浮层(卡片四周找空位)。 */
|
|
21
28
|
dropdown?: boolean;
|
|
29
|
+
/** 锚点卡片(.dci-card)引用:浮层定位与「卡片内按下不关闭」判定用。 */
|
|
30
|
+
anchorRef?: RefObject<HTMLElement | null>;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
/** 组标题按来源映射到 i18n;未知来源回退原名(与官方同语义)。 */
|
|
25
34
|
const MAX_HEIGHT = 400;
|
|
35
|
+
/** 放不下时压到的最小高度(菜单内滚动)。 */
|
|
36
|
+
const MIN_HEIGHT = 120;
|
|
37
|
+
/** 浮层与视口边缘的最小间距。 */
|
|
38
|
+
const VIEWPORT_MARGIN = 12;
|
|
39
|
+
/** 浮层与卡片的间距(与 normal 形态的 4px 一致)。 */
|
|
40
|
+
const CARD_GAP = 4;
|
|
41
|
+
/** 左/右侧位的宽度上限(卡片更宽时也不铺满)。 */
|
|
42
|
+
const SIDE_MAX_WIDTH = 360;
|
|
43
|
+
/** 左/右侧位可接受的最小宽度(低于此值视为该侧放不下)。 */
|
|
44
|
+
const SIDE_MIN_WIDTH = 180;
|
|
26
45
|
|
|
27
46
|
function sourceLabel(source: string): string {
|
|
28
47
|
return tOptional(`menu.source.${source}`) ?? source;
|
|
@@ -32,6 +51,152 @@ function optionId(source: string, index: number): string {
|
|
|
32
51
|
return `dsh-slash-option-${source}-${index}`;
|
|
33
52
|
}
|
|
34
53
|
|
|
54
|
+
function clampNumber(value: number, lo: number, hi: number): number {
|
|
55
|
+
return Math.min(hi, Math.max(lo, value));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** dropdown 浮层的视口坐标(px,均为整数)。 */
|
|
59
|
+
interface DropdownPlacement {
|
|
60
|
+
left: number;
|
|
61
|
+
top: number;
|
|
62
|
+
width: number;
|
|
63
|
+
maxHeight: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type PlacementSide = 'top' | 'bottom' | 'left' | 'right';
|
|
67
|
+
|
|
68
|
+
/** 一个方向的候选位;space = 该方向可用轴长(上/下=纵向,左/右=横向)。 */
|
|
69
|
+
interface PlacementCandidate {
|
|
70
|
+
side: PlacementSide;
|
|
71
|
+
space: number;
|
|
72
|
+
/** 该方向能否完整容纳自然高度(左右位还要求宽度够)。 */
|
|
73
|
+
fits: boolean;
|
|
74
|
+
placement: DropdownPlacement;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 自然高度(内容高度,已按 MAX_HEIGHT 封顶;宽/窄两种宽度分别量)。 */
|
|
78
|
+
interface NaturalHeights {
|
|
79
|
+
wide: number;
|
|
80
|
+
side: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 四个方向的候选位:上/下位与卡片同宽、水平对齐卡片;左/右位宽度取
|
|
85
|
+
* min(卡片宽, 360) 且不超过该侧可用空间。
|
|
86
|
+
*/
|
|
87
|
+
function buildPlacementCandidates(cardRect: DOMRect, natural: NaturalHeights): PlacementCandidate[] {
|
|
88
|
+
const vw = window.innerWidth;
|
|
89
|
+
const vh = window.innerHeight;
|
|
90
|
+
const margin = VIEWPORT_MARGIN;
|
|
91
|
+
const cardWidth = Math.max(0, Math.min(cardRect.width, vw - margin * 2));
|
|
92
|
+
const sideMaxWidth = Math.min(cardWidth, SIDE_MAX_WIDTH);
|
|
93
|
+
const alignedLeft = clampNumber(Math.round(cardRect.left), margin, Math.max(margin, vw - margin - cardWidth));
|
|
94
|
+
/** 左/右位的顶边对齐卡片顶,再夹进视口。 */
|
|
95
|
+
const sideTop = (height: number): number =>
|
|
96
|
+
clampNumber(Math.round(cardRect.top), margin, Math.max(margin, vh - margin - height));
|
|
97
|
+
/** 上/下位高度:够放自然高度就用自然高度,否则压到该侧空间(不低于 MIN_HEIGHT)。 */
|
|
98
|
+
const verticalHeight = (space: number, wanted: number): number => {
|
|
99
|
+
const naturalHeight = Math.min(MAX_HEIGHT, wanted);
|
|
100
|
+
return naturalHeight <= space ? naturalHeight : Math.round(clampNumber(space, MIN_HEIGHT, MAX_HEIGHT));
|
|
101
|
+
};
|
|
102
|
+
/** 左/右位宽度:优先 min(卡片宽, 360) 与可用空间的较小者;太窄时退到最小宽度。 */
|
|
103
|
+
const sideWidth = (space: number): number => {
|
|
104
|
+
const preferred = Math.min(sideMaxWidth, Math.max(space, 0));
|
|
105
|
+
return preferred >= SIDE_MIN_WIDTH
|
|
106
|
+
? preferred
|
|
107
|
+
: Math.min(Math.max(sideMaxWidth, SIDE_MIN_WIDTH), SIDE_MIN_WIDTH);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const topSpace = cardRect.top - CARD_GAP - margin;
|
|
111
|
+
const bottomSpace = vh - cardRect.bottom - CARD_GAP - margin;
|
|
112
|
+
const topHeight = verticalHeight(topSpace, natural.wide);
|
|
113
|
+
const bottomHeight = verticalHeight(bottomSpace, natural.wide);
|
|
114
|
+
const sideSpace = vh - margin * 2;
|
|
115
|
+
const sideHeight = verticalHeight(sideSpace, natural.side);
|
|
116
|
+
const sideFits = Math.min(MAX_HEIGHT, natural.side) <= sideSpace;
|
|
117
|
+
const leftSpace = cardRect.left - CARD_GAP - margin;
|
|
118
|
+
const rightSpace = vw - cardRect.right - CARD_GAP - margin;
|
|
119
|
+
const leftWidth = Math.round(sideWidth(leftSpace));
|
|
120
|
+
const rightWidth = Math.round(sideWidth(rightSpace));
|
|
121
|
+
|
|
122
|
+
return [
|
|
123
|
+
{
|
|
124
|
+
side: 'top',
|
|
125
|
+
space: topSpace,
|
|
126
|
+
fits: natural.wide <= topSpace,
|
|
127
|
+
placement: {
|
|
128
|
+
left: alignedLeft,
|
|
129
|
+
top: Math.round(cardRect.top - CARD_GAP - topHeight),
|
|
130
|
+
width: Math.round(cardWidth),
|
|
131
|
+
maxHeight: topHeight,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
side: 'bottom',
|
|
136
|
+
space: bottomSpace,
|
|
137
|
+
fits: natural.wide <= bottomSpace,
|
|
138
|
+
placement: {
|
|
139
|
+
left: alignedLeft,
|
|
140
|
+
top: Math.round(cardRect.bottom + CARD_GAP),
|
|
141
|
+
width: Math.round(cardWidth),
|
|
142
|
+
maxHeight: bottomHeight,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
side: 'left',
|
|
147
|
+
space: leftSpace,
|
|
148
|
+
fits: leftSpace >= SIDE_MIN_WIDTH && sideFits,
|
|
149
|
+
placement: {
|
|
150
|
+
left: Math.round(cardRect.left - CARD_GAP - leftWidth),
|
|
151
|
+
top: sideTop(sideHeight),
|
|
152
|
+
width: leftWidth,
|
|
153
|
+
maxHeight: sideHeight,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
side: 'right',
|
|
158
|
+
space: rightSpace,
|
|
159
|
+
fits: rightSpace >= SIDE_MIN_WIDTH && sideFits,
|
|
160
|
+
placement: {
|
|
161
|
+
left: Math.round(cardRect.right + CARD_GAP),
|
|
162
|
+
top: sideTop(sideHeight),
|
|
163
|
+
width: rightWidth,
|
|
164
|
+
maxHeight: sideHeight,
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 浮层整体夹进视口(左/上留 margin,右/下不出界;高度上限夹在 MAX_HEIGHT)。 */
|
|
171
|
+
function clampPlacementToViewport(placement: DropdownPlacement): DropdownPlacement {
|
|
172
|
+
const vw = window.innerWidth;
|
|
173
|
+
const vh = window.innerHeight;
|
|
174
|
+
const margin = VIEWPORT_MARGIN;
|
|
175
|
+
const width = Math.round(clampNumber(placement.width, 0, Math.max(0, vw - margin * 2)));
|
|
176
|
+
// 实际渲染高度 = min(自然高度, maxHeight):条目少的小菜单自然高度可能 < MIN_HEIGHT,
|
|
177
|
+
// 此时不能把 max-height 抬到最小高度——否则上/下位的 top 会按更大的高度夹取,
|
|
178
|
+
// 反而把浮层推进卡片(MIN_HEIGHT 只在“空间不够、需要压缩”时由 verticalHeight 兼底)。
|
|
179
|
+
const maxHeight = Math.round(clampNumber(placement.maxHeight, 1, MAX_HEIGHT));
|
|
180
|
+
const left = Math.round(clampNumber(placement.left, margin, Math.max(margin, vw - margin - width)));
|
|
181
|
+
const top = Math.round(clampNumber(placement.top, margin, Math.max(margin, vh - margin - maxHeight)));
|
|
182
|
+
return { left, top, width, maxHeight };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 四向选位:能完整容纳自然高度的方向里取可用空间最大的一侧(悬浮卡四周有空
|
|
187
|
+
* 就选空最大且放得下的一侧;侧栏形态天然选左侧对话区);都放不下时退最大化
|
|
188
|
+
* 空间的一侧并夹进视口——极小窗口允许与卡片少量重叠,优先保住编辑区。
|
|
189
|
+
*/
|
|
190
|
+
function chooseDropdownPlacement(cardRect: DOMRect, natural: NaturalHeights): DropdownPlacement | null {
|
|
191
|
+
const candidates = buildPlacementCandidates(cardRect, natural);
|
|
192
|
+
if (candidates.length === 0) return null;
|
|
193
|
+
const fitting = candidates.filter((candidate) => candidate.fits);
|
|
194
|
+
const pool = fitting.length > 0 ? fitting : candidates;
|
|
195
|
+
let best = pool[0];
|
|
196
|
+
for (const candidate of pool) if (candidate.space > best.space) best = candidate;
|
|
197
|
+
return clampPlacementToViewport(best.placement);
|
|
198
|
+
}
|
|
199
|
+
|
|
35
200
|
function candidateIcon(icon: unknown): JSX.Element | null {
|
|
36
201
|
if (icon === 'file') return <IconFile size={16} />;
|
|
37
202
|
if (icon === 'folder') return <IconFolder size={16} />;
|
|
@@ -55,46 +220,126 @@ function ChevronRight({ size = 14 }: { size?: number }): JSX.Element {
|
|
|
55
220
|
}
|
|
56
221
|
|
|
57
222
|
export function SlashMenu(props: SlashMenuProps): JSX.Element | null {
|
|
58
|
-
const { triggers, dropdown } = props;
|
|
223
|
+
const { triggers, dropdown, anchorRef } = props;
|
|
59
224
|
const menu = useSnapshot(triggers.menu) as MenuState | undefined;
|
|
60
225
|
const headers = useSnapshot(triggers.headers);
|
|
61
226
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
227
|
+
/** normal 形态的 max-height(卡片上方空间)。 */
|
|
62
228
|
const [maxHeight, setMaxHeight] = useState(MAX_HEIGHT);
|
|
63
|
-
/**
|
|
64
|
-
const [
|
|
229
|
+
/** dropdown 形态的浮层几何;null = 首帧未测量(隐藏渲染,layout effect 内落位)。 */
|
|
230
|
+
const [placement, setPlacement] = useState<DropdownPlacement | null>(null);
|
|
231
|
+
const placementRef = useRef<DropdownPlacement | null>(null);
|
|
232
|
+
/** 自然高度缓存:key = generation + 内容签名;宽度变化时重量。 */
|
|
233
|
+
const naturalRef = useRef<{ key: string; wideWidth: number; sideWidth: number; wide: number; side: number } | null>(null);
|
|
65
234
|
|
|
66
235
|
const open = menu?.open === true;
|
|
67
236
|
const highlight = open ? menu.highlight ?? null : null;
|
|
237
|
+
/** 内容签名:pending→ready、钻取/分组变化都按它触发重新测量。 */
|
|
238
|
+
const menuKey = menu
|
|
239
|
+
? `${menu.generation}:${menu.groups.map((group) => `${group.source}:${group.status}:${group.items.length}`).join('|')}`
|
|
240
|
+
: '';
|
|
241
|
+
|
|
242
|
+
/** 定位锚点:优先外部传入的 .dci-card 引用(portal 后 DOM 祖先不可用)。 */
|
|
243
|
+
const anchorCard = (): HTMLElement | null => {
|
|
244
|
+
const anchored = anchorRef?.current ?? null;
|
|
245
|
+
if (anchored) return anchored;
|
|
246
|
+
if (!dropdown) return rootRef.current?.closest<HTMLElement>('.dci-card') ?? null;
|
|
247
|
+
return document.querySelector<HTMLElement>('.dci-card');
|
|
248
|
+
};
|
|
68
249
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
// 头部行下方,吃满卡片内头部行以下的距离——卡片靠视口顶时上方没有
|
|
72
|
-
// 空间,向下弹才能留在视口与卡片边界内。
|
|
250
|
+
// normal 形态:卡片顶上方浮层。0.3.9 已验收的取值原样保留
|
|
251
|
+
// (max-height = min(400, 卡片顶到视口顶 - 16))。
|
|
73
252
|
useLayoutEffect(() => {
|
|
74
|
-
if (!open) return;
|
|
75
|
-
const card =
|
|
253
|
+
if (!open || dropdown) return;
|
|
254
|
+
const card = anchorCard();
|
|
76
255
|
if (!card) return;
|
|
77
|
-
if (dropdown) {
|
|
78
|
-
const header = card.querySelector<HTMLElement>('.dci-accessory');
|
|
79
|
-
const cardRect = card.getBoundingClientRect();
|
|
80
|
-
const headerBottom = header
|
|
81
|
-
? header.getBoundingClientRect().bottom - cardRect.top
|
|
82
|
-
: 36;
|
|
83
|
-
setMenuTop(Math.round(headerBottom) + 2);
|
|
84
|
-
// 侧栏吃满面板内剩余空间;悬浮卡可较小,吃满视口内剩余空间(可
|
|
85
|
-
// 伸出卡片、不出视口)。
|
|
86
|
-
const inFloat = !!rootRef.current?.closest('.dci-float');
|
|
87
|
-
const budget = inFloat
|
|
88
|
-
? window.innerHeight - (cardRect.top + headerBottom) - 16
|
|
89
|
-
: cardRect.height - headerBottom - 10;
|
|
90
|
-
setMaxHeight(Math.max(120, Math.min(MAX_HEIGHT, budget)));
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
setMenuTop(null);
|
|
94
256
|
const top = card.getBoundingClientRect().top;
|
|
95
|
-
setMaxHeight(Math.max(
|
|
257
|
+
setMaxHeight(Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, top - 16)));
|
|
96
258
|
}, [open, menu?.generation, dropdown]);
|
|
97
259
|
|
|
260
|
+
// dropdown 形态:量自然高度 → 四向选位 → 内联几何;打开期间逐帧复算
|
|
261
|
+
// (窗口 resize、侧栏宽度变化、悬浮卡拖动都会改变卡片/视口矩形)。
|
|
262
|
+
useLayoutEffect(() => {
|
|
263
|
+
if (!dropdown) return;
|
|
264
|
+
if (!open) {
|
|
265
|
+
naturalRef.current = null;
|
|
266
|
+
placementRef.current = null;
|
|
267
|
+
setPlacement(null);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const key = menuKey;
|
|
271
|
+
let frame = 0;
|
|
272
|
+
const apply = (next: DropdownPlacement | null): void => {
|
|
273
|
+
const prev = placementRef.current;
|
|
274
|
+
if (next === null) {
|
|
275
|
+
if (prev !== null) {
|
|
276
|
+
placementRef.current = null;
|
|
277
|
+
setPlacement(null);
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (
|
|
282
|
+
prev !== null &&
|
|
283
|
+
prev.left === next.left &&
|
|
284
|
+
prev.top === next.top &&
|
|
285
|
+
prev.width === next.width &&
|
|
286
|
+
prev.maxHeight === next.maxHeight
|
|
287
|
+
) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
placementRef.current = next;
|
|
291
|
+
setPlacement(next);
|
|
292
|
+
};
|
|
293
|
+
/** 临时放开 max-height 量自然高度;量完还原 React 托管的样式(不触发渲染)。 */
|
|
294
|
+
const measureHeight = (el: HTMLElement, width: number): number => {
|
|
295
|
+
const prevMaxHeight = el.style.maxHeight;
|
|
296
|
+
const prevWidth = el.style.width;
|
|
297
|
+
try {
|
|
298
|
+
el.style.maxHeight = `${MAX_HEIGHT}px`;
|
|
299
|
+
el.style.width = `${Math.max(1, Math.round(width))}px`;
|
|
300
|
+
return Math.min(MAX_HEIGHT, Math.max(0, Math.round(el.getBoundingClientRect().height)));
|
|
301
|
+
} finally {
|
|
302
|
+
el.style.maxHeight = prevMaxHeight;
|
|
303
|
+
el.style.width = prevWidth;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const recalc = (): void => {
|
|
307
|
+
const el = rootRef.current;
|
|
308
|
+
const card = anchorCard();
|
|
309
|
+
if (!el || !card) return;
|
|
310
|
+
const cardRect = card.getBoundingClientRect();
|
|
311
|
+
if (cardRect.width < 1 || cardRect.height < 1) {
|
|
312
|
+
apply(null);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const wideWidth = Math.max(1, Math.min(cardRect.width, window.innerWidth - VIEWPORT_MARGIN * 2));
|
|
316
|
+
const sideWidth = Math.max(1, Math.min(wideWidth, SIDE_MAX_WIDTH));
|
|
317
|
+
const cached = naturalRef.current;
|
|
318
|
+
let natural: NaturalHeights;
|
|
319
|
+
if (
|
|
320
|
+
cached &&
|
|
321
|
+
cached.key === key &&
|
|
322
|
+
Math.abs(cached.wideWidth - wideWidth) < 1 &&
|
|
323
|
+
Math.abs(cached.sideWidth - sideWidth) < 1
|
|
324
|
+
) {
|
|
325
|
+
natural = { wide: cached.wide, side: cached.side };
|
|
326
|
+
} else {
|
|
327
|
+
const wide = measureHeight(el, wideWidth);
|
|
328
|
+
const side = Math.abs(sideWidth - wideWidth) < 1 ? wide : measureHeight(el, sideWidth);
|
|
329
|
+
natural = { wide, side };
|
|
330
|
+
naturalRef.current = { key, wideWidth, sideWidth, wide, side };
|
|
331
|
+
}
|
|
332
|
+
apply(chooseDropdownPlacement(cardRect, natural));
|
|
333
|
+
};
|
|
334
|
+
recalc();
|
|
335
|
+
const tick = (): void => {
|
|
336
|
+
recalc();
|
|
337
|
+
frame = window.requestAnimationFrame(tick);
|
|
338
|
+
};
|
|
339
|
+
frame = window.requestAnimationFrame(tick);
|
|
340
|
+
return () => window.cancelAnimationFrame(frame);
|
|
341
|
+
}, [open, dropdown, menuKey]);
|
|
342
|
+
|
|
98
343
|
// 高亮行滚动进视野(官方 scrollIntoView block nearest)。
|
|
99
344
|
useEffect(() => {
|
|
100
345
|
if (!highlight) return;
|
|
@@ -103,13 +348,14 @@ export function SlashMenu(props: SlashMenuProps): JSX.Element | null {
|
|
|
103
348
|
?.scrollIntoView({ block: 'nearest' });
|
|
104
349
|
}, [highlight]);
|
|
105
350
|
|
|
106
|
-
//
|
|
351
|
+
// 外部指针按下关闭;卡片内(含工具行/拖动区)不关闭(官方 dismiss 语义)。
|
|
352
|
+
// portal 后菜单已不在卡片 DOM 子树里,祖先查询失效——按保存的 .dci-card 元素判定。
|
|
107
353
|
useEffect(() => {
|
|
108
354
|
if (!open) return;
|
|
109
355
|
const onPointerDown = (event: PointerEvent): void => {
|
|
110
356
|
if (!(event.target instanceof Node)) return;
|
|
111
357
|
if (rootRef.current?.contains(event.target)) return;
|
|
112
|
-
if (
|
|
358
|
+
if (anchorCard()?.contains(event.target)) return;
|
|
113
359
|
triggers.dismiss();
|
|
114
360
|
};
|
|
115
361
|
document.addEventListener('pointerdown', onPointerDown, true);
|
|
@@ -119,14 +365,19 @@ export function SlashMenu(props: SlashMenuProps): JSX.Element | null {
|
|
|
119
365
|
if (!open || !menu) return null;
|
|
120
366
|
|
|
121
367
|
const crumbsFor = (source: string) => headers?.get(source);
|
|
368
|
+
const menuStyle: CSSProperties = dropdown
|
|
369
|
+
? placement
|
|
370
|
+
? { left: placement.left, top: placement.top, width: placement.width, maxHeight: placement.maxHeight }
|
|
371
|
+
: { left: 0, top: 0, maxHeight: MAX_HEIGHT, visibility: 'hidden' }
|
|
372
|
+
: { maxHeight };
|
|
122
373
|
|
|
123
|
-
|
|
374
|
+
const menuNode = (
|
|
124
375
|
<div
|
|
125
376
|
ref={rootRef}
|
|
126
377
|
className="dci-slash-menu"
|
|
127
378
|
data-trigger-menu=""
|
|
128
379
|
data-dropdown={dropdown || undefined}
|
|
129
|
-
style={
|
|
380
|
+
style={menuStyle}
|
|
130
381
|
>
|
|
131
382
|
{menu.groups.map((group) => {
|
|
132
383
|
const trail = crumbsFor(group.source);
|
|
@@ -252,4 +503,6 @@ export function SlashMenu(props: SlashMenuProps): JSX.Element | null {
|
|
|
252
503
|
</div>
|
|
253
504
|
</div>
|
|
254
505
|
);
|
|
506
|
+
|
|
507
|
+
return dropdown ? createPortal(menuNode, document.body) : menuNode;
|
|
255
508
|
}
|
|
@@ -171,9 +171,11 @@ function StatPanel(props: {
|
|
|
171
171
|
);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
/** 官方
|
|
174
|
+
/** 官方 formatExactTokens:千分位分组 + " tok";NaN/Infinity/负值不渲染("—")。 */
|
|
175
175
|
function exactCount(value: number | undefined): string {
|
|
176
|
-
|
|
176
|
+
if (value === undefined) return '0 tok';
|
|
177
|
+
if (!Number.isFinite(value) || value < 0) return '—';
|
|
178
|
+
const digits = String(Math.trunc(value));
|
|
177
179
|
const groups: string[] = [];
|
|
178
180
|
for (let end = digits.length; end > 0; end -= 3) groups.unshift(digits.slice(Math.max(0, end - 3), end));
|
|
179
181
|
return `${groups.join(',')} tok`;
|
|
@@ -230,15 +232,13 @@ function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void; onC
|
|
|
230
232
|
panelRef={panelRef}
|
|
231
233
|
position={position}
|
|
232
234
|
rows={[
|
|
233
|
-
[t('stats.modelTime'), (stats.llmMs ?? 0) > 0 ? formatDuration(stats.llmMs!) : '—'],
|
|
234
|
-
[t('stats.toolTime'), (stats.toolMs ?? 0) > 0 ? formatDuration(stats.toolMs!) : '—'],
|
|
235
|
-
[t('stats.ttft'), (stats.ttftSteps ?? 0) > 0 ? formatDuration(stats.ttftMs! / stats.ttftSteps!) : '—'],
|
|
235
|
+
[t('stats.modelTime'), (stats.llmMs ?? 0) > 0 ? (formatDuration(stats.llmMs!) ?? '—') : '—'],
|
|
236
|
+
[t('stats.toolTime'), (stats.toolMs ?? 0) > 0 ? (formatDuration(stats.toolMs!) ?? '—') : '—'],
|
|
236
237
|
[
|
|
237
|
-
t('stats.
|
|
238
|
-
(stats.
|
|
239
|
-
? t('stats.tps', { tps: formatTokensPerSecond((stats.decodeTokens ?? 0) / ((stats.decodeMs ?? 1) / 1000)) })
|
|
240
|
-
: '—',
|
|
238
|
+
t('stats.ttft'),
|
|
239
|
+
(stats.ttftSteps ?? 0) > 0 ? (formatDuration(stats.ttftMs! / stats.ttftSteps!) ?? '—') : '—',
|
|
241
240
|
],
|
|
241
|
+
[t('stats.tpsLabel'), tps !== null ? t('stats.tps', { tps }) : '—'],
|
|
242
242
|
]}
|
|
243
243
|
/>
|
|
244
244
|
) : null}
|
|
@@ -252,6 +252,8 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
|
|
|
252
252
|
const { rootRef, panelRef, position } = useStatDialogSeat(open, onClose);
|
|
253
253
|
if (stats.totalTokens === undefined) return null;
|
|
254
254
|
const total = stats.totalTokens;
|
|
255
|
+
const totalText = formatTokens(total);
|
|
256
|
+
if (totalText === null) return null;
|
|
255
257
|
const cacheHitText =
|
|
256
258
|
stats.cacheHitText ??
|
|
257
259
|
formatCacheHitPercent(
|
|
@@ -267,14 +269,14 @@ function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void
|
|
|
267
269
|
aria-expanded={open}
|
|
268
270
|
aria-label={
|
|
269
271
|
cacheHitText !== null
|
|
270
|
-
? `${
|
|
271
|
-
:
|
|
272
|
+
? `${totalText} · ${t('stats.cacheHit', { percent: cacheHitText })}`
|
|
273
|
+
: totalText
|
|
272
274
|
}
|
|
273
275
|
onClick={onToggle}
|
|
274
276
|
>
|
|
275
277
|
<DatabaseIcon />
|
|
276
278
|
<span className="dci-stat-label">
|
|
277
|
-
{
|
|
279
|
+
{totalText}
|
|
278
280
|
{cacheHitText !== null ? (
|
|
279
281
|
<>
|
|
280
282
|
<span className="dci-stat-sep" aria-hidden="true">·</span>
|
|
@@ -320,7 +322,11 @@ export function StatsRow(props: StatsRowProps): JSX.Element | null {
|
|
|
320
322
|
const pressure = useProjection
|
|
321
323
|
? (useProjection('contextPressure') as PressureSnapshot | undefined)
|
|
322
324
|
: undefined;
|
|
323
|
-
const hasGauge =
|
|
325
|
+
const hasGauge =
|
|
326
|
+
pressure !== undefined &&
|
|
327
|
+
typeof pressure.contextWindow === 'number' &&
|
|
328
|
+
Number.isFinite(pressure.contextWindow) &&
|
|
329
|
+
pressure.contextWindow > 0;
|
|
324
330
|
// 面板打开期间其数据源消失(切换会话 / 投影短暂缺席)时自动收起,避免恢复后「自己重开」。
|
|
325
331
|
useEffect(() => {
|
|
326
332
|
if (open === null) return;
|
|
@@ -361,7 +367,9 @@ interface BreakdownSnapshot {
|
|
|
361
367
|
toolsTokens?: number;
|
|
362
368
|
}
|
|
363
369
|
|
|
370
|
+
/** 上下文分段 token 紧凑文本;NaN/Infinity/负值不渲染("—")。 */
|
|
364
371
|
function shortTokens(n: number): string {
|
|
372
|
+
if (!Number.isFinite(n) || n < 0) return '—';
|
|
365
373
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
|
|
366
374
|
if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`;
|
|
367
375
|
return `${n}`;
|
|
@@ -386,16 +394,25 @@ export function ContextGauge(props: {
|
|
|
386
394
|
? (useProjection('contextBreakdown') as BreakdownSnapshot | undefined)
|
|
387
395
|
: undefined;
|
|
388
396
|
|
|
389
|
-
|
|
390
|
-
|
|
397
|
+
const windowTokens = pressure?.contextWindow;
|
|
398
|
+
if (windowTokens === undefined || !Number.isFinite(windowTokens) || windowTokens <= 0) return null;
|
|
391
399
|
// 官方 contextOccupancy 语义:优先 projectedTokens(含投影增长),缺失退 pressureTokens。
|
|
392
|
-
|
|
393
|
-
const
|
|
400
|
+
// 任一脏值(NaN/Infinity/负数)都不进入几何计算。
|
|
401
|
+
const projected = pressure?.projectedTokens;
|
|
402
|
+
const pressureTokens = pressure?.pressureTokens;
|
|
403
|
+
const usedRaw = projected !== undefined ? projected : pressureTokens !== undefined ? pressureTokens : 0;
|
|
404
|
+
const usedTokens = Number.isFinite(usedRaw) && usedRaw >= 0 ? usedRaw : 0;
|
|
405
|
+
const pct = Math.min(100, Math.max(0, Math.round((usedTokens / windowTokens) * 100)));
|
|
394
406
|
const dash = (pct / 100) * GAUGE_CIRCUMFERENCE;
|
|
407
|
+
const breakdownTokens = (value: number | undefined): number =>
|
|
408
|
+
value !== undefined && Number.isFinite(value) && value > 0 ? value : 0;
|
|
409
|
+
const systemTokens = breakdownTokens(breakdown?.systemTokens);
|
|
410
|
+
const toolsTokens = breakdownTokens(breakdown?.toolsTokens);
|
|
411
|
+
const messageTokens = breakdownTokens(breakdown?.messageTokens);
|
|
395
412
|
const segments: readonly [string, number][] = [
|
|
396
|
-
['dci-gauge-colorSystem',
|
|
397
|
-
['dci-gauge-colorTools',
|
|
398
|
-
['dci-gauge-colorMessages',
|
|
413
|
+
['dci-gauge-colorSystem', systemTokens],
|
|
414
|
+
['dci-gauge-colorTools', toolsTokens],
|
|
415
|
+
['dci-gauge-colorMessages', messageTokens],
|
|
399
416
|
];
|
|
400
417
|
return (
|
|
401
418
|
<span className="dci-gauge-root" ref={rootRef}>
|
|
@@ -450,21 +467,21 @@ export function ContextGauge(props: {
|
|
|
450
467
|
<span className={`dci-gauge-swatch dci-gauge-colorSystem`} aria-hidden="true" />
|
|
451
468
|
{t("stats.prompt")}
|
|
452
469
|
</dt>
|
|
453
|
-
<dd>~{shortTokens(
|
|
470
|
+
<dd>~{shortTokens(systemTokens)}</dd>
|
|
454
471
|
</div>
|
|
455
472
|
<div className="dci-gauge-row">
|
|
456
473
|
<dt>
|
|
457
474
|
<span className={`dci-gauge-swatch dci-gauge-colorTools`} aria-hidden="true" />
|
|
458
475
|
{t("stats.tools")}
|
|
459
476
|
</dt>
|
|
460
|
-
<dd>~{shortTokens(
|
|
477
|
+
<dd>~{shortTokens(toolsTokens)}</dd>
|
|
461
478
|
</div>
|
|
462
479
|
<div className="dci-gauge-row">
|
|
463
480
|
<dt>
|
|
464
481
|
<span className={`dci-gauge-swatch dci-gauge-colorMessages`} aria-hidden="true" />
|
|
465
482
|
{t("stats.messages")}
|
|
466
483
|
</dt>
|
|
467
|
-
<dd>~{shortTokens(
|
|
484
|
+
<dd>~{shortTokens(messageTokens)}</dd>
|
|
468
485
|
</div>
|
|
469
486
|
</dl>
|
|
470
487
|
</div>,
|