dsh-transfer-hud 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +181 -2
- package/client.js +445 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +6 -0
- package/index.js +1010 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +50 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xiuerhuahuazi
|
|
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,182 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-transfer-hud
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
English | [中文](#中文)
|
|
4
|
+
|
|
5
|
+
A thin strip below the message composer that shows what DeepSeek Harness is downloading
|
|
6
|
+
right now. Zero runtime dependencies; a Host half (`index.js`) plus a Web Client half
|
|
7
|
+
(`client.js`). Also stops (background) downloads from the strip.
|
|
8
|
+
|
|
9
|
+
> **Version:** this npm release is **1.0.0** and packages the workspace HUD revision
|
|
10
|
+
> **v1.1.1** (refined download classifier). The first public release renumbers the
|
|
11
|
+
> internal revision to 1.0.0.
|
|
12
|
+
|
|
13
|
+
## English
|
|
14
|
+
|
|
15
|
+
### What it shows
|
|
16
|
+
|
|
17
|
+
| Source | Row | Progress |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| **Background jobs** (`ctx.jobs`: `pwsh`/`bash` jobs started with `run_in_background`, or promoted after a timeout) | real job row: label, status, **Stop** button | percent / bytes / rate / ETA when the output ring carries a meter (curl, wget, aria2, tqdm / `hf download`, huggingface, modelscope, `git` counters, …), parsed from **stdout and stderr** — most downloaders print their meter to stderr |
|
|
20
|
+
| **Foreground shell commands** (`pwsh`, `bash`, `ops_bash`, `terminal_send`, …) | indeterminate row (`foreground: true`, no Stop button) | none — the Host only sees the call, not its live output; the row proves a download is running and disappears shortly after the tool result |
|
|
21
|
+
|
|
22
|
+
When a foreground call starts a background job (`run_in_background: true`, or a
|
|
23
|
+
foreground command promoted on timeout), the job row replaces the foreground row: the
|
|
24
|
+
Host reads `job/registered`'s `toolCall.callId` and dedupes.
|
|
25
|
+
|
|
26
|
+
### What counts as a download
|
|
27
|
+
|
|
28
|
+
The classifier is deliberately narrow so ordinary API traffic never shows a row:
|
|
29
|
+
|
|
30
|
+
- **Unambiguous downloaders** in command position (optional `sudo`/`nohup`/`time`/
|
|
31
|
+
`timeout`/`VAR=` wrappers): `aria2c`, `yt-dlp`, `youtube-dl`, `scp`, `sftp`, `rsync`,
|
|
32
|
+
`axel`, `hf` / `huggingface-cli` / `snapshot_download` / `modelscope`,
|
|
33
|
+
`git clone` / `git lfs`, `pip download` / `pip install`, PowerShell
|
|
34
|
+
`Start-BitsTransfer` / `bitsadmin` / `DownloadFile` / `DownloadString` / `DownloadData`,
|
|
35
|
+
`[System.Net.WebClient]` / `[System.Net.HttpClient]`.
|
|
36
|
+
- **Dual-use `curl` / `wget` / `Invoke-WebRequest` / `iwr`** count only with real download
|
|
37
|
+
intent: an explicit output flag (`-o`, `-O`, `--output`, `--remote-name`,
|
|
38
|
+
`--remote-header-name`, `-J`, or a short-flag cluster like `-sSLo`; `-OutFile` for
|
|
39
|
+
`iwr`), **or** a non-loopback URL whose path ends in a file-like extension
|
|
40
|
+
(`.bin .zip .tar .tgz .gz .bz2 .xz .zst .7z .rar .whl .exe .msi .iso .dmg .pkg .deb .rpm
|
|
41
|
+
.apk .safetensors .gguf .ckpt .onnx .pt .pth .model .mp4 .mkv .webm .npy .npz .parquet
|
|
42
|
+
.arrow .h5 .hdf5 .tflite .mlmodel`).
|
|
43
|
+
- **Ignored on purpose:** loopback / `localhost` / `127.*` / `::1` / `0.0.0.0` URLs without
|
|
44
|
+
an output flag (API polls), and JSON / JSONL / CSV responses — those are API payloads,
|
|
45
|
+
not downloads.
|
|
46
|
+
- The downloader must sit in command position, so `grep -r curl src/` or `cat download.md`
|
|
47
|
+
are ignored. An opaque call such as `python fetch.py` is recognised only when the call's
|
|
48
|
+
description says "download/fetch/…" or its file/URL matches a known artifact pattern.
|
|
49
|
+
|
|
50
|
+
### Install
|
|
51
|
+
|
|
52
|
+
Requires DeepSeek Harness `>=0.2.1-rc.2 <0.3.0-0` and a Web or Desktop profile
|
|
53
|
+
(the plugin has a Host half and a browser Client half).
|
|
54
|
+
|
|
55
|
+
1. Open **Settings → Plugins**, install bundle `dsh-transfer-hud@1.0.0`.
|
|
56
|
+
2. Or ask the agent: `plugin_manager` → `action: "install_bundle"`,
|
|
57
|
+
`target: "dsh-transfer-hud@1.0.0"`.
|
|
58
|
+
|
|
59
|
+
**Restart the app after installing or upgrading.** HMR applies a new bundle, but the
|
|
60
|
+
profile's Host watch roots are empty (`root: []`) and a replaced package needs a fresh
|
|
61
|
+
JavaScript module generation, so the new Host code is loaded on restart; the Client half
|
|
62
|
+
then follows with the usual client HMR / page refresh. Verification: after the restart,
|
|
63
|
+
run a foreground download (e.g. `curl -o file.bin <url>`) or a background job
|
|
64
|
+
(`run_in_background: true`) and the strip appears below the composer. Remove the bundle to
|
|
65
|
+
uninstall.
|
|
66
|
+
|
|
67
|
+
### Configuration
|
|
68
|
+
|
|
69
|
+
None. The row takes no config; the strip is automatic and only reads activity that
|
|
70
|
+
already belongs to this Harness.
|
|
71
|
+
|
|
72
|
+
### Limits
|
|
73
|
+
|
|
74
|
+
- **Foreground downloads are indeterminate.** A foreground tool call exposes no live
|
|
75
|
+
output to the Host, so there is no percent/rate/ETA — only "a download is running".
|
|
76
|
+
- **Session-scoped.** Rows belong to the session the strip is mounted in. A download
|
|
77
|
+
started by a subagent/teammate session is not shown in the parent conversation.
|
|
78
|
+
- **Opaque scripts need a hint.** See "What counts as a download" above.
|
|
79
|
+
- **No progress output, no meter.** `Invoke-WebRequest` with
|
|
80
|
+
`$ProgressPreference = 'SilentlyContinue'` prints nothing to parse; the row still
|
|
81
|
+
appears from the label hint but stays indeterminate.
|
|
82
|
+
- **Stop only stops background jobs.** A foreground command cannot be killed from the
|
|
83
|
+
HUD; it follows its own approval/timeout/cancellation path.
|
|
84
|
+
- UI rendering (strip/panel layout) can only be verified in a browser.
|
|
85
|
+
|
|
86
|
+
### Development
|
|
87
|
+
|
|
88
|
+
Host and Client test harnesses live in `test/` in the source repository and are excluded
|
|
89
|
+
from the published tarball:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
node plugins/dsh-transfer-hud/test/host.test.mjs
|
|
93
|
+
node plugins/dsh-transfer-hud/test/client.test.mjs
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### License
|
|
97
|
+
|
|
98
|
+
MIT.
|
|
99
|
+
|
|
100
|
+
## 中文
|
|
101
|
+
|
|
102
|
+
消息输入框下方的一条细长下载进度条,显示本 Harness 当前正在下载的内容,并可从进度条上
|
|
103
|
+
停止(后台)任务。零运行时依赖,由 Host 半(`index.js`)与 Web Client 半(`client.js`)组成。
|
|
104
|
+
|
|
105
|
+
> **版本**:本次 npm 发布为 **1.0.0**,打包的是工作区 HUD **v1.1.1**(细化后的下载分类器);
|
|
106
|
+
> 首次公开发布将内部修订号重编为 1.0.0。
|
|
107
|
+
|
|
108
|
+
### 显示内容
|
|
109
|
+
|
|
110
|
+
| 来源 | 行 | 进度 |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| **后台任务**(`ctx.jobs`:用 `run_in_background` 启动、或超时转后台的 `pwsh`/`bash` 任务) | 真实任务行:标签、状态、**停止**按钮 | 输出环携带进度时显示百分比/字节/速度/剩余时间(curl、wget、aria2、tqdm / `hf download`、huggingface、modelscope、`git` 计数……),同时解析 **stdout 与 stderr**——多数下载器把进度打印到 stderr |
|
|
113
|
+
| **前台 shell 命令**(`pwsh`、`bash`、`ops_bash`、`terminal_send`……) | 不确定行(`foreground: true`,无停止按钮) | 无——Host 只能看到调用,看不到其运行中的输出;该行用于表明正在下载,并在工具结果返回后不久消失 |
|
|
114
|
+
|
|
115
|
+
当前台调用启动后台任务(`run_in_background: true`,或超时转后台)时,任务行会替换前台行:
|
|
116
|
+
Host 读取 `job/registered` 的 `toolCall.callId` 去重。
|
|
117
|
+
|
|
118
|
+
### 什么算作下载
|
|
119
|
+
|
|
120
|
+
分类器刻意收窄,普通 API 流量不会出现行:
|
|
121
|
+
|
|
122
|
+
- **无歧义下载器**(位于命令位置,可带 `sudo`/`nohup`/`time`/`timeout`/`VAR=` 前缀):
|
|
123
|
+
`aria2c`、`yt-dlp`、`youtube-dl`、`scp`、`sftp`、`rsync`、`axel`、`hf` /
|
|
124
|
+
`huggingface-cli` / `snapshot_download` / `modelscope`、`git clone` / `git lfs`、
|
|
125
|
+
`pip download` / `pip install`、PowerShell `Start-BitsTransfer` / `bitsadmin` /
|
|
126
|
+
`DownloadFile` / `DownloadString` / `DownloadData`、`[System.Net.WebClient]` /
|
|
127
|
+
`[System.Net.HttpClient]`。
|
|
128
|
+
- **两用的 `curl` / `wget` / `Invoke-WebRequest` / `iwr`** 只有具备真实下载意图才计入:
|
|
129
|
+
显式输出参数(`-o`、`-O`、`--output`、`--remote-name`、`--remote-header-name`、`-J`,
|
|
130
|
+
或 `-sSLo` 这类短参数组合;`iwr` 的 `-OutFile`),**或**非回环 URL 的路径以文件类扩展名
|
|
131
|
+
结尾(`.bin .zip .tar .tgz .gz .bz2 .xz .zst .7z .rar .whl .exe .msi .iso .dmg .pkg
|
|
132
|
+
.deb .rpm .apk .safetensors .gguf .ckpt .onnx .pt .pth .model .mp4 .mkv .webm .npy .npz
|
|
133
|
+
.parquet .arrow .h5 .hdf5 .tflite .mlmodel`)。
|
|
134
|
+
- **有意忽略**:无输出参数时访问回环/`localhost`/`127.*`/`::1`/`0.0.0.0` 的 URL(API 轮询),
|
|
135
|
+
以及 JSON / JSONL / CSV 响应——那是 API 载荷而不是下载。
|
|
136
|
+
- 下载器必须位于命令位置,因此 `grep -r curl src/`、`cat download.md` 不会被误判。
|
|
137
|
+
`python fetch.py` 这类不透明调用只有描述包含 download/fetch 等词、或文件/URL 命中已知
|
|
138
|
+
产物模式时才会被识别。
|
|
139
|
+
|
|
140
|
+
### 安装
|
|
141
|
+
|
|
142
|
+
需要 DeepSeek Harness `>=0.2.1-rc.2 <0.3.0-0`,以及 Web 或 Desktop profile
|
|
143
|
+
(插件同时包含 Host 半与浏览器 Client 半)。
|
|
144
|
+
|
|
145
|
+
1. 打开 **设置 → 插件**,安装 bundle `dsh-transfer-hud@1.0.0`。
|
|
146
|
+
2. 或让 agent 调用:`plugin_manager` → `action: "install_bundle"`,
|
|
147
|
+
`target: "dsh-transfer-hud@1.0.0"`。
|
|
148
|
+
|
|
149
|
+
**安装或升级后请重启应用。** HMR 会应用新的 bundle,但该 profile 的 Host watch root 为空
|
|
150
|
+
(`root: []`),且替换已安装的包需要新的 JavaScript 模块代号,因此新的 Host 代码要重启后
|
|
151
|
+
才加载;Client 半随后走常规的 client HMR / 页面刷新。验证方式:重启后运行一个前台下载
|
|
152
|
+
(如 `curl -o file.bin <url>`)或后台任务(`run_in_background: true`),输入框下方会出现
|
|
153
|
+
进度条。移除该 bundle 即卸载。
|
|
154
|
+
|
|
155
|
+
### 配置
|
|
156
|
+
|
|
157
|
+
无。该行不需要配置,进度条自动出现,且只读取本 Harness 自身已有的活动。
|
|
158
|
+
|
|
159
|
+
### 限制
|
|
160
|
+
|
|
161
|
+
- **前台下载是不确定的**:前台工具调用不向 Host 暴露运行中输出,所以没有百分比/速度/
|
|
162
|
+
剩余时间,只表示“正在下载”。
|
|
163
|
+
- **按会话隔离**:行属于进度条挂载的会话;子代理/队友会话发起的下载不会显示在父会话中。
|
|
164
|
+
- **不透明脚本需要提示**:见上文“什么算作下载”。
|
|
165
|
+
- **没有进度输出就没有进度**:`Invoke-WebRequest` 在
|
|
166
|
+
`$ProgressPreference = 'SilentlyContinue'` 下不打印可解析内容;行仍会按标签提示出现,
|
|
167
|
+
但保持不确定状态。
|
|
168
|
+
- **停止只作用于后台任务**:前台命令无法从进度条取消,它遵循自身的审批/超时/取消机制。
|
|
169
|
+
- 界面渲染(进度条/浮层布局)只能在浏览器中验证。
|
|
170
|
+
|
|
171
|
+
### 开发
|
|
172
|
+
|
|
173
|
+
Host 与 Client 的测试位于源码仓库的 `test/`,不包含在发布的 tarball 中:
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
node plugins/dsh-transfer-hud/test/host.test.mjs
|
|
177
|
+
node plugins/dsh-transfer-hud/test/client.test.mjs
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### 许可证
|
|
181
|
+
|
|
182
|
+
MIT。
|
package/client.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download HUD — Client half.
|
|
3
|
+
*
|
|
4
|
+
* A thin, space-free strip below the message composer that appears only while a
|
|
5
|
+
* background job looks like a download. Clicking it opens a details popover with
|
|
6
|
+
* per-job progress, throughput, remaining time and a stop action.
|
|
7
|
+
*
|
|
8
|
+
* Data comes from the Host half's same-origin route; this module renders only.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: 'dsh-transfer-hud',
|
|
13
|
+
factory(require) {
|
|
14
|
+
const React = require('react')
|
|
15
|
+
const h = React.createElement
|
|
16
|
+
const { useCallback, useEffect, useMemo, useRef, useState } = React
|
|
17
|
+
|
|
18
|
+
const STATE_URL = '/dsh-download-hud/state'
|
|
19
|
+
const KILL_URL = '/dsh-download-hud/kill'
|
|
20
|
+
const POLL_MS = 1000
|
|
21
|
+
const LINGER_MS = 15000
|
|
22
|
+
const PANEL_MAX_WIDTH = 520
|
|
23
|
+
|
|
24
|
+
const TEXT = {
|
|
25
|
+
en: {
|
|
26
|
+
live: 'Downloading',
|
|
27
|
+
jobs: 'downloads',
|
|
28
|
+
stopped: 'Stopped',
|
|
29
|
+
done: 'Done',
|
|
30
|
+
failed: 'Failed',
|
|
31
|
+
eta: 'left',
|
|
32
|
+
stop: 'Stop',
|
|
33
|
+
stopping: 'Stopping…',
|
|
34
|
+
noProgress: 'running',
|
|
35
|
+
details: 'Downloads',
|
|
36
|
+
elapsed: 'elapsed',
|
|
37
|
+
command: 'command',
|
|
38
|
+
},
|
|
39
|
+
zh: {
|
|
40
|
+
live: '下载中',
|
|
41
|
+
jobs: '个下载',
|
|
42
|
+
stopped: '已停止',
|
|
43
|
+
done: '已完成',
|
|
44
|
+
failed: '失败',
|
|
45
|
+
eta: '剩余',
|
|
46
|
+
stop: '取消',
|
|
47
|
+
stopping: '正在停止…',
|
|
48
|
+
noProgress: '运行中',
|
|
49
|
+
details: '下载任务',
|
|
50
|
+
elapsed: '已用时',
|
|
51
|
+
command: '命令',
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const CSS = `
|
|
56
|
+
.ddh-strip{position:relative;display:flex;align-items:center;gap:6px;box-sizing:border-box;flex:1 1 auto;min-width:220px;max-width:680px;padding:3px 2px 3px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;cursor:pointer;background:transparent;border:0;border-radius:5px;text-align:left;font-family:inherit}
|
|
57
|
+
.ddh-strip:hover{color:var(--dsw-alias-label-primary)}
|
|
58
|
+
.ddh-line{display:flex;align-items:center;gap:6px;min-width:0;flex:1}
|
|
59
|
+
.ddh-icon{flex:none;display:inline-flex;color:var(--dsw-alias-brand-primary)}
|
|
60
|
+
.ddh-label{flex:none;font-weight:500}
|
|
61
|
+
.ddh-meta{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}
|
|
62
|
+
.ddh-chevron{flex:none;display:inline-flex;transition:transform .12s ease}
|
|
63
|
+
.ddh-chevron-open{transform:rotate(180deg)}
|
|
64
|
+
.ddh-track{position:absolute;left:2px;bottom:1px;height:2px;border-radius:1px;max-width:calc(100% - 4px);background:var(--dsw-alias-brand-primary);transition:width .25s ease}
|
|
65
|
+
.ddh-track-done{background:var(--dsw-alias-state-success-primary)}
|
|
66
|
+
.ddh-track-failed{background:var(--dsw-alias-state-error-primary)}
|
|
67
|
+
.ddh-fill{height:100%;border-radius:2px;background:var(--dsw-alias-brand-primary);transition:width .25s ease}
|
|
68
|
+
.ddh-fill-done{background:var(--dsw-alias-state-success-primary)}
|
|
69
|
+
.ddh-fill-failed{background:var(--dsw-alias-state-error-primary)}
|
|
70
|
+
.ddh-panel{position:fixed;z-index:120;display:flex;flex-direction:column;box-sizing:border-box;background:var(--dsw-alias-bg-layer-1);border:0.5px solid var(--dsw-alias-border-l2);border-radius:10px;box-shadow:0 10px 32px rgba(0,0,0,.28);overflow:hidden;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
|
|
71
|
+
.ddh-panel-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 12px 6px;color:var(--dsw-alias-label-tertiary);font-size:11px;border-bottom:0.5px solid var(--dsw-alias-border-l1)}
|
|
72
|
+
.ddh-rows{display:flex;flex-direction:column;max-height:min(420px,60vh);overflow:auto;padding:2px 0}
|
|
73
|
+
.ddh-row{display:flex;flex-direction:column;gap:2px;padding:7px 12px;min-width:0}
|
|
74
|
+
.ddh-row + .ddh-row{border-top:0.5px solid var(--dsw-alias-border-l1)}
|
|
75
|
+
.ddh-row-top{display:flex;align-items:center;gap:8px;min-width:0}
|
|
76
|
+
.ddh-dot{flex:none;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-brand-primary)}
|
|
77
|
+
.ddh-dot-done{background:var(--dsw-alias-state-success-primary)}
|
|
78
|
+
.ddh-dot-failed{background:var(--dsw-alias-state-error-primary)}
|
|
79
|
+
.ddh-dot-idle{background:var(--dsw-alias-state-idle-primary)}
|
|
80
|
+
.ddh-row-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary)}
|
|
81
|
+
.ddh-row-pct{flex:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}
|
|
82
|
+
.ddh-stop{flex:none;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font:inherit;padding:1px 6px;border-radius:5px}
|
|
83
|
+
.ddh-stop:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-error-primary)}
|
|
84
|
+
.ddh-stop:disabled{opacity:.5;cursor:default}
|
|
85
|
+
.ddh-row-sub{display:flex;gap:10px;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--dsw-alias-label-tertiary);font-size:11px;font-variant-numeric:tabular-nums}
|
|
86
|
+
.ddh-row-bar{height:2px;border-radius:1px;background:var(--dsw-alias-border-l1);overflow:hidden;margin-top:2px}
|
|
87
|
+
.ddh-note{padding:8px 12px;color:var(--dsw-alias-state-error-primary);font-size:11px}
|
|
88
|
+
`
|
|
89
|
+
|
|
90
|
+
function pickLocale(ctx) {
|
|
91
|
+
const language = (() => {
|
|
92
|
+
try {
|
|
93
|
+
const snapshot = ctx.locale.getLocale()
|
|
94
|
+
const id = snapshot && (snapshot.id || snapshot.locale || snapshot.current)
|
|
95
|
+
if (typeof id === 'string' && id) return id
|
|
96
|
+
} catch {}
|
|
97
|
+
try {
|
|
98
|
+
return navigator.language || 'en'
|
|
99
|
+
} catch {
|
|
100
|
+
return 'en'
|
|
101
|
+
}
|
|
102
|
+
})()
|
|
103
|
+
return String(language).toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function formatBytes(value) {
|
|
107
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return ''
|
|
108
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
109
|
+
let amount = value
|
|
110
|
+
let index = 0
|
|
111
|
+
while (amount >= 1024 && index < units.length - 1) {
|
|
112
|
+
amount /= 1024
|
|
113
|
+
index += 1
|
|
114
|
+
}
|
|
115
|
+
const digits = index === 0 ? 0 : amount >= 100 ? 0 : 1
|
|
116
|
+
return `${amount.toFixed(digits)} ${units[index]}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function formatDuration(seconds) {
|
|
120
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return ''
|
|
121
|
+
const total = Math.round(seconds)
|
|
122
|
+
if (total < 60) return `${total}s`
|
|
123
|
+
const minutes = Math.floor(total / 60)
|
|
124
|
+
if (minutes < 60) return `${minutes}m ${String(total % 60).padStart(2, '0')}s`
|
|
125
|
+
const hours = Math.floor(minutes / 60)
|
|
126
|
+
return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isLive(job) {
|
|
130
|
+
return job.status === 'running' || job.status === 'stopping'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function statusText(job, text) {
|
|
134
|
+
if (job.status === 'completed') return text.done
|
|
135
|
+
if (job.status === 'failed') return text.failed
|
|
136
|
+
if (job.status === 'killed') return text.stopped
|
|
137
|
+
if (job.status === 'stopping') return text.stopping
|
|
138
|
+
return text.live
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function visibleJobs(state, now) {
|
|
142
|
+
const jobs = state && Array.isArray(state.jobs) ? state.jobs : []
|
|
143
|
+
return jobs.filter((job) => {
|
|
144
|
+
if (!job || job.download !== true) return false
|
|
145
|
+
if (isLive(job)) return true
|
|
146
|
+
const finishedAt = typeof job.finishedAt === 'number' ? job.finishedAt : 0
|
|
147
|
+
return finishedAt > 0 && now - finishedAt < LINGER_MS
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function aggregate(jobs) {
|
|
152
|
+
const live = jobs.filter(isLive)
|
|
153
|
+
let done = 0
|
|
154
|
+
let total = 0
|
|
155
|
+
let percentSum = 0
|
|
156
|
+
let percentCount = 0
|
|
157
|
+
let rate = 0
|
|
158
|
+
let eta = 0
|
|
159
|
+
for (const job of live) {
|
|
160
|
+
if (typeof job.done === 'number' && typeof job.total === 'number' && job.total > 0) {
|
|
161
|
+
done += job.done
|
|
162
|
+
total += job.total
|
|
163
|
+
} else if (typeof job.percent === 'number') {
|
|
164
|
+
percentSum += job.percent
|
|
165
|
+
percentCount += 1
|
|
166
|
+
}
|
|
167
|
+
if (typeof job.rateBps === 'number') rate += job.rateBps
|
|
168
|
+
if (typeof job.etaSeconds === 'number' && job.etaSeconds > eta) eta = job.etaSeconds
|
|
169
|
+
}
|
|
170
|
+
let percent = null
|
|
171
|
+
if (total > 0) percent = Math.round((done / total) * 1000) / 10
|
|
172
|
+
else if (percentCount > 0) percent = Math.round((percentSum / percentCount) * 10) / 10
|
|
173
|
+
return { live, done, total, percent, rate, eta }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function createDownloadHud(ctx, text) {
|
|
177
|
+
return function DownloadHud(props) {
|
|
178
|
+
const sessionId = useMemo(() => {
|
|
179
|
+
const candidates = [props && props.sessionId, props && props.session && props.session.id]
|
|
180
|
+
for (const candidate of candidates) {
|
|
181
|
+
if (typeof candidate === 'string' && candidate) return candidate
|
|
182
|
+
}
|
|
183
|
+
return ''
|
|
184
|
+
}, [props && props.sessionId])
|
|
185
|
+
|
|
186
|
+
const [state, setState] = useState(null)
|
|
187
|
+
const [open, setOpen] = useState(false)
|
|
188
|
+
const [busyId, setBusyId] = useState('')
|
|
189
|
+
const [nowTick, setNowTick] = useState(() => Date.now())
|
|
190
|
+
const stripRef = useRef(null)
|
|
191
|
+
const panelRef = useRef(null)
|
|
192
|
+
const [anchor, setAnchor] = useState(null)
|
|
193
|
+
|
|
194
|
+
const url = useMemo(() => {
|
|
195
|
+
return sessionId ? `${STATE_URL}?sessionId=${encodeURIComponent(sessionId)}` : STATE_URL
|
|
196
|
+
}, [sessionId])
|
|
197
|
+
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
let cancelled = false
|
|
200
|
+
let timer = 0
|
|
201
|
+
const tick = () => {
|
|
202
|
+
if (typeof document !== 'undefined' && document.hidden) return
|
|
203
|
+
fetch(url, { headers: { accept: 'application/json' }, cache: 'no-store' })
|
|
204
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
205
|
+
.then((data) => {
|
|
206
|
+
if (cancelled || !data) return
|
|
207
|
+
setState(data)
|
|
208
|
+
setNowTick(Date.now())
|
|
209
|
+
})
|
|
210
|
+
.catch(() => {})
|
|
211
|
+
}
|
|
212
|
+
tick()
|
|
213
|
+
timer = setInterval(tick, POLL_MS)
|
|
214
|
+
// A hidden tab pauses polling; catch up as soon as it is visible so a
|
|
215
|
+
// download that started while the window was in the background shows.
|
|
216
|
+
const onVisible = () => {
|
|
217
|
+
if (typeof document === 'undefined' || document.hidden) return
|
|
218
|
+
tick()
|
|
219
|
+
}
|
|
220
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisible)
|
|
221
|
+
return () => {
|
|
222
|
+
cancelled = true
|
|
223
|
+
clearInterval(timer)
|
|
224
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisible)
|
|
225
|
+
}
|
|
226
|
+
}, [url])
|
|
227
|
+
|
|
228
|
+
const jobs = visibleJobs(state, nowTick)
|
|
229
|
+
const totals = aggregate(jobs)
|
|
230
|
+
|
|
231
|
+
const measure = useCallback(() => {
|
|
232
|
+
const node = stripRef.current
|
|
233
|
+
if (!node) return
|
|
234
|
+
const rect = node.getBoundingClientRect()
|
|
235
|
+
const width = Math.min(PANEL_MAX_WIDTH, Math.max(260, rect.width))
|
|
236
|
+
const left = Math.min(
|
|
237
|
+
Math.max(8, rect.left),
|
|
238
|
+
Math.max(8, (typeof window === 'undefined' ? rect.left + width : window.innerWidth) - width - 8),
|
|
239
|
+
)
|
|
240
|
+
setAnchor({
|
|
241
|
+
left,
|
|
242
|
+
width,
|
|
243
|
+
bottom: typeof window === 'undefined' ? 0 : window.innerHeight - rect.top + 6,
|
|
244
|
+
})
|
|
245
|
+
}, [])
|
|
246
|
+
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
if (!open) return undefined
|
|
249
|
+
measure()
|
|
250
|
+
const onResize = () => measure()
|
|
251
|
+
window.addEventListener('resize', onResize)
|
|
252
|
+
window.addEventListener('scroll', onResize, true)
|
|
253
|
+
const onDown = (event) => {
|
|
254
|
+
const node = event.target
|
|
255
|
+
if (stripRef.current && stripRef.current.contains(node)) return
|
|
256
|
+
if (panelRef.current && panelRef.current.contains(node)) return
|
|
257
|
+
setOpen(false)
|
|
258
|
+
}
|
|
259
|
+
const onKey = (event) => {
|
|
260
|
+
if (event.key === 'Escape') setOpen(false)
|
|
261
|
+
}
|
|
262
|
+
document.addEventListener('mousedown', onDown)
|
|
263
|
+
document.addEventListener('keydown', onKey)
|
|
264
|
+
return () => {
|
|
265
|
+
window.removeEventListener('resize', onResize)
|
|
266
|
+
window.removeEventListener('scroll', onResize, true)
|
|
267
|
+
document.removeEventListener('mousedown', onDown)
|
|
268
|
+
document.removeEventListener('keydown', onKey)
|
|
269
|
+
}
|
|
270
|
+
}, [open, measure])
|
|
271
|
+
|
|
272
|
+
const stop = useCallback((job) => {
|
|
273
|
+
if (!job || !job.id || busyId) return
|
|
274
|
+
setBusyId(job.id)
|
|
275
|
+
fetch(KILL_URL, {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: { 'content-type': 'application/json' },
|
|
278
|
+
body: JSON.stringify({ id: job.id, sessionId: job.owner || sessionId || undefined }),
|
|
279
|
+
})
|
|
280
|
+
.catch(() => {})
|
|
281
|
+
.finally(() => setBusyId(''))
|
|
282
|
+
}, [busyId, sessionId])
|
|
283
|
+
|
|
284
|
+
if (jobs.length === 0) return null
|
|
285
|
+
|
|
286
|
+
const single = totals.live.length === 1 ? totals.live[0] : null
|
|
287
|
+
const percent = totals.percent === null ? (single && single.percent !== null ? single.percent : null) : totals.percent
|
|
288
|
+
const parts = []
|
|
289
|
+
if (jobs.length === 1) {
|
|
290
|
+
parts.push(`${totals.live.length === 0 ? statusText(jobs[0], text) : text.live} · ${jobs[0].label}`)
|
|
291
|
+
} else {
|
|
292
|
+
parts.push(`${text.live} ${totals.live.length || jobs.length} ${text.jobs}`)
|
|
293
|
+
}
|
|
294
|
+
if (typeof percent === 'number') parts.push(`${percent}%`)
|
|
295
|
+
if (totals.total > 0) parts.push(`${formatBytes(totals.done)} / ${formatBytes(totals.total)}`)
|
|
296
|
+
if (totals.rate > 0 && totals.live.length > 0) parts.push(`${formatBytes(totals.rate)}/s`)
|
|
297
|
+
if (totals.eta > 0 && totals.live.length > 0) parts.push(`${text.eta} ${formatDuration(totals.eta)}`)
|
|
298
|
+
|
|
299
|
+
const worst = jobs.some((job) => job.status === 'failed')
|
|
300
|
+
? 'failed'
|
|
301
|
+
: jobs.every((job) => job.status === 'completed')
|
|
302
|
+
? 'done'
|
|
303
|
+
: 'live'
|
|
304
|
+
const barWidth = typeof percent === 'number' ? Math.max(1.5, Math.min(100, percent)) : 12
|
|
305
|
+
const barClass = worst === 'failed' ? 'ddh-fill ddh-fill-failed' : worst === 'done' ? 'ddh-fill ddh-fill-done' : 'ddh-fill'
|
|
306
|
+
const trackClass = worst === 'failed' ? 'ddh-track ddh-track-failed' : worst === 'done' ? 'ddh-track ddh-track-done' : 'ddh-track'
|
|
307
|
+
const chevronClass = open ? 'ddh-chevron ddh-chevron-open' : 'ddh-chevron'
|
|
308
|
+
|
|
309
|
+
const panel = open && anchor
|
|
310
|
+
? h('div', {
|
|
311
|
+
ref: panelRef,
|
|
312
|
+
className: 'ddh-panel',
|
|
313
|
+
role: 'dialog',
|
|
314
|
+
'aria-label': text.details,
|
|
315
|
+
style: { left: `${anchor.left}px`, bottom: `${anchor.bottom}px`, width: `${anchor.width}px` },
|
|
316
|
+
}, [
|
|
317
|
+
h('div', { key: 'head', className: 'ddh-panel-head' }, [
|
|
318
|
+
h('span', { key: 'title' }, text.details),
|
|
319
|
+
h('span', { key: 'count' }, `${jobs.length}`),
|
|
320
|
+
]),
|
|
321
|
+
h('div', { key: 'rows', className: 'ddh-rows' }, jobs.map((job) => {
|
|
322
|
+
const dotSuffix = job.status === 'completed' ? ' ddh-dot-done'
|
|
323
|
+
: job.status === 'failed' ? ' ddh-dot-failed'
|
|
324
|
+
: isLive(job) ? '' : ' ddh-dot-idle'
|
|
325
|
+
const rowPercent = typeof job.percent === 'number'
|
|
326
|
+
? `${Math.round(job.percent * 10) / 10}%`
|
|
327
|
+
: (isLive(job) ? text.noProgress : '')
|
|
328
|
+
const sub = []
|
|
329
|
+
if (job.foreground) sub.push(text.command)
|
|
330
|
+
if (typeof job.done === 'number' && typeof job.total === 'number' && job.total > 0) {
|
|
331
|
+
sub.push(`${formatBytes(job.done)} / ${formatBytes(job.total)}`)
|
|
332
|
+
}
|
|
333
|
+
if (typeof job.rateBps === 'number' && job.rateBps > 0) sub.push(`${formatBytes(job.rateBps)}/s`)
|
|
334
|
+
if (typeof job.etaSeconds === 'number' && job.etaSeconds > 0 && isLive(job)) {
|
|
335
|
+
sub.push(`${text.eta} ${formatDuration(job.etaSeconds)}`)
|
|
336
|
+
}
|
|
337
|
+
if (!isLive(job)) sub.push(statusText(job, text))
|
|
338
|
+
else sub.push(`${text.elapsed} ${formatDuration((job.elapsedMs || 0) / 1000)}`)
|
|
339
|
+
const rowPercentValue = typeof job.percent === 'number' ? Math.max(1.5, Math.min(100, job.percent)) : 0
|
|
340
|
+
return h('div', { key: job.id, className: 'ddh-row' }, [
|
|
341
|
+
h('div', { key: 'top', className: 'ddh-row-top' }, [
|
|
342
|
+
h('span', { key: 'dot', className: `ddh-dot${dotSuffix}` }),
|
|
343
|
+
h('span', { key: 'name', className: 'ddh-row-name', title: job.fullLabel || job.label }, job.label),
|
|
344
|
+
rowPercent ? h('span', { key: 'pct', className: 'ddh-row-pct' }, rowPercent) : null,
|
|
345
|
+
isLive(job) && job.killable !== false
|
|
346
|
+
? h('button', {
|
|
347
|
+
key: 'stop',
|
|
348
|
+
type: 'button',
|
|
349
|
+
className: 'ddh-stop',
|
|
350
|
+
disabled: busyId === job.id,
|
|
351
|
+
onClick: (event) => {
|
|
352
|
+
event.stopPropagation()
|
|
353
|
+
stop(job)
|
|
354
|
+
},
|
|
355
|
+
}, text.stop)
|
|
356
|
+
: null,
|
|
357
|
+
]),
|
|
358
|
+
h('div', { key: 'sub', className: 'ddh-row-sub' }, sub.join(' · ')),
|
|
359
|
+
rowPercentValue > 0
|
|
360
|
+
? h('div', { key: 'bar', className: 'ddh-row-bar' }, [
|
|
361
|
+
h('div', {
|
|
362
|
+
className: job.status === 'failed' ? 'ddh-fill ddh-fill-failed' : 'ddh-fill',
|
|
363
|
+
style: { width: `${rowPercentValue}%` },
|
|
364
|
+
}),
|
|
365
|
+
])
|
|
366
|
+
: null,
|
|
367
|
+
])
|
|
368
|
+
})),
|
|
369
|
+
state && Array.isArray(state.warnings) && state.warnings.length > 0
|
|
370
|
+
? h('div', { key: 'note', className: 'ddh-note' }, String(state.warnings[state.warnings.length - 1]))
|
|
371
|
+
: null,
|
|
372
|
+
])
|
|
373
|
+
: null
|
|
374
|
+
|
|
375
|
+
return h(React.Fragment, null, [
|
|
376
|
+
h('button', {
|
|
377
|
+
key: 'strip',
|
|
378
|
+
ref: stripRef,
|
|
379
|
+
type: 'button',
|
|
380
|
+
className: 'ddh-strip',
|
|
381
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
382
|
+
onClick: () => setOpen((previous) => !previous),
|
|
383
|
+
}, [
|
|
384
|
+
h('span', { key: 'line', className: 'ddh-line' }, [
|
|
385
|
+
h('span', { key: 'icon', className: 'ddh-icon', 'aria-hidden': 'true' }, [
|
|
386
|
+
h('svg', { key: 'svg', width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none' }, [
|
|
387
|
+
h('path', {
|
|
388
|
+
d: 'M8 2v7', stroke: 'currentColor', strokeWidth: '1.6', strokeLinecap: 'round',
|
|
389
|
+
}),
|
|
390
|
+
h('path', {
|
|
391
|
+
d: 'M4.8 6.4 8 9.6l3.2-3.2', stroke: 'currentColor', strokeWidth: '1.6',
|
|
392
|
+
strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
393
|
+
}),
|
|
394
|
+
h('path', {
|
|
395
|
+
d: 'M3 12.5h10', stroke: 'currentColor', strokeWidth: '1.6', strokeLinecap: 'round',
|
|
396
|
+
}),
|
|
397
|
+
]),
|
|
398
|
+
]),
|
|
399
|
+
h('span', { key: 'meta', className: 'ddh-meta' }, parts.join(' · ')),
|
|
400
|
+
h('span', { key: 'chevron', className: chevronClass, 'aria-hidden': 'true' }, [
|
|
401
|
+
h('svg', { key: 'svg', width: 12, height: 12, viewBox: '0 0 16 16', fill: 'none' }, [
|
|
402
|
+
h('path', {
|
|
403
|
+
d: 'M4 6.5 8 10.5l4-4', stroke: 'currentColor', strokeWidth: '1.5',
|
|
404
|
+
strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
405
|
+
}),
|
|
406
|
+
]),
|
|
407
|
+
]),
|
|
408
|
+
]),
|
|
409
|
+
typeof percent === 'number'
|
|
410
|
+
? h('span', {
|
|
411
|
+
key: 'track',
|
|
412
|
+
className: trackClass,
|
|
413
|
+
style: { width: `${barWidth}%` },
|
|
414
|
+
})
|
|
415
|
+
: null,
|
|
416
|
+
]),
|
|
417
|
+
panel,
|
|
418
|
+
])
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
inject: ['slots', 'locale'],
|
|
424
|
+
apply(ctx) {
|
|
425
|
+
const text = TEXT[pickLocale(ctx)]
|
|
426
|
+
const Component = createDownloadHud(ctx, text)
|
|
427
|
+
ctx.effect(() => {
|
|
428
|
+
const tag = document.createElement('style')
|
|
429
|
+
tag.dataset.plugin = 'dsh-transfer-hud'
|
|
430
|
+
tag.dataset.pluginCss = 'dsh-transfer-hud/hud.css'
|
|
431
|
+
tag.textContent = CSS
|
|
432
|
+
document.head.appendChild(tag)
|
|
433
|
+
return () => {
|
|
434
|
+
if (tag.parentNode) tag.parentNode.removeChild(tag)
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
|
|
438
|
+
name: 'conversation.composer.dock',
|
|
439
|
+
id: 'dsh-download-hud',
|
|
440
|
+
order: 20,
|
|
441
|
+
}, Component))
|
|
442
|
+
},
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
})
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<rect x="5" y="5" width="54" height="54" rx="13" fill="#1f6feb" opacity="0.12"/>
|
|
3
|
+
<path d="M32 15.5V36" stroke="#1f6feb" stroke-width="5.5" stroke-linecap="round"/>
|
|
4
|
+
<path d="M21.5 27.5 32 38l10.5-10.5" fill="none" stroke="#1f6feb" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
5
|
+
<path d="M18 46.5h28" stroke="#1f6feb" stroke-width="5.5" stroke-linecap="round"/>
|
|
6
|
+
</svg>
|