dsh-quick-compact 0.0.0-stage → 1.5.3
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/LICENSE +21 -0
- package/README.md +97 -2
- package/client.js +802 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +5 -0
- package/index.js +2 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +52 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FrontierMindLab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,98 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-quick-compact
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[中文](#中文) · [English](#english)
|
|
4
|
+
|
|
5
|
+
每次压缩都要手打 `/compact`?在 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)输入框工具栏里点一下小环就行。
|
|
6
|
+
|
|
7
|
+
**小环形态:输入框工具栏里的一枚小环**
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<img src="https://raw.githubusercontent.com/FrontierMindLab/dsh-quick-compact/main/plugins/dsh-quick-compact/docs/location.gif" alt="小环在输入框工具栏里的位置" width="720">
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
**卡片形态:点开就是面板**
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<img src="https://raw.githubusercontent.com/FrontierMindLab/dsh-quick-compact/main/plugins/dsh-quick-compact/docs/panel.png" alt="面板示意:大环 + 用量构成 + /compact 行" width="720">
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
**点一下的样子**
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="https://raw.githubusercontent.com/FrontierMindLab/dsh-quick-compact/main/plugins/dsh-quick-compact/docs/demo.gif" alt="演示:点开面板 → 一键压缩 → 压缩烟花" width="720">
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
GIF 是示意:插件里的压缩烟花更细腻,动画约 2.2 秒,而且是整页放,不打开面板也会放。
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
<img src="https://raw.githubusercontent.com/FrontierMindLab/dsh-quick-compact/main/plugins/dsh-quick-compact/docs/fireworks.gif" alt="压缩成功后的烟花" width="720">
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
## 中文
|
|
32
|
+
|
|
33
|
+
### 它是什么
|
|
34
|
+
|
|
35
|
+
DSH 输入框右下角本来就有一只官方上下文环,但它只显示数字;想压缩得手打 `/compact`。
|
|
36
|
+
|
|
37
|
+
这个插件在**输入框工具栏**里再放一枚更小的环(18px + 百分比),点它一次展开用量构成,再点 `/compact` 那一行就执行官方压缩——和手打命令完全等价,压缩成功还有一簇烟花。
|
|
38
|
+
|
|
39
|
+
### 功能
|
|
40
|
+
|
|
41
|
+
- **用量小环**:读宿主的 `contextPressure` 投影(已用 ÷ 路由容量),显示整数百分比;环随用量填满,高压时自动变色(青柠 → 橙 → 红)。
|
|
42
|
+
- **点开面板**:大号环 + `上下文已用 N%` + `≈ 148k / 1.0M`,下面是 `contextBreakdown` 投影给出的构成条:系统提示 / 工具定义 / 对话内容,各自 token 数。
|
|
43
|
+
- **执行压缩**:点 `/compact 压缩上下文` → 调命令通道 `remote.commands.execute(sessionId, '/compact', [])`,与手打命令等价;失败把宿主返回的错误原样显示。
|
|
44
|
+
- **压缩烟花**(v1.4.0+):压缩发生那一刻,环心炸开一簇小方点 + 一圈冲击波,约 2.2 秒散尽;铺在整个页面上,**不打开面板也放**(插件按钮调用未明确失败时播放;上下文用量一次下降 15 个百分点以上也会触发);`pointer-events:none`,不挡输入。`prefers-reduced-motion` 时不放。
|
|
45
|
+
- **不替换官方 UI**:官方那只环原样保留,插件只在自己的插槽里画,零侵入。
|
|
46
|
+
|
|
47
|
+
### 安装
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
# 从插件市场 / npm 形态安装(DSH 0.2.x)
|
|
51
|
+
dsh plugin --profile <profile> add dsh-quick-compact
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
或本地目录安装:插件管理器 `install_bundle` → 选本目录,装完硬刷新页面(`Cmd/Ctrl + Shift + R`)。
|
|
55
|
+
|
|
56
|
+
### 兼容
|
|
57
|
+
|
|
58
|
+
- DSH 0.2.x(桌面版 / Web 版),客户端插槽 `conversation.input.right`,纯增量实现。
|
|
59
|
+
- 依赖宿主投影 `contextPressure`(`@deepseek-ai/dsh-token-meter` 注册,随 DSH 基础包提供)与命令通道 `remote.commands`;缺任一个时降级为「只有压缩图标」,不报错。
|
|
60
|
+
|
|
61
|
+
### 相关插件与致谢
|
|
62
|
+
|
|
63
|
+
- 自动压缩引擎是 DSH 官方的 `@deepseek-ai/dsh-compaction-basic`;本插件只触发官方 `/compact`,不自带压缩实现。
|
|
64
|
+
- 可选联动 [windwhiterain/dsh-compaction-threshold](https://github.com/windwhiterain/dsh-compaction-threshold)(MIT):装了它,面板里会多一条压缩阈值滑块,通过其公开 HTTP 接口读写;**未复制其代码**,未安装时该控件自动隐藏。
|
|
65
|
+
|
|
66
|
+
### 许可
|
|
67
|
+
|
|
68
|
+
MIT,见 [LICENSE](LICENSE)。
|
|
69
|
+
|
|
70
|
+
## English
|
|
71
|
+
|
|
72
|
+
A tiny ring gauge in the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) composer toolbar: see how full your context is, open a breakdown, and compact with one click.
|
|
73
|
+
|
|
74
|
+
The GIFs above are illustrative — see the Fireworks bullet for the real behavior and triggers.
|
|
75
|
+
|
|
76
|
+
### What it does
|
|
77
|
+
|
|
78
|
+
- **Ring gauge**: reads the host's `contextPressure` projection, shows an integer percentage, fills as the context grows, and shifts color under pressure (lime → amber → red).
|
|
79
|
+
- **Popover**: big ring + `context used N%` + `≈ 148k / 1.0M`, plus a breakdown of system prompt / tool definitions / conversation with token counts from `contextBreakdown`.
|
|
80
|
+
- **One-click compact**: clicking `/compact` runs the official command channel (`remote.commands.execute(sessionId, '/compact', [])`) — identical to typing it yourself.
|
|
81
|
+
- **Fireworks**: on a compact, a small burst of particles and a shockwave ring play for about 2.2s on a `pointer-events:none` canvas overlay — with or without the panel open (when the plugin's `/compact` call does not explicitly fail, and when context usage drops by ≥15 percentage points); skipped under `prefers-reduced-motion`.
|
|
82
|
+
- **Non-invasive**: the built-in context meter stays untouched; the plugin renders in its own slot.
|
|
83
|
+
|
|
84
|
+
### Install
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
dsh plugin --profile <profile> add dsh-quick-compact
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Or install the directory through the plugin manager, then hard-refresh the page.
|
|
91
|
+
|
|
92
|
+
### Compatibility
|
|
93
|
+
|
|
94
|
+
DSH 0.2.x (desktop / web). Uses the `conversation.input.right` client slot and degrades gracefully when `contextPressure` or `remote.commands` is unavailable.
|
|
95
|
+
|
|
96
|
+
### License
|
|
97
|
+
|
|
98
|
+
MIT. See [LICENSE](LICENSE).
|
package/client.js
ADDED
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-quick-compact',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react');
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
const NS = 'quick-compact';
|
|
7
|
+
|
|
8
|
+
// ── 文案(zh/en 同时注册,跟随界面语言) ────────────────────────────
|
|
9
|
+
const ZH = {
|
|
10
|
+
compact: '压缩',
|
|
11
|
+
tip: '上下文已用 {percent}% · 点击压缩',
|
|
12
|
+
tipUnknown: '压缩上下文(等价于输入 /compact)',
|
|
13
|
+
aria: '上下文已用 {percent}%,打开压缩面板',
|
|
14
|
+
title: '压缩上下文',
|
|
15
|
+
used: '上下文已用',
|
|
16
|
+
idle: '等你发下一条消息后开始统计',
|
|
17
|
+
breakdownSystem: '系统提示',
|
|
18
|
+
breakdownTools: '工具定义',
|
|
19
|
+
breakdownMessages: '对话内容',
|
|
20
|
+
action: '压缩上下文',
|
|
21
|
+
busy: '压缩中…',
|
|
22
|
+
done: '已提交压缩',
|
|
23
|
+
failed: '压缩失败',
|
|
24
|
+
noApi: '命令通道没就绪,稍后再试',
|
|
25
|
+
close: '关闭',
|
|
26
|
+
thLabel: '自动压缩阈值',
|
|
27
|
+
thHint: '占用到 {percent}% 时自动压缩',
|
|
28
|
+
thOff: '阈值控制不可用(没有找到可调的压缩引擎)',
|
|
29
|
+
};
|
|
30
|
+
const EN = {
|
|
31
|
+
compact: 'Compact',
|
|
32
|
+
tip: '{percent}% of context used · click to compact',
|
|
33
|
+
tipUnknown: 'Compact context (same as typing /compact)',
|
|
34
|
+
aria: '{percent}% of context used, open the compact panel',
|
|
35
|
+
title: 'Compact context',
|
|
36
|
+
used: 'Context used',
|
|
37
|
+
idle: 'Stats appear after the next message',
|
|
38
|
+
breakdownSystem: 'System prompt',
|
|
39
|
+
breakdownTools: 'Tool definitions',
|
|
40
|
+
breakdownMessages: 'Conversation',
|
|
41
|
+
action: 'Compact context',
|
|
42
|
+
busy: 'Compacting…',
|
|
43
|
+
done: 'Compaction submitted',
|
|
44
|
+
failed: 'Compaction failed',
|
|
45
|
+
noApi: 'Command channel is not ready yet',
|
|
46
|
+
close: 'Close',
|
|
47
|
+
thLabel: 'Auto-compact at',
|
|
48
|
+
thHint: 'Compacts automatically at {percent}% of context',
|
|
49
|
+
thOff: 'Threshold control unavailable (no adjustable compaction engine found)',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// 宿主命令通道(remote.commands.execute(sessionId, '/compact', [])),与手打 /compact 等价
|
|
53
|
+
let commandApi = null;
|
|
54
|
+
|
|
55
|
+
// dsh-compaction-threshold 的宿主接口(GET 读 / POST 写自动压缩阈值)
|
|
56
|
+
const THRESHOLD_URL = '/compaction-threshold/api/state';
|
|
57
|
+
|
|
58
|
+
// ── 视觉规格(跟官方 ContextMeter 对齐:14px 环 + 13px 等宽数字) ─────
|
|
59
|
+
const RING_SIZE = 18;
|
|
60
|
+
const RING_STROKE = 2;
|
|
61
|
+
const PANEL_RING = 34;
|
|
62
|
+
const PANEL_STROKE = 2.5;
|
|
63
|
+
const PANEL_WIDTH = 264;
|
|
64
|
+
|
|
65
|
+
// 环的三档状态色:超过 30% 就转琥珀提示(2026-10-29 所有者要求,原 70%),80% 转红
|
|
66
|
+
const TINT = {
|
|
67
|
+
normal: '#AEF000',
|
|
68
|
+
warn: 'var(--dsw-alias-state-warn-primary)',
|
|
69
|
+
hot: 'var(--dsw-alias-state-error-primary)',
|
|
70
|
+
};
|
|
71
|
+
const SEG = [
|
|
72
|
+
{ key: 'systemTokens', label: 'breakdownSystem', color: 'var(--dsw-static-neutral-bluish-400, #adb2b8)' },
|
|
73
|
+
{ key: 'toolsTokens', label: 'breakdownTools', color: '#a78bfa' },
|
|
74
|
+
{ key: 'messageTokens', label: 'breakdownMessages', color: 'var(--dsw-static-blue-450, #4d93f8)' },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const STYLE_ID = 'dsh-quick-compact-style';
|
|
78
|
+
const CSS = [
|
|
79
|
+
// 触发控件:官方用量表同款「小环 + 百分比」,做成可点的幽灵按钮
|
|
80
|
+
'.qc-trigger{display:inline-flex;align-items:center;gap:7px;flex:none;height:26px;margin:0;padding:0 8px;',
|
|
81
|
+
'border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);',
|
|
82
|
+
'font:inherit;font-size:13px;line-height:20px;cursor:pointer;white-space:nowrap;',
|
|
83
|
+
'-webkit-appearance:none;appearance:none;transition:background .15s ease,color .15s ease}',
|
|
84
|
+
'.qc-trigger:hover,.qc-trigger[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2));',
|
|
85
|
+
'color:var(--dsw-alias-label-primary)}',
|
|
86
|
+
'.qc-trigger:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;border-radius:6px}',
|
|
87
|
+
'.qc-trigger:disabled{opacity:.55;cursor:default}',
|
|
88
|
+
'.qc-trigger__pct{display:inline-flex;align-items:baseline;font-variant-numeric:tabular-nums;',
|
|
89
|
+
'transition:color .15s ease}',
|
|
90
|
+
'.qc-trigger__num{font-size:14px;font-weight:600;letter-spacing:-.2px;line-height:1}',
|
|
91
|
+
'.qc-trigger__unit{font-size:9.5px;font-weight:600;line-height:1;margin-left:1px;',
|
|
92
|
+
'color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))} ',
|
|
93
|
+
'.qc-ring{display:block;overflow:visible;flex:none;transition:transform .18s cubic-bezier(.22,.61,.36,1)}',
|
|
94
|
+
'.qc-trigger:hover .qc-ring,.qc-trigger[aria-expanded="true"] .qc-ring{transform:scale(1.08)}',
|
|
95
|
+
'.qc-track{fill:none;stroke:var(--dsw-alias-border-l3,var(--dsw-alias-border-l2))}',
|
|
96
|
+
'.qc-fill{fill:none;stroke-linecap:round;transition:stroke-dasharray .45s cubic-bezier(.22,.61,.36,1),stroke .2s ease}',
|
|
97
|
+
'.qc-glyph{display:inline-flex;flex:none}',
|
|
98
|
+
// 面板:沿用宿主菜单面板的表面/模糊/阴影令牌,跟原生弹层一致
|
|
99
|
+
'.qc-panel{position:fixed;left:0;top:0;right:auto;bottom:auto;margin:0;z-index:2147483000;width:' + PANEL_WIDTH + 'px;box-sizing:border-box;padding:12px;',
|
|
100
|
+
'max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow:auto;',
|
|
101
|
+
'border:0;border-radius:var(--dsw-radius-lg,16px);background:var(--dsw-specific-menu,var(--dsw-alias-bg-overlay));',
|
|
102
|
+
'-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));',
|
|
103
|
+
'backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));',
|
|
104
|
+
'box-shadow:var(--dsw-elevation-prominent,0 12px 32px rgba(0,0,0,.34));',
|
|
105
|
+
'color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:20px}',
|
|
106
|
+
'.qc-head{display:flex;align-items:center;gap:10px}',
|
|
107
|
+
'.qc-head__body{display:flex;flex-direction:column;min-width:0;flex:1}',
|
|
108
|
+
'.qc-head__line{display:flex;align-items:baseline;gap:4px;white-space:nowrap}',
|
|
109
|
+
'.qc-head__label{font-size:11px;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}',
|
|
110
|
+
'.qc-head__pct{color:var(--dsw-alias-label-primary);font-weight:500;font-variant-numeric:tabular-nums}',
|
|
111
|
+
'.qc-head__figs{font-size:11px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;',
|
|
112
|
+
'white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
|
|
113
|
+
'.qc-bar{display:flex;gap:1px;height:4px;border-radius:999px;overflow:hidden;',
|
|
114
|
+
'background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2));margin:12px 0 0}',
|
|
115
|
+
'.qc-bar__seg{height:100%;border-radius:1px;flex:none;min-width:2px}',
|
|
116
|
+
'.qc-rows{display:flex;flex-direction:column;margin:6px 0 0;padding:0;list-style:none}',
|
|
117
|
+
'.qc-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:2px 0;font-size:12px;margin:0}',
|
|
118
|
+
'.qc-row__label{display:flex;align-items:center;color:var(--dsw-alias-label-secondary);margin:0}',
|
|
119
|
+
'.qc-row__value{margin:0;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary);white-space:nowrap}',
|
|
120
|
+
'.qc-swatch{width:8px;height:8px;border-radius:2px;display:inline-block;margin-right:6px;flex:none}',
|
|
121
|
+
'.qc-sep{height:1px;background:var(--dsw-alias-border-l1);margin:10px -2px}',
|
|
122
|
+
'.qc-cmd{display:flex;align-items:center;gap:8px;box-sizing:border-box;padding:7px 8px;',
|
|
123
|
+
'width:calc(100% + 4px);margin:0 -2px;border:0;border-radius:var(--dsw-radius-sm,8px);background:transparent;',
|
|
124
|
+
'color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:20px;cursor:pointer;',
|
|
125
|
+
'text-align:left;transition:background .15s ease}',
|
|
126
|
+
'.qc-cmd:hover{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2))}',
|
|
127
|
+
'.qc-cmd:disabled{opacity:.6;cursor:default}',
|
|
128
|
+
'.qc-cmd__name{font-family:var(--ds-font-family-code,ui-monospace,SFMono-Regular,Menlo,monospace)}',
|
|
129
|
+
'.qc-cmd__desc{color:var(--dsw-alias-label-secondary);font-size:11px}',
|
|
130
|
+
'.qc-cmd__arrow{margin-left:auto;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary));font-size:13px}',
|
|
131
|
+
'.qc-msg{margin-top:8px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}',
|
|
132
|
+
'.qc-msg--error{color:var(--dsw-alias-state-error-primary)}',
|
|
133
|
+
// 自动压缩阈值:一行标题 + 一条细轨滑块,跟面板其余部分同字号
|
|
134
|
+
'.qc-th{display:flex;flex-direction:column;gap:6px;margin:0 -2px}',
|
|
135
|
+
'.qc-th__head{display:flex;align-items:baseline;justify-content:space-between;gap:10px}',
|
|
136
|
+
'.qc-th__label{font-size:11px;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}',
|
|
137
|
+
'.qc-th__value{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary);font-weight:500;font-size:12px}',
|
|
138
|
+
'.qc-th__range{--qc-fill:0%;width:100%;height:16px;margin:0;padding:0;background:transparent;',
|
|
139
|
+
'-webkit-appearance:none;appearance:none;cursor:pointer}',
|
|
140
|
+
'.qc-th__range:disabled{opacity:.5;cursor:default}',
|
|
141
|
+
'.qc-th__range::-webkit-slider-runnable-track{height:4px;border-radius:999px;',
|
|
142
|
+
'background:linear-gradient(to right,var(--dsw-alias-brand-primary) 0 var(--qc-fill),',
|
|
143
|
+
'var(--dsw-alias-border-l2) var(--qc-fill) 100%)}',
|
|
144
|
+
'.qc-th__range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:12px;height:12px;',
|
|
145
|
+
'margin-top:-4px;border-radius:50%;background:var(--dsw-alias-bg-overlay,#fff);',
|
|
146
|
+
'border:2px solid var(--dsw-alias-brand-primary);box-shadow:0 1px 3px rgba(0,0,0,.28)}',
|
|
147
|
+
'.qc-th__range:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px;border-radius:6px}',
|
|
148
|
+
'.qc-th__hint{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}',
|
|
149
|
+
// 压缩烟花:全屏 canvas 覆盖层,只在压缩发生那一刻出现约 2.2 秒,不吃鼠标事件
|
|
150
|
+
'.qc-fw{position:fixed;left:0;top:0;z-index:2147483001;pointer-events:none;display:block}',
|
|
151
|
+
].join('');
|
|
152
|
+
|
|
153
|
+
function mountStyle() {
|
|
154
|
+
if (typeof document === 'undefined') return function () {};
|
|
155
|
+
if (document.getElementById(STYLE_ID)) return function () {};
|
|
156
|
+
const el = document.createElement('style');
|
|
157
|
+
el.id = STYLE_ID;
|
|
158
|
+
el.textContent = CSS;
|
|
159
|
+
document.head.appendChild(el);
|
|
160
|
+
return function () {
|
|
161
|
+
if (el.parentNode) el.parentNode.removeChild(el);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── 小工具 ───────────────────────────────────────────────────────
|
|
166
|
+
function num(v) {
|
|
167
|
+
return typeof v === 'number' && isFinite(v) && v >= 0 ? v : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function fmtTokens(n) {
|
|
171
|
+
if (typeof n !== 'number' || !isFinite(n) || n < 0) return '—';
|
|
172
|
+
if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + 'M';
|
|
173
|
+
if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e5 ? 0 : 1) + 'k';
|
|
174
|
+
return String(Math.round(n));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readUsage(pressure) {
|
|
178
|
+
if (!pressure || typeof pressure !== 'object') return { used: null, window: null, percent: null };
|
|
179
|
+
const used = num(pressure.projectedTokens) !== null ? num(pressure.projectedTokens) : num(pressure.pressureTokens);
|
|
180
|
+
const window = num(pressure.contextWindow);
|
|
181
|
+
const percent = used !== null && window !== null && window > 0
|
|
182
|
+
? Math.min(100, Math.round((used / window) * 100))
|
|
183
|
+
: null;
|
|
184
|
+
return { used, window, percent };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 面板定位:全部按「屏幕上看到的坐标」(getBoundingClientRect 那一套)算,最后一定夹在屏幕内。
|
|
188
|
+
// 2026-11-13 修三件事:
|
|
189
|
+
// 1. 面板进浏览器顶层(popover),不再被输入框外层裁掉、盖住,也不被外层的 transform 带偏;
|
|
190
|
+
// 2. 界面被 CSS zoom 缩放时,写进样式的 px 会再被放大一次,按实测比例 scale 换算回去;
|
|
191
|
+
// 3. 小环挪了位置(输入框变高、侧栏开合)而窗口大小没变时,面板也要跟着走。
|
|
192
|
+
const PANEL_MARGIN = 8;
|
|
193
|
+
|
|
194
|
+
/** 屏幕可视区大小(屏幕 px)。CSS zoom 下 innerWidth 和 documentElement.clientWidth
|
|
195
|
+
* 都已经是屏幕 px,不能再乘缩放比例(乘了会在缩小时把可视区算小,面板被夹到左上方)。 */
|
|
196
|
+
function viewportBox() {
|
|
197
|
+
let w = typeof window !== 'undefined' ? window.innerWidth : 0;
|
|
198
|
+
let h = typeof window !== 'undefined' ? window.innerHeight : 0;
|
|
199
|
+
const de = typeof document !== 'undefined' ? document.documentElement : null;
|
|
200
|
+
if (de) {
|
|
201
|
+
if (de.clientWidth > 0) w = Math.min(w || de.clientWidth, de.clientWidth);
|
|
202
|
+
if (de.clientHeight > 0) h = Math.min(h || de.clientHeight, de.clientHeight);
|
|
203
|
+
}
|
|
204
|
+
return { w: w || 0, h: h || 0 };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 锚在小环上方、右边对齐,算面板左上角。rect、width、height 都是屏幕坐标。
|
|
208
|
+
* scale = 屏幕 px ÷ 样式 px(没缩放时是 1)。
|
|
209
|
+
* 返回 left/top 是屏幕坐标;x/y、maxW/maxH 是换算后写进样式的值。
|
|
210
|
+
* 面板比可视区还大时挪位置救不了,所以同时给出尺寸上限,超出的部分在面板里滚动。 */
|
|
211
|
+
function panelPos(rect, width, height, scale) {
|
|
212
|
+
const k = scale > 0 ? scale : 1;
|
|
213
|
+
const vp = viewportBox();
|
|
214
|
+
const roomW = Math.max(0, vp.w - PANEL_MARGIN * 2);
|
|
215
|
+
const roomH = Math.max(0, vp.h - PANEL_MARGIN * 2);
|
|
216
|
+
const w = Math.min(width > 0 ? width : PANEL_WIDTH * k, roomW);
|
|
217
|
+
const h = Math.min(height > 0 ? height : 0, roomH);
|
|
218
|
+
let left = rect ? rect.right - w : vp.w - w - PANEL_MARGIN;
|
|
219
|
+
left = Math.max(PANEL_MARGIN, Math.min(left, vp.w - w - PANEL_MARGIN));
|
|
220
|
+
let top = rect ? rect.top - PANEL_MARGIN - h : vp.h - 96 - h;
|
|
221
|
+
top = Math.min(top, vp.h - h - PANEL_MARGIN);
|
|
222
|
+
top = Math.max(PANEL_MARGIN, top);
|
|
223
|
+
return {
|
|
224
|
+
left: left, top: top,
|
|
225
|
+
// 留两位小数:缩放时取整到整数 px 会让面板和小环差一两个屏幕 px
|
|
226
|
+
x: Math.round(left / k * 100) / 100, y: Math.round(top / k * 100) / 100,
|
|
227
|
+
maxW: Math.floor(roomW / k), maxH: Math.floor(roomH / k),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function samePos(a, b) {
|
|
232
|
+
return !!a && !!b && a.x === b.x && a.y === b.y && a.maxW === b.maxW && a.maxH === b.maxH;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 实测缩放比例:面板在屏幕上的宽 ÷ 样式里的宽。量不出来就当没缩放。 */
|
|
236
|
+
function measureScale(el) {
|
|
237
|
+
try {
|
|
238
|
+
const cssWidth = parseFloat(window.getComputedStyle(el).width);
|
|
239
|
+
const seen = el.getBoundingClientRect().width;
|
|
240
|
+
if (cssWidth > 0 && seen > 0) {
|
|
241
|
+
const k = seen / cssWidth;
|
|
242
|
+
if (k > 0.2 && k < 5) return k;
|
|
243
|
+
}
|
|
244
|
+
} catch (_) {}
|
|
245
|
+
return 1;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function ringTint(percent) {
|
|
249
|
+
if (percent === null) return TINT.normal;
|
|
250
|
+
if (percent >= 80) return TINT.hot;
|
|
251
|
+
if (percent >= 30) return TINT.warn;
|
|
252
|
+
return TINT.normal;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function describe(res) {
|
|
256
|
+
try {
|
|
257
|
+
if (res && res.error) {
|
|
258
|
+
const e = res.error;
|
|
259
|
+
if (typeof e === 'string') return e;
|
|
260
|
+
return e.message || e.code || JSON.stringify(e);
|
|
261
|
+
}
|
|
262
|
+
if (res && res.message) return String(res.message);
|
|
263
|
+
return JSON.stringify(res);
|
|
264
|
+
} catch (_) { return String(res); }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── 环形进度(与官方 ContextMeter 同几何:14 viewBox、r 5.5、2px 描边) ──
|
|
268
|
+
function Ring(props) {
|
|
269
|
+
const size = props.size;
|
|
270
|
+
const stroke = props.stroke;
|
|
271
|
+
const r = (size - stroke) / 2 - 0.5;
|
|
272
|
+
const circumference = 2 * Math.PI * r;
|
|
273
|
+
const percent = props.percent;
|
|
274
|
+
const color = props.color || TINT.normal;
|
|
275
|
+
const dash = percent === null ? 0 : (Math.max(0, Math.min(100, percent)) / 100) * circumference;
|
|
276
|
+
const center = size / 2;
|
|
277
|
+
return h('svg', {
|
|
278
|
+
className: 'qc-ring', viewBox: '0 0 ' + size + ' ' + size,
|
|
279
|
+
width: size, height: size, 'aria-hidden': 'true', focusable: 'false',
|
|
280
|
+
},
|
|
281
|
+
// 很细的一圈墨边:深色主题下几乎看不见,浅色主题下勾出硬轮廓
|
|
282
|
+
h('circle', {
|
|
283
|
+
className: 'qc-rim', cx: center, cy: center, r: r + stroke / 2,
|
|
284
|
+
fill: 'none', stroke: '#101619', style: { strokeWidth: 0.85 },
|
|
285
|
+
}),
|
|
286
|
+
h('circle', { className: 'qc-track', cx: center, cy: center, r: r, style: { strokeWidth: stroke } }),
|
|
287
|
+
percent === null ? null : h('circle', {
|
|
288
|
+
className: 'qc-fill', cx: center, cy: center, r: r,
|
|
289
|
+
strokeDasharray: dash + ' ' + circumference,
|
|
290
|
+
transform: 'rotate(-90 ' + center + ' ' + center + ')',
|
|
291
|
+
style: { stroke: color, strokeWidth: stroke },
|
|
292
|
+
})
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── 压缩烟花:从环心炸一簇粒子 + 一圈冲击波,约 2.2 秒散尽 ──────────────
|
|
297
|
+
// 刻意做成小方点而不是圆形光斑:方点更硬、更像工程图里的图元,
|
|
298
|
+
// 与他定下的界面审美一致(冷白 + 硬边 + 克制),不要彩虹烟花那种甜腻感。
|
|
299
|
+
const FW_DURATION = 2200;
|
|
300
|
+
const FW_NODES = 54;
|
|
301
|
+
|
|
302
|
+
/** 环当前处于哪一档,烟花就以那一档颜色为主,另外两档只做零星点缀。 */
|
|
303
|
+
function fireworkPalette(percent) {
|
|
304
|
+
const root = typeof getComputedStyle === 'function' ? getComputedStyle(document.documentElement) : null;
|
|
305
|
+
function pick(name, fallback) {
|
|
306
|
+
if (!root) return fallback;
|
|
307
|
+
const v = (root.getPropertyValue(name) || '').trim();
|
|
308
|
+
return v || fallback;
|
|
309
|
+
}
|
|
310
|
+
const brand = pick('--dsw-alias-brand-primary', '#4d93f8');
|
|
311
|
+
const warn = pick('--dsw-alias-state-warn-primary', '#f0b429');
|
|
312
|
+
const hot = pick('--dsw-alias-state-error-primary', '#e5484d');
|
|
313
|
+
const queue = percent === null ? 0 : percent;
|
|
314
|
+
if (queue >= 80) return [hot, warn, brand];
|
|
315
|
+
if (queue >= 30) return [warn, hot, brand];
|
|
316
|
+
return [brand, warn, hot];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function fireFireworks(anchor, palette) {
|
|
320
|
+
if (typeof document === 'undefined' || !anchor) return;
|
|
321
|
+
try {
|
|
322
|
+
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
323
|
+
} catch (_) {}
|
|
324
|
+
|
|
325
|
+
// 同一时刻只留一簇:连点就用新的换掉旧的,不让粒子堆满屏
|
|
326
|
+
const stale = document.getElementById('qc-fw');
|
|
327
|
+
if (stale && stale.parentNode) stale.parentNode.removeChild(stale);
|
|
328
|
+
|
|
329
|
+
const rect = anchor.getBoundingClientRect();
|
|
330
|
+
const ox = rect.left + rect.width / 2;
|
|
331
|
+
const oy = rect.top + rect.height / 2;
|
|
332
|
+
const width = window.innerWidth;
|
|
333
|
+
const height = window.innerHeight;
|
|
334
|
+
|
|
335
|
+
const canvas = document.createElement('canvas');
|
|
336
|
+
canvas.id = 'qc-fw';
|
|
337
|
+
canvas.className = 'qc-fw';
|
|
338
|
+
canvas.setAttribute('aria-hidden', 'true');
|
|
339
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
340
|
+
canvas.width = Math.max(1, Math.round(width * dpr));
|
|
341
|
+
canvas.height = Math.max(1, Math.round(height * dpr));
|
|
342
|
+
canvas.style.width = width + 'px';
|
|
343
|
+
canvas.style.height = height + 'px';
|
|
344
|
+
document.body.appendChild(canvas);
|
|
345
|
+
|
|
346
|
+
const g = canvas.getContext('2d');
|
|
347
|
+
if (!g) { if (canvas.parentNode) canvas.parentNode.removeChild(canvas); return; }
|
|
348
|
+
g.scale(dpr, dpr);
|
|
349
|
+
|
|
350
|
+
const colors = (palette && palette.length ? palette : ['#4d93f8']).slice(0, 3);
|
|
351
|
+
const primary = colors[0];
|
|
352
|
+
const accent = colors[1] || colors[0];
|
|
353
|
+
const parts = [];
|
|
354
|
+
for (let i = 0; i < FW_NODES; i++) {
|
|
355
|
+
// 角度均分再加抖动 → 各方向都有,但不像机械放射线
|
|
356
|
+
const angle = (Math.PI * 2 * i) / FW_NODES + (Math.random() - 0.5) * 0.42;
|
|
357
|
+
const speed = 1.05 + Math.random() * 2.1;
|
|
358
|
+
parts.push({
|
|
359
|
+
x: ox, y: oy,
|
|
360
|
+
vx: Math.cos(angle) * speed,
|
|
361
|
+
vy: Math.sin(angle) * speed - 0.38,
|
|
362
|
+
size: 1.3 + Math.random() * 2.2,
|
|
363
|
+
ttl: 0.55 + Math.random() * 0.4,
|
|
364
|
+
rot: Math.random() * Math.PI,
|
|
365
|
+
spin: (Math.random() - 0.5) * 0.34,
|
|
366
|
+
// 五分之一用点缀色,其余同色 —— 单色系更干净,不是彩虹
|
|
367
|
+
color: i % 5 === 0 ? accent : primary,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
// 主色再补一小撮更慢更亮的,让中心显得"通电"而不是均匀散开
|
|
371
|
+
for (let j = 0; j < 10; j++) {
|
|
372
|
+
const angle = Math.random() * Math.PI * 2;
|
|
373
|
+
const speed = 0.36 + Math.random() * 0.78;
|
|
374
|
+
parts.push({
|
|
375
|
+
x: ox, y: oy,
|
|
376
|
+
vx: Math.cos(angle) * speed,
|
|
377
|
+
vy: Math.sin(angle) * speed,
|
|
378
|
+
size: 1.6 + Math.random() * 1.6,
|
|
379
|
+
ttl: 0.4 + Math.random() * 0.3,
|
|
380
|
+
rot: Math.random() * Math.PI,
|
|
381
|
+
spin: 0,
|
|
382
|
+
color: primary,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const start = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
|
|
387
|
+
let raf = 0;
|
|
388
|
+
|
|
389
|
+
function stop() {
|
|
390
|
+
if (raf) { try { cancelAnimationFrame(raf); } catch (_) {} raf = 0; }
|
|
391
|
+
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function frame(now) {
|
|
395
|
+
const t = (now - start) / FW_DURATION;
|
|
396
|
+
if (t >= 1) { stop(); return; }
|
|
397
|
+
g.clearRect(0, 0, width, height);
|
|
398
|
+
|
|
399
|
+
// 冲击波:一圈迅速撑开又变细的描边环,前 42% 时长走完
|
|
400
|
+
const wave = Math.min(1, t / 0.46);
|
|
401
|
+
if (wave < 1) {
|
|
402
|
+
g.save();
|
|
403
|
+
g.globalAlpha = (1 - wave) * 0.8;
|
|
404
|
+
g.strokeStyle = colors[0];
|
|
405
|
+
g.lineWidth = 2.2 * (1 - wave) + 0.35;
|
|
406
|
+
g.beginPath();
|
|
407
|
+
g.arc(ox, oy, 7 + wave * 56, 0, Math.PI * 2);
|
|
408
|
+
g.stroke();
|
|
409
|
+
g.restore();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (let i = 0; i < parts.length; i++) {
|
|
413
|
+
const p = parts[i];
|
|
414
|
+
const k = t / p.ttl;
|
|
415
|
+
if (k >= 1) continue;
|
|
416
|
+
p.x += p.vx;
|
|
417
|
+
p.y += p.vy;
|
|
418
|
+
p.vx *= 0.978;
|
|
419
|
+
p.vy = p.vy * 0.978 + 0.058;
|
|
420
|
+
p.rot += p.spin;
|
|
421
|
+
const alpha = 1 - k * k;
|
|
422
|
+
g.save();
|
|
423
|
+
g.globalAlpha = alpha * 0.95;
|
|
424
|
+
g.translate(p.x, p.y);
|
|
425
|
+
g.rotate(p.rot);
|
|
426
|
+
g.fillStyle = p.color;
|
|
427
|
+
g.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);
|
|
428
|
+
g.restore();
|
|
429
|
+
}
|
|
430
|
+
raf = requestAnimationFrame(frame);
|
|
431
|
+
}
|
|
432
|
+
raf = requestAnimationFrame(frame);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// 没有用量数据时的兜底图标:上下两个向内收的折角(压缩)
|
|
436
|
+
function CompressGlyph() {
|
|
437
|
+
return h('span', { className: 'qc-glyph' },
|
|
438
|
+
h('svg', { width: 13, height: 13, viewBox: '0 0 14 14', 'aria-hidden': 'true', focusable: 'false' },
|
|
439
|
+
h('path', {
|
|
440
|
+
d: 'M3.5 2.5 L7 6 L10.5 2.5', fill: 'none', stroke: 'currentColor',
|
|
441
|
+
strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
442
|
+
}),
|
|
443
|
+
h('path', {
|
|
444
|
+
d: 'M3.5 11.5 L7 8 L10.5 11.5', fill: 'none', stroke: 'currentColor',
|
|
445
|
+
strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
446
|
+
})
|
|
447
|
+
)
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── 主控件 ───────────────────────────────────────────────────────
|
|
452
|
+
function CompactControl(props) {
|
|
453
|
+
const t = props.t || function (k) { return k; };
|
|
454
|
+
const sessionId = props.sessionId;
|
|
455
|
+
const useProjection = props.useProjection;
|
|
456
|
+
|
|
457
|
+
const pressure = typeof useProjection === 'function' ? useProjection('contextPressure') : null;
|
|
458
|
+
const breakdown = typeof useProjection === 'function' ? useProjection('contextBreakdown') : null;
|
|
459
|
+
const usage = readUsage(pressure);
|
|
460
|
+
|
|
461
|
+
const [open, setOpen] = React.useState(false);
|
|
462
|
+
const [pos, setPos] = React.useState(null);
|
|
463
|
+
const [busy, setBusy] = React.useState(false);
|
|
464
|
+
const [msg, setMsg] = React.useState(null);
|
|
465
|
+
const [err, setErr] = React.useState(false);
|
|
466
|
+
const [th, setTh] = React.useState(null);
|
|
467
|
+
const thTimer = React.useRef(null);
|
|
468
|
+
const btnRef = React.useRef(null);
|
|
469
|
+
const panelRef = React.useRef(null);
|
|
470
|
+
const lastFire = React.useRef(0);
|
|
471
|
+
const prevPct = React.useRef(null);
|
|
472
|
+
|
|
473
|
+
const percent = usage.percent;
|
|
474
|
+
|
|
475
|
+
/** 放一次烟花。1.2 秒内的重复触发合并掉,避免手动点击和自动检测双响。 */
|
|
476
|
+
const fire = React.useCallback(function () {
|
|
477
|
+
const now = Date.now();
|
|
478
|
+
if (now - lastFire.current < 1200) return;
|
|
479
|
+
lastFire.current = now;
|
|
480
|
+
fireFireworks(btnRef.current, fireworkPalette(percent));
|
|
481
|
+
}, [percent]);
|
|
482
|
+
|
|
483
|
+
// 自动压缩没有按钮点击可挂,就用「上下文占用骤降」当信号:
|
|
484
|
+
// 压完一次至少省掉十几个点,掉 15 点以上认为是压缩发生(阈值 20%–95%,正常都够)。
|
|
485
|
+
React.useEffect(function () {
|
|
486
|
+
const prev = prevPct.current;
|
|
487
|
+
prevPct.current = percent;
|
|
488
|
+
if (prev === null || percent === null) return;
|
|
489
|
+
if (prev - percent >= 15) fire();
|
|
490
|
+
}, [percent, fire]);
|
|
491
|
+
|
|
492
|
+
React.useEffect(function () {
|
|
493
|
+
if (!open) return undefined;
|
|
494
|
+
function onKey(e) { if (e.key === 'Escape') { e.stopPropagation(); setOpen(false); } }
|
|
495
|
+
function onDown(e) {
|
|
496
|
+
if (btnRef.current && btnRef.current.contains(e.target)) return;
|
|
497
|
+
if (panelRef.current && panelRef.current.contains(e.target)) return;
|
|
498
|
+
setOpen(false);
|
|
499
|
+
}
|
|
500
|
+
document.addEventListener('keydown', onKey, true);
|
|
501
|
+
document.addEventListener('mousedown', onDown, true);
|
|
502
|
+
return function () {
|
|
503
|
+
document.removeEventListener('keydown', onKey, true);
|
|
504
|
+
document.removeEventListener('mousedown', onDown, true);
|
|
505
|
+
};
|
|
506
|
+
}, [open]);
|
|
507
|
+
|
|
508
|
+
// 打开面板时读一次自动压缩阈值(插件未装/未重启时静默隐藏)
|
|
509
|
+
React.useEffect(function () {
|
|
510
|
+
if (!open) return undefined;
|
|
511
|
+
let alive = true;
|
|
512
|
+
fetch(THRESHOLD_URL, { method: 'GET' })
|
|
513
|
+
.then(function (r) { return r.json(); })
|
|
514
|
+
.then(function (p) {
|
|
515
|
+
if (!alive) return;
|
|
516
|
+
if (!p || p.ok !== true || !p.value || typeof p.value.thresholdRatio !== 'number') { setTh(null); return; }
|
|
517
|
+
setTh({
|
|
518
|
+
ratio: p.value.thresholdRatio,
|
|
519
|
+
min: typeof p.value.minRatio === 'number' ? p.value.minRatio : 0.2,
|
|
520
|
+
max: typeof p.value.maxRatio === 'number' ? p.value.maxRatio : 0.95,
|
|
521
|
+
engines: typeof p.value.engines === 'number' ? p.value.engines : 0,
|
|
522
|
+
});
|
|
523
|
+
})
|
|
524
|
+
.catch(function () { if (alive) setTh(null); });
|
|
525
|
+
return function () { alive = false; };
|
|
526
|
+
}, [open]);
|
|
527
|
+
|
|
528
|
+
React.useEffect(function () {
|
|
529
|
+
return function () { if (thTimer.current) window.clearTimeout(thTimer.current); };
|
|
530
|
+
}, []);
|
|
531
|
+
|
|
532
|
+
// 打开后:进顶层、按实测尺寸定位,然后每一帧看小环位置、面板尺寸、窗口大小有没有变,
|
|
533
|
+
// 变了就重算。只听 resize 的话,输入框变高或侧栏开合时面板会留在原地(漂移)。
|
|
534
|
+
React.useLayoutEffect(function () {
|
|
535
|
+
if (!open) return undefined;
|
|
536
|
+
const el = panelRef.current;
|
|
537
|
+
if (el && typeof el.showPopover === 'function') {
|
|
538
|
+
// 顶层元素不受外层 overflow / 层叠 / transform 影响;manual = 关闭仍由本组件自己管
|
|
539
|
+
try { el.setAttribute('popover', 'manual'); el.showPopover(); }
|
|
540
|
+
catch (_) { try { el.removeAttribute('popover'); } catch (__) {} }
|
|
541
|
+
}
|
|
542
|
+
function place() {
|
|
543
|
+
const panel = panelRef.current;
|
|
544
|
+
if (!panel) return;
|
|
545
|
+
const rect = btnRef.current ? btnRef.current.getBoundingClientRect() : null;
|
|
546
|
+
const box = panel.getBoundingClientRect();
|
|
547
|
+
const next = panelPos(rect, box.width, box.height, measureScale(panel));
|
|
548
|
+
setPos(function (prev) { return samePos(prev, next) ? prev : next; });
|
|
549
|
+
}
|
|
550
|
+
place();
|
|
551
|
+
let raf = 0;
|
|
552
|
+
let last = '';
|
|
553
|
+
function tick() {
|
|
554
|
+
const panel = panelRef.current;
|
|
555
|
+
const btn = btnRef.current;
|
|
556
|
+
// 小环或面板不在页面上了(会话没了、工具栏收起):没有可锚的地方,关掉并停止逐帧检查
|
|
557
|
+
if (!panel || !btn) { setOpen(false); return; }
|
|
558
|
+
const a = btn.getBoundingClientRect();
|
|
559
|
+
if (a.width === 0 && a.height === 0) { setOpen(false); return; }
|
|
560
|
+
raf = window.requestAnimationFrame(tick);
|
|
561
|
+
const b = panel.getBoundingClientRect();
|
|
562
|
+
const key = [a.left, a.top, a.right, b.width, b.height, window.innerWidth, window.innerHeight]
|
|
563
|
+
.map(Math.round).join(',');
|
|
564
|
+
if (key !== last) { last = key; place(); }
|
|
565
|
+
}
|
|
566
|
+
raf = window.requestAnimationFrame(tick);
|
|
567
|
+
return function () { window.cancelAnimationFrame(raf); };
|
|
568
|
+
}, [open]);
|
|
569
|
+
|
|
570
|
+
// 换了会话或会话没了:旧面板不留着,否则回来时它不会重新进顶层
|
|
571
|
+
React.useEffect(function () {
|
|
572
|
+
setOpen(false);
|
|
573
|
+
}, [sessionId]);
|
|
574
|
+
|
|
575
|
+
if (!sessionId) return null;
|
|
576
|
+
|
|
577
|
+
function openPanel() {
|
|
578
|
+
setPos(null); // 位置等面板进了页面、量到尺寸再定,之前先不显示
|
|
579
|
+
setMsg(null);
|
|
580
|
+
setErr(false);
|
|
581
|
+
setOpen(true);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function run() {
|
|
585
|
+
if (busy) return;
|
|
586
|
+
const api = commandApi;
|
|
587
|
+
if (!api || typeof api.execute !== 'function') {
|
|
588
|
+
setErr(true);
|
|
589
|
+
setMsg(t('noApi'));
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
setBusy(true);
|
|
593
|
+
setErr(false);
|
|
594
|
+
setMsg(null);
|
|
595
|
+
try {
|
|
596
|
+
const res = await api.execute(sessionId, '/compact', []);
|
|
597
|
+
if (!res || res.ok !== false) {
|
|
598
|
+
fire();
|
|
599
|
+
setMsg(t('done'));
|
|
600
|
+
window.setTimeout(function () { setOpen(false); setBusy(false); }, 900);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
setErr(true);
|
|
604
|
+
setMsg(t('failed') + ':' + describe(res));
|
|
605
|
+
} catch (e) {
|
|
606
|
+
setErr(true);
|
|
607
|
+
setMsg(t('failed') + ':' + String((e && e.message) || e));
|
|
608
|
+
}
|
|
609
|
+
setBusy(false);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** 把阈值写回宿主(dsh-compaction-threshold 会立刻应用到已挂载的引擎)。 */
|
|
613
|
+
function commitThreshold(value) {
|
|
614
|
+
fetch(THRESHOLD_URL, {
|
|
615
|
+
method: 'POST',
|
|
616
|
+
headers: { 'content-type': 'application/json' },
|
|
617
|
+
body: JSON.stringify({ thresholdRatio: value }),
|
|
618
|
+
})
|
|
619
|
+
.then(function (r) { return r.json(); })
|
|
620
|
+
.then(function (p) {
|
|
621
|
+
if (!p || p.ok !== true || !p.value) return;
|
|
622
|
+
setTh({
|
|
623
|
+
ratio: p.value.thresholdRatio,
|
|
624
|
+
min: typeof p.value.minRatio === 'number' ? p.value.minRatio : 0.2,
|
|
625
|
+
max: typeof p.value.maxRatio === 'number' ? p.value.maxRatio : 0.95,
|
|
626
|
+
engines: typeof p.value.engines === 'number' ? p.value.engines : 0,
|
|
627
|
+
});
|
|
628
|
+
})
|
|
629
|
+
.catch(function () {});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** 拖动时即时改本地显示,停手 300ms 再落盘,避免每像素发一次请求。 */
|
|
633
|
+
function onThresholdInput(e) {
|
|
634
|
+
const value = Number(e.target.value);
|
|
635
|
+
if (!isFinite(value)) return;
|
|
636
|
+
setTh(function (prev) { return prev ? Object.assign({}, prev, { ratio: value }) : prev; });
|
|
637
|
+
if (thTimer.current) window.clearTimeout(thTimer.current);
|
|
638
|
+
thTimer.current = window.setTimeout(function () { commitThreshold(value); }, 300);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const tooltip = percent === null ? t('tipUnknown') : t('tip', { percent: percent });
|
|
642
|
+
|
|
643
|
+
const button = h('button', {
|
|
644
|
+
ref: btnRef,
|
|
645
|
+
type: 'button',
|
|
646
|
+
className: 'qc-trigger',
|
|
647
|
+
title: tooltip,
|
|
648
|
+
'aria-label': percent === null ? tooltip : t('aria', { percent: percent }),
|
|
649
|
+
'aria-haspopup': 'dialog',
|
|
650
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
651
|
+
disabled: busy,
|
|
652
|
+
onClick: function () {
|
|
653
|
+
if (busy) return;
|
|
654
|
+
if (open) { setOpen(false); return; }
|
|
655
|
+
openPanel();
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
h(Ring, { size: RING_SIZE, stroke: RING_STROKE, percent: percent, color: ringTint(percent) }),
|
|
659
|
+
percent === null
|
|
660
|
+
? h(CompressGlyph)
|
|
661
|
+
: h('span', { className: 'qc-trigger__pct' },
|
|
662
|
+
h('span', { className: 'qc-trigger__num' }, String(percent)),
|
|
663
|
+
h('span', { className: 'qc-trigger__unit' }, '%'))
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
if (!open) return button;
|
|
667
|
+
|
|
668
|
+
// 面板:大环 + 用量构成 + /compact 命令行(第二下点击才执行)
|
|
669
|
+
const total = breakdown && typeof breakdown === 'object'
|
|
670
|
+
? (num(breakdown.systemTokens) || 0) + (num(breakdown.toolsTokens) || 0) + (num(breakdown.messageTokens) || 0)
|
|
671
|
+
: 0;
|
|
672
|
+
|
|
673
|
+
const segments = [];
|
|
674
|
+
if (percent !== null) {
|
|
675
|
+
if (breakdown && total > 0) {
|
|
676
|
+
SEG.forEach(function (row) {
|
|
677
|
+
const value = num(breakdown[row.key]) || 0;
|
|
678
|
+
if (value <= 0) return;
|
|
679
|
+
segments.push({ key: row.key, color: row.color, width: (percent * value) / total });
|
|
680
|
+
});
|
|
681
|
+
} else {
|
|
682
|
+
segments.push({ key: 'total', color: ringTint(percent), width: percent });
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const head = h('div', { className: 'qc-head' },
|
|
687
|
+
h(Ring, { size: PANEL_RING, stroke: PANEL_STROKE, percent: percent, color: ringTint(percent) }),
|
|
688
|
+
h('div', { className: 'qc-head__body' },
|
|
689
|
+
h('span', { className: 'qc-head__line' },
|
|
690
|
+
h('span', { className: 'qc-head__label' }, t('used')),
|
|
691
|
+
percent === null ? null : h('span', { className: 'qc-head__pct' }, percent + '%')
|
|
692
|
+
),
|
|
693
|
+
h('span', { className: 'qc-head__figs' },
|
|
694
|
+
usage.used !== null && usage.window !== null
|
|
695
|
+
? '≈ ' + fmtTokens(usage.used) + ' / ' + fmtTokens(usage.window)
|
|
696
|
+
: t('idle'))
|
|
697
|
+
)
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
const bar = segments.length === 0 ? null : h('div', { className: 'qc-bar' },
|
|
701
|
+
segments.map(function (seg) {
|
|
702
|
+
return h('div', {
|
|
703
|
+
key: seg.key, className: 'qc-bar__seg',
|
|
704
|
+
style: { width: Math.max(1, seg.width) + '%', background: seg.color },
|
|
705
|
+
});
|
|
706
|
+
})
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
const rows = breakdown && total > 0 ? h('ul', { className: 'qc-rows' },
|
|
710
|
+
SEG.map(function (row) {
|
|
711
|
+
return h('li', { key: row.key, className: 'qc-row' },
|
|
712
|
+
h('span', { className: 'qc-row__label' },
|
|
713
|
+
h('span', { className: 'qc-swatch', style: { background: row.color } }),
|
|
714
|
+
t(row.label)),
|
|
715
|
+
h('span', { className: 'qc-row__value' }, '≈ ' + fmtTokens(num(breakdown[row.key]) || 0))
|
|
716
|
+
);
|
|
717
|
+
})
|
|
718
|
+
) : null;
|
|
719
|
+
|
|
720
|
+
const thresholdReady = !!th && th.engines > 0;
|
|
721
|
+
const threshold = th === null ? null : (thresholdReady ? h('div', { className: 'qc-th' },
|
|
722
|
+
h('div', { className: 'qc-th__head' },
|
|
723
|
+
h('span', { className: 'qc-th__label' }, t('thLabel')),
|
|
724
|
+
h('span', { className: 'qc-th__value' }, Math.round(th.ratio * 100) + '%')
|
|
725
|
+
),
|
|
726
|
+
h('input', {
|
|
727
|
+
type: 'range',
|
|
728
|
+
className: 'qc-th__range',
|
|
729
|
+
min: th.min,
|
|
730
|
+
max: th.max,
|
|
731
|
+
step: 0.01,
|
|
732
|
+
value: th.ratio,
|
|
733
|
+
'aria-label': t('thLabel'),
|
|
734
|
+
style: { '--qc-fill': (Math.max(0, Math.min(1, (th.ratio - th.min) / ((th.max - th.min) || 1))) * 100).toFixed(1) + '%' },
|
|
735
|
+
onChange: onThresholdInput,
|
|
736
|
+
}),
|
|
737
|
+
h('span', { className: 'qc-th__hint' }, t('thHint', { percent: Math.round(th.ratio * 100) }))
|
|
738
|
+
) : h('div', { className: 'qc-th' }, h('span', { className: 'qc-th__hint' }, t('thOff'))));
|
|
739
|
+
|
|
740
|
+
const action = h('button', {
|
|
741
|
+
type: 'button',
|
|
742
|
+
className: 'qc-cmd',
|
|
743
|
+
disabled: busy,
|
|
744
|
+
onClick: run,
|
|
745
|
+
},
|
|
746
|
+
h('span', { className: 'qc-cmd__name' }, '/compact'),
|
|
747
|
+
h('span', { className: 'qc-cmd__desc' }, busy ? t('busy') : t('action')),
|
|
748
|
+
h('span', { className: 'qc-cmd__arrow', 'aria-hidden': 'true' }, '›')
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
const panel = h('div', {
|
|
752
|
+
ref: panelRef,
|
|
753
|
+
role: 'dialog',
|
|
754
|
+
'aria-label': t('title'),
|
|
755
|
+
className: 'qc-panel',
|
|
756
|
+
style: pos
|
|
757
|
+
? { transform: 'translate3d(' + pos.x + 'px,' + pos.y + 'px,0)', maxWidth: pos.maxW + 'px', maxHeight: pos.maxH + 'px' }
|
|
758
|
+
: { visibility: 'hidden' },
|
|
759
|
+
},
|
|
760
|
+
head,
|
|
761
|
+
bar,
|
|
762
|
+
rows,
|
|
763
|
+
threshold === null ? null : h('div', { className: 'qc-sep' }),
|
|
764
|
+
threshold,
|
|
765
|
+
h('div', { className: 'qc-sep' }),
|
|
766
|
+
action,
|
|
767
|
+
msg ? h('div', { className: 'qc-msg' + (err ? ' qc-msg--error' : ''), role: 'status' }, msg) : null
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
return h(React.Fragment, null, button, panel);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
inject: ['slots', 'locale'],
|
|
775
|
+
apply(ctx) {
|
|
776
|
+
ctx.effect(function () { return ctx.locale.register(NS, { zh: ZH, en: EN }); }, 'dsh-quick-compact: dictionaries');
|
|
777
|
+
ctx.effect(mountStyle, 'dsh-quick-compact: styles');
|
|
778
|
+
const t = ctx.locale.bind(NS);
|
|
779
|
+
|
|
780
|
+
// 宿主命令通道:remote.commands.execute(...)
|
|
781
|
+
ctx.effect(function () {
|
|
782
|
+
return ctx.inject(['remote', 'remote.commands'], function (c) {
|
|
783
|
+
try {
|
|
784
|
+
commandApi = (c.remote && c.remote.commands) ? c.remote.commands : (c.get ? c.get('remote.commands') : null);
|
|
785
|
+
} catch (_) { commandApi = null; }
|
|
786
|
+
return function () { commandApi = null; };
|
|
787
|
+
});
|
|
788
|
+
}, 'dsh-quick-compact: command channel');
|
|
789
|
+
|
|
790
|
+
ctx.slots.inject('conversation.input.right', function () {
|
|
791
|
+
return ctx.slots.register({
|
|
792
|
+
name: 'conversation.input.right',
|
|
793
|
+
id: 'quick-compact',
|
|
794
|
+
order: 900,
|
|
795
|
+
locale: NS,
|
|
796
|
+
label: function () { return t('compact'); },
|
|
797
|
+
}, function (props) { return h(CompactControl, Object.assign({ t: t }, props)); });
|
|
798
|
+
});
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
},
|
|
802
|
+
});
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36" role="img" aria-label="上下文用量 / 一键压缩">
|
|
2
|
+
<circle cx="18" cy="18" r="13" fill="none" stroke="#8d939c" stroke-opacity="0.35" stroke-width="3"/>
|
|
3
|
+
<circle cx="18" cy="18" r="13" fill="none" stroke="#4d93f8" stroke-width="3" stroke-linecap="round"
|
|
4
|
+
stroke-dasharray="49 82" transform="rotate(-90 18 18)"/>
|
|
5
|
+
</svg>
|
package/index.js
ADDED
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-quick-compact",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "1.5.3",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Compact gauge for DeepSeek Harness: an 18px ring + tabular percentage in the composer toolbar that opens a usage breakdown and runs /compact.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./client": "./client.js",
|
|
10
|
+
"./locale/*.json": "./locale/*.json",
|
|
11
|
+
"./icon": "./icon.svg"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"client.js",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"locale/*.json",
|
|
18
|
+
"icon.svg",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"dsh": {
|
|
23
|
+
"bundle": {
|
|
24
|
+
"patch": "./cordis.patch.yml"
|
|
25
|
+
},
|
|
26
|
+
"client": {
|
|
27
|
+
"platform": "web",
|
|
28
|
+
"immediately": false,
|
|
29
|
+
"inject": [
|
|
30
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"keywords": [
|
|
36
|
+
"dsh",
|
|
37
|
+
"dsh-plugin",
|
|
38
|
+
"deepseek-harness",
|
|
39
|
+
"compact",
|
|
40
|
+
"context",
|
|
41
|
+
"context-meter",
|
|
42
|
+
"ui"
|
|
43
|
+
],
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/FrontierMindLab/dsh-quick-compact.git",
|
|
47
|
+
"directory": "plugins/dsh-quick-compact"
|
|
48
|
+
},
|
|
49
|
+
"author": "FrontierMindLab",
|
|
50
|
+
"homepage": "https://github.com/FrontierMindLab/dsh-quick-compact#readme",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/FrontierMindLab/dsh-quick-compact/issues"
|
|
53
|
+
}
|
|
54
|
+
}
|