dsh-codinput 0.1.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 +30 -0
- package/LICENSE +21 -0
- package/NOTICE +18 -0
- package/README.en.md +167 -0
- package/README.md +167 -0
- package/TESTING.md +95 -0
- package/assets/en/preview-ball-detail.png +0 -0
- package/assets/en/preview-float.png +0 -0
- package/assets/en/preview-normal.png +0 -0
- package/assets/en/preview-side.png +0 -0
- package/assets/en/preview-split.png +0 -0
- package/assets/icon.svg +1 -0
- 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/cordis.patch.yml +5 -0
- package/lib/client.js +544 -0
- package/lib/index.js +5 -0
- package/package.json +79 -0
- package/src/client/attachments.ts +43 -0
- package/src/client/cm.ts +207 -0
- package/src/client/command.ts +78 -0
- package/src/client/components/AttachmentRail.tsx +241 -0
- package/src/client/components/CodinputBar.tsx +357 -0
- package/src/client/components/CodinputSurface.tsx +375 -0
- package/src/client/components/EditorPane.tsx +214 -0
- package/src/client/components/ExpandBall.tsx +122 -0
- package/src/client/components/MenuSurface.tsx +72 -0
- package/src/client/components/ModelMenu.tsx +234 -0
- package/src/client/components/PermissionMenu.tsx +257 -0
- package/src/client/components/PopupSelectView.tsx +140 -0
- package/src/client/components/SlashMenu.tsx +255 -0
- package/src/client/components/StatsRow.tsx +188 -0
- package/src/client/components/Toolbar.tsx +239 -0
- package/src/client/components/popup-filter.ts +13 -0
- package/src/client/components/useSnapshot.ts +18 -0
- package/src/client/diag.ts +58 -0
- package/src/client/host-types.ts +328 -0
- package/src/client/hotkey.ts +82 -0
- package/src/client/i18n.ts +312 -0
- package/src/client/icons.tsx +296 -0
- package/src/client/index.tsx +178 -0
- package/src/client/modeldir.ts +42 -0
- package/src/client/permission.ts +123 -0
- package/src/client/prefs.ts +123 -0
- package/src/client/services.ts +78 -0
- package/src/client/settings.tsx +236 -0
- package/src/client/sidebar.tsx +114 -0
- package/src/client/sideinput.ts +171 -0
- package/src/client/stats.ts +166 -0
- package/src/client/styles.ts +489 -0
- package/src/client/triggers.ts +84 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* popupSelect 壳:官方 PopupSelectController 的 headless 状态渲染
|
|
3
|
+
* (命令的选项弹层,如 /model 类确认选择)。键盘与指针路由回控制器。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
7
|
+
import { filterOptions } from './popup-filter';
|
|
8
|
+
import type { PopupSelectController, PopupState } from '../host-types';
|
|
9
|
+
import { useSnapshot } from './useSnapshot';
|
|
10
|
+
import { t } from '../i18n';
|
|
11
|
+
|
|
12
|
+
export interface PopupSelectViewProps {
|
|
13
|
+
popup: PopupSelectController;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function PopupSelectView(props: PopupSelectViewProps): JSX.Element | null {
|
|
17
|
+
const { popup } = props;
|
|
18
|
+
const state = useSnapshot(popup.state) as PopupState | undefined;
|
|
19
|
+
const searchRef = useRef<HTMLInputElement | null>(null);
|
|
20
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
21
|
+
|
|
22
|
+
const filtered = useMemo(
|
|
23
|
+
() => (state ? filterOptions(state.options, state.search) : []),
|
|
24
|
+
[state?.options, state?.search],
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!state?.open) return;
|
|
29
|
+
const onDown = (event: PointerEvent): void => {
|
|
30
|
+
if (rootRef.current && event.target instanceof Node && rootRef.current.contains(event.target)) return;
|
|
31
|
+
popup.dismiss();
|
|
32
|
+
};
|
|
33
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
34
|
+
if (!state.open || state.submitting) return;
|
|
35
|
+
if (state.confirming) {
|
|
36
|
+
if (event.key === 'Escape') {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
popup.cancelConfirmation();
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (event.key === 'ArrowDown') {
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
popup.move(1);
|
|
45
|
+
} else if (event.key === 'ArrowUp') {
|
|
46
|
+
event.preventDefault();
|
|
47
|
+
popup.move(-1);
|
|
48
|
+
} else if (event.key === 'Enter') {
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
void popup.select(state.active);
|
|
51
|
+
} else if (event.key === 'Escape') {
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
popup.dismiss({ focusComposer: true });
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
window.addEventListener('pointerdown', onDown, true);
|
|
57
|
+
window.addEventListener('keydown', onKey, true);
|
|
58
|
+
return () => {
|
|
59
|
+
window.removeEventListener('pointerdown', onDown, true);
|
|
60
|
+
window.removeEventListener('keydown', onKey, true);
|
|
61
|
+
};
|
|
62
|
+
}, [state?.open, state?.submitting, state?.confirming, state?.active, popup]);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (state?.open) searchRef.current?.focus();
|
|
66
|
+
}, [state?.open]);
|
|
67
|
+
|
|
68
|
+
if (!state?.open) return null;
|
|
69
|
+
|
|
70
|
+
const confirming = state.confirming;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="dci-menu" ref={rootRef}>
|
|
74
|
+
{state.command ? <div className="dci-group-title">{state.command}</div> : null}
|
|
75
|
+
{state.status === 'failed' ? (
|
|
76
|
+
<div className="dci-error">{state.error ?? t('menu.loadFailed')}</div>
|
|
77
|
+
) : null}
|
|
78
|
+
{state.status === 'failed' ? (
|
|
79
|
+
<button type="button" className="dci-btn" onClick={() => popup.retry()}>
|
|
80
|
+
重试
|
|
81
|
+
</button>
|
|
82
|
+
) : null}
|
|
83
|
+
{state.status === 'ready' && !confirming ? (
|
|
84
|
+
<input
|
|
85
|
+
ref={searchRef}
|
|
86
|
+
className="dci-search"
|
|
87
|
+
type="text"
|
|
88
|
+
placeholder={t("menu.filter")}
|
|
89
|
+
value={state.search}
|
|
90
|
+
onChange={(e) => popup.setSearch(e.target.value)}
|
|
91
|
+
/>
|
|
92
|
+
) : null}
|
|
93
|
+
{confirming ? (
|
|
94
|
+
<div className="dci-confirm">
|
|
95
|
+
<div className="dci-row-name">{String(confirming.label ?? '')}</div>
|
|
96
|
+
{confirming.detail ? <div className="dci-row-desc">{String(confirming.detail)}</div> : null}
|
|
97
|
+
<label>
|
|
98
|
+
<input type="checkbox" checked={state.acknowledged} onChange={(e) => popup.acknowledge(e.target.checked)} />
|
|
99
|
+
<span>{t("popup.ack")}</span>
|
|
100
|
+
</label>
|
|
101
|
+
<div className="dci-confirm-actions">
|
|
102
|
+
<button type="button" className="dci-btn" onClick={() => popup.cancelConfirmation()}>
|
|
103
|
+
取消
|
|
104
|
+
</button>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
className="dci-btn"
|
|
108
|
+
data-primary="true"
|
|
109
|
+
disabled={state.submitting || !state.acknowledged}
|
|
110
|
+
onClick={() => void popup.confirm()}
|
|
111
|
+
>
|
|
112
|
+
确认
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
) : (
|
|
117
|
+
<>
|
|
118
|
+
{state.status === 'pending' ? <div className="dci-empty">{t('menu.loading')}</div> : null}
|
|
119
|
+
{state.status === 'ready' && filtered.length === 0 ? <div className="dci-empty">{t('menu.empty')}</div> : null}
|
|
120
|
+
{filtered.map((option, index) => (
|
|
121
|
+
<button
|
|
122
|
+
key={`${String(option.label)}:${index}`}
|
|
123
|
+
type="button"
|
|
124
|
+
className="dci-opt"
|
|
125
|
+
data-active={index === state.active || undefined}
|
|
126
|
+
onMouseEnter={() => popup.highlight(index)}
|
|
127
|
+
onClick={() => void popup.select(index)}
|
|
128
|
+
>
|
|
129
|
+
<span className="dci-opt-copy">
|
|
130
|
+
<span className="dci-opt-name">{String(option.label ?? '')}</span>
|
|
131
|
+
{option.detail ? <span className="dci-opt-desc">{String(option.detail)}</span> : null}
|
|
132
|
+
</span>
|
|
133
|
+
</button>
|
|
134
|
+
))}
|
|
135
|
+
</>
|
|
136
|
+
)}
|
|
137
|
+
{state.error && state.status === 'ready' ? <div className="dci-error">{state.error}</div> : null}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* '/' '@' 候选菜单:官方 MenuView 复刻。主模式为卡片顶部锚定的全宽
|
|
3
|
+
* 浮层(absolute bottom calc(100%+4px),随卡片等宽,max-height 320 随
|
|
4
|
+
* 上方空间收缩);侧栏形态(side)改为头部行下方**向下**弹出——卡片
|
|
5
|
+
* 铺满面板后上方没有空间,向下弹才能留在卡片边界内不被面板裁剪;
|
|
6
|
+
* crumbs 导航行在滚动区外;组标题按来源本地化(指令/技能/子智能体,
|
|
7
|
+
* 未知来源显示原名),item.section 生成小节标题;pending 空组出骨架屏;
|
|
8
|
+
* 拾取/悬停/面包屑/钻取全部路由回控制器(combobox 语义:
|
|
9
|
+
* aria-activedescendant,焦点不离开编辑器)。卡片内指针按下不关闭。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
13
|
+
import { t, tOptional } from '../i18n';
|
|
14
|
+
import type { InputTriggerController, MenuState } from '../host-types';
|
|
15
|
+
import { useSnapshot } from './useSnapshot';
|
|
16
|
+
import { IconChevronOfficial, IconFile, IconFolder, IconSearch } from '../icons';
|
|
17
|
+
|
|
18
|
+
export interface SlashMenuProps {
|
|
19
|
+
triggers: InputTriggerController;
|
|
20
|
+
/** 向下弹出(侧栏/悬浮形态):菜单锚在头部行下方、留在卡片边界内。 */
|
|
21
|
+
dropdown?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 组标题按来源映射到 i18n;未知来源回退原名(与官方同语义)。 */
|
|
25
|
+
const MAX_HEIGHT = 320;
|
|
26
|
+
|
|
27
|
+
function sourceLabel(source: string): string {
|
|
28
|
+
return tOptional(`menu.source.${source}`) ?? source;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function optionId(source: string, index: number): string {
|
|
32
|
+
return `dsh-slash-option-${source}-${index}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function candidateIcon(icon: unknown): JSX.Element | null {
|
|
36
|
+
if (icon === 'file') return <IconFile size={16} />;
|
|
37
|
+
if (icon === 'folder') return <IconFolder size={16} />;
|
|
38
|
+
if (icon === 'session') return <IconSearch size={16} />;
|
|
39
|
+
// 官方命令图标是 React 组件(primitives Icon*Outline16 同构),
|
|
40
|
+
// 菜单按 <icon size={16}/> 渲染(官方 MenuView 同语义)。
|
|
41
|
+
if (typeof icon === 'function') {
|
|
42
|
+
const Comp = icon as React.ComponentType<{ size?: number }>;
|
|
43
|
+
return <Comp size={16} />;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 右向 chevron(官方 IconChevronRightOutline14 的等价绘制)。 */
|
|
49
|
+
function ChevronRight({ size = 14 }: { size?: number }): JSX.Element {
|
|
50
|
+
return (
|
|
51
|
+
<span style={{ display: 'inline-flex', transform: 'rotate(-90deg)' }}>
|
|
52
|
+
<IconChevronOfficial size={size} />
|
|
53
|
+
</span>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function SlashMenu(props: SlashMenuProps): JSX.Element | null {
|
|
58
|
+
const { triggers, dropdown } = props;
|
|
59
|
+
const menu = useSnapshot(triggers.menu) as MenuState | undefined;
|
|
60
|
+
const headers = useSnapshot(triggers.headers);
|
|
61
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
62
|
+
const [maxHeight, setMaxHeight] = useState(MAX_HEIGHT);
|
|
63
|
+
/** 向下弹出时的菜单顶边(相对卡片顶;null = 向上弹出)。 */
|
|
64
|
+
const [menuTop, setMenuTop] = useState<number | null>(null);
|
|
65
|
+
|
|
66
|
+
const open = menu?.open === true;
|
|
67
|
+
const highlight = open ? menu.highlight ?? null : null;
|
|
68
|
+
|
|
69
|
+
// 可用空间收缩 max-height:向上弹出(主模式)菜单底边固定在卡片顶上方
|
|
70
|
+
// 4px,可吃满卡片到视口顶的距离;向下弹出(侧栏/悬浮)菜单顶边固定在
|
|
71
|
+
// 头部行下方,吃满卡片内头部行以下的距离——卡片靠视口顶时上方没有
|
|
72
|
+
// 空间,向下弹才能留在视口与卡片边界内。
|
|
73
|
+
useLayoutEffect(() => {
|
|
74
|
+
if (!open) return;
|
|
75
|
+
const card = rootRef.current?.closest('.dci-card');
|
|
76
|
+
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
|
+
const top = card.getBoundingClientRect().top;
|
|
95
|
+
setMaxHeight(Math.max(120, Math.min(MAX_HEIGHT, top - 16)));
|
|
96
|
+
}, [open, menu?.generation, dropdown]);
|
|
97
|
+
|
|
98
|
+
// 高亮行滚动进视野(官方 scrollIntoView block nearest)。
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!highlight) return;
|
|
101
|
+
document
|
|
102
|
+
.getElementById(optionId(highlight.source, highlight.index))
|
|
103
|
+
?.scrollIntoView({ block: 'nearest' });
|
|
104
|
+
}, [highlight]);
|
|
105
|
+
|
|
106
|
+
// 外部指针按下关闭;卡片内(含工具行)不关闭(官方 dismiss 语义)。
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (!open) return;
|
|
109
|
+
const onPointerDown = (event: PointerEvent): void => {
|
|
110
|
+
if (!(event.target instanceof Node)) return;
|
|
111
|
+
if (rootRef.current?.contains(event.target)) return;
|
|
112
|
+
if (rootRef.current?.closest('[data-composer-card]')?.contains(event.target)) return;
|
|
113
|
+
triggers.dismiss();
|
|
114
|
+
};
|
|
115
|
+
document.addEventListener('pointerdown', onPointerDown, true);
|
|
116
|
+
return () => document.removeEventListener('pointerdown', onPointerDown, true);
|
|
117
|
+
}, [open, triggers]);
|
|
118
|
+
|
|
119
|
+
if (!open || !menu) return null;
|
|
120
|
+
|
|
121
|
+
const crumbsFor = (source: string) => headers?.get(source);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div
|
|
125
|
+
ref={rootRef}
|
|
126
|
+
className="dci-slash-menu"
|
|
127
|
+
data-trigger-menu=""
|
|
128
|
+
data-dropdown={dropdown || undefined}
|
|
129
|
+
style={dropdown ? { top: menuTop ?? 38, maxHeight } : { maxHeight }}
|
|
130
|
+
>
|
|
131
|
+
{menu.groups.map((group) => {
|
|
132
|
+
const trail = crumbsFor(group.source);
|
|
133
|
+
if (trail === undefined || trail.length === 0) return null;
|
|
134
|
+
return (
|
|
135
|
+
<nav key={`${group.source}:crumbs`} className="dci-slash-crumbs" aria-label={t("menu.nav")}>
|
|
136
|
+
{trail.map((crumb, index) => (
|
|
137
|
+
<span key={`${index}-${crumb.value}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
|
|
138
|
+
{index > 0 ? (
|
|
139
|
+
<span className="dci-slash-crumbSeparator" aria-hidden="true">
|
|
140
|
+
<ChevronRight size={12} />
|
|
141
|
+
</span>
|
|
142
|
+
) : null}
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
className="dci-slash-crumb"
|
|
146
|
+
data-current={crumb.current === true || undefined}
|
|
147
|
+
aria-current={crumb.current === true ? 'location' : undefined}
|
|
148
|
+
disabled={crumb.current === true}
|
|
149
|
+
onMouseDown={(e) => {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
triggers.pickCrumb(group.source, index);
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
{crumb.label}
|
|
155
|
+
</button>
|
|
156
|
+
</span>
|
|
157
|
+
))}
|
|
158
|
+
</nav>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
<div
|
|
162
|
+
className="dci-slash-viewport"
|
|
163
|
+
role="listbox"
|
|
164
|
+
aria-label={t("menu.triggerCandidates")}
|
|
165
|
+
aria-activedescendant={highlight ? optionId(highlight.source, highlight.index) : undefined}
|
|
166
|
+
>
|
|
167
|
+
{menu.groups.map((group) => {
|
|
168
|
+
if (group.status === 'ready' && group.items.length === 0) return null;
|
|
169
|
+
const hasSections = group.items.some((item) => item.section !== undefined);
|
|
170
|
+
return (
|
|
171
|
+
<Fragment key={group.source}>
|
|
172
|
+
{group.showGroupTitle === false || hasSections ? null : (
|
|
173
|
+
<div className="dci-slash-groupTitle" role="presentation" data-source={group.source}>
|
|
174
|
+
{sourceLabel(group.source)}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
{group.status === 'pending' && group.items.length === 0 ? (
|
|
178
|
+
<div role="status" aria-label={tOptional("menu.loading")} data-source={group.source}>
|
|
179
|
+
<div className="dci-slash-skeletonRow">
|
|
180
|
+
<span className="dci-slash-skeletonBar" style={{ width: '32%' }} />
|
|
181
|
+
</div>
|
|
182
|
+
<div className="dci-slash-skeletonRow">
|
|
183
|
+
<span className="dci-slash-skeletonBar" style={{ width: '48%' }} />
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
) : (
|
|
187
|
+
group.items.map((item, index) => {
|
|
188
|
+
const active = highlight?.source === group.source && highlight.index === index;
|
|
189
|
+
return (
|
|
190
|
+
<Fragment key={optionId(group.source, index)}>
|
|
191
|
+
{item.section !== undefined && item.section !== group.items[index - 1]?.section ? (
|
|
192
|
+
<div className="dci-slash-sectionTitle" role="presentation">
|
|
193
|
+
{item.section}
|
|
194
|
+
</div>
|
|
195
|
+
) : null}
|
|
196
|
+
<button
|
|
197
|
+
id={optionId(group.source, index)}
|
|
198
|
+
type="button"
|
|
199
|
+
role="option"
|
|
200
|
+
aria-selected={active}
|
|
201
|
+
className="dci-slash-item"
|
|
202
|
+
data-active={active || undefined}
|
|
203
|
+
onMouseDown={(e) => {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
triggers.pick(group.source, index);
|
|
206
|
+
}}
|
|
207
|
+
onMouseMove={active ? undefined : () => triggers.hover(group.source, index)}
|
|
208
|
+
>
|
|
209
|
+
{item.icon !== undefined ? (
|
|
210
|
+
<span className="dci-slash-itemIcon" aria-hidden="true">
|
|
211
|
+
{candidateIcon(item.icon)}
|
|
212
|
+
</span>
|
|
213
|
+
) : null}
|
|
214
|
+
{/* 官方语义:主名 = label ?? name;label 与 name 不同时另出灰色别名。 */}
|
|
215
|
+
<span className="dci-slash-itemName">{item.label ?? item.name}</span>
|
|
216
|
+
{item.label !== undefined && item.label.toLowerCase() !== item.name.toLowerCase() ? (
|
|
217
|
+
<span className="dci-slash-itemAlias">{item.name}</span>
|
|
218
|
+
) : null}
|
|
219
|
+
{item.description !== undefined ? (
|
|
220
|
+
<span className="dci-slash-itemDescription">{item.description}</span>
|
|
221
|
+
) : null}
|
|
222
|
+
{item.drill === true ? (
|
|
223
|
+
<span className="dci-slash-trailing">
|
|
224
|
+
<span className="dci-slash-drillHintText" aria-hidden="true">
|
|
225
|
+
进入目录
|
|
226
|
+
</span>
|
|
227
|
+
<kbd className="dci-slash-drillHint" aria-hidden="true">
|
|
228
|
+
Tab
|
|
229
|
+
</kbd>
|
|
230
|
+
<span
|
|
231
|
+
role="button"
|
|
232
|
+
aria-label={tOptional("menu.drill")}
|
|
233
|
+
className="dci-slash-drill"
|
|
234
|
+
onMouseDown={(e) => {
|
|
235
|
+
e.preventDefault();
|
|
236
|
+
e.stopPropagation();
|
|
237
|
+
triggers.pick(group.source, index, 'drill');
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
<ChevronRight size={14} />
|
|
241
|
+
</span>
|
|
242
|
+
</span>
|
|
243
|
+
) : null}
|
|
244
|
+
</button>
|
|
245
|
+
</Fragment>
|
|
246
|
+
);
|
|
247
|
+
})
|
|
248
|
+
)}
|
|
249
|
+
</Fragment>
|
|
250
|
+
);
|
|
251
|
+
})}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/** 数据行(卡片下方):官方 composer stats 复刻(bOPqQW + bRhRbq)——两枚
|
|
2
|
+
* 胶囊(仪表盘「轮/步 · tok/s」、数据库「累计 tok · 缓存命中」),点击弹出
|
|
3
|
+
* 官方同构详情面板(会话统计 / Token 用量),锚点上方弹出、点外关闭。 */
|
|
4
|
+
|
|
5
|
+
import { useEffect, useRef, useState } from 'react';
|
|
6
|
+
import { useSessionStats, formatTokens, type SessionStats } from '../stats';
|
|
7
|
+
import type { UseProjection } from '../host-types';
|
|
8
|
+
import { t } from '../i18n';
|
|
9
|
+
|
|
10
|
+
export interface StatsRowProps {
|
|
11
|
+
useProjection: UseProjection | undefined;
|
|
12
|
+
sessionId: string | undefined;
|
|
13
|
+
/** chat 树解析的轮次计时(模型用时 / TTFT)。 */
|
|
14
|
+
chatTimings?: { modelMs?: number; ttftMs?: number };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 官方仪表盘 glyph(弧 + 指针 + 中点,stroke 1.25)。 */
|
|
18
|
+
export function GaugeIcon({ size = 14 }: { size?: number }): JSX.Element {
|
|
19
|
+
return (
|
|
20
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
21
|
+
<path d="M3.49 13.26A6.375 6.375 0 1 1 12.51 13.26" stroke="currentColor" strokeWidth="1.25" />
|
|
22
|
+
<path d="M8 8.75L11.4 5.35" stroke="currentColor" strokeWidth="1.25" />
|
|
23
|
+
<circle cx="8" cy="8.75" r="1.55" fill="currentColor" />
|
|
24
|
+
</svg>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 官方数据库 glyph(椭圆柱,stroke 1.25)。 */
|
|
29
|
+
export function DatabaseIcon({ size = 14 }: { size?: number }): JSX.Element {
|
|
30
|
+
return (
|
|
31
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
32
|
+
<ellipse cx="8" cy="3.6" rx="5.75" ry="2.4" stroke="currentColor" strokeWidth="1.25" />
|
|
33
|
+
<path d="M2.25 3.6V12.3A5.75 2.4 0 0 0 13.75 12.3V3.6" stroke="currentColor" strokeWidth="1.25" />
|
|
34
|
+
<path d="M2.25 7.95A5.75 2.4 0 0 0 13.75 7.95" stroke="currentColor" strokeWidth="1.25" />
|
|
35
|
+
</svg>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 官方详情面板(bRhRbq:标题行 + 发丝线 + dt/dd 栅格)。 */
|
|
40
|
+
function StatPanel(props: {
|
|
41
|
+
label: string;
|
|
42
|
+
icon: JSX.Element;
|
|
43
|
+
titleValue?: string;
|
|
44
|
+
rows: readonly [string, string][];
|
|
45
|
+
}): JSX.Element {
|
|
46
|
+
const { label, icon, titleValue, rows } = props;
|
|
47
|
+
return (
|
|
48
|
+
<div className="dci-stat-panel" role="dialog" aria-label={label}>
|
|
49
|
+
<div className="dci-stat-panelTitle">
|
|
50
|
+
<span className="dci-stat-panelLabel">
|
|
51
|
+
{icon}
|
|
52
|
+
{label}
|
|
53
|
+
</span>
|
|
54
|
+
{titleValue !== undefined ? <span className="dci-stat-panelValue">{titleValue}</span> : null}
|
|
55
|
+
</div>
|
|
56
|
+
<div className="dci-stat-panelRule" aria-hidden="true" />
|
|
57
|
+
<dl className="dci-stat-details">
|
|
58
|
+
{rows.map(([dt, dd]) => (
|
|
59
|
+
<div key={dt} style={{ display: 'contents' }}>
|
|
60
|
+
<dt>{dt}</dt>
|
|
61
|
+
<dd>{dd}</dd>
|
|
62
|
+
</div>
|
|
63
|
+
))}
|
|
64
|
+
</dl>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function Pill(props: { stats: SessionStats; open: boolean; onToggle(): void }): JSX.Element {
|
|
70
|
+
const { stats, open, onToggle } = props;
|
|
71
|
+
const roundsPart =
|
|
72
|
+
stats.turns !== undefined && stats.steps !== undefined
|
|
73
|
+
? t('stats.turns', { turns: stats.turns, steps: stats.steps })
|
|
74
|
+
: stats.turns !== undefined
|
|
75
|
+
? t('stats.turns', { turns: stats.turns, steps: 0 })
|
|
76
|
+
: t('stats.turns', { turns: 0, steps: 0 });
|
|
77
|
+
const speedPart = stats.tokensPerSecond !== undefined ? `${Math.round(stats.tokensPerSecond)} tok/s` : null;
|
|
78
|
+
const modelSec = stats.modelMs !== undefined ? stats.modelMs / 1000 : null;
|
|
79
|
+
const ttftSec = stats.ttftMs !== undefined ? stats.ttftMs / 1000 : null;
|
|
80
|
+
return (
|
|
81
|
+
<span className="dci-stat-anchor">
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
className="dci-stat-pill"
|
|
85
|
+
aria-haspopup="dialog"
|
|
86
|
+
aria-expanded={open}
|
|
87
|
+
onClick={onToggle}
|
|
88
|
+
>
|
|
89
|
+
<GaugeIcon />
|
|
90
|
+
<span className="dci-stat-label">
|
|
91
|
+
{roundsPart}
|
|
92
|
+
{speedPart !== null ? (
|
|
93
|
+
<>
|
|
94
|
+
<span className="dci-stat-sep" aria-hidden="true">·</span>
|
|
95
|
+
{speedPart}
|
|
96
|
+
</>
|
|
97
|
+
) : null}
|
|
98
|
+
</span>
|
|
99
|
+
</button>
|
|
100
|
+
{open ? (
|
|
101
|
+
<StatPanel
|
|
102
|
+
label={t("stats.session")}
|
|
103
|
+
icon={<GaugeIcon />}
|
|
104
|
+
rows={[
|
|
105
|
+
[t('stats.modelTime'), modelSec !== null ? `${modelSec.toFixed(1)}s` : '—'],
|
|
106
|
+
[t('stats.ttft'), ttftSec !== null ? `${ttftSec.toFixed(1)}s` : '—'],
|
|
107
|
+
[t('stats.tpsLabel'), speedPart ?? '—'],
|
|
108
|
+
]}
|
|
109
|
+
/>
|
|
110
|
+
) : null}
|
|
111
|
+
</span>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function TokenPill(props: { stats: SessionStats; open: boolean; onToggle(): void }): JSX.Element | null {
|
|
116
|
+
const { stats, open, onToggle } = props;
|
|
117
|
+
if (stats.totalTokens === undefined) return null;
|
|
118
|
+
const comma = (n: number): string => Math.round(n).toLocaleString('en-US');
|
|
119
|
+
return (
|
|
120
|
+
<span className="dci-stat-anchor">
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
className="dci-stat-pill"
|
|
124
|
+
aria-haspopup="dialog"
|
|
125
|
+
aria-expanded={open}
|
|
126
|
+
onClick={onToggle}
|
|
127
|
+
>
|
|
128
|
+
<DatabaseIcon />
|
|
129
|
+
<span className="dci-stat-label">
|
|
130
|
+
{formatTokens(stats.totalTokens)}
|
|
131
|
+
{stats.cacheHitPct !== undefined ? (
|
|
132
|
+
<>
|
|
133
|
+
<span className="dci-stat-sep" aria-hidden="true">·</span>
|
|
134
|
+
缓存命中 {Math.round(stats.cacheHitPct)}%
|
|
135
|
+
</>
|
|
136
|
+
) : null}
|
|
137
|
+
</span>
|
|
138
|
+
</button>
|
|
139
|
+
{open ? (
|
|
140
|
+
<StatPanel
|
|
141
|
+
label={t("stats.usage")}
|
|
142
|
+
icon={<DatabaseIcon />}
|
|
143
|
+
titleValue={`${comma(stats.totalTokens)} tok`}
|
|
144
|
+
rows={[
|
|
145
|
+
[t('stats.cacheHitLabel'), stats.cacheHitPct !== undefined ? `${Math.round(stats.cacheHitPct)}%` : '—'],
|
|
146
|
+
[t('stats.uncached'), `${comma(stats.uncachedInputTokens ?? 0)} tok`],
|
|
147
|
+
[t('stats.cacheRead'), `${comma(stats.cacheReadTokens ?? 0)} tok`],
|
|
148
|
+
[t('stats.output'), `${comma(stats.outputTokens ?? 0)} tok`],
|
|
149
|
+
]}
|
|
150
|
+
/>
|
|
151
|
+
) : null}
|
|
152
|
+
</span>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function StatsRow(props: StatsRowProps): JSX.Element | null {
|
|
157
|
+
const { useProjection, sessionId, chatTimings } = props;
|
|
158
|
+
const [open, setOpen] = useState<'rounds' | 'tokens' | null>(null);
|
|
159
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
160
|
+
const sampled = useSessionStats(useProjection, sessionId);
|
|
161
|
+
// chat 树计时优先(历史会话也有值),采样值兜底。
|
|
162
|
+
const stats = sampled
|
|
163
|
+
? {
|
|
164
|
+
...sampled,
|
|
165
|
+
...(chatTimings?.modelMs !== undefined ? { modelMs: chatTimings.modelMs } : {}),
|
|
166
|
+
...(chatTimings?.ttftMs !== undefined ? { ttftMs: chatTimings.ttftMs } : {}),
|
|
167
|
+
}
|
|
168
|
+
: sampled;
|
|
169
|
+
|
|
170
|
+
// 点外面关闭(面板内/胶囊上不关)。
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (open === null) return;
|
|
173
|
+
const onDown = (event: PointerEvent): void => {
|
|
174
|
+
if (rootRef.current && event.target instanceof Node && rootRef.current.contains(event.target)) return;
|
|
175
|
+
setOpen(null);
|
|
176
|
+
};
|
|
177
|
+
window.addEventListener('pointerdown', onDown, true);
|
|
178
|
+
return () => window.removeEventListener('pointerdown', onDown, true);
|
|
179
|
+
}, [open]);
|
|
180
|
+
|
|
181
|
+
if (!stats) return null;
|
|
182
|
+
return (
|
|
183
|
+
<div className="dci-stat-root" data-composer-stats="codinput" ref={rootRef}>
|
|
184
|
+
<Pill stats={stats} open={open === 'rounds'} onToggle={() => setOpen(open === 'rounds' ? null : 'rounds')} />
|
|
185
|
+
<TokenPill stats={stats} open={open === 'tokens'} onToggle={() => setOpen(open === 'tokens' ? null : 'tokens')} />
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|