@climber47/dsh-step-clock 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/LICENSE +21 -0
- package/README.md +84 -0
- package/README.zh.md +78 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +389 -0
- package/lib/index.js +26 -0
- package/package.json +67 -0
- package/src/client/index.js +345 -0
- package/src/client/styles.js +26 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 climber47
|
|
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
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# dsh-step-clock
|
|
2
|
+
|
|
3
|
+
[中文](README.zh.md) | English
|
|
4
|
+
|
|
5
|
+
A live per-step elapsed-time bar for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) web GUI. It answers one question the built-in turn timer does not: **how long has the step that is running right now been running?**
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
● 正在执行 bash,已运行 1 分 12 秒 第 12 步 [bash] 1:12
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
and, once that step finishes, keeps the result on screen:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
● 上一步(第 12 步)已完成,用时 3 分 45 秒 3:45
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The bar sits in the composer dock — the strip just above the input box, where the todo and goal bars live — with a second seat below the composer so a tall goal bar cannot push it out of view.
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
The harness already shows a turn-level clock at the bottom of the conversation, but it only appears after 15 seconds and it measures the whole turn. When you are watching a long `bash` call you cannot tell whether it started two seconds ago or has been stuck for four minutes, and the turn clock cannot tell you either.
|
|
22
|
+
|
|
23
|
+
This plugin measures **the current step**, from the instant its activity actually started:
|
|
24
|
+
|
|
25
|
+
- **Tool steps** are anchored to the tool call's own start time. An in-flight call exists in the live snapshot only while it is in flight, so `0:47` means the call itself has been running 47 seconds — not an estimate derived from the turn.
|
|
26
|
+
- **Thinking steps** fall back to the step boundary, the closest honest anchor for model time.
|
|
27
|
+
- The clock ticks from `0:00` with **no delay threshold**.
|
|
28
|
+
|
|
29
|
+
## What it says
|
|
30
|
+
|
|
31
|
+
The bar states the situation in plain language rather than showing a bare number:
|
|
32
|
+
|
|
33
|
+
| State | Wording |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| Running a tool | `正在执行 <tool>,已运行 47 秒` |
|
|
36
|
+
| Several tools at once | `正在执行 bash,另有 2 个工具并行,本步已运行 5 秒` |
|
|
37
|
+
| Model streaming | `模型正在思考,本步已耗时 23 秒` |
|
|
38
|
+
| Submitted, no output yet | `已提交,正在等待模型响应,已等待 3 秒` |
|
|
39
|
+
| Finished step | `上一步(第 12 步)已完成,用时 3 分 45 秒` |
|
|
40
|
+
| Nothing running | `空闲,等待下一步` |
|
|
41
|
+
|
|
42
|
+
Alongside it: the step number, a chip of the running tool names (up to 4, then `+N`, hover for all), and a compact `m:ss` clock at the right edge.
|
|
43
|
+
|
|
44
|
+
Durations read naturally — `47 秒`, `1 分 12 秒`, `2 分钟`, `1 小时 3 分` — so `0:03` is never ambiguous. The wording is Chinese, matching the language of the session this was built for.
|
|
45
|
+
|
|
46
|
+
### Why the finished step stays
|
|
47
|
+
|
|
48
|
+
A step is frequently over in well under a second, so a bar that exists only *during* a step is easy to miss. Holding the last step's duration until the next step replaces it means a slow step — a `bash` call that ran for four minutes, say — can be identified after the fact. The record is kept only when the step's own `start` and `end` timestamps are both present; when they are not, the bar reports itself idle rather than inventing a duration.
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
dsh plugin --profile web add @climber47/dsh-step-clock
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Then restart `dsh web`. The bar appears above the composer during the next running step.
|
|
57
|
+
|
|
58
|
+
## How it works
|
|
59
|
+
|
|
60
|
+
A dsh bundle, mounted as one inserted row:
|
|
61
|
+
|
|
62
|
+
- `package.json` declares `dsh.bundle.patch`, which is what makes the package installable, and `dsh.client` (`platform: web`), which is what serves the browser half.
|
|
63
|
+
- `cordis.patch.yml` inserts the `step-clock` row.
|
|
64
|
+
- `lib/index.js` is the (intentionally empty) host half. A bundle's row resolves the package root, so the package must be importable; this plugin has no host behaviour.
|
|
65
|
+
- `lib/client.js` is the browser half, in the `window.__ModuleLoader__.load({ id, factory })` registration shape a client bundle must have. React is taken from the module loader via `require('react')` rather than bundled.
|
|
66
|
+
- It registers two additive entries — `conversation.input.dock` above the composer and `conversation.composer.dock` below it — both `replaceRisk: none`, so every shipped todo / goal / queue / stats entry is untouched. It owns its stylesheet through `ctx.styles.insert`, disposed with its fiber.
|
|
67
|
+
- The shipped bundle is generated from `src/client/` by `npm run build`, so the readable source and the artifact cannot drift; `npm test` rebuilds and drives the bundle through its real loader contract.
|
|
68
|
+
|
|
69
|
+
It reads only facts the engine already publishes — the Chat timeline snapshot and the live running-call list — and polls nothing itself.
|
|
70
|
+
|
|
71
|
+
## Requirements
|
|
72
|
+
|
|
73
|
+
- dsh `>=0.1.5-rc.1`
|
|
74
|
+
- React 18 (a peer dependency, provided by the harness shell)
|
|
75
|
+
|
|
76
|
+
## Contributing
|
|
77
|
+
|
|
78
|
+
`contrib/awesome-dsh-plugin-entry.yml` is the single registry entry this package
|
|
79
|
+
submits to the curated list, kept here so the entry and the code stay in one
|
|
80
|
+
place. It is not part of the npm package.
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# dsh-step-clock
|
|
2
|
+
|
|
3
|
+
中文 | [English](README.md)
|
|
4
|
+
|
|
5
|
+
一个给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 网页版用的**单步耗时实时秒表**。它回答内建计时器回答不了的那个问题:**正在跑的这一小步,已经跑了多久?**
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
● 正在执行 bash,已运行 1 分 12 秒 第 12 步 [bash] 1:12
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
那一步跑完之后,它会继续把结果留在屏幕上:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
● 上一步(第 12 步)已完成,用时 3 分 45 秒 3:45
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
状态条位于输入框上方的那一条(和 todo、goal 条同排),并在输入框**下方**另有一处,这样目标条再高也挤不掉它。
|
|
18
|
+
|
|
19
|
+
## 为什么需要它
|
|
20
|
+
|
|
21
|
+
Harness 本身已在对话底部显示一个轮次(turn)级时钟,但它**要等 15 秒才出现**,而且计的是**整轮**的时间。当你盯着一个长时间的 `bash` 调用时,你无法判断它是刚开始两秒,还是已经卡了四分钟——那个轮次时钟也回答不了。
|
|
22
|
+
|
|
23
|
+
本插件计量的是**当前这一小步**,并且从它真正开始活动的时刻起算:
|
|
24
|
+
|
|
25
|
+
- **工具步骤**锚定在该工具调用自身的开始时间上。运行中的调用只在其运行期间存在于实时快照里,所以 `0:47` 表示**这次调用本身**已跑了 47 秒,而不是从轮次推算出来的估计值。
|
|
26
|
+
- **思考步骤**回退到步骤边界,这是模型耗时最诚实的锚点。
|
|
27
|
+
- 秒表**从 `0:00` 就开始跳,没有任何延迟门槛**。
|
|
28
|
+
|
|
29
|
+
## 它会说什么
|
|
30
|
+
|
|
31
|
+
它用直白的中文句子描述当前状况,而不是丢一个光秃秃的数字:
|
|
32
|
+
|
|
33
|
+
| 状态 | 文案 |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| 正在执行工具 | `正在执行 <工具名>,已运行 47 秒` |
|
|
36
|
+
| 多个工具并行 | `正在执行 bash,另有 2 个工具并行,本步已运行 5 秒` |
|
|
37
|
+
| 模型流式输出中 | `模型正在思考,本步已耗时 23 秒` |
|
|
38
|
+
| 已提交、还没输出 | `已提交,正在等待模型响应,已等待 3 秒` |
|
|
39
|
+
| 上一步已完成 | `上一步(第 12 步)已完成,用时 3 分 45 秒` |
|
|
40
|
+
| 什么都没跑 | `空闲,等待下一步` |
|
|
41
|
+
|
|
42
|
+
旁边还有:步号、正在执行的工具名标签(最多 4 个,超出 `+N`,悬停看全部)、以及最右侧一个紧凑的 `m:ss` 数字。
|
|
43
|
+
|
|
44
|
+
时长按中文习惯换算——`47 秒`、`1 分 12 秒`、`2 分钟`、`1 小时 3 分`——所以`0:03` 不会产生"3 秒还是 3 分钟"的歧义。
|
|
45
|
+
|
|
46
|
+
### 为什么完成后还要留着
|
|
47
|
+
|
|
48
|
+
一个步骤经常在一秒内就结束了,只在"运行期间"存在的状态条极易被错过。把上一步的用时留到下一步开始前,你就能**事后**发现某个慢步骤——比如一个跑了四分钟的 `bash`。只有在该步骤自己的 `start` 与 `end` 时间戳**都存在**时才记录;不存在时它会老老实实显示"空闲",而不是编一个看起来像真的耗时。
|
|
49
|
+
|
|
50
|
+
## 安装
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
dsh plugin --profile web add @climber47/dsh-step-clock
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
然后重启 `dsh web`。下一次有步骤运行时,状态条就会出现在输入框上方。
|
|
57
|
+
|
|
58
|
+
## 实现方式
|
|
59
|
+
|
|
60
|
+
这是一个 dsh bundle,以单条 insert 行挂载:
|
|
61
|
+
|
|
62
|
+
- `package.json` 声明 `dsh.bundle.patch`(这是它能被安装的关键)与 `dsh.client`(`platform: web`,这是浏览器半边被加载的关键)。
|
|
63
|
+
- `cordis.patch.yml` 插入 `step-clock` 行。
|
|
64
|
+
- `lib/index.js` 是(有意为空的)宿主半边。bundle 的 insert 行会解析包根,所以包必须可被导入;本插件没有宿主行为。
|
|
65
|
+
- `lib/client.js` 是浏览器半边,采用客户端 bundle 必须的 `window.__ModuleLoader__.load({ id, factory })` 注册形状。React 通过 `require('react')` 从模块加载器取得,不打包进产物。
|
|
66
|
+
- 它注册**两个纯新增**条目——输入框上方的 `conversation.input.dock` 与下方的 `conversation.composer.dock`,两者都是 `replaceRisk: none`,不会动自带的 todo / goal / queue / stats 条目。样式通过 `ctx.styles.insert` 归属自身,随 fiber 一起销毁。
|
|
67
|
+
- 发布的 bundle 由 `npm run build` 从 `src/client/` 生成,因此可读源码与产物不会漂移;`npm test` 会先重新构建,再把 bundle 喂进它真实的加载器契约里跑行为测试。
|
|
68
|
+
|
|
69
|
+
它只读取引擎**已经发布**的事实——Chat 时间线快照与运行中调用列表——自身不做任何轮询。
|
|
70
|
+
|
|
71
|
+
## 环境要求
|
|
72
|
+
|
|
73
|
+
- dsh `>=0.1.5-rc.1`
|
|
74
|
+
- React 18(peer 依赖,由 harness 外壳提供)
|
|
75
|
+
|
|
76
|
+
## 许可
|
|
77
|
+
|
|
78
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# dsh-step-clock bundle patch: inserts its plugin row into the web plugin roster.
|
|
2
|
+
#
|
|
3
|
+
# A single row mounts the package. This plugin has no host behaviour — the
|
|
4
|
+
# browser half (exports "./client") is declared by `dsh.client` in package.json
|
|
5
|
+
# and served from this package's lib/client.js by the client module host.
|
|
6
|
+
- insert:
|
|
7
|
+
- id: step-clock
|
|
8
|
+
name: '@climber47/dsh-step-clock'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@climber47/dsh-step-clock",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
const React = react;
|
|
9
|
+
//#region src/client/styles.js
|
|
10
|
+
/**
|
|
11
|
+
* @climber47/dsh-step-clock — browser half styles.
|
|
12
|
+
*
|
|
13
|
+
* Kept beside the component so the bundle's injected stylesheet has a readable
|
|
14
|
+
* source. Colours and geometry come from dsh theme tokens and the composer
|
|
15
|
+
* layout variables, with fallbacks so a missing token degrades instead of
|
|
16
|
+
* breaking the bar. The `--dsh-composer-*` variables are declared on the
|
|
17
|
+
* conversation root, which is an ancestor of both dock seats.
|
|
18
|
+
*
|
|
19
|
+
* @module @climber47/dsh-step-clock/client/styles
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const CSS = [
|
|
23
|
+
// The dock row is full viewport width; this wrapper centres a composer-width
|
|
24
|
+
// column inside it, mirroring the shipped GoalBar dock convention.
|
|
25
|
+
'.dsh-stepclock-dock{box-sizing:border-box;width:calc(100% - var(--dsh-composer-side-clearance,16px) - var(--dsh-composer-side-clearance,16px) - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px));margin:0 auto;}',
|
|
26
|
+
'.dsh-stepclock-root{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;box-sizing:border-box;width:100%;max-width:calc(var(--dsh-composer-card-max-width,780px) - 4 * var(--dsh-composer-dock-inset,8px));margin:0 auto;padding:6px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-specific-tip,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-label-secondary);font-size:13px;line-height:18px;}',
|
|
27
|
+
// A filled dot marks real activity; a hollow one marks an idle bar.
|
|
28
|
+
'.dsh-stepclock-dot{flex:none;align-self:center;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-brand-primary);}',
|
|
29
|
+
'.dsh-stepclock-dot-idle{flex:none;align-self:center;width:8px;height:8px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);}',
|
|
30
|
+
'.dsh-stepclock-say{flex:1 1 auto;min-width:0;color:var(--dsw-alias-label-primary);}',
|
|
31
|
+
'.dsh-stepclock-where{flex:none;opacity:.6;font-size:12px;}',
|
|
32
|
+
'.dsh-stepclock-time{flex:none;color:var(--dsw-alias-brand-primary);font-weight:600;font-size:15px;}',
|
|
33
|
+
'.dsh-stepclock-chip{flex:none;max-width:22em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:1px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:var(--dsw-alias-bg-layer-1);font-size:12px;line-height:16px;}',
|
|
34
|
+
'.dsh-stepclock-mono{font-variant-numeric:tabular-nums;font-feature-settings:"tnum";}',
|
|
35
|
+
].join('')
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region src/client/index.js
|
|
38
|
+
/**
|
|
39
|
+
* @climber47/dsh-step-clock — browser half (readable source).
|
|
40
|
+
*
|
|
41
|
+
* The shipped file is `lib/client.js`, which wraps this component in the
|
|
42
|
+
* `window.__ModuleLoader__.load({ id, factory })` registration shape a dsh
|
|
43
|
+
* client bundle must have. This file is the same logic kept readable so a
|
|
44
|
+
* reader can review what the package does without unwrapping the bundle.
|
|
45
|
+
*
|
|
46
|
+
* What it does
|
|
47
|
+
* ------------
|
|
48
|
+
* Renders one ambient entry in the composer dock while an agent step is
|
|
49
|
+
* running, in plain language:
|
|
50
|
+
*
|
|
51
|
+
* 正在执行 bash,已运行 1 分 12 秒 第 12 步 [bash] 1:12
|
|
52
|
+
*
|
|
53
|
+
* and keeps the finished step on screen once it ends:
|
|
54
|
+
*
|
|
55
|
+
* 上一步(第 12 步)已完成,用时 3 分 45 秒 3:45
|
|
56
|
+
*
|
|
57
|
+
* Why the second line matters
|
|
58
|
+
* ---------------------------
|
|
59
|
+
* A step is often over in under a second, so a bar that only exists *during*
|
|
60
|
+
* a step is easy to miss entirely. Holding the last step's duration until the
|
|
61
|
+
* next step replaces it means a slow step can be identified after the fact.
|
|
62
|
+
* The record is only kept when the step's own `start` and `end` timestamps are
|
|
63
|
+
* both available; when they are not, the bar says it is idle rather than
|
|
64
|
+
* inventing a duration.
|
|
65
|
+
*
|
|
66
|
+
* It reads the live facts the engine already publishes rather than polling
|
|
67
|
+
* anything itself:
|
|
68
|
+
*
|
|
69
|
+
* - the open Turn and its open Step, from the Chat timeline snapshot, give
|
|
70
|
+
* the step number and the step's start time;
|
|
71
|
+
* - `legacy.runningCalls` holds one entry per in-flight Tool call and exists
|
|
72
|
+
* only while that call is in flight, so its `time` is the exact moment the
|
|
73
|
+
* call started — this is what makes "bash has been running 47s" precise
|
|
74
|
+
* instead of an estimate;
|
|
75
|
+
* - `legacy.partial` tells whether the model is currently streaming, which
|
|
76
|
+
* separates "thinking" from "waiting for the model".
|
|
77
|
+
*
|
|
78
|
+
* The clock ticks from 0s with no delay threshold.
|
|
79
|
+
*
|
|
80
|
+
* @module @climber47/dsh-step-clock/client
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
/** Tool names named inline before collapsing the remainder into `+N`. */
|
|
85
|
+
const MAX_NAMES = 4
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Last-finished-step records, keyed by session so two open conversations never
|
|
89
|
+
* clash. Module-level so the record survives re-renders.
|
|
90
|
+
*/
|
|
91
|
+
const RECORDS = new Map()
|
|
92
|
+
|
|
93
|
+
/** Floor a duration to whole seconds. */
|
|
94
|
+
function secondsOf(ms) {
|
|
95
|
+
return Math.max(0, Math.floor(ms / 1000))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Natural-language duration: `47 秒` / `1 分 12 秒` / `1 小时 3 分`.
|
|
100
|
+
* An exact minute drops the redundant `0 秒`; hours imply minutes.
|
|
101
|
+
* @param ms - duration in milliseconds.
|
|
102
|
+
* @returns the display string.
|
|
103
|
+
*/
|
|
104
|
+
function humanDuration(ms) {
|
|
105
|
+
const total = secondsOf(ms)
|
|
106
|
+
if (total < 60) return total + ' 秒'
|
|
107
|
+
const minutes = Math.floor(total / 60)
|
|
108
|
+
if (minutes < 60) {
|
|
109
|
+
const rest = total % 60
|
|
110
|
+
return rest === 0 ? minutes + ' 分钟' : minutes + ' 分 ' + rest + ' 秒'
|
|
111
|
+
}
|
|
112
|
+
const hours = Math.floor(minutes / 60)
|
|
113
|
+
const restMinutes = minutes % 60
|
|
114
|
+
return restMinutes === 0 ? hours + ' 小时' : hours + ' 小时 ' + restMinutes + ' 分'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Compact clock for the right edge: `m:ss`, widening to `h:mm:ss` past an hour.
|
|
119
|
+
* @param ms - duration in milliseconds.
|
|
120
|
+
* @returns the display string.
|
|
121
|
+
*/
|
|
122
|
+
function clockOf(ms) {
|
|
123
|
+
const total = secondsOf(ms)
|
|
124
|
+
const minutes = Math.floor(total / 60)
|
|
125
|
+
const seconds = total % 60
|
|
126
|
+
const pad = function (value) {
|
|
127
|
+
return (value < 10 ? '0' : '') + value
|
|
128
|
+
}
|
|
129
|
+
if (minutes < 60) return minutes + ':' + pad(seconds)
|
|
130
|
+
return Math.floor(minutes / 60) + ':' + pad(minutes % 60) + ':' + pad(seconds)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Newest Turn still open in the loaded timeline, or null. */
|
|
134
|
+
function latestOpenTurn(timeline) {
|
|
135
|
+
if (timeline === undefined || timeline === null) return null
|
|
136
|
+
const order = timeline.turnOrder
|
|
137
|
+
const turns = timeline.turns
|
|
138
|
+
if (order === undefined || turns === undefined || typeof turns.get !== 'function') return null
|
|
139
|
+
for (let index = order.length - 1; index >= 0; index -= 1) {
|
|
140
|
+
const turn = turns.get(order[index])
|
|
141
|
+
if (turn !== undefined && turn !== null && turn.status === 'open') return turn
|
|
142
|
+
}
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Newest Step of a Turn, when it is still open. */
|
|
147
|
+
function openStepOf(turn) {
|
|
148
|
+
const steps = turn.steps
|
|
149
|
+
if (steps === undefined || steps.length === 0) return null
|
|
150
|
+
const step = steps[steps.length - 1]
|
|
151
|
+
if (step === undefined || step === null || step.status !== 'open') return null
|
|
152
|
+
return step
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Newest Turn in the loaded timeline whatever its status — the idle record source. */
|
|
156
|
+
function latestTurn(timeline) {
|
|
157
|
+
if (timeline === undefined || timeline === null) return null
|
|
158
|
+
const order = timeline.turnOrder
|
|
159
|
+
const turns = timeline.turns
|
|
160
|
+
if (order === undefined || turns === undefined || typeof turns.get !== 'function' || order.length === 0) return null
|
|
161
|
+
return turns.get(order[order.length - 1])
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Read or create this session's record slot. */
|
|
165
|
+
function recordFor(sessionKey) {
|
|
166
|
+
const existing = RECORDS.get(sessionKey)
|
|
167
|
+
if (existing !== undefined) return existing
|
|
168
|
+
const fresh = { final: null, pending: null, seenTurn: null, seenStep: null }
|
|
169
|
+
RECORDS.set(sessionKey, fresh)
|
|
170
|
+
return fresh
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The dock entry component.
|
|
175
|
+
*
|
|
176
|
+
* Receives only standard slot props: `useChat` selects from the live Chat
|
|
177
|
+
* snapshot (driving re-render on every engine update) and `timer` supplies a
|
|
178
|
+
* disposable interval, so nothing here creates a process-wide side effect.
|
|
179
|
+
*/
|
|
180
|
+
function StepClock(props) {
|
|
181
|
+
const useChat = props.useChat
|
|
182
|
+
const timer = props.timer
|
|
183
|
+
const label = props.label === undefined ? '' : props.label
|
|
184
|
+
const sessionKey = props.sessionId === undefined ? 'default' : String(props.sessionId)
|
|
185
|
+
// Wall clock, injectable so the duration maths is testable without freezing time.
|
|
186
|
+
const now = props.now === undefined ? Date.now() : props.now
|
|
187
|
+
const hold = React.useState(0)
|
|
188
|
+
const tick = hold[0]
|
|
189
|
+
const bump = hold[1]
|
|
190
|
+
const timeline = useChat(function (snapshot) {
|
|
191
|
+
return snapshot.timeline
|
|
192
|
+
})
|
|
193
|
+
const legacy = useChat(function (snapshot) {
|
|
194
|
+
return snapshot.legacy
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const turn = latestOpenTurn(timeline)
|
|
198
|
+
const step = turn === null ? null : openStepOf(turn)
|
|
199
|
+
const stepNumber = step === null ? 0 : step.step
|
|
200
|
+
const stepStart =
|
|
201
|
+
step !== null && step.start !== undefined && step.start !== null && typeof step.start.time === 'number'
|
|
202
|
+
? step.start.time
|
|
203
|
+
: null
|
|
204
|
+
|
|
205
|
+
const runningCalls =
|
|
206
|
+
legacy === undefined || legacy === null || legacy.runningCalls === undefined || legacy.runningCalls === null
|
|
207
|
+
? []
|
|
208
|
+
: legacy.runningCalls
|
|
209
|
+
const partial = legacy === undefined || legacy === null ? null : legacy.partial
|
|
210
|
+
|
|
211
|
+
let firstCallTime = null
|
|
212
|
+
const names = []
|
|
213
|
+
let callCount = 0
|
|
214
|
+
for (let index = 0; index < runningCalls.length; index += 1) {
|
|
215
|
+
const call = runningCalls[index]
|
|
216
|
+
if (call === undefined || call === null) continue
|
|
217
|
+
if (call.step !== stepNumber) continue
|
|
218
|
+
callCount += 1
|
|
219
|
+
if (typeof call.time === 'number' && (firstCallTime === null || call.time < firstCallTime)) firstCallTime = call.time
|
|
220
|
+
if (typeof call.name === 'string' && call.name !== '' && names.indexOf(call.name) < 0) names.push(call.name)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const thinking = stepNumber > 0 && partial !== null && partial !== undefined && partial.step === stepNumber
|
|
224
|
+
|
|
225
|
+
// A running Tool call is the most precise anchor there is: it starts when the
|
|
226
|
+
// call is logged and disappears when the result lands. Thinking falls back to
|
|
227
|
+
// the step boundary, the closest honest anchor for model time.
|
|
228
|
+
let anchor = firstCallTime
|
|
229
|
+
if (anchor === null && thinking && stepStart !== null) anchor = stepStart
|
|
230
|
+
if (anchor === null) anchor = stepStart
|
|
231
|
+
|
|
232
|
+
// Always ticking: an active clock needs it, and the idle record has to reveal
|
|
233
|
+
// itself once its duration is worth reporting.
|
|
234
|
+
React.useEffect(function () {
|
|
235
|
+
const dispose = timer.interval(function () {
|
|
236
|
+
bump(function (value) {
|
|
237
|
+
return value + 1
|
|
238
|
+
})
|
|
239
|
+
}, 500)
|
|
240
|
+
return function () {
|
|
241
|
+
if (typeof dispose === 'function') dispose()
|
|
242
|
+
}
|
|
243
|
+
}, [])
|
|
244
|
+
|
|
245
|
+
const live = turn !== null && step !== null
|
|
246
|
+
|
|
247
|
+
// Record the step of any closed Turn.
|
|
248
|
+
//
|
|
249
|
+
// Keyed on (turn, step) rather than on the turn alone: the engine reopens a
|
|
250
|
+
// finished Turn when follow-up work arrives, so a Turn can close more than
|
|
251
|
+
// once, and a turn-keyed guard would silently stop updating after the first
|
|
252
|
+
// close. Recording also runs while a Turn is live, which is what lets a
|
|
253
|
+
// reopened Turn's newly finished step land without waiting for it to close
|
|
254
|
+
// again.
|
|
255
|
+
//
|
|
256
|
+
// Only a step whose own `start` AND `end` are both present is recorded; an
|
|
257
|
+
// absent pair means the loaded window does not carry the evidence, and the
|
|
258
|
+
// bar reports itself idle rather than inventing a duration.
|
|
259
|
+
const record = recordFor(sessionKey)
|
|
260
|
+
const candidate = latestTurn(timeline)
|
|
261
|
+
if (candidate !== undefined && candidate !== null) {
|
|
262
|
+
const steps = candidate.steps
|
|
263
|
+
const last = steps === undefined || steps === null || steps.length === 0 ? null : steps[steps.length - 1]
|
|
264
|
+
if (last !== undefined && last !== null && last.status !== 'open') {
|
|
265
|
+
const began = last.start === undefined || last.start === null ? null : last.start.time
|
|
266
|
+
const ended = last.end === undefined || last.end === null ? null : last.end.time
|
|
267
|
+
if (typeof began === 'number' && typeof ended === 'number' && ended >= began) {
|
|
268
|
+
if (record.seenTurn !== candidate.turn || record.seenStep !== last.step) {
|
|
269
|
+
record.final = record.pending
|
|
270
|
+
record.pending = { turn: candidate.turn, step: last.step, ms: ended - began, closedAt: now }
|
|
271
|
+
record.seenTurn = candidate.turn
|
|
272
|
+
record.seenStep = last.step
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let say = ''
|
|
279
|
+
let where = ''
|
|
280
|
+
let clock = ''
|
|
281
|
+
let tone = 'idle'
|
|
282
|
+
let chips = []
|
|
283
|
+
const shown = record.pending === null ? record.final : record.pending
|
|
284
|
+
|
|
285
|
+
if (live) {
|
|
286
|
+
tone = 'active'
|
|
287
|
+
const elapsedMs = anchor === null ? 0 : now - anchor
|
|
288
|
+
if (callCount > 0) {
|
|
289
|
+
chips = names.length > MAX_NAMES ? names.slice(0, MAX_NAMES).concat('+' + (names.length - MAX_NAMES)) : names
|
|
290
|
+
const lead = names.length > 0 ? '正在执行 ' + names[0] : '正在执行工具'
|
|
291
|
+
say =
|
|
292
|
+
callCount > 1
|
|
293
|
+
? lead + ',另有 ' + (callCount - 1) + ' 个工具并行,本步已运行 ' + humanDuration(elapsedMs)
|
|
294
|
+
: lead + ',已运行 ' + humanDuration(elapsedMs)
|
|
295
|
+
where = '第 ' + stepNumber + ' 步'
|
|
296
|
+
} else if (thinking) {
|
|
297
|
+
say = '模型正在思考,本步已耗时 ' + humanDuration(elapsedMs)
|
|
298
|
+
where = '第 ' + stepNumber + ' 步'
|
|
299
|
+
} else {
|
|
300
|
+
say = '已提交,正在等待模型响应,已等待 ' + humanDuration(elapsedMs)
|
|
301
|
+
where = '第 ' + stepNumber + ' 步'
|
|
302
|
+
}
|
|
303
|
+
clock = clockOf(elapsedMs)
|
|
304
|
+
} else if (shown !== null) {
|
|
305
|
+
// No step is running (yet). Keep reporting the previous step, so the gap
|
|
306
|
+
// between a Turn closing and the next step starting — and a fresh page load
|
|
307
|
+
// whose newest Turn is already closed — both stay informative.
|
|
308
|
+
tone = 'done'
|
|
309
|
+
say = '上一步(第 ' + shown.step + ' 步)已完成,用时 ' + humanDuration(shown.ms)
|
|
310
|
+
clock = clockOf(shown.ms)
|
|
311
|
+
} else {
|
|
312
|
+
say = '空闲,等待下一步'
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const children = []
|
|
316
|
+
children.push(
|
|
317
|
+
React.createElement('span', {
|
|
318
|
+
key: 'dot',
|
|
319
|
+
className: tone === 'idle' ? 'dsh-stepclock-dot-idle' : 'dsh-stepclock-dot',
|
|
320
|
+
}),
|
|
321
|
+
)
|
|
322
|
+
children.push(React.createElement('span', { key: 'say', className: 'dsh-stepclock-say' }, say))
|
|
323
|
+
if (where !== '') children.push(React.createElement('span', { key: 'where', className: 'dsh-stepclock-where' }, where))
|
|
324
|
+
if (chips.length > 0) {
|
|
325
|
+
children.push(
|
|
326
|
+
React.createElement(
|
|
327
|
+
'span',
|
|
328
|
+
{ key: 'chips', className: 'dsh-stepclock-chip', title: names.join(' + ') },
|
|
329
|
+
chips.join(' · '),
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
if (clock !== '') {
|
|
334
|
+
children.push(React.createElement('span', { key: 'clock', className: 'dsh-stepclock-time dsh-stepclock-mono' }, clock))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return React.createElement(
|
|
338
|
+
'div',
|
|
339
|
+
{ className: 'dsh-stepclock-dock' },
|
|
340
|
+
React.createElement(
|
|
341
|
+
'div',
|
|
342
|
+
{ className: 'dsh-stepclock-root', role: 'status', 'aria-live': 'off', title: label + ' ' + say },
|
|
343
|
+
children,
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Services this plugin waits for before applying.
|
|
350
|
+
*
|
|
351
|
+
* Declared rather than assumed: `styles` and `slots` are owned by the renderer
|
|
352
|
+
* module, and Cordis parks this plugin until they exist instead of calling
|
|
353
|
+
* `apply` with a half-built context. `timer` supplies the ticking interval.
|
|
354
|
+
*/
|
|
355
|
+
const inject = ['slots', 'styles', 'timer']
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Register the dock entry.
|
|
359
|
+
*
|
|
360
|
+
* A single additive entry in `conversation.input.dock` (the strip above the
|
|
361
|
+
* composer, beside the todo / goal / queue bars). `replaceRisk: none`: a fresh
|
|
362
|
+
* `id` is added beside the shipped entries rather than replacing any of them.
|
|
363
|
+
*/
|
|
364
|
+
function apply(ctx) {
|
|
365
|
+
ctx.effect(function () {
|
|
366
|
+
return ctx.styles.insert(CSS)
|
|
367
|
+
})
|
|
368
|
+
const mount = function (slot, id, order, label) {
|
|
369
|
+
ctx.slots.inject(slot, function () {
|
|
370
|
+
return ctx.slots.register({ name: slot, id: id, order: order }, function (props) {
|
|
371
|
+
return React.createElement(StepClock, {
|
|
372
|
+
useChat: props.useChat,
|
|
373
|
+
timer: ctx.timer,
|
|
374
|
+
sessionId: props.sessionId,
|
|
375
|
+
label: label,
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
mount('conversation.input.dock', 'step-clock', 15, '上')
|
|
381
|
+
}
|
|
382
|
+
//#endregion
|
|
383
|
+
exports.StepClock = StepClock;
|
|
384
|
+
exports.CSS = CSS;
|
|
385
|
+
exports.apply = apply;
|
|
386
|
+
exports.inject = inject;
|
|
387
|
+
return module.exports;
|
|
388
|
+
}
|
|
389
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @climber47/dsh-step-clock — host half.
|
|
3
|
+
*
|
|
4
|
+
* This plugin is browser-only: every piece of behaviour is the dock entry in
|
|
5
|
+
* the client half (`src/client/index.js`, served from `lib/client.js` as the
|
|
6
|
+
* package's `./client` export and declared through `dsh.client` in
|
|
7
|
+
* package.json). The host half therefore contributes nothing at runtime.
|
|
8
|
+
*
|
|
9
|
+
* It still exists, and is still loaded, because a dsh bundle's inserted row
|
|
10
|
+
* resolves the package root: a package that could not be imported would not
|
|
11
|
+
* mount. Both exports below are the empty, valid shape for that contract.
|
|
12
|
+
*
|
|
13
|
+
* @module @climber47/dsh-step-clock
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** No services are required: the host half registers nothing. */
|
|
17
|
+
export const inject = []
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Mount the (empty) host half.
|
|
21
|
+
*
|
|
22
|
+
* The client half is loaded independently by the client module host, which
|
|
23
|
+
* scans installed packages for a `dsh.client` declaration; it does not depend
|
|
24
|
+
* on anything this function does.
|
|
25
|
+
*/
|
|
26
|
+
export function apply() {}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@climber47/dsh-step-clock",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Live per-step elapsed-time clock for the DeepSeek Harness web GUI: shows which step is running, which tools it is running, and how long it has been going.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deepseek",
|
|
7
|
+
"harness",
|
|
8
|
+
"dsh",
|
|
9
|
+
"dsh-plugin",
|
|
10
|
+
"ui"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
15
|
+
},
|
|
16
|
+
"main": "lib/index.js",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "node scripts/build-client.mjs",
|
|
19
|
+
"test": "node scripts/build-client.mjs && node --test \"tests/**/*.mjs\""
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"default": "./lib/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./client": {
|
|
26
|
+
"default": "./lib/client.js"
|
|
27
|
+
},
|
|
28
|
+
"./src/*": "./src/*",
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"lib/index.js",
|
|
33
|
+
"lib/client.js",
|
|
34
|
+
"src",
|
|
35
|
+
"cordis.patch.yml",
|
|
36
|
+
"README.md",
|
|
37
|
+
"README.zh.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"dsh": {
|
|
42
|
+
"engines": {
|
|
43
|
+
"dsh": ">=0.1.5-rc.1"
|
|
44
|
+
},
|
|
45
|
+
"bundle": {
|
|
46
|
+
"patch": "./cordis.patch.yml"
|
|
47
|
+
},
|
|
48
|
+
"client": {
|
|
49
|
+
"inject": [
|
|
50
|
+
"@deepseek-ai/dsh-client-ui-renderer"
|
|
51
|
+
],
|
|
52
|
+
"platform": "web"
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"react": "^18.2.0"
|
|
57
|
+
},
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "git+https://github.com/WsTe47/dsh-step-clock.git"
|
|
61
|
+
},
|
|
62
|
+
"bugs": {
|
|
63
|
+
"url": "https://github.com/WsTe47/dsh-step-clock/issues"
|
|
64
|
+
},
|
|
65
|
+
"homepage": "https://github.com/WsTe47/dsh-step-clock#readme",
|
|
66
|
+
"author": "climber47"
|
|
67
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @climber47/dsh-step-clock — browser half (readable source).
|
|
3
|
+
*
|
|
4
|
+
* The shipped file is `lib/client.js`, which wraps this component in the
|
|
5
|
+
* `window.__ModuleLoader__.load({ id, factory })` registration shape a dsh
|
|
6
|
+
* client bundle must have. This file is the same logic kept readable so a
|
|
7
|
+
* reader can review what the package does without unwrapping the bundle.
|
|
8
|
+
*
|
|
9
|
+
* What it does
|
|
10
|
+
* ------------
|
|
11
|
+
* Renders one ambient entry in the composer dock while an agent step is
|
|
12
|
+
* running, in plain language:
|
|
13
|
+
*
|
|
14
|
+
* 正在执行 bash,已运行 1 分 12 秒 第 12 步 [bash] 1:12
|
|
15
|
+
*
|
|
16
|
+
* and keeps the finished step on screen once it ends:
|
|
17
|
+
*
|
|
18
|
+
* 上一步(第 12 步)已完成,用时 3 分 45 秒 3:45
|
|
19
|
+
*
|
|
20
|
+
* Why the second line matters
|
|
21
|
+
* ---------------------------
|
|
22
|
+
* A step is often over in under a second, so a bar that only exists *during*
|
|
23
|
+
* a step is easy to miss entirely. Holding the last step's duration until the
|
|
24
|
+
* next step replaces it means a slow step can be identified after the fact.
|
|
25
|
+
* The record is only kept when the step's own `start` and `end` timestamps are
|
|
26
|
+
* both available; when they are not, the bar says it is idle rather than
|
|
27
|
+
* inventing a duration.
|
|
28
|
+
*
|
|
29
|
+
* It reads the live facts the engine already publishes rather than polling
|
|
30
|
+
* anything itself:
|
|
31
|
+
*
|
|
32
|
+
* - the open Turn and its open Step, from the Chat timeline snapshot, give
|
|
33
|
+
* the step number and the step's start time;
|
|
34
|
+
* - `legacy.runningCalls` holds one entry per in-flight Tool call and exists
|
|
35
|
+
* only while that call is in flight, so its `time` is the exact moment the
|
|
36
|
+
* call started — this is what makes "bash has been running 47s" precise
|
|
37
|
+
* instead of an estimate;
|
|
38
|
+
* - `legacy.partial` tells whether the model is currently streaming, which
|
|
39
|
+
* separates "thinking" from "waiting for the model".
|
|
40
|
+
*
|
|
41
|
+
* The clock ticks from 0s with no delay threshold.
|
|
42
|
+
*
|
|
43
|
+
* @module @climber47/dsh-step-clock/client
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { CSS } from './styles.js'
|
|
47
|
+
|
|
48
|
+
/** Tool names named inline before collapsing the remainder into `+N`. */
|
|
49
|
+
const MAX_NAMES = 4
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Last-finished-step records, keyed by session so two open conversations never
|
|
53
|
+
* clash. Module-level so the record survives re-renders.
|
|
54
|
+
*/
|
|
55
|
+
const RECORDS = new Map()
|
|
56
|
+
|
|
57
|
+
/** Floor a duration to whole seconds. */
|
|
58
|
+
function secondsOf(ms) {
|
|
59
|
+
return Math.max(0, Math.floor(ms / 1000))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Natural-language duration: `47 秒` / `1 分 12 秒` / `1 小时 3 分`.
|
|
64
|
+
* An exact minute drops the redundant `0 秒`; hours imply minutes.
|
|
65
|
+
* @param ms - duration in milliseconds.
|
|
66
|
+
* @returns the display string.
|
|
67
|
+
*/
|
|
68
|
+
function humanDuration(ms) {
|
|
69
|
+
const total = secondsOf(ms)
|
|
70
|
+
if (total < 60) return total + ' 秒'
|
|
71
|
+
const minutes = Math.floor(total / 60)
|
|
72
|
+
if (minutes < 60) {
|
|
73
|
+
const rest = total % 60
|
|
74
|
+
return rest === 0 ? minutes + ' 分钟' : minutes + ' 分 ' + rest + ' 秒'
|
|
75
|
+
}
|
|
76
|
+
const hours = Math.floor(minutes / 60)
|
|
77
|
+
const restMinutes = minutes % 60
|
|
78
|
+
return restMinutes === 0 ? hours + ' 小时' : hours + ' 小时 ' + restMinutes + ' 分'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Compact clock for the right edge: `m:ss`, widening to `h:mm:ss` past an hour.
|
|
83
|
+
* @param ms - duration in milliseconds.
|
|
84
|
+
* @returns the display string.
|
|
85
|
+
*/
|
|
86
|
+
function clockOf(ms) {
|
|
87
|
+
const total = secondsOf(ms)
|
|
88
|
+
const minutes = Math.floor(total / 60)
|
|
89
|
+
const seconds = total % 60
|
|
90
|
+
const pad = function (value) {
|
|
91
|
+
return (value < 10 ? '0' : '') + value
|
|
92
|
+
}
|
|
93
|
+
if (minutes < 60) return minutes + ':' + pad(seconds)
|
|
94
|
+
return Math.floor(minutes / 60) + ':' + pad(minutes % 60) + ':' + pad(seconds)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Newest Turn still open in the loaded timeline, or null. */
|
|
98
|
+
function latestOpenTurn(timeline) {
|
|
99
|
+
if (timeline === undefined || timeline === null) return null
|
|
100
|
+
const order = timeline.turnOrder
|
|
101
|
+
const turns = timeline.turns
|
|
102
|
+
if (order === undefined || turns === undefined || typeof turns.get !== 'function') return null
|
|
103
|
+
for (let index = order.length - 1; index >= 0; index -= 1) {
|
|
104
|
+
const turn = turns.get(order[index])
|
|
105
|
+
if (turn !== undefined && turn !== null && turn.status === 'open') return turn
|
|
106
|
+
}
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Newest Step of a Turn, when it is still open. */
|
|
111
|
+
function openStepOf(turn) {
|
|
112
|
+
const steps = turn.steps
|
|
113
|
+
if (steps === undefined || steps.length === 0) return null
|
|
114
|
+
const step = steps[steps.length - 1]
|
|
115
|
+
if (step === undefined || step === null || step.status !== 'open') return null
|
|
116
|
+
return step
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Newest Turn in the loaded timeline whatever its status — the idle record source. */
|
|
120
|
+
function latestTurn(timeline) {
|
|
121
|
+
if (timeline === undefined || timeline === null) return null
|
|
122
|
+
const order = timeline.turnOrder
|
|
123
|
+
const turns = timeline.turns
|
|
124
|
+
if (order === undefined || turns === undefined || typeof turns.get !== 'function' || order.length === 0) return null
|
|
125
|
+
return turns.get(order[order.length - 1])
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Read or create this session's record slot. */
|
|
129
|
+
function recordFor(sessionKey) {
|
|
130
|
+
const existing = RECORDS.get(sessionKey)
|
|
131
|
+
if (existing !== undefined) return existing
|
|
132
|
+
const fresh = { final: null, pending: null, seenTurn: null, seenStep: null }
|
|
133
|
+
RECORDS.set(sessionKey, fresh)
|
|
134
|
+
return fresh
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The dock entry component.
|
|
139
|
+
*
|
|
140
|
+
* Receives only standard slot props: `useChat` selects from the live Chat
|
|
141
|
+
* snapshot (driving re-render on every engine update) and `timer` supplies a
|
|
142
|
+
* disposable interval, so nothing here creates a process-wide side effect.
|
|
143
|
+
*/
|
|
144
|
+
export function StepClock(props) {
|
|
145
|
+
const useChat = props.useChat
|
|
146
|
+
const timer = props.timer
|
|
147
|
+
const label = props.label === undefined ? '' : props.label
|
|
148
|
+
const sessionKey = props.sessionId === undefined ? 'default' : String(props.sessionId)
|
|
149
|
+
// Wall clock, injectable so the duration maths is testable without freezing time.
|
|
150
|
+
const now = props.now === undefined ? Date.now() : props.now
|
|
151
|
+
const hold = React.useState(0)
|
|
152
|
+
const tick = hold[0]
|
|
153
|
+
const bump = hold[1]
|
|
154
|
+
const timeline = useChat(function (snapshot) {
|
|
155
|
+
return snapshot.timeline
|
|
156
|
+
})
|
|
157
|
+
const legacy = useChat(function (snapshot) {
|
|
158
|
+
return snapshot.legacy
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const turn = latestOpenTurn(timeline)
|
|
162
|
+
const step = turn === null ? null : openStepOf(turn)
|
|
163
|
+
const stepNumber = step === null ? 0 : step.step
|
|
164
|
+
const stepStart =
|
|
165
|
+
step !== null && step.start !== undefined && step.start !== null && typeof step.start.time === 'number'
|
|
166
|
+
? step.start.time
|
|
167
|
+
: null
|
|
168
|
+
|
|
169
|
+
const runningCalls =
|
|
170
|
+
legacy === undefined || legacy === null || legacy.runningCalls === undefined || legacy.runningCalls === null
|
|
171
|
+
? []
|
|
172
|
+
: legacy.runningCalls
|
|
173
|
+
const partial = legacy === undefined || legacy === null ? null : legacy.partial
|
|
174
|
+
|
|
175
|
+
let firstCallTime = null
|
|
176
|
+
const names = []
|
|
177
|
+
let callCount = 0
|
|
178
|
+
for (let index = 0; index < runningCalls.length; index += 1) {
|
|
179
|
+
const call = runningCalls[index]
|
|
180
|
+
if (call === undefined || call === null) continue
|
|
181
|
+
if (call.step !== stepNumber) continue
|
|
182
|
+
callCount += 1
|
|
183
|
+
if (typeof call.time === 'number' && (firstCallTime === null || call.time < firstCallTime)) firstCallTime = call.time
|
|
184
|
+
if (typeof call.name === 'string' && call.name !== '' && names.indexOf(call.name) < 0) names.push(call.name)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const thinking = stepNumber > 0 && partial !== null && partial !== undefined && partial.step === stepNumber
|
|
188
|
+
|
|
189
|
+
// A running Tool call is the most precise anchor there is: it starts when the
|
|
190
|
+
// call is logged and disappears when the result lands. Thinking falls back to
|
|
191
|
+
// the step boundary, the closest honest anchor for model time.
|
|
192
|
+
let anchor = firstCallTime
|
|
193
|
+
if (anchor === null && thinking && stepStart !== null) anchor = stepStart
|
|
194
|
+
if (anchor === null) anchor = stepStart
|
|
195
|
+
|
|
196
|
+
// Always ticking: an active clock needs it, and the idle record has to reveal
|
|
197
|
+
// itself once its duration is worth reporting.
|
|
198
|
+
React.useEffect(function () {
|
|
199
|
+
const dispose = timer.interval(function () {
|
|
200
|
+
bump(function (value) {
|
|
201
|
+
return value + 1
|
|
202
|
+
})
|
|
203
|
+
}, 500)
|
|
204
|
+
return function () {
|
|
205
|
+
if (typeof dispose === 'function') dispose()
|
|
206
|
+
}
|
|
207
|
+
}, [])
|
|
208
|
+
|
|
209
|
+
const live = turn !== null && step !== null
|
|
210
|
+
|
|
211
|
+
// Record the step of any closed Turn.
|
|
212
|
+
//
|
|
213
|
+
// Keyed on (turn, step) rather than on the turn alone: the engine reopens a
|
|
214
|
+
// finished Turn when follow-up work arrives, so a Turn can close more than
|
|
215
|
+
// once, and a turn-keyed guard would silently stop updating after the first
|
|
216
|
+
// close. Recording also runs while a Turn is live, which is what lets a
|
|
217
|
+
// reopened Turn's newly finished step land without waiting for it to close
|
|
218
|
+
// again.
|
|
219
|
+
//
|
|
220
|
+
// Only a step whose own `start` AND `end` are both present is recorded; an
|
|
221
|
+
// absent pair means the loaded window does not carry the evidence, and the
|
|
222
|
+
// bar reports itself idle rather than inventing a duration.
|
|
223
|
+
const record = recordFor(sessionKey)
|
|
224
|
+
const candidate = latestTurn(timeline)
|
|
225
|
+
if (candidate !== undefined && candidate !== null) {
|
|
226
|
+
const steps = candidate.steps
|
|
227
|
+
const last = steps === undefined || steps === null || steps.length === 0 ? null : steps[steps.length - 1]
|
|
228
|
+
if (last !== undefined && last !== null && last.status !== 'open') {
|
|
229
|
+
const began = last.start === undefined || last.start === null ? null : last.start.time
|
|
230
|
+
const ended = last.end === undefined || last.end === null ? null : last.end.time
|
|
231
|
+
if (typeof began === 'number' && typeof ended === 'number' && ended >= began) {
|
|
232
|
+
if (record.seenTurn !== candidate.turn || record.seenStep !== last.step) {
|
|
233
|
+
record.final = record.pending
|
|
234
|
+
record.pending = { turn: candidate.turn, step: last.step, ms: ended - began, closedAt: now }
|
|
235
|
+
record.seenTurn = candidate.turn
|
|
236
|
+
record.seenStep = last.step
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let say = ''
|
|
243
|
+
let where = ''
|
|
244
|
+
let clock = ''
|
|
245
|
+
let tone = 'idle'
|
|
246
|
+
let chips = []
|
|
247
|
+
const shown = record.pending === null ? record.final : record.pending
|
|
248
|
+
|
|
249
|
+
if (live) {
|
|
250
|
+
tone = 'active'
|
|
251
|
+
const elapsedMs = anchor === null ? 0 : now - anchor
|
|
252
|
+
if (callCount > 0) {
|
|
253
|
+
chips = names.length > MAX_NAMES ? names.slice(0, MAX_NAMES).concat('+' + (names.length - MAX_NAMES)) : names
|
|
254
|
+
const lead = names.length > 0 ? '正在执行 ' + names[0] : '正在执行工具'
|
|
255
|
+
say =
|
|
256
|
+
callCount > 1
|
|
257
|
+
? lead + ',另有 ' + (callCount - 1) + ' 个工具并行,本步已运行 ' + humanDuration(elapsedMs)
|
|
258
|
+
: lead + ',已运行 ' + humanDuration(elapsedMs)
|
|
259
|
+
where = '第 ' + stepNumber + ' 步'
|
|
260
|
+
} else if (thinking) {
|
|
261
|
+
say = '模型正在思考,本步已耗时 ' + humanDuration(elapsedMs)
|
|
262
|
+
where = '第 ' + stepNumber + ' 步'
|
|
263
|
+
} else {
|
|
264
|
+
say = '已提交,正在等待模型响应,已等待 ' + humanDuration(elapsedMs)
|
|
265
|
+
where = '第 ' + stepNumber + ' 步'
|
|
266
|
+
}
|
|
267
|
+
clock = clockOf(elapsedMs)
|
|
268
|
+
} else if (shown !== null) {
|
|
269
|
+
// No step is running (yet). Keep reporting the previous step, so the gap
|
|
270
|
+
// between a Turn closing and the next step starting — and a fresh page load
|
|
271
|
+
// whose newest Turn is already closed — both stay informative.
|
|
272
|
+
tone = 'done'
|
|
273
|
+
say = '上一步(第 ' + shown.step + ' 步)已完成,用时 ' + humanDuration(shown.ms)
|
|
274
|
+
clock = clockOf(shown.ms)
|
|
275
|
+
} else {
|
|
276
|
+
say = '空闲,等待下一步'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const children = []
|
|
280
|
+
children.push(
|
|
281
|
+
React.createElement('span', {
|
|
282
|
+
key: 'dot',
|
|
283
|
+
className: tone === 'idle' ? 'dsh-stepclock-dot-idle' : 'dsh-stepclock-dot',
|
|
284
|
+
}),
|
|
285
|
+
)
|
|
286
|
+
children.push(React.createElement('span', { key: 'say', className: 'dsh-stepclock-say' }, say))
|
|
287
|
+
if (where !== '') children.push(React.createElement('span', { key: 'where', className: 'dsh-stepclock-where' }, where))
|
|
288
|
+
if (chips.length > 0) {
|
|
289
|
+
children.push(
|
|
290
|
+
React.createElement(
|
|
291
|
+
'span',
|
|
292
|
+
{ key: 'chips', className: 'dsh-stepclock-chip', title: names.join(' + ') },
|
|
293
|
+
chips.join(' · '),
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
if (clock !== '') {
|
|
298
|
+
children.push(React.createElement('span', { key: 'clock', className: 'dsh-stepclock-time dsh-stepclock-mono' }, clock))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return React.createElement(
|
|
302
|
+
'div',
|
|
303
|
+
{ className: 'dsh-stepclock-dock' },
|
|
304
|
+
React.createElement(
|
|
305
|
+
'div',
|
|
306
|
+
{ className: 'dsh-stepclock-root', role: 'status', 'aria-live': 'off', title: label + ' ' + say },
|
|
307
|
+
children,
|
|
308
|
+
),
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Services this plugin waits for before applying.
|
|
314
|
+
*
|
|
315
|
+
* Declared rather than assumed: `styles` and `slots` are owned by the renderer
|
|
316
|
+
* module, and Cordis parks this plugin until they exist instead of calling
|
|
317
|
+
* `apply` with a half-built context. `timer` supplies the ticking interval.
|
|
318
|
+
*/
|
|
319
|
+
export const inject = ['slots', 'styles', 'timer']
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Register the dock entry.
|
|
323
|
+
*
|
|
324
|
+
* A single additive entry in `conversation.input.dock` (the strip above the
|
|
325
|
+
* composer, beside the todo / goal / queue bars). `replaceRisk: none`: a fresh
|
|
326
|
+
* `id` is added beside the shipped entries rather than replacing any of them.
|
|
327
|
+
*/
|
|
328
|
+
export function apply(ctx) {
|
|
329
|
+
ctx.effect(function () {
|
|
330
|
+
return ctx.styles.insert(CSS)
|
|
331
|
+
})
|
|
332
|
+
const mount = function (slot, id, order, label) {
|
|
333
|
+
ctx.slots.inject(slot, function () {
|
|
334
|
+
return ctx.slots.register({ name: slot, id: id, order: order }, function (props) {
|
|
335
|
+
return React.createElement(StepClock, {
|
|
336
|
+
useChat: props.useChat,
|
|
337
|
+
timer: ctx.timer,
|
|
338
|
+
sessionId: props.sessionId,
|
|
339
|
+
label: label,
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
mount('conversation.input.dock', 'step-clock', 15, '上')
|
|
345
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @climber47/dsh-step-clock — browser half styles.
|
|
3
|
+
*
|
|
4
|
+
* Kept beside the component so the bundle's injected stylesheet has a readable
|
|
5
|
+
* source. Colours and geometry come from dsh theme tokens and the composer
|
|
6
|
+
* layout variables, with fallbacks so a missing token degrades instead of
|
|
7
|
+
* breaking the bar. The `--dsh-composer-*` variables are declared on the
|
|
8
|
+
* conversation root, which is an ancestor of both dock seats.
|
|
9
|
+
*
|
|
10
|
+
* @module @climber47/dsh-step-clock/client/styles
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const CSS = [
|
|
14
|
+
// The dock row is full viewport width; this wrapper centres a composer-width
|
|
15
|
+
// column inside it, mirroring the shipped GoalBar dock convention.
|
|
16
|
+
'.dsh-stepclock-dock{box-sizing:border-box;width:calc(100% - var(--dsh-composer-side-clearance,16px) - var(--dsh-composer-side-clearance,16px) - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px));margin:0 auto;}',
|
|
17
|
+
'.dsh-stepclock-root{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;box-sizing:border-box;width:100%;max-width:calc(var(--dsh-composer-card-max-width,780px) - 4 * var(--dsh-composer-dock-inset,8px));margin:0 auto;padding:6px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-specific-tip,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-label-secondary);font-size:13px;line-height:18px;}',
|
|
18
|
+
// A filled dot marks real activity; a hollow one marks an idle bar.
|
|
19
|
+
'.dsh-stepclock-dot{flex:none;align-self:center;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-brand-primary);}',
|
|
20
|
+
'.dsh-stepclock-dot-idle{flex:none;align-self:center;width:8px;height:8px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);}',
|
|
21
|
+
'.dsh-stepclock-say{flex:1 1 auto;min-width:0;color:var(--dsw-alias-label-primary);}',
|
|
22
|
+
'.dsh-stepclock-where{flex:none;opacity:.6;font-size:12px;}',
|
|
23
|
+
'.dsh-stepclock-time{flex:none;color:var(--dsw-alias-brand-primary);font-weight:600;font-size:15px;}',
|
|
24
|
+
'.dsh-stepclock-chip{flex:none;max-width:22em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:1px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:var(--dsw-alias-bg-layer-1);font-size:12px;line-height:16px;}',
|
|
25
|
+
'.dsh-stepclock-mono{font-variant-numeric:tabular-nums;font-feature-settings:"tnum";}',
|
|
26
|
+
].join('')
|