@gezi-wen/dsh-reminder 0.1.2
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 +135 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +935 -0
- package/lib/index.js +26 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gezi-wen
|
|
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,135 @@
|
|
|
1
|
+
# dsh-reminder(时间提醒)
|
|
2
|
+
|
|
3
|
+
DSH 会话头部的一个时间模块:**现在时间 / 距你上次发消息 / 提醒 / 倒计时**。
|
|
4
|
+
|
|
5
|
+
定位是**会话内的短期时间提醒** —— 管"此刻这台机器上的计时",不承担"关了也不会忘"的责任。
|
|
6
|
+
收起时只占一小条,点开向下拉出面板;到点时模块边框呼吸发亮 + 界面浮条。
|
|
7
|
+
|
|
8
|
+
> **DSH 版本兼容性(0.1.1 核对于 `0.1.7-rc.1`)**
|
|
9
|
+
> `conversation.session.header.actions` 槽位、`ctx.slots` API、以及「到点唤醒会话」走的 `sessions.binding(id).session.prompt()` 链,在 0.1.6 → 0.1.7 之间**均未改变**(`@deepseek-ai/dsh-client-ui-slots` 两版 SHA256 完全相同)。
|
|
10
|
+
> 0.1.1 另加了槽位挂载的失败留痕:`ctx.slots` 缺失或注入抛异常会在控制台报警 —— 以前这两种情况下模块是**彻底无声**的,只表现为「这插件好像没装」。
|
|
11
|
+
|
|
12
|
+
纯前端插件(`dsh.client.platform = web`):
|
|
13
|
+
|
|
14
|
+
- 时间是浏览器本地时间
|
|
15
|
+
- 提醒与倒计时存在浏览器 `localStorage`
|
|
16
|
+
- 到点提示 = **模块边框呼吸灯 + 界面浮条**
|
|
17
|
+
- **host 半不参与任何事** —— 不发系统通知、不读文件、不建网络连接
|
|
18
|
+
|
|
19
|
+
## 两个模块,分工是「留不留」
|
|
20
|
+
|
|
21
|
+
| | 提醒 | 倒计时 |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| 是什么 | **一天内的一个钟点**(如 21:40) | **一段时长**(天 / 时 / 分 / 秒) |
|
|
24
|
+
| 寿命 | **长期保留**在列表里;响过之后回到初始状态,继续等下一个钟点 | **一次性**:跑完自动从列表清理 |
|
|
25
|
+
| 开关 | `▶` 启动、`⏸` 关闭(两个动作分开,当前生效的那个亮着) | `⏸` 暂停 / 继续、`×` 删除,带进度条 |
|
|
26
|
+
| 设定 | 时 / 分 **两个**滚轮 | 天 / 时 / 分 / 秒 **四个**滚轮 |
|
|
27
|
+
|
|
28
|
+
## 界面
|
|
29
|
+
|
|
30
|
+
收起态:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
┌──────────────────────────────┐
|
|
34
|
+
│ 2026年9月19日 ┌─────┐│
|
|
35
|
+
│ 周六 15:47 │01:27││ ← 右侧是最近一个到期的计时
|
|
36
|
+
└───────────────────────└─────┘┘
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
展开态分三段:现在时间(含秒)、提醒、倒计时。
|
|
40
|
+
|
|
41
|
+
- 「还剩多久」**只在 10 分钟内**转成金色,其余时候是灰的
|
|
42
|
+
- 条目的操作按钮 hover 才浮出来
|
|
43
|
+
- 模块右边的小胶囊 = 最近一个要响的东西,不用展开就能看见
|
|
44
|
+
- **到点浮条只显示你设的内容**(名字),不加「到点了」这类旁白
|
|
45
|
+
|
|
46
|
+
## 用法
|
|
47
|
+
|
|
48
|
+
- **点模块**展开面板;`Esc`、点面板外面、或再点一次收起
|
|
49
|
+
- **提醒** → `+ 新建`:名称 + 时间(时 / 分滚轮)→ 保存即启动
|
|
50
|
+
- 到点响一次,之后**照常留在列表里**等下一个钟点(长期保留、不消费)
|
|
51
|
+
- 列表里 `▶` 启动、`⏸` 关闭、`⋯` 编辑(编辑页左下角是 `删除`)
|
|
52
|
+
- **倒计时** → `+ 新建`:名称 + 时长(天 / 时 / 分 / 秒滚轮)→ `开始`
|
|
53
|
+
- 它是一次性的:**跑完自动从列表清理**
|
|
54
|
+
- 列表里 `⏸` 暂停 / 继续、`×` 删除
|
|
55
|
+
- **到点唤醒会话**(面板最底部,**默认关**):提醒到点时往当前会话发一条署名消息,把空闲的会话叫醒
|
|
56
|
+
- 提醒专属;倒计时不唤醒
|
|
57
|
+
- 会话正在跑的时候不发 —— 不打断正在进行的回合
|
|
58
|
+
- 发了没有、为什么没发,面板上看得到(`上次唤醒:已投递` / `跳过:会话正在跑` / …)
|
|
59
|
+
- ⚠️ **每次唤醒 = 一轮完整的模型调用**(真花钱),所以默认关、一条条单独开
|
|
60
|
+
|
|
61
|
+
### 时间怎么选
|
|
62
|
+
|
|
63
|
+
- 一律**滚轮**,不接受文字输入 —— 避免有人敲进 `ahfkhas` 这类非时间字符
|
|
64
|
+
- 三种改法:点上下行、鼠标滚轮、`↑` / `↓` / `PageUp` / `PageDown`
|
|
65
|
+
- **24 / 60 进制**:时 0-23、分 0-59、秒 0-59、天 0-99。列之间**不做进位** ——
|
|
66
|
+
要 90 分钟就写「1 时 30 分」,不写「90 分」
|
|
67
|
+
- **不做重复规则**(只一次 / 每天 / 每周):提醒天生就是"这个钟点",没什么可选的
|
|
68
|
+
|
|
69
|
+
## 安装
|
|
70
|
+
|
|
71
|
+
```jsonc
|
|
72
|
+
// profile 的 package.json
|
|
73
|
+
"dependencies": {
|
|
74
|
+
"@gezi-wen/dsh-reminder": "link:<你的路径>/dsh-reminder" // 本地目录用 link:,发布版写版本号
|
|
75
|
+
},
|
|
76
|
+
"dsh": { "profile": { "bundles": [ ..., "@gezi-wen/dsh-reminder" ] } }
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
然后在该 profile 目录跑 `pnpm install`,重启(或刷新页面)。
|
|
80
|
+
|
|
81
|
+
## host 侧注册了什么
|
|
82
|
+
|
|
83
|
+
**什么都没注册。** host 半是故意空的(`lib/index.js` 只有一个 `apply()` 打一行启动标记):
|
|
84
|
+
|
|
85
|
+
- 不注册工具、不注册服务、不读文件、不起进程、不建网络连接
|
|
86
|
+
- 数据全在浏览器 `localStorage`,定时也全在浏览器
|
|
87
|
+
- host 半被加载之后不做任何事 —— profile 与它的关系只是"这个包在 `bundles` 里"
|
|
88
|
+
|
|
89
|
+
原则是**把复杂度压到最小**:会话头部的一个时间模块,不该为了"更稳"而拖出一条宿主侧的数据通道。
|
|
90
|
+
|
|
91
|
+
## 可选升级路线(当前故意不做)
|
|
92
|
+
|
|
93
|
+
下面几条都是讨论过、**明确决定先不做**的方向。记在这里是因为它们各有真实代价 —— 将来真要做时照这张表决策,别凭感觉加。
|
|
94
|
+
|
|
95
|
+
| 想解决 | 要动什么 | 代价 |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| **提醒活过 DSH 重启** | 数据从 `localStorage` 搬到宿主侧文件 | 需要一条 host ↔ client 的数据通道。注意 `localStorage` 的隔离键是 scheme + host + **端口**,而桌面壳每次启动都是 `--port 0`(OS 随机分配)→ **重启必丢**,刷新页面不受影响 |
|
|
98
|
+
| **让智能体自己设提醒** | 宿主侧注册一个工具(agent 只能调宿主工具) | 和上一条是**同一个改动** —— 一加工具,宿主就必须自己存数据,"重启不丢"会被一起带进来 |
|
|
99
|
+
| **页面关着也能到点提醒** | 定时也搬到宿主侧常驻 | 还要重新设计"提醒长什么样"(系统通知 / 托盘 / 等页面回来再补),并重新确认"不要系统通知"这条取舍 |
|
|
100
|
+
|
|
101
|
+
**别做半迁移。** 只把某一类提醒搬到宿主侧、另一类留在 `localStorage`,结果是两个真相源 + "为什么这条留下了那条没了"的困惑。要做就整库一起搬。
|
|
102
|
+
|
|
103
|
+
## 设计上值得一提的三处
|
|
104
|
+
|
|
105
|
+
**一、面板挂在模块自己的容器上,且右对齐。** 不是开一个全屏 overlay,而是
|
|
106
|
+
「模块 + 下拉面板」包在同一个 `position: relative` 的容器里,面板绝对定位到它 ——
|
|
107
|
+
跟着模块走,不需要算坐标。用 `right: 0` 而不是 `left: 0`:模块在会话头部右侧,
|
|
108
|
+
往右展开会顶出窗口(实测溢出 127px)。
|
|
109
|
+
|
|
110
|
+
**二、滚轮的对齐靠几何保证,不靠 margin 凑。** 列高 = 可见行数 × 行高(3 × 24px),
|
|
111
|
+
中间那一行天然落在列的正中,而高亮条也画在列的正中 —— 两者必然重合。
|
|
112
|
+
(早先用 `margin-top` 把首行往下推,本来就偏 10px;再叠上一条被上一行孤儿声明吃掉的
|
|
113
|
+
`.ck-picker` 规则,整块滚轮就退化成了上下两坨。)
|
|
114
|
+
|
|
115
|
+
**三、会话事件窗口要「订阅」而不是「读一次」。**
|
|
116
|
+
「距你上次发消息」取自 `sessions.binding(sessionId).eventSource`。
|
|
117
|
+
实测它第一次 `getSnapshot()` 拿到的 `entries` 是**空数组**,要 `subscribe()` 等事件填进来。
|
|
118
|
+
用户消息的事件名是 `user/message`。
|
|
119
|
+
拿不到就整行隐藏 —— 不编假数据。
|
|
120
|
+
|
|
121
|
+
## 已知限制
|
|
122
|
+
|
|
123
|
+
- **数据活不过一次 DSH 重启。** 提醒与倒计时存在浏览器 `localStorage`,
|
|
124
|
+
而 `localStorage` 的隔离键是 scheme + host + **端口**;桌面壳每次启动都用 `--port 0`
|
|
125
|
+
(OS 随机分配端口)→ 重启之后 origin 变了,这些数据就读不到。
|
|
126
|
+
这是 2026-09-19 当面确认的定位:**它就是"会话内的短期提醒"**,不承担跨重启持久化 ——
|
|
127
|
+
真要做,就不止是个 DSH 插件了(要 host 半存盘 + 定时 + 系统级提醒)。
|
|
128
|
+
刷新页面不影响。
|
|
129
|
+
- 「距你上次发消息」依赖 DSH 客户端内部接口(`sessions` 服务 + 会话事件流)。
|
|
130
|
+
接口若变,这一行会安静地消失,不影响其余功能。
|
|
131
|
+
- 不含系统通知。到点提醒只有界面内的呼吸灯与浮条 —— 页面不在前台时会错过。
|
|
132
|
+
|
|
133
|
+
## 许可
|
|
134
|
+
|
|
135
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-reminder(时间提醒)— 浏览器半。
|
|
3
|
+
*
|
|
4
|
+
* 手写 ModuleLoader bundle(与 dsh-timeclock / sage-livingroom 同格式):
|
|
5
|
+
* window.__ModuleLoader__.load({ id, factory })
|
|
6
|
+
* factory(require) 返回 { apply, inject }
|
|
7
|
+
*
|
|
8
|
+
* 形制要点:
|
|
9
|
+
* - 纯 client:时间是浏览器本地时间,提醒/倒计时存 localStorage,
|
|
10
|
+
* 到点提示 = 本模块边框呼吸灯 + 界面浮条。host 半没有任何参与。
|
|
11
|
+
* - 定位是**会话内的短期时间提醒**(2026-09-19 用户定):
|
|
12
|
+
* 「提醒」= 一天内的一个钟点,长期留在列表、跑完回初始状态;
|
|
13
|
+
* 「倒计时」= 一次性时长(天/时/分/秒),跑完自动清理。
|
|
14
|
+
* 不做重复规则、不做系统通知、不做跨重启持久化 —— 那就不止是个 DSH 插件了。
|
|
15
|
+
* - 入口挂在 conversation.session.header.actions(order 30,坐在「客厅」31 左边)。
|
|
16
|
+
* 槽位 standardProps 直接给 sessionId,用它去问 sessions 服务要事件流。
|
|
17
|
+
* - 面板不另开 overlay:整个「模块 + 下拉」包在同一个 .ck-wrap 里,
|
|
18
|
+
* 面板绝对定位到它(right:0)→ 跟着模块走,且不会顶出窗口右侧。
|
|
19
|
+
*
|
|
20
|
+
* 「距你上次发消息」是尽力而为:DSH 的会话事件类型没有随包发出来,
|
|
21
|
+
* 所以这里不硬编码事件名,而是运行时宽松探测(见 extractLastUserMs)。
|
|
22
|
+
* 探不到就隐藏那一行,不留假数据。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
window.__ModuleLoader__.load({
|
|
26
|
+
id: "dsh-reminder",
|
|
27
|
+
factory: (require) => {
|
|
28
|
+
var module = { exports: {} };
|
|
29
|
+
var exports = module.exports;
|
|
30
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
31
|
+
let react = require("react");
|
|
32
|
+
const h = react.createElement;
|
|
33
|
+
|
|
34
|
+
/* ══════════════════════════════════════════════════════════
|
|
35
|
+
0 · 开关与常量
|
|
36
|
+
══════════════════════════════════════════════════════════ */
|
|
37
|
+
const STORE_KEY = "sage-reminder/v1";
|
|
38
|
+
const WEEK_FULL = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
|
39
|
+
const WARN_MS = 10 * 60 * 1000; // 10 分钟内转金色
|
|
40
|
+
|
|
41
|
+
function pad2(n) { return (n < 10 ? "0" : "") + n; }
|
|
42
|
+
|
|
43
|
+
function uid() {
|
|
44
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* ══════════════════════════════════════════════════════════
|
|
48
|
+
1 · 存储(浏览器本地;清缓存会丢 —— 这是当面确认过的取舍)
|
|
49
|
+
══════════════════════════════════════════════════════════ */
|
|
50
|
+
function readStore() {
|
|
51
|
+
try {
|
|
52
|
+
const raw = window.localStorage.getItem(STORE_KEY);
|
|
53
|
+
if (!raw) return { alarms: [], timers: [], lastFired: null, wakeOnFire: false, lastWake: null };
|
|
54
|
+
const d = JSON.parse(raw);
|
|
55
|
+
return {
|
|
56
|
+
alarms: Array.isArray(d && d.alarms) ? d.alarms : [],
|
|
57
|
+
timers: Array.isArray(d && d.timers) ? d.timers : [],
|
|
58
|
+
lastFired: (d && d.lastFired) || null,
|
|
59
|
+
wakeOnFire: !!(d && d.wakeOnFire),
|
|
60
|
+
lastWake: (d && d.lastWake) || null,
|
|
61
|
+
};
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return { alarms: [], timers: [], lastFired: null, wakeOnFire: false, lastWake: null };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeStore(s) {
|
|
68
|
+
try {
|
|
69
|
+
window.localStorage.setItem(STORE_KEY, JSON.stringify({
|
|
70
|
+
alarms: s.alarms || [],
|
|
71
|
+
timers: s.timers || [],
|
|
72
|
+
lastFired: s.lastFired || null,
|
|
73
|
+
wakeOnFire: !!s.wakeOnFire,
|
|
74
|
+
lastWake: s.lastWake || null,
|
|
75
|
+
}));
|
|
76
|
+
} catch (e) {
|
|
77
|
+
/* 隐私模式 / 配额满:静默降级为内存态,不炸界面 */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ══════════════════════════════════════════════════════════
|
|
82
|
+
2 · 提醒引擎
|
|
83
|
+
reminder = { id, name, hh, mm, enabled:bool }
|
|
84
|
+
——「提醒」= 一天内的一个钟点(用户 2026-09-19 定:闹钟本就一个时、单天内的设定)。
|
|
85
|
+
没有重复规则(只一次 / 每天 / 每周全部取消):它长期留在列表里,
|
|
86
|
+
响过之后回到初始状态继续等下一个钟点,由 ▶ / ⏸ 显式开关。
|
|
87
|
+
旧数据里的 repeat / days / date 字段一律忽略。
|
|
88
|
+
══════════════════════════════════════════════════════════ */
|
|
89
|
+
|
|
90
|
+
/** 下一次响铃的绝对时间(ms):今天这个点还没到就是今天,过了就是明天。 */
|
|
91
|
+
function nextFire(alarm, from) {
|
|
92
|
+
if (!alarm || !alarm.enabled) return null;
|
|
93
|
+
const base = new Date(from);
|
|
94
|
+
const at = (y, m, d) => new Date(y, m, d, alarm.hh, alarm.mm, 0, 0).getTime();
|
|
95
|
+
let t = at(base.getFullYear(), base.getMonth(), base.getDate());
|
|
96
|
+
if (t <= from) {
|
|
97
|
+
const tm = new Date(base.getFullYear(), base.getMonth(), base.getDate() + 1);
|
|
98
|
+
t = at(tm.getFullYear(), tm.getMonth(), tm.getDate());
|
|
99
|
+
}
|
|
100
|
+
return t;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 提醒的副标题:钟点(`21:40`)。 */
|
|
104
|
+
function alarmMeta(alarm) {
|
|
105
|
+
return pad2(alarm.hh) + ":" + pad2(alarm.mm);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 「还有 N 分钟」这类剩余描述。有更大的单位时把「分钟」缩成「分」——
|
|
109
|
+
列表行右边就这点地方,全写会挤掉名字(2026-09-19 实测)。 */
|
|
110
|
+
function fmtRemain(ms) {
|
|
111
|
+
if (ms <= 0) return "到点了";
|
|
112
|
+
const s = Math.floor(ms / 1000);
|
|
113
|
+
const d = Math.floor(s / 86400);
|
|
114
|
+
const hh = Math.floor((s % 86400) / 3600);
|
|
115
|
+
const mm = Math.floor((s % 3600) / 60);
|
|
116
|
+
if (d > 0) return "还有 " + d + " 天 " + hh + " 小时";
|
|
117
|
+
if (hh > 0) return "还有 " + hh + " 小时 " + mm + " 分";
|
|
118
|
+
if (mm > 0) return "还有 " + mm + " 分钟";
|
|
119
|
+
return "还有 " + (s % 60) + " 秒";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 倒计时读秒:mm:ss(超 1 小时给 h:mm:ss,超 1 天给 N天 h:mm:ss)。 */
|
|
123
|
+
function fmtClock(ms) {
|
|
124
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
125
|
+
const d = Math.floor(s / 86400);
|
|
126
|
+
const hh = Math.floor((s % 86400) / 3600);
|
|
127
|
+
const mm = Math.floor((s % 3600) / 60);
|
|
128
|
+
const ss = s % 60;
|
|
129
|
+
if (d > 0) return d + "天 " + pad2(hh) + ":" + pad2(mm) + ":" + pad2(ss);
|
|
130
|
+
return hh > 0 ? hh + ":" + pad2(mm) + ":" + pad2(ss) : pad2(mm) + ":" + pad2(ss);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 时长说人话(选择器下方的提示行):`1 天 02 时 30 分 00 秒` / `10 分 00 秒`。
|
|
134
|
+
高位为 0 就省掉,看着不啰嗦。 */
|
|
135
|
+
function fmtDur(ms) {
|
|
136
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
137
|
+
const d = Math.floor(s / 86400);
|
|
138
|
+
const hh = Math.floor((s % 86400) / 3600);
|
|
139
|
+
const mm = Math.floor((s % 3600) / 60);
|
|
140
|
+
const ss = s % 60;
|
|
141
|
+
const out = [];
|
|
142
|
+
if (d > 0) out.push(d + " 天");
|
|
143
|
+
if (d > 0 || hh > 0) out.push((d > 0 ? pad2(hh) : hh) + " 时");
|
|
144
|
+
out.push((d > 0 || hh > 0 ? pad2(mm) : mm) + " 分");
|
|
145
|
+
out.push(pad2(ss) + " 秒");
|
|
146
|
+
return out.join(" ");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* ══════════════════════════════════════════════════════════
|
|
150
|
+
3 · 「距你上次发消息」——宽松探测
|
|
151
|
+
DSH 的会话事件类型没随包发布,事件名无从查证,所以不猜名字,
|
|
152
|
+
而是按形状找:type 里带 user/prompt/input 且不带 assistant/tool,
|
|
153
|
+
并从常见字段名里取时间戳。找不到就返回 null(UI 隐藏该行)。
|
|
154
|
+
══════════════════════════════════════════════════════════ */
|
|
155
|
+
const TIME_FIELDS = ["time", "timestamp", "ts", "at", "createdAt", "occurredAt"];
|
|
156
|
+
|
|
157
|
+
function pickTime(obj, depth) {
|
|
158
|
+
if (!obj || typeof obj !== "object" || depth > 3) return null;
|
|
159
|
+
for (let i = 0; i < TIME_FIELDS.length; i++) {
|
|
160
|
+
const v = obj[TIME_FIELDS[i]];
|
|
161
|
+
if (typeof v === "number" && v > 1000000000000) return v; // 毫秒级时间戳
|
|
162
|
+
}
|
|
163
|
+
// 往下找一层(事件多半把载荷放在 data / payload 里)
|
|
164
|
+
const subs = [obj.data, obj.payload, obj.body];
|
|
165
|
+
for (let i = 0; i < subs.length; i++) {
|
|
166
|
+
const t = pickTime(subs[i], depth + 1);
|
|
167
|
+
if (t) return t;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function looksLikeUserMessage(type) {
|
|
173
|
+
const t = String(type || "").toLowerCase();
|
|
174
|
+
if (!t) return false;
|
|
175
|
+
if (/assistant|tool|system|compaction|checkpoint/.test(t)) return false;
|
|
176
|
+
return /user|prompt|human|input/.test(t);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 从事件窗口里倒着找最近一条「用户消息」的时间戳。 */
|
|
180
|
+
function extractLastUserMs(entries) {
|
|
181
|
+
if (!Array.isArray(entries)) return null;
|
|
182
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
183
|
+
const wrap = entries[i];
|
|
184
|
+
const ev = wrap && wrap.event ? wrap.event : wrap;
|
|
185
|
+
if (!ev) continue;
|
|
186
|
+
if (!looksLikeUserMessage(ev.type)) continue;
|
|
187
|
+
const t = pickTime(ev, 0);
|
|
188
|
+
if (t) return t;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* ══════════════════════════════════════════════════════════
|
|
194
|
+
4 · 样式(data-plugin-css 幂等注入;类名统一 ck- 前缀)
|
|
195
|
+
══════════════════════════════════════════════════════════ */
|
|
196
|
+
const CSS = [
|
|
197
|
+
/* — 模块(收起态)— */
|
|
198
|
+
".ck-wrap{position:relative;display:inline-flex}",
|
|
199
|
+
".ck-mod{display:inline-flex;align-items:center;gap:7px;height:34px;padding:0 9px 0 10px;",
|
|
200
|
+
" border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));border-radius:9px;",
|
|
201
|
+
" background:var(--dsw-alias-bg-layer-1,transparent);color:inherit;cursor:pointer;",
|
|
202
|
+
" font-family:inherit;white-space:nowrap;transition:border-color .15s,background .15s,box-shadow .3s}",
|
|
203
|
+
".ck-mod:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.16));background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.04))}",
|
|
204
|
+
".ck-mod.open{border-color:rgba(232,184,75,.5)}",
|
|
205
|
+
/* 到点提醒:边框呼吸灯(替代系统通知) */
|
|
206
|
+
".ck-mod.alarm{border-color:rgba(232,184,75,.75);animation:ck-breath 1.7s ease-in-out infinite}",
|
|
207
|
+
"@keyframes ck-breath{",
|
|
208
|
+
" 0%,100%{box-shadow:0 0 0 1px rgba(232,184,75,.22),0 0 8px rgba(232,184,75,.16)}",
|
|
209
|
+
" 50%{box-shadow:0 0 0 1px rgba(232,184,75,.6),0 0 20px rgba(232,184,75,.45)}}",
|
|
210
|
+
".ck-mod.alarm .ck-date,.ck-mod.alarm .ck-time{color:#f0d08a}",
|
|
211
|
+
".ck-lines{flex:1;min-width:0;display:flex;flex-direction:column;justify-content:center;line-height:1.25}",
|
|
212
|
+
".ck-date{font-size:9.5px;letter-spacing:.02em;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45));font-variant-numeric:tabular-nums}",
|
|
213
|
+
".ck-time{font-size:11.5px;font-weight:500;letter-spacing:.01em;color:var(--dsw-alias-label-primary,inherit);font-variant-numeric:tabular-nums}",
|
|
214
|
+
".ck-badge{display:inline-flex;align-items:center;height:18px;padding:0 6px;border-radius:999px;",
|
|
215
|
+
" background:rgba(232,184,75,.12);border:1px solid rgba(232,184,75,.32);color:#e8b84b;",
|
|
216
|
+
" font-size:10.5px;font-variant-numeric:tabular-nums}",
|
|
217
|
+
".ck-caret{color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45));font-size:8px;transition:transform .18s}",
|
|
218
|
+
".ck-mod.open .ck-caret{transform:rotate(180deg)}",
|
|
219
|
+
|
|
220
|
+
/* — 下拉面板 — */
|
|
221
|
+
/* right:0 —— 会话头部在窗口右侧,面板往左展开才不会顶出窗口(左对齐会溢出) */
|
|
222
|
+
".ck-panel{position:absolute;top:calc(100% + 6px);right:0;width:288px;max-width:calc(100vw - 20px);z-index:60;",
|
|
223
|
+
" background:var(--dsw-alias-bg-layer-2,#222);border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.12));",
|
|
224
|
+
" border-radius:12px;box-shadow:0 18px 48px rgba(0,0,0,.55);overflow:hidden;",
|
|
225
|
+
" opacity:0;transform:translateY(-6px);pointer-events:none;",
|
|
226
|
+
" transition:opacity .16s ease-out,transform .18s cubic-bezier(.2,.9,.3,1.1)}",
|
|
227
|
+
".ck-panel.open{opacity:1;transform:none;pointer-events:auto}",
|
|
228
|
+
/* 列表限高:闹钟/倒计时再多,面板也不会长成一根柱子 */
|
|
229
|
+
".ck-list{max-height:min(62vh,440px);overflow-y:auto;overscroll-behavior:contain;",
|
|
230
|
+
" scrollbar-width:thin;scrollbar-color:var(--dsw-alias-border-l2,rgba(255,255,255,.16)) transparent}",
|
|
231
|
+
".ck-list::-webkit-scrollbar{width:8px}",
|
|
232
|
+
".ck-list::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2,rgba(255,255,255,.16));border-radius:4px}",
|
|
233
|
+
".ck-list::-webkit-scrollbar-track{background:transparent}",
|
|
234
|
+
".ck-sec{padding:11px 12px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08))}",
|
|
235
|
+
".ck-sec:last-child{border-bottom:none}",
|
|
236
|
+
".ck-sechead{display:flex;align-items:center;gap:6px;margin-bottom:8px}",
|
|
237
|
+
".ck-secname{font-size:11px;font-weight:600;letter-spacing:.08em;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45))}",
|
|
238
|
+
".ck-seccount{font-size:10px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45));opacity:.6}",
|
|
239
|
+
".ck-add{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));",
|
|
240
|
+
" border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));border-radius:6px;padding:1px 7px;cursor:pointer;background:none;font-family:inherit}",
|
|
241
|
+
".ck-add:hover{color:var(--dsw-alias-label-primary,inherit)}",
|
|
242
|
+
|
|
243
|
+
".ck-now{font-size:19px;font-weight:500;letter-spacing:.02em;font-variant-numeric:tabular-nums;line-height:1.2}",
|
|
244
|
+
".ck-nowsub{font-size:11.5px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));margin-top:3px}",
|
|
245
|
+
".ck-since{margin-top:9px;display:flex;align-items:baseline;gap:6px;font-size:11.5px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45))}",
|
|
246
|
+
".ck-since b{font-weight:500;color:var(--dsw-alias-label-primary,inherit);font-variant-numeric:tabular-nums}",
|
|
247
|
+
|
|
248
|
+
".ck-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:8px}",
|
|
249
|
+
".ck-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.05))}",
|
|
250
|
+
".ck-dot{width:6px;height:6px;border-radius:50%;flex:none;background:var(--dsw-alias-label-secondary,rgba(255,255,255,.3))}",
|
|
251
|
+
".ck-item.on .ck-dot{background:#e8b84b;box-shadow:0 0 0 3px rgba(232,184,75,.16)}",
|
|
252
|
+
".ck-item.off{opacity:.45}",
|
|
253
|
+
".ck-main{min-width:0;flex:1}",
|
|
254
|
+
".ck-name{font-size:12.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
255
|
+
".ck-meta{font-size:10.5px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45));font-variant-numeric:tabular-nums;display:block;margin-top:1px;opacity:.72;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
256
|
+
".ck-left{font-size:12.5px;font-variant-numeric:tabular-nums;white-space:nowrap}",
|
|
257
|
+
".ck-left.warn{color:#e8b84b}",
|
|
258
|
+
".ck-acts{display:flex;gap:2px;opacity:0;transition:opacity .12s}",
|
|
259
|
+
".ck-item:hover .ck-acts{opacity:1}",
|
|
260
|
+
".ck-mini{width:20px;height:20px;border-radius:5px;border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));",
|
|
261
|
+
" color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));font-size:11px;display:inline-flex;",
|
|
262
|
+
" align-items:center;justify-content:center;cursor:pointer;background:none;font-family:inherit}",
|
|
263
|
+
".ck-mini:hover{color:var(--dsw-alias-label-primary,inherit)}",
|
|
264
|
+
/* ▶ / ⏸ 成对出现:当前生效的那个亮,另一个变暗(都还能点) */
|
|
265
|
+
".ck-mini.dim{opacity:.32}",
|
|
266
|
+
/* 开关(到点唤醒会话):胶囊按钮,开=金色 */
|
|
267
|
+
".ck-toggle{margin-left:auto;height:22px;padding:0 10px;border-radius:999px;background:none;font-family:inherit;",
|
|
268
|
+
" border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));",
|
|
269
|
+
" font-size:11px;cursor:pointer}",
|
|
270
|
+
".ck-toggle.on{background:rgba(232,184,75,.14);border-color:rgba(232,184,75,.45);color:#e8b84b}",
|
|
271
|
+
".ck-note{font-size:10.5px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45));opacity:.78;margin-top:7px;line-height:1.5}",
|
|
272
|
+
".ck-wake-last{color:#e8b84b;opacity:.92}",
|
|
273
|
+
".ck-empty{font-size:11.5px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45));padding:4px 8px 2px}",
|
|
274
|
+
|
|
275
|
+
".ck-cd{padding:8px 8px 9px;display:block}",
|
|
276
|
+
".ck-cdtop{display:flex;align-items:baseline;gap:8px}",
|
|
277
|
+
".ck-cdname{font-size:12.5px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
278
|
+
".ck-cdleft{font-size:14px;font-variant-numeric:tabular-nums;color:#e8b84b;letter-spacing:.02em}",
|
|
279
|
+
".ck-bar{height:3px;border-radius:2px;background:var(--dsw-alias-bg-layer-3,rgba(255,255,255,.12));margin-top:7px;overflow:hidden}",
|
|
280
|
+
".ck-bar > i{display:block;height:100%;background:#e8b84b;border-radius:2px;transition:width .9s linear}",
|
|
281
|
+
|
|
282
|
+
/* — 编辑视图 — */
|
|
283
|
+
".ck-edit{display:none}",
|
|
284
|
+
".ck-panel.editing .ck-list{display:none}",
|
|
285
|
+
".ck-panel.editing .ck-edit{display:block}",
|
|
286
|
+
".ck-ehead{display:flex;align-items:center;gap:8px;padding:11px 12px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08))}",
|
|
287
|
+
".ck-back{width:22px;height:22px;border-radius:6px;border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));",
|
|
288
|
+
" color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));font-size:12px;display:inline-flex;",
|
|
289
|
+
" align-items:center;justify-content:center;cursor:pointer;background:none;font-family:inherit}",
|
|
290
|
+
".ck-etitle{font-size:12.5px;font-weight:600}",
|
|
291
|
+
".ck-field{padding:10px 12px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08))}",
|
|
292
|
+
".ck-flabel{display:block;font-size:10.5px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45));opacity:.75;margin-bottom:6px}",
|
|
293
|
+
".ck-input{width:100%;height:30px;padding:0 9px;border-radius:8px;",
|
|
294
|
+
" border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));background:var(--dsw-alias-bg-layer-1,rgba(0,0,0,.2));",
|
|
295
|
+
" color:var(--dsw-alias-label-primary,inherit);font-size:12.5px;font-family:inherit;box-sizing:border-box}",
|
|
296
|
+
".ck-input:focus{outline:none;border-color:rgba(232,184,75,.5)}",
|
|
297
|
+
".ck-hint{font-size:10.5px;color:#e8b84b;opacity:.85;margin:-1px 0 8px}",
|
|
298
|
+
".ck-hint b{font-weight:500}",
|
|
299
|
+
".ck-foot{display:flex;align-items:center;gap:6px;padding:10px 12px}",
|
|
300
|
+
".ck-spacer{flex:1}",
|
|
301
|
+
".ck-btn{height:28px;padding:0 11px;border-radius:8px;font-size:11.5px;cursor:pointer;font-family:inherit;",
|
|
302
|
+
" border:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));background:none}",
|
|
303
|
+
".ck-btn.primary{background:#e8b84b;border-color:transparent;color:#1a1408;font-weight:600}",
|
|
304
|
+
".ck-btn.danger{color:#e57373;border-color:rgba(229,115,115,.35)}",
|
|
305
|
+
|
|
306
|
+
/* — 时间滚轮(并排 · 缩小)—
|
|
307
|
+
对齐原理:列高 = 可见行数 × 行高,中间那行正好落在列的正中,
|
|
308
|
+
高亮条也画在列的正中 → 两者天然重合,不靠 margin 去凑。
|
|
309
|
+
(旧版这里被上一行孤儿声明吃掉了一整条规则,滚轮会退回成上下两坨 —— 见 2026-09-19 记录)
|
|
310
|
+
单位(时/分、天/时/分/秒)标在每列正上方:列数可变(提醒 2 列、倒计时 4 列),
|
|
311
|
+
每列等宽 34px,所以单位行与滚轮列天然对齐。 */
|
|
312
|
+
".ck-picker{display:flex;flex-direction:column;align-items:center;gap:3px}",
|
|
313
|
+
".ck-units{display:flex;padding:0 6px}",
|
|
314
|
+
".ck-unit{width:34px;text-align:center;font-size:10.5px;color:var(--dsw-alias-label-secondary,rgba(255,255,255,.45));opacity:.75}",
|
|
315
|
+
".ck-slot{position:relative;display:flex;align-items:center;justify-content:center;height:72px;padding:0 6px;",
|
|
316
|
+
" -webkit-mask-image:linear-gradient(180deg,rgba(0,0,0,.3) 0,#000 33.5%,#000 66.5%,rgba(0,0,0,.3) 100%);",
|
|
317
|
+
" mask-image:linear-gradient(180deg,rgba(0,0,0,.3) 0,#000 33.5%,#000 66.5%,rgba(0,0,0,.3) 100%)}",
|
|
318
|
+
".ck-slot::before{content:'';position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);height:24px;",
|
|
319
|
+
" border-radius:8px;background:var(--dsw-alias-bg-layer-1,rgba(0,0,0,.22));",
|
|
320
|
+
" border-top:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));",
|
|
321
|
+
" border-bottom:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08))}",
|
|
322
|
+
".ck-pcol{position:relative;display:flex;flex-direction:column;align-items:center;width:34px;outline:none;cursor:pointer}",
|
|
323
|
+
".ck-pcol:focus-visible .ck-pk.on{text-decoration:underline;text-underline-offset:4px}",
|
|
324
|
+
".ck-pk{height:24px;line-height:24px;font-size:13px;font-variant-numeric:tabular-nums;cursor:pointer;",
|
|
325
|
+
" color:var(--dsw-alias-label-secondary,rgba(255,255,255,.6));opacity:.34;user-select:none}",
|
|
326
|
+
".ck-pk.near{opacity:.6}",
|
|
327
|
+
".ck-pk.on{font-size:18px;font-weight:600;color:#e8b84b;opacity:1}",
|
|
328
|
+
|
|
329
|
+
/* — 到点浮条(固定定位,贴头部下方居中)— */
|
|
330
|
+
".ck-toast{position:fixed;left:50%;transform:translateX(-50%);top:70px;z-index:900;",
|
|
331
|
+
" display:inline-flex;align-items:center;gap:10px;padding:9px 14px;border-radius:10px;",
|
|
332
|
+
" background:linear-gradient(180deg,rgba(232,184,75,.18),rgba(232,184,75,.07));",
|
|
333
|
+
" border:1px solid rgba(232,184,75,.42);color:#f0d08a;font-size:12.5px;",
|
|
334
|
+
" box-shadow:0 12px 32px rgba(0,0,0,.45);animation:ck-fadein .2s ease-out}",
|
|
335
|
+
"@keyframes ck-fadein{from{opacity:0;transform:translateX(-50%) translateY(-6px)}to{opacity:1;transform:translateX(-50%)}}",
|
|
336
|
+
".ck-toast button{font-size:11.5px;color:#e8b84b;text-decoration:underline;text-underline-offset:2px;",
|
|
337
|
+
" cursor:pointer;background:none;border:none;font-family:inherit}",
|
|
338
|
+
].join("\n");
|
|
339
|
+
|
|
340
|
+
function injectCss() {
|
|
341
|
+
const tagId = "dsh-reminder/panel.css";
|
|
342
|
+
if (typeof document === "undefined") return;
|
|
343
|
+
if (document.querySelector('style[data-plugin-css="' + tagId + '"]')) return;
|
|
344
|
+
const tag = document.createElement("style");
|
|
345
|
+
tag.dataset.plugin = "dsh-reminder";
|
|
346
|
+
tag.dataset.pluginCss = tagId;
|
|
347
|
+
tag.textContent = CSS;
|
|
348
|
+
document.head.appendChild(tag);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* ══════════════════════════════════════════════════════════
|
|
352
|
+
5 · 小组件
|
|
353
|
+
══════════════════════════════════════════════════════════ */
|
|
354
|
+
|
|
355
|
+
/* ── 时间滚轮 ──
|
|
356
|
+
可见行数 × 行高 = 列高,中间行就在列的正中(高亮条画在同一个位置),
|
|
357
|
+
所以「选中项」与「高亮条」永远对齐 —— 别再给首行加 margin 去挪它。 */
|
|
358
|
+
const PICK_ROWS = 3;
|
|
359
|
+
const PICK_ROW_H = 24;
|
|
360
|
+
|
|
361
|
+
/** 单列滚轮:中间那行是当前值。点上下行、滚轮、↑↓ 键都能转。 */
|
|
362
|
+
function Picker(props) {
|
|
363
|
+
const value = props.value;
|
|
364
|
+
const max = props.max;
|
|
365
|
+
const onChange = props.onChange;
|
|
366
|
+
const colRef = react.useRef(null);
|
|
367
|
+
const half = (PICK_ROWS - 1) / 2;
|
|
368
|
+
const items = [];
|
|
369
|
+
for (let i = -half; i <= half; i++) {
|
|
370
|
+
items.push({ off: i, v: ((value + i) % max + max) % max });
|
|
371
|
+
}
|
|
372
|
+
const step = (d) => onChange(((value + d) % max + max) % max);
|
|
373
|
+
|
|
374
|
+
/* React 17+ 把 wheel 以 passive 挂在根容器上,onWheel 里 preventDefault 是空操作
|
|
375
|
+
—— 调时间时页面会跟着滚。所以这里用原生非 passive 监听。 */
|
|
376
|
+
react.useEffect(() => {
|
|
377
|
+
const el = colRef.current;
|
|
378
|
+
if (!el) return undefined;
|
|
379
|
+
const onWheel = (e) => {
|
|
380
|
+
e.preventDefault();
|
|
381
|
+
step(e.deltaY > 0 ? 1 : -1);
|
|
382
|
+
};
|
|
383
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
384
|
+
return () => el.removeEventListener("wheel", onWheel);
|
|
385
|
+
}, [value, max]);
|
|
386
|
+
|
|
387
|
+
return h("div", {
|
|
388
|
+
className: "ck-pcol",
|
|
389
|
+
ref: colRef,
|
|
390
|
+
tabIndex: 0,
|
|
391
|
+
role: "spinbutton",
|
|
392
|
+
"aria-label": props.label || "",
|
|
393
|
+
"aria-valuenow": value,
|
|
394
|
+
"aria-valuemin": 0,
|
|
395
|
+
"aria-valuemax": max - 1,
|
|
396
|
+
"aria-valuetext": pad2(value) + (props.label || ""),
|
|
397
|
+
onKeyDown: (e) => {
|
|
398
|
+
if (e.key === "ArrowUp") { e.preventDefault(); step(1); }
|
|
399
|
+
else if (e.key === "ArrowDown") { e.preventDefault(); step(-1); }
|
|
400
|
+
else if (e.key === "PageUp") { e.preventDefault(); step(10); }
|
|
401
|
+
else if (e.key === "PageDown") { e.preventDefault(); step(-10); }
|
|
402
|
+
},
|
|
403
|
+
}, items.map((it) => h("span", {
|
|
404
|
+
key: it.off,
|
|
405
|
+
className: "ck-pk" + (it.off === 0 ? " on" : (Math.abs(it.off) === 1 ? " near" : "")),
|
|
406
|
+
onClick: () => onChange(((value + it.off) % max + max) % max),
|
|
407
|
+
}, pad2(it.v))));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** 并排的时间滚轮,列数可变:提醒 = 时/分(2 列),倒计时 = 天/时/分/秒(4 列)。
|
|
411
|
+
单位标在每列正上方(与列同宽 34px,天然对齐),所有列共用一条高亮条。 */
|
|
412
|
+
function TimeWheels(props) {
|
|
413
|
+
const cols = props.cols;
|
|
414
|
+
return h("div", { className: "ck-picker" },
|
|
415
|
+
h("div", { className: "ck-units" },
|
|
416
|
+
cols.map((c, i) => h("span", { key: i, className: "ck-unit" }, c.unit))),
|
|
417
|
+
h("div", { className: "ck-slot" },
|
|
418
|
+
cols.map((c, i) => h(Picker, {
|
|
419
|
+
key: i, value: c.value, max: c.max, label: c.unit, onChange: c.onChange,
|
|
420
|
+
}))));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/* ══════════════════════════════════════════════════════════
|
|
424
|
+
6 · 主组件
|
|
425
|
+
══════════════════════════════════════════════════════════ */
|
|
426
|
+
function ClockModule(props) {
|
|
427
|
+
const sessionId = props && props.sessionId;
|
|
428
|
+
const wrapRef = react.useRef(null);
|
|
429
|
+
|
|
430
|
+
const [now, setNow] = react.useState(() => Date.now());
|
|
431
|
+
const [open, setOpen] = react.useState(false);
|
|
432
|
+
const [store, setStore] = react.useState(readStore);
|
|
433
|
+
const [view, setView] = react.useState("list"); // list | alarm | timer
|
|
434
|
+
const [editing, setEditing] = react.useState(null); // 正在编辑的闹钟
|
|
435
|
+
const [lastUserMs, setLastUserMs] = react.useState(null);
|
|
436
|
+
|
|
437
|
+
/* — 每秒心跳 — */
|
|
438
|
+
react.useEffect(() => {
|
|
439
|
+
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
440
|
+
return () => clearInterval(t);
|
|
441
|
+
}, []);
|
|
442
|
+
|
|
443
|
+
/* — 读「距上次发消息」(尽力而为) —
|
|
444
|
+
实测:binding().eventSource.getSnapshot() 通道是通的,但**首次快照 entries 为空**
|
|
445
|
+
(事件窗口按需填充)。所以这里订阅它,窗口填进来之后再取。 */
|
|
446
|
+
react.useEffect(() => {
|
|
447
|
+
if (!sessionId) return undefined;
|
|
448
|
+
let es = null;
|
|
449
|
+
try {
|
|
450
|
+
const sessions = appCtx && appCtx.get ? appCtx.get("sessions") : null;
|
|
451
|
+
const binding = sessions && typeof sessions.binding === "function"
|
|
452
|
+
? sessions.binding(sessionId) : null;
|
|
453
|
+
es = binding && binding.eventSource;
|
|
454
|
+
} catch (e) { es = null; }
|
|
455
|
+
if (!es || typeof es.getSnapshot !== "function") return undefined;
|
|
456
|
+
const read = () => {
|
|
457
|
+
let snap = null;
|
|
458
|
+
try { snap = es.getSnapshot(); } catch (e) { snap = null; }
|
|
459
|
+
const t = extractLastUserMs(snap && snap.entries);
|
|
460
|
+
if (t) setLastUserMs(t);
|
|
461
|
+
};
|
|
462
|
+
read();
|
|
463
|
+
let un = null;
|
|
464
|
+
try { un = typeof es.subscribe === "function" ? es.subscribe(read) : null; } catch (e) { un = null; }
|
|
465
|
+
return () => { if (typeof un === "function") un(); };
|
|
466
|
+
}, [sessionId]);
|
|
467
|
+
|
|
468
|
+
/* — 持久化 — */
|
|
469
|
+
react.useEffect(() => { writeStore(store); }, [store]);
|
|
470
|
+
|
|
471
|
+
/* — 到点检测(闹钟 / 倒计时)——
|
|
472
|
+
触发记录写进 store.lastFired,**不用组件 state**:实测组件会被重新挂载,
|
|
473
|
+
用 useState 记「正在响」会丢(timer 已被消费、提示却不见了,2026-09-18 实测)。 */
|
|
474
|
+
react.useEffect(() => {
|
|
475
|
+
const quiet = store.lastFired && (now - store.lastFired.at) < 60000;
|
|
476
|
+
if (quiet) return;
|
|
477
|
+
for (let i = 0; i < store.alarms.length; i++) {
|
|
478
|
+
const a = store.alarms[i];
|
|
479
|
+
if (!a.enabled) continue;
|
|
480
|
+
const fire = nextFire(a, now - 1000);
|
|
481
|
+
if (fire && fire <= now) {
|
|
482
|
+
/* 用户 2026-09-19 定:提醒「跑完恢复初始状态」——
|
|
483
|
+
不做消费,照旧留在列表里、保持启动状态,等下一个钟点。 */
|
|
484
|
+
setStore((s) => ({ ...s, lastFired: { name: a.name || "提醒", at: Date.now() } }));
|
|
485
|
+
/* 到点唤醒会话:**总开关 + 这一条的开关都开着**才注入(两个默认都关)。
|
|
486
|
+
60 秒静默期天然挡住连发。 */
|
|
487
|
+
if (store.wakeOnFire && a.wake) wakeSession(a.name || "提醒");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
for (let i = 0; i < store.timers.length; i++) {
|
|
492
|
+
const t = store.timers[i];
|
|
493
|
+
if (t.paused) continue;
|
|
494
|
+
if (t.endsAt && t.endsAt <= now) {
|
|
495
|
+
const nm = t.name || "倒计时";
|
|
496
|
+
setStore((s) => ({
|
|
497
|
+
...s,
|
|
498
|
+
timers: s.timers.filter((x) => x.id !== t.id),
|
|
499
|
+
lastFired: { name: nm, at: Date.now() },
|
|
500
|
+
}));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}, [now, store]);
|
|
505
|
+
|
|
506
|
+
/* — 收起面板的副作用(Esc / 点外面) — */
|
|
507
|
+
react.useEffect(() => {
|
|
508
|
+
if (!open) return undefined;
|
|
509
|
+
const close = () => { setOpen(false); setView("list"); };
|
|
510
|
+
const onKey = (e) => { if (e.key === "Escape") close(); };
|
|
511
|
+
const onDown = (e) => {
|
|
512
|
+
const el = wrapRef.current;
|
|
513
|
+
if (el && e.target && !el.contains(e.target)) close();
|
|
514
|
+
};
|
|
515
|
+
document.addEventListener("keydown", onKey);
|
|
516
|
+
document.addEventListener("mousedown", onDown, true);
|
|
517
|
+
return () => {
|
|
518
|
+
document.removeEventListener("keydown", onKey);
|
|
519
|
+
document.removeEventListener("mousedown", onDown, true);
|
|
520
|
+
};
|
|
521
|
+
}, [open]);
|
|
522
|
+
|
|
523
|
+
/* 「正在响」由 lastFired 的时间推导,不占组件 state —— 重挂载也不丢。
|
|
524
|
+
响后 60 秒内持续提示;静默期同时防止 daily/weekly 闹钟在同一分钟内反复触发。 */
|
|
525
|
+
const firing = (store.lastFired && (now - store.lastFired.at) < 60000) ? store.lastFired : null;
|
|
526
|
+
|
|
527
|
+
const d = new Date(now);
|
|
528
|
+
const dateStr = d.getFullYear() + "年" + (d.getMonth() + 1) + "月" + d.getDate() + "日";
|
|
529
|
+
const week = WEEK_FULL[d.getDay()];
|
|
530
|
+
const hm = pad2(d.getHours()) + ":" + pad2(d.getMinutes());
|
|
531
|
+
|
|
532
|
+
/* 收起态右侧徽标:最近一个要响的东西 */
|
|
533
|
+
let badge = null;
|
|
534
|
+
let badgeMs = null;
|
|
535
|
+
store.alarms.forEach((a) => {
|
|
536
|
+
if (!a.enabled) return;
|
|
537
|
+
const t = nextFire(a, now);
|
|
538
|
+
if (t && (badgeMs === null || t - now < badgeMs)) badgeMs = t - now;
|
|
539
|
+
});
|
|
540
|
+
store.timers.forEach((t) => {
|
|
541
|
+
const ms = t.paused ? (t.remainingMs || 0) : Math.max(0, (t.endsAt || 0) - now);
|
|
542
|
+
if (badgeMs === null || ms < badgeMs) badgeMs = ms;
|
|
543
|
+
});
|
|
544
|
+
if (badgeMs !== null) badge = fmtClock(badgeMs);
|
|
545
|
+
|
|
546
|
+
/* ── 数据操作 ── */
|
|
547
|
+
function saveAlarm(a) {
|
|
548
|
+
setStore((s) => {
|
|
549
|
+
const exists = s.alarms.some((x) => x.id === a.id);
|
|
550
|
+
return { ...s, alarms: exists ? s.alarms.map((x) => x.id === a.id ? a : x) : s.alarms.concat([a]) };
|
|
551
|
+
});
|
|
552
|
+
setView("list");
|
|
553
|
+
setEditing(null);
|
|
554
|
+
}
|
|
555
|
+
function deleteAlarm(id) {
|
|
556
|
+
setStore((s) => ({ ...s, alarms: s.alarms.filter((x) => x.id !== id) }));
|
|
557
|
+
setView("list");
|
|
558
|
+
setEditing(null);
|
|
559
|
+
}
|
|
560
|
+
/** ▶ / ⏸:显式启动 / 关闭一个提醒(不是"切换",两个动作各自明确)。 */
|
|
561
|
+
function setAlarmEnabled(id, on) {
|
|
562
|
+
setStore((s) => ({ ...s, alarms: s.alarms.map((x) => x.id === id ? { ...x, enabled: on } : x) }));
|
|
563
|
+
}
|
|
564
|
+
function addTimer(name, totalMs) {
|
|
565
|
+
const timer = { id: uid(), name: name, totalMs: totalMs, endsAt: Date.now() + totalMs, paused: false, remainingMs: 0 };
|
|
566
|
+
setStore((s) => ({ ...s, timers: s.timers.concat([timer]) }));
|
|
567
|
+
setView("list");
|
|
568
|
+
}
|
|
569
|
+
function pauseTimer(id) {
|
|
570
|
+
setStore((s) => ({
|
|
571
|
+
...s,
|
|
572
|
+
timers: s.timers.map((t) => {
|
|
573
|
+
if (t.id !== id) return t;
|
|
574
|
+
if (t.paused) return { ...t, paused: false, endsAt: Date.now() + (t.remainingMs || 0), remainingMs: 0 };
|
|
575
|
+
return { ...t, paused: true, remainingMs: Math.max(0, (t.endsAt || 0) - Date.now()) };
|
|
576
|
+
}),
|
|
577
|
+
}));
|
|
578
|
+
}
|
|
579
|
+
function removeTimer(id) {
|
|
580
|
+
setStore((s) => ({ ...s, timers: s.timers.filter((t) => t.id !== id) }));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* ── 到点唤醒会话(2026-09-19 第一步:**先验证"注入能不能真把人叫醒"**)──
|
|
584
|
+
走 sessions 服务的正规接口 `session.prompt()` —— 就是官方 composer 的
|
|
585
|
+
`send()` 那条路(dsh-client-ui-conversation:2938),等价于"亲手发了一条消息",
|
|
586
|
+
会真的开一轮 agent 回合。不碰 DOM、不伪造事件。
|
|
587
|
+
|
|
588
|
+
三条自我约束:
|
|
589
|
+
① **署名** —— 注入文本明说是插件发的,绝不冒充用户的话(他自己定的规矩);
|
|
590
|
+
② **只在会话空闲时注入** —— snapshot.running 为真就跳过,别打断正在跑的回合;
|
|
591
|
+
③ 失败只 console 一行,不弹窗、不改界面状态(这是"叫醒"不是"报错")。 */
|
|
592
|
+
function wakeSession(name) {
|
|
593
|
+
/* 每一步的结果都写进 store.lastWake —— 面板上会显示,
|
|
594
|
+
这样"到底叫醒了没有"不用开控制台就能看出来(2026-09-19 验证期需要)。 */
|
|
595
|
+
const done = (result) => {
|
|
596
|
+
console.log("[dsh-reminder] 唤醒:", result);
|
|
597
|
+
setStore((s) => ({ ...s, lastWake: { at: Date.now(), name: name, result: result } }));
|
|
598
|
+
};
|
|
599
|
+
let sess = null;
|
|
600
|
+
try {
|
|
601
|
+
const sessions = appCtx && appCtx.get ? appCtx.get("sessions") : null;
|
|
602
|
+
const binding = sessions && typeof sessions.binding === "function"
|
|
603
|
+
? sessions.binding(sessionId) : null;
|
|
604
|
+
sess = binding && binding.session;
|
|
605
|
+
} catch (e) { sess = null; }
|
|
606
|
+
if (!sess || typeof sess.prompt !== "function") {
|
|
607
|
+
done("跳过:拿不到会话");
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
let running = false;
|
|
611
|
+
try {
|
|
612
|
+
const snap = typeof sess.getSnapshot === "function" ? sess.getSnapshot() : null;
|
|
613
|
+
running = !!(snap && snap.running);
|
|
614
|
+
} catch (e) { running = false; }
|
|
615
|
+
if (running) {
|
|
616
|
+
done("跳过:会话正在跑");
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const text = "【时间提醒】" + name + " 到点了。\n"
|
|
620
|
+
+ "(本条由 dsh-reminder 插件自动注入)";
|
|
621
|
+
try {
|
|
622
|
+
const p = sess.prompt([{ type: "text", text: text }], "queue");
|
|
623
|
+
if (p && typeof p.then === "function") {
|
|
624
|
+
p.then((r) => done(r && r.ok ? "已投递" : ("被拒:" + JSON.stringify(r && r.error))))
|
|
625
|
+
.catch((e) => done("异常:" + String((e && e.message) || e)));
|
|
626
|
+
} else {
|
|
627
|
+
done("已调用(没拿到 Promise,无法确认)");
|
|
628
|
+
}
|
|
629
|
+
} catch (e) {
|
|
630
|
+
done("抛错:" + String((e && e.message) || e));
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/* ── 收起态模块 ── */
|
|
635
|
+
const modCls = "ck-mod" + (open ? " open" : "") + (firing ? " alarm" : "");
|
|
636
|
+
const moduleEl = h("button", {
|
|
637
|
+
type: "button",
|
|
638
|
+
className: modCls,
|
|
639
|
+
"aria-expanded": open ? "true" : "false",
|
|
640
|
+
title: "时间提醒 — 现在时间 / 提醒 / 倒计时",
|
|
641
|
+
onClick: () => { setOpen(!open); if (open) setView("list"); },
|
|
642
|
+
},
|
|
643
|
+
h("span", { className: "ck-lines" },
|
|
644
|
+
h("span", { className: "ck-date" }, dateStr),
|
|
645
|
+
h("span", { className: "ck-time" }, week + " " + hm)),
|
|
646
|
+
badge ? h("span", { className: "ck-badge" }, badge) : null,
|
|
647
|
+
h("span", { className: "ck-caret" }, "▼"));
|
|
648
|
+
|
|
649
|
+
/* ── 面板:列表视图 ── */
|
|
650
|
+
const listKids = [];
|
|
651
|
+
listKids.push(h("div", { className: "ck-sec", key: "now" },
|
|
652
|
+
h("div", { className: "ck-now" }, hm + ":" + pad2(d.getSeconds())),
|
|
653
|
+
h("div", { className: "ck-nowsub" }, dateStr + " " + week),
|
|
654
|
+
lastUserMs !== null
|
|
655
|
+
? h("div", { className: "ck-since" }, "距你上次发消息 ",
|
|
656
|
+
h("b", null, fmtRemain(now - lastUserMs).replace("还有 ", "")))
|
|
657
|
+
: null));
|
|
658
|
+
|
|
659
|
+
/* 提醒段(原「闹钟」)——用户 2026-09-19 定:这一段改名「提醒」,
|
|
660
|
+
语义仍是「一天内的一个钟点」,长期保留、跑完回初始状态。 */
|
|
661
|
+
const alarmKids = [];
|
|
662
|
+
if (store.alarms.length === 0) {
|
|
663
|
+
alarmKids.push(h("div", { className: "ck-empty", key: "e" }, "还没有提醒"));
|
|
664
|
+
}
|
|
665
|
+
store.alarms.slice().sort((a, b) => (nextFire(a, now) || Infinity) - (nextFire(b, now) || Infinity))
|
|
666
|
+
.forEach((a) => {
|
|
667
|
+
const t = nextFire(a, now);
|
|
668
|
+
const ms = t ? t - now : null;
|
|
669
|
+
const cls = "ck-item" + (a.enabled ? " on" : " off");
|
|
670
|
+
alarmKids.push(h("div", { className: cls, key: a.id },
|
|
671
|
+
h("span", { className: "ck-dot" }),
|
|
672
|
+
h("span", { className: "ck-main" },
|
|
673
|
+
h("span", { className: "ck-name" }, a.name || "(未命名)"),
|
|
674
|
+
h("span", { className: "ck-meta", title: a.wake ? "到点会唤醒会话(还要总开关开着)" : "" },
|
|
675
|
+
alarmMeta(a) + (a.wake ? " · ⚡" : ""))),
|
|
676
|
+
h("span", {
|
|
677
|
+
className: "ck-left" + (a.enabled && ms !== null && ms <= WARN_MS ? " warn" : ""),
|
|
678
|
+
}, a.enabled
|
|
679
|
+
? (t ? fmtRemain(ms).replace("还有 ", "") : "已过期")
|
|
680
|
+
: "已关闭"),
|
|
681
|
+
h("span", { className: "ck-acts" },
|
|
682
|
+
h("button", {
|
|
683
|
+
className: "ck-mini" + (a.enabled ? " dim" : ""),
|
|
684
|
+
title: "启动", onClick: () => setAlarmEnabled(a.id, true),
|
|
685
|
+
}, "▶"),
|
|
686
|
+
h("button", {
|
|
687
|
+
className: "ck-mini" + (a.enabled ? "" : " dim"),
|
|
688
|
+
title: "关闭", onClick: () => setAlarmEnabled(a.id, false),
|
|
689
|
+
}, "⏸"),
|
|
690
|
+
h("button", { className: "ck-mini", title: "编辑", onClick: () => { setEditing(a); setView("alarm"); } }, "⋯"))));
|
|
691
|
+
});
|
|
692
|
+
listKids.push(h("div", { className: "ck-sec", key: "alarms" },
|
|
693
|
+
h("div", { className: "ck-sechead" },
|
|
694
|
+
h("span", { className: "ck-secname" }, "提醒"),
|
|
695
|
+
h("span", { className: "ck-seccount" }, store.alarms.length + " 个"),
|
|
696
|
+
h("button", { className: "ck-add", onClick: () => { setEditing(null); setView("alarm"); } }, "+ 新建")),
|
|
697
|
+
alarmKids));
|
|
698
|
+
|
|
699
|
+
/* 倒计时段 */
|
|
700
|
+
const timerKids = [];
|
|
701
|
+
if (store.timers.length === 0) {
|
|
702
|
+
timerKids.push(h("div", { className: "ck-empty", key: "e" }, "还没有倒计时"));
|
|
703
|
+
}
|
|
704
|
+
store.timers.forEach((t) => {
|
|
705
|
+
const ms = t.paused ? (t.remainingMs || 0) : Math.max(0, (t.endsAt || 0) - now);
|
|
706
|
+
const pct = t.totalMs ? Math.max(0, Math.min(100, (1 - ms / t.totalMs) * 100)) : 0;
|
|
707
|
+
timerKids.push(h("div", { className: "ck-item ck-cd on", key: t.id },
|
|
708
|
+
h("div", { className: "ck-cdtop" },
|
|
709
|
+
h("span", { className: "ck-cdname" }, t.name || "倒计时"),
|
|
710
|
+
h("span", { className: "ck-cdleft" }, fmtClock(ms)),
|
|
711
|
+
h("span", { className: "ck-acts" },
|
|
712
|
+
h("button", { className: "ck-mini", title: t.paused ? "继续" : "暂停", onClick: () => pauseTimer(t.id) }, t.paused ? "▶" : "⏸"),
|
|
713
|
+
h("button", { className: "ck-mini", title: "删除", onClick: () => removeTimer(t.id) }, "×"))),
|
|
714
|
+
h("div", { className: "ck-bar" }, h("i", { style: { width: pct + "%" } }))));
|
|
715
|
+
});
|
|
716
|
+
listKids.push(h("div", { className: "ck-sec", key: "timers" },
|
|
717
|
+
h("div", { className: "ck-sechead" },
|
|
718
|
+
h("span", { className: "ck-secname" }, "倒计时"),
|
|
719
|
+
h("span", { className: "ck-seccount" }, store.timers.length + " 个"),
|
|
720
|
+
h("button", { className: "ck-add", onClick: () => setView("timer") }, "+ 新建")),
|
|
721
|
+
timerKids));
|
|
722
|
+
|
|
723
|
+
/* 到点唤醒会话(总开关)—— 2026-09-19 第一步,先验证"注入能不能真把人叫醒" */
|
|
724
|
+
listKids.push(h("div", { className: "ck-sec", key: "wake" },
|
|
725
|
+
h("div", { className: "ck-sechead" },
|
|
726
|
+
h("span", { className: "ck-secname" }, "到点唤醒会话"),
|
|
727
|
+
h("button", {
|
|
728
|
+
className: "ck-toggle" + (store.wakeOnFire ? " on" : ""),
|
|
729
|
+
title: "提醒到点时,往当前会话发一条署名消息,把空闲的会话叫醒",
|
|
730
|
+
onClick: () => setStore((s) => ({ ...s, wakeOnFire: !s.wakeOnFire })),
|
|
731
|
+
}, store.wakeOnFire ? "开" : "关")),
|
|
732
|
+
h("div", { className: "ck-note" }, store.wakeOnFire
|
|
733
|
+
? "提醒到点时,会往当前会话发一条署名消息(等价于我替你发了一句),把空闲的会话叫醒。会话正在跑时不发;倒计时不唤醒。"
|
|
734
|
+
: "关着时到点只有呼吸灯 + 浮条。打开后,勾了「唤醒」的提醒到点会发一条署名消息试试能不能把会话叫醒。"),
|
|
735
|
+
store.lastWake
|
|
736
|
+
? h("div", { className: "ck-note ck-wake-last" },
|
|
737
|
+
"上次唤醒(" + fmtClock(Date.now() - store.lastWake.at) + " 前):" + store.lastWake.result)
|
|
738
|
+
: null));
|
|
739
|
+
|
|
740
|
+
/* ── 面板:闹钟编辑 ── */
|
|
741
|
+
const editEl = h(AlarmEditor, {
|
|
742
|
+
key: "edit",
|
|
743
|
+
alarm: editing,
|
|
744
|
+
onSave: saveAlarm,
|
|
745
|
+
onDelete: deleteAlarm,
|
|
746
|
+
onCancel: () => { setView("list"); setEditing(null); },
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
/* ── 面板:倒计时新建 ── */
|
|
750
|
+
const timerEl = h(TimerEditor, {
|
|
751
|
+
key: "tedit",
|
|
752
|
+
onStart: addTimer,
|
|
753
|
+
onCancel: () => setView("list"),
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
const panelCls = "ck-panel" + (open ? " open" : "") + (view !== "list" ? " editing" : "");
|
|
757
|
+
const panelEl = h("div", { className: panelCls },
|
|
758
|
+
h("div", { className: "ck-list" }, listKids),
|
|
759
|
+
view === "alarm" ? editEl : null,
|
|
760
|
+
view === "timer" ? timerEl : null);
|
|
761
|
+
|
|
762
|
+
/* ── 到点浮条 ──
|
|
763
|
+
用户 2026-09-19 定:**只显示设定的内容**(名字),不加「到点了」这类旁白。 */
|
|
764
|
+
const toastEl = firing ? h("div", { className: "ck-toast" },
|
|
765
|
+
h("span", null, "⏰"),
|
|
766
|
+
h("span", null, h("b", null, firing.name)),
|
|
767
|
+
h("button", { onClick: () => setStore((s) => ({ ...s, lastFired: null })) }, "知道了")) : null;
|
|
768
|
+
|
|
769
|
+
return h("span", { className: "ck-wrap", ref: wrapRef }, moduleEl, panelEl, toastEl);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/* ══════════════════════════════════════════════════════════
|
|
773
|
+
7 · 提醒编辑器(新建 / 修改 / 删除)
|
|
774
|
+
只有「名字 + 一天内的钟点」,没有重复规则(用户 2026-09-19 定:不做重复规则)。
|
|
775
|
+
══════════════════════════════════════════════════════════ */
|
|
776
|
+
function AlarmEditor(props) {
|
|
777
|
+
const a = props.alarm;
|
|
778
|
+
const isNew = !a;
|
|
779
|
+
|
|
780
|
+
const [name, setName] = react.useState(a ? (a.name || "") : "");
|
|
781
|
+
const [hh, setHh] = react.useState(a ? a.hh : 21);
|
|
782
|
+
const [mm, setMm] = react.useState(a ? a.mm : 40);
|
|
783
|
+
/* 「到点唤醒会话」逐条开关:默认 eligible(真正生效还要面板底部的总开关也开着) */
|
|
784
|
+
const [wake, setWake] = react.useState(a ? a.wake !== false : true);
|
|
785
|
+
|
|
786
|
+
/* 预览:按当前表单算下一次响铃(今天这个点已经过了就算明天) */
|
|
787
|
+
const nextT = nextFire({ hh: hh, mm: mm, enabled: true }, Date.now());
|
|
788
|
+
const hintText = pad2(hh) + ":" + pad2(mm) + " · 距下次响铃 "
|
|
789
|
+
+ fmtRemain(nextT - Date.now()).replace("还有 ", "");
|
|
790
|
+
|
|
791
|
+
function submit() {
|
|
792
|
+
props.onSave({
|
|
793
|
+
id: a ? a.id : uid(),
|
|
794
|
+
name: name.trim() || "提醒",
|
|
795
|
+
hh: hh, mm: mm,
|
|
796
|
+
wake: wake,
|
|
797
|
+
/* 编辑已有那条时保持它原来的开关状态 —— 别因为改个名字就把它悄悄打开 */
|
|
798
|
+
enabled: a ? a.enabled !== false : true,
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return h("div", { className: "ck-edit" },
|
|
803
|
+
h("div", { className: "ck-ehead" },
|
|
804
|
+
h("button", { className: "ck-back", onClick: props.onCancel }, "←"),
|
|
805
|
+
h("span", { className: "ck-etitle" }, isNew ? "新建提醒" : "编辑提醒")),
|
|
806
|
+
|
|
807
|
+
h("div", { className: "ck-field" },
|
|
808
|
+
h("span", { className: "ck-flabel" }, "名称"),
|
|
809
|
+
h("input", {
|
|
810
|
+
className: "ck-input",
|
|
811
|
+
value: name,
|
|
812
|
+
placeholder: "给这个提醒起个名字",
|
|
813
|
+
onChange: (e) => setName(e.target.value),
|
|
814
|
+
})),
|
|
815
|
+
|
|
816
|
+
h("div", { className: "ck-field" },
|
|
817
|
+
h("span", { className: "ck-flabel" }, "时间"),
|
|
818
|
+
h("div", { className: "ck-hint" }, hintText),
|
|
819
|
+
h(TimeWheels, { cols: [
|
|
820
|
+
{ value: hh, max: 24, unit: "时", onChange: setHh },
|
|
821
|
+
{ value: mm, max: 60, unit: "分", onChange: setMm },
|
|
822
|
+
] })),
|
|
823
|
+
|
|
824
|
+
h("div", { className: "ck-field" },
|
|
825
|
+
h("div", { className: "ck-sechead" },
|
|
826
|
+
h("span", { className: "ck-secname" }, "到点唤醒会话"),
|
|
827
|
+
h("button", {
|
|
828
|
+
className: "ck-toggle" + (wake ? " on" : ""),
|
|
829
|
+
onClick: () => setWake(!wake),
|
|
830
|
+
}, wake ? "开" : "关")),
|
|
831
|
+
h("div", { className: "ck-note" }, "面板底部的总开关关着时,这里开着也不会发。")),
|
|
832
|
+
|
|
833
|
+
h("div", { className: "ck-foot" },
|
|
834
|
+
isNew ? null : h("button", { className: "ck-btn danger", onClick: () => props.onDelete(a.id) }, "删除"),
|
|
835
|
+
h("span", { className: "ck-spacer" }),
|
|
836
|
+
h("button", { className: "ck-btn", onClick: props.onCancel }, "取消"),
|
|
837
|
+
h("button", { className: "ck-btn primary", onClick: submit }, "保存")));
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/* ══════════════════════════════════════════════════════════
|
|
841
|
+
8 · 倒计时新建(时长:天 / 时 / 分 / 秒)
|
|
842
|
+
天 0-99、时 0-23、分 0-59、秒 0-59 —— 24 / 60 进制(用户 2026-09-19 定)。
|
|
843
|
+
列之间不做进位:要 90 分钟就写「1 时 30 分」,不写「90 分」。
|
|
844
|
+
倒计时是一次性的:跑完自动从列表清理(提醒相反,长期保留)。
|
|
845
|
+
══════════════════════════════════════════════════════════ */
|
|
846
|
+
function TimerEditor(props) {
|
|
847
|
+
const [name, setName] = react.useState("");
|
|
848
|
+
const [dd, setDd] = react.useState(0);
|
|
849
|
+
const [hh, setHh] = react.useState(0);
|
|
850
|
+
const [mm, setMm] = react.useState(10);
|
|
851
|
+
const [ss, setSs] = react.useState(0);
|
|
852
|
+
const totalMs = (((dd * 24 + hh) * 60 + mm) * 60 + ss) * 1000;
|
|
853
|
+
return h("div", { className: "ck-edit" },
|
|
854
|
+
h("div", { className: "ck-ehead" },
|
|
855
|
+
h("button", { className: "ck-back", onClick: props.onCancel }, "←"),
|
|
856
|
+
h("span", { className: "ck-etitle" }, "新建倒计时")),
|
|
857
|
+
h("div", { className: "ck-field" },
|
|
858
|
+
h("span", { className: "ck-flabel" }, "名称"),
|
|
859
|
+
h("input", {
|
|
860
|
+
className: "ck-input", value: name,
|
|
861
|
+
placeholder: "例如:泡面、午休、专注",
|
|
862
|
+
onChange: (e) => setName(e.target.value),
|
|
863
|
+
})),
|
|
864
|
+
h("div", { className: "ck-field" },
|
|
865
|
+
h("span", { className: "ck-flabel" }, "时长"),
|
|
866
|
+
h("div", { className: "ck-hint" }, totalMs > 0 ? "计时 " + fmtDur(totalMs) : "先选一个时长"),
|
|
867
|
+
h(TimeWheels, { cols: [
|
|
868
|
+
{ value: dd, max: 100, unit: "天", onChange: setDd },
|
|
869
|
+
{ value: hh, max: 24, unit: "时", onChange: setHh },
|
|
870
|
+
{ value: mm, max: 60, unit: "分", onChange: setMm },
|
|
871
|
+
{ value: ss, max: 60, unit: "秒", onChange: setSs },
|
|
872
|
+
] })),
|
|
873
|
+
h("div", { className: "ck-foot" },
|
|
874
|
+
h("span", { className: "ck-spacer" }),
|
|
875
|
+
h("button", { className: "ck-btn", onClick: props.onCancel }, "取消"),
|
|
876
|
+
h("button", {
|
|
877
|
+
className: "ck-btn primary",
|
|
878
|
+
onClick: () => {
|
|
879
|
+
if (totalMs <= 0) return;
|
|
880
|
+
props.onStart(name.trim() || ("倒计时 " + fmtClock(totalMs)), totalMs);
|
|
881
|
+
},
|
|
882
|
+
}, "开始")));
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/* ══════════════════════════════════════════════════════════
|
|
886
|
+
9 · apply
|
|
887
|
+
══════════════════════════════════════════════════════════ */
|
|
888
|
+
/**
|
|
889
|
+
* 挂一个槽位,失败必须在日志里响一声。
|
|
890
|
+
*
|
|
891
|
+
* DSH 0.1.7 的发布说明明写「客户端 Session 支持多实例共存,相关 API 及 slot
|
|
892
|
+
* 有变化」——这一层动过。槽位名或 slots API 一旦对不上,老写法是**彻底无声**的:
|
|
893
|
+
* 按钮不出现、控制台一个字没有,只表现为「这插件好像没装」。
|
|
894
|
+
* 这里把「API 没了」和「注入抛异常」两种情况变成可见的警告;槽位名本身若被改掉,
|
|
895
|
+
* 依然要靠 0.1.7 上的实测去发现(inject 对未知槽位不一定抛)。
|
|
896
|
+
*/
|
|
897
|
+
function mountSlot(ctx, spec, render) {
|
|
898
|
+
const slots = ctx && ctx.slots;
|
|
899
|
+
if (!slots || typeof slots.inject !== "function" || typeof slots.register !== "function") {
|
|
900
|
+
console.warn(`[${spec.id}] ctx.slots 不可用,界面未挂载 —— DSH 的 slots API 可能变了`);
|
|
901
|
+
return false;
|
|
902
|
+
}
|
|
903
|
+
try {
|
|
904
|
+
slots.inject(spec.name, () => slots.register(spec, render));
|
|
905
|
+
return true;
|
|
906
|
+
} catch (e) {
|
|
907
|
+
console.warn(`[${spec.id}] 挂载到槽位 ${spec.name} 失败:`, e);
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
let appCtx = null;
|
|
913
|
+
const inject = ["slots"];
|
|
914
|
+
|
|
915
|
+
async function apply(ctx) {
|
|
916
|
+
appCtx = ctx;
|
|
917
|
+
injectCss();
|
|
918
|
+
mountSlot(
|
|
919
|
+
ctx,
|
|
920
|
+
{
|
|
921
|
+
name: "conversation.session.header.actions",
|
|
922
|
+
id: "dsh-reminder",
|
|
923
|
+
order: 30,
|
|
924
|
+
label: "时间提醒",
|
|
925
|
+
},
|
|
926
|
+
// 把槽位标准 props(含 sessionId)透传给组件
|
|
927
|
+
(props) => h(ClockModule, props || {}),
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
exports.apply = apply;
|
|
932
|
+
exports.inject = inject;
|
|
933
|
+
return module.exports;
|
|
934
|
+
},
|
|
935
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-reminder(时间提醒)— host 半
|
|
3
|
+
*
|
|
4
|
+
* 这一半**故意是空的**:显示的是浏览器本地时间,提醒与倒计时数据存在
|
|
5
|
+
* 浏览器 localStorage,到点提示靠界面自身(模块边框呼吸灯 + 浮条)。
|
|
6
|
+
* 没有任何需要 host 侧参与的东西 —— 不读文件、不起进程、不建数据通道。
|
|
7
|
+
*
|
|
8
|
+
* 2026-09-19 用户明确:**就停在"会话内的短期提醒"这个定位上**,
|
|
9
|
+
* 不加 host 半存盘 / 定时 / 系统通知 —— 那就不止是个 DSH 插件了。
|
|
10
|
+
*
|
|
11
|
+
* 对比:sage-livingroom 要 SSH 拉远程服务器文件,所以它有 TypertRemoteService;
|
|
12
|
+
* 这里没有这类需求,保持最小形状即可(与 dsh-timeclock 同款)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const name = 'dsh-reminder'
|
|
16
|
+
|
|
17
|
+
/** 不依赖任何 host 服务。 */
|
|
18
|
+
export const inject = []
|
|
19
|
+
|
|
20
|
+
export function apply() {
|
|
21
|
+
// 全部 UI 与逻辑都在浏览器半:lib/client.js
|
|
22
|
+
//
|
|
23
|
+
// 三级验证第③级的标记 —— 启动日志里出现这行,才证明 DSH 真加载了这份代码。
|
|
24
|
+
// 必须用 console.log:ctx.logger 的输出不进 stdout,会白等一场。
|
|
25
|
+
console.log('[dsh-reminder] host apply() called')
|
|
26
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gezi-wen/dsh-reminder",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Session time module for the DeepSeek Harness (dsh) web UI — current time, time since your last message, reminders and a countdown, sitting in the conversation header. Pure client-side plugin; state lives in browser local storage.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib/index.js",
|
|
14
|
+
"lib/client.js",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"dsh": {
|
|
21
|
+
"bundle": {
|
|
22
|
+
"patch": "./cordis.patch.yml"
|
|
23
|
+
},
|
|
24
|
+
"client": {
|
|
25
|
+
"platform": "web"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@deepseek-ai/cordis": "*",
|
|
30
|
+
"react": "^18.0.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"react": "^18.0.0"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/gezi-wen/dsh-reminder.git"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/gezi-wen/dsh-reminder#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/gezi-wen/dsh-reminder/issues"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"dsh",
|
|
45
|
+
"dsh-plugin",
|
|
46
|
+
"deepseek",
|
|
47
|
+
"deepseek-harness",
|
|
48
|
+
"cordis",
|
|
49
|
+
"plugin",
|
|
50
|
+
"web-ui",
|
|
51
|
+
"header-widget",
|
|
52
|
+
"clock",
|
|
53
|
+
"reminder",
|
|
54
|
+
"alarm",
|
|
55
|
+
"countdown",
|
|
56
|
+
"timer",
|
|
57
|
+
"session-timer",
|
|
58
|
+
"time-tracking"
|
|
59
|
+
]
|
|
60
|
+
}
|