@ottttto/dsh-scheduled-send 0.2.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 +55 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +536 -0
- package/package.json +53 -0
- package/src/client-core.js +225 -0
- package/src/client-view.js +291 -0
- package/src/delivery.js +101 -0
- package/src/deps.js +2 -0
- package/src/host-routes.js +97 -0
- package/src/index.js +75 -0
- package/src/scheduler.js +146 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 ottttto
|
|
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,55 @@
|
|
|
1
|
+
# 定时发送 Scheduled Send (`@ottttto/dsh-scheduled-send`)
|
|
2
|
+
|
|
3
|
+
> **EN**: A DeepSeek Harness (DSH) web plugin that sends your drafted message at a chosen time — as you, unattended, in the original conversation, with offline catch-up delivery. 中文说明如下。
|
|
4
|
+
|
|
5
|
+
**到点以你的身份发送、无人值守、会话隔离、断电补发** —— 在 DSH Web 输入框打好草稿,点 ⏰ 选个时间,到点后消息以**正常用户气泡**出现在原会话中并自动触发模型响应。
|
|
6
|
+
|
|
7
|
+
## 特性
|
|
8
|
+
|
|
9
|
+
- ⏰ **定时按钮**:挂在输入框工具行(与发送按钮同行),弹出时间确认(`datetime-local`,默认当前时间 + 5 分钟)。
|
|
10
|
+
- 📋 **折叠列表**:已排程任务挂在输入框上方,按到期时间升序;多条时常显最先到期 1 条 + 「其余 N 条 ⌄」可展开/收起;任意条数均可手动折叠;移动端(≤480px)默认收起。
|
|
11
|
+
- ✉️ **以你的身份发送**:到点投递走与真实发送一致的路径(`role: 'user'` 气泡 + `followup`),渲染为正常用户消息并触发模型响应。
|
|
12
|
+
- 🔒 **会话隔离**:任务绑定创建时的会话(`conversationId`),切换会话只看到该会话的任务,互不串扰。
|
|
13
|
+
- 🔁 **断电补发**:任务持久化在本地(`<dataDir>/tasks.json`),重启 DSH 后恢复队列并向原会话补发;会话不在线时保留任务,会话恢复即补发。
|
|
14
|
+
- ✕ **一键取消**:列表中每条任务均可随时取消。
|
|
15
|
+
- 📱 **移动端适配**:近全宽弹窗、≥40px 触控高度、防横向溢出。
|
|
16
|
+
|
|
17
|
+
## 安装
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
dsh plugin --profile web add @ottttto/dsh-scheduled-send@0.2.0
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
然后重启 DSH Web GUI。
|
|
24
|
+
|
|
25
|
+
## 使用
|
|
26
|
+
|
|
27
|
+
1. 在输入框输入内容(支持多行 / Markdown 源码)。
|
|
28
|
+
2. 点输入框工具行的 **⏰** → 确认时间(默认 +5 分钟)→ 确认。
|
|
29
|
+
3. 输入框被清空;上方出现已排程条目(含计划时间与倒计时),可随时点取消。
|
|
30
|
+
4. 到点后消息以你的正常用户气泡出现在**创建时的会话**并触发模型响应。
|
|
31
|
+
|
|
32
|
+
## 隐私
|
|
33
|
+
|
|
34
|
+
- **无遥测**:不发起任何统计、上报或第三方网络请求。
|
|
35
|
+
- **数据本地**:定时任务仅持久化在本机 `<dataDir>/tasks.json`(默认 `$HOME/.dsh/dsh-scheduled-send`),不上传任何内容。
|
|
36
|
+
|
|
37
|
+
## 限制
|
|
38
|
+
|
|
39
|
+
- 模型切换功能已移除(v0.2.0 起不再支持为定时任务指定模型);旧任务中的 `model` 字段被忽略,照常投递。
|
|
40
|
+
- 到点投递要求创建时的会话在 DSH 宿主中处于活跃状态;不活跃则保留任务,会话恢复即补发。
|
|
41
|
+
|
|
42
|
+
## 开发
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm test # 全量测试(node --test)
|
|
46
|
+
npm run build # 重新生成 lib/client.js(改 src/client-*.js 后必须执行)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
结构:`src/scheduler.js`(持久化队列/补发/退避)、`src/delivery.js`(kind:'user' 气泡注入)、
|
|
50
|
+
`src/host-routes.js`(state/schedule 路由)、`src/client-core.js`(纯逻辑)、
|
|
51
|
+
`src/client-view.js`(⏰ 按钮 + dock 列表)、`lib/client.js`(构建产物)。
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT © ottttto
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# dsh-scheduled-send: mount the host-side cordis service into DSH.
|
|
2
|
+
# Note: this insert row mounts a fresh plugin; it does NOT override existing
|
|
3
|
+
# loader rows, so no config re-statement is required.
|
|
4
|
+
#
|
|
5
|
+
# No config block here on purpose: apply() resolves the data directory itself
|
|
6
|
+
# ($HOME/.dsh/dsh-scheduled-send). Loader patch values are applied literally —
|
|
7
|
+
# brace-style {{ }} template placeholders are NOT evaluated, and a template
|
|
8
|
+
# dataDir would create a literally-named placeholder directory.
|
|
9
|
+
- insert:
|
|
10
|
+
- id: dsh-scheduled-send
|
|
11
|
+
name: dsh-scheduled-send
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
// GENERATED by scripts/build-client.mjs — do not edit by hand.
|
|
2
|
+
window.__ModuleLoader__.load({
|
|
3
|
+
id: "dsh-scheduled-send",
|
|
4
|
+
factory: (require) => {
|
|
5
|
+
var module = { exports: {} };
|
|
6
|
+
var exports = module.exports;
|
|
7
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
8
|
+
var react = require("react");
|
|
9
|
+
var react_jsx_runtime = react;
|
|
10
|
+
// Client-layer core for dsh-scheduled-send (browser side, framework-free and
|
|
11
|
+
// testable in Node). The lib/client.js bundle wraps this with React/slots.
|
|
12
|
+
|
|
13
|
+
/** Media query marking the mobile layout (FIX 5). */
|
|
14
|
+
const MOBILE_QUERY = '(max-width: 480px)';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Grace window during which a freshly POSTed task survives a refresh whose
|
|
18
|
+
* server snapshot was taken BEFORE the task existed (FIX 4 race).
|
|
19
|
+
*/
|
|
20
|
+
const LOCAL_ADD_GRACE_MS = 10_000;
|
|
21
|
+
|
|
22
|
+
/** Safe matchMedia probe: never throws, false when unavailable. */
|
|
23
|
+
function isMobileViewport(matchMedia) {
|
|
24
|
+
try {
|
|
25
|
+
if (typeof matchMedia !== 'function') return false;
|
|
26
|
+
return !!matchMedia(MOBILE_QUERY)?.matches;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Sort a task list by sendAt ascending (display order). */
|
|
33
|
+
function sortTasks(tasks) {
|
|
34
|
+
return [...(tasks || [])].sort((a, b) => (a.sendAt || 0) - (b.sendAt || 0));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Collapse rule for the dock (FIX 2):
|
|
39
|
+
* - default (desktop): >1 entry → show ONLY the soonest (sendAt-min) entry
|
|
40
|
+
* plus a 「其余 N 条定时任务 ⌄」 summary toggle; ≤1 entry → expanded.
|
|
41
|
+
* - default (mobile): fully collapsed (summary only).
|
|
42
|
+
* - user 'expanded' → all entries; user 'collapsed' → nothing but the
|
|
43
|
+
* summary (manual collapse-all works for ANY count, including 1).
|
|
44
|
+
* @param {Array} tasks already sorted ascending by sendAt
|
|
45
|
+
* @param {{user?: 'expanded'|'collapsed'|null, mobile?: boolean}} [opts]
|
|
46
|
+
*/
|
|
47
|
+
function collapseState(tasks, { user = null, mobile = false } = {}) {
|
|
48
|
+
const list = tasks || [];
|
|
49
|
+
if (!list.length) {
|
|
50
|
+
return { display: 'expanded', visibleCount: 0, hiddenCount: 0, summary: null, collapsed: false };
|
|
51
|
+
}
|
|
52
|
+
let display = mobile ? 'summary' : (list.length > 1 ? 'one' : 'expanded');
|
|
53
|
+
if (user === 'expanded') display = 'expanded';
|
|
54
|
+
else if (user === 'collapsed') display = list.length > 1 ? 'one' : 'summary';
|
|
55
|
+
if (display === 'one') {
|
|
56
|
+
return { display, visibleCount: 1, hiddenCount: list.length - 1, summary: `其余 ${list.length - 1} 条定时任务 ⌄`, collapsed: true };
|
|
57
|
+
}
|
|
58
|
+
if (display === 'summary') {
|
|
59
|
+
return { display, visibleCount: 0, hiddenCount: list.length, summary: `${list.length} 条定时任务 ⌄`, collapsed: true };
|
|
60
|
+
}
|
|
61
|
+
return { display: 'expanded', visibleCount: list.length, hiddenCount: 0, summary: '收起 ⌃', collapsed: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Local-timezone "YYYY-MM-DD HH:mm" for a planned send time. */
|
|
65
|
+
function formatLocalTime(ms) {
|
|
66
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';
|
|
67
|
+
const d = new Date(ms);
|
|
68
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
69
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Countdown until sendAt ("N分N秒后" / "N小时N分后" / "即将发送"). */
|
|
73
|
+
function formatCountdown(sendAt, now) {
|
|
74
|
+
const ms = sendAt - now;
|
|
75
|
+
if (ms <= 0) return '即将发送';
|
|
76
|
+
const totalSec = Math.floor(ms / 1000);
|
|
77
|
+
const h = Math.floor(totalSec / 3600);
|
|
78
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
79
|
+
const s = totalSec % 60;
|
|
80
|
+
if (h > 0) return `${h}小时${m}分后`;
|
|
81
|
+
if (m > 0) return `${m}分${s}秒后`;
|
|
82
|
+
return `${s}秒后`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Default confirmation time = now + 5 minutes. */
|
|
86
|
+
function defaultSendAt(now = Date.now(), offsetMs = 5 * 60_000) {
|
|
87
|
+
return now + offsetMs;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Stateful client controller: polls the host state route, holds the visible
|
|
92
|
+
* task list (extended with the server-confirmed task right after POST so new
|
|
93
|
+
* entries show WITHOUT a refresh), and exposes cancel.
|
|
94
|
+
*
|
|
95
|
+
* FIX 1: setSession() reports session changes so the view can refresh
|
|
96
|
+
* immediately; refresh failures keep the LAST data and surface an error
|
|
97
|
+
* instead of flashing empty.
|
|
98
|
+
*
|
|
99
|
+
* FIX 4: refresh() merges by id — a recently POSTed task (within
|
|
100
|
+
* LOCAL_ADD_GRACE_MS) survives a stale server snapshot that predates it.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} deps
|
|
103
|
+
* @param {() => Promise<object>} deps.fetchState GET the host state route
|
|
104
|
+
* @param {(payload:object)=>Promise<{task:object}>} deps.postSchedule
|
|
105
|
+
* @param {(id:string)=>Promise<boolean>} deps.cancelSchedule
|
|
106
|
+
* @param {() => number} [deps.now]
|
|
107
|
+
*/
|
|
108
|
+
function createScheduledClientState({ fetchState, postSchedule, cancelSchedule, now = () => Date.now() } = {}) {
|
|
109
|
+
let tasks = [];
|
|
110
|
+
let error = null;
|
|
111
|
+
let timer = null;
|
|
112
|
+
let stopped = false;
|
|
113
|
+
let sessionId = null; // this dock belongs to exactly one conversation
|
|
114
|
+
const recentAdds = new Map(); // id → addedAt (FIX 4 grace window)
|
|
115
|
+
// per-conversation cache: switching sessions shows the cached list
|
|
116
|
+
// INSTANTLY (no ~1s wait for the network), then refresh() revalidates.
|
|
117
|
+
const sessionCache = new Map(); // sid → task[]
|
|
118
|
+
|
|
119
|
+
// strict conversation filter: with a session bound, only entries belonging
|
|
120
|
+
// to THIS conversation are ever visible; other-session tasks are dropped
|
|
121
|
+
// from the local list on every refresh.
|
|
122
|
+
const own = (it) => !sessionId || !it?.conversationId || it.conversationId === sessionId;
|
|
123
|
+
const dedupeById = (list) => {
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
return list.filter((t) => (t?.id && !seen.has(t.id) ? (seen.add(t.id), true) : false));
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
/**
|
|
130
|
+
* Bind this client to one conversation. Returns true when the session
|
|
131
|
+
* actually CHANGED (the view refreshes immediately in that case — FIX 1).
|
|
132
|
+
*/
|
|
133
|
+
setSession(sid) {
|
|
134
|
+
const next = sid || null;
|
|
135
|
+
const changed = next !== sessionId;
|
|
136
|
+
if (sessionId !== null) sessionCache.set(sessionId, tasks.filter(own));
|
|
137
|
+
sessionId = next;
|
|
138
|
+
if (changed) {
|
|
139
|
+
// instant swap: show the cached list for the target session first
|
|
140
|
+
tasks = sessionId !== null && sessionCache.has(sessionId)
|
|
141
|
+
? [...sessionCache.get(sessionId)]
|
|
142
|
+
: [];
|
|
143
|
+
error = null;
|
|
144
|
+
}
|
|
145
|
+
return changed;
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
/** Session this client is currently bound to (view fetch uses it). */
|
|
149
|
+
currentSession() {
|
|
150
|
+
return sessionId;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/** Pending tasks sorted ascending by sendAt, own session only. */
|
|
154
|
+
visibleTasks() {
|
|
155
|
+
return sortTasks(tasks).filter(own);
|
|
156
|
+
},
|
|
157
|
+
/** Last refresh error, or null (view shows old data + this line). */
|
|
158
|
+
lastError() {
|
|
159
|
+
return error;
|
|
160
|
+
},
|
|
161
|
+
snapshot() {
|
|
162
|
+
return { tasks: sortTasks(tasks).filter(own), error, fetchedAt: now() };
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
async refresh() {
|
|
166
|
+
let s;
|
|
167
|
+
try {
|
|
168
|
+
s = await fetchState();
|
|
169
|
+
} catch (err) {
|
|
170
|
+
// FIX 1 failure mode: keep the previous list, surface the error —
|
|
171
|
+
// never flash an empty dock on a transient failure.
|
|
172
|
+
error = String(err?.message || err);
|
|
173
|
+
return this.snapshot();
|
|
174
|
+
}
|
|
175
|
+
error = null;
|
|
176
|
+
const server = (s?.tasks ?? []).filter(own);
|
|
177
|
+
// FIX 4: keep freshly POSTed tasks whose id the (possibly stale) server
|
|
178
|
+
// snapshot does not know yet; the server list stays authoritative for
|
|
179
|
+
// everything else (cancellations included).
|
|
180
|
+
const t = now();
|
|
181
|
+
const freshLocal = tasks.filter(
|
|
182
|
+
(it) => it?.id && !server.some((x) => x.id === it.id) && own(it) && t - (recentAdds.get(it.id) ?? -Infinity) < LOCAL_ADD_GRACE_MS,
|
|
183
|
+
);
|
|
184
|
+
for (const id of [...recentAdds.keys()]) {
|
|
185
|
+
if (t - recentAdds.get(id) >= LOCAL_ADD_GRACE_MS || server.some((x) => x.id === id)) recentAdds.delete(id);
|
|
186
|
+
}
|
|
187
|
+
tasks = dedupeById([...server, ...freshLocal]);
|
|
188
|
+
if (sessionId !== null) sessionCache.set(sessionId, tasks); // keep the instant-swap cache fresh
|
|
189
|
+
return this.snapshot();
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* User-facing schedule entry: POST to the host route and locally enqueue
|
|
194
|
+
* the SERVER-RETURNED task so it is visible IMMEDIATELY (无需刷新立即显示,
|
|
195
|
+
* id-deduped). Nothing is added on failure, so the view keeps the form
|
|
196
|
+
* with its content intact (失败回滚保留表单).
|
|
197
|
+
*/
|
|
198
|
+
async scheduleMessage(payload) {
|
|
199
|
+
if (!postSchedule) throw new Error('postSchedule 未配置');
|
|
200
|
+
const result = await postSchedule(payload);
|
|
201
|
+
const task = result?.task ?? result;
|
|
202
|
+
if (task?.id && own(task)) {
|
|
203
|
+
tasks = dedupeById([...tasks, task]);
|
|
204
|
+
recentAdds.set(task.id, now());
|
|
205
|
+
}
|
|
206
|
+
return task;
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
/** Cancel a pending task: DELETE on the host + drop locally. */
|
|
210
|
+
async cancelTask(id) {
|
|
211
|
+
if (cancelSchedule) await cancelSchedule(id);
|
|
212
|
+
tasks = tasks.filter((t) => t.id !== id);
|
|
213
|
+
recentAdds.delete(id);
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
start(intervalMs = 4_000) {
|
|
217
|
+
if (timer) return;
|
|
218
|
+
stopped = false;
|
|
219
|
+
const loop = async () => {
|
|
220
|
+
if (stopped) return;
|
|
221
|
+
await this.refresh().catch(() => {});
|
|
222
|
+
timer = setTimeout(loop, intervalMs);
|
|
223
|
+
timer.unref?.();
|
|
224
|
+
};
|
|
225
|
+
void loop();
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
stop() {
|
|
229
|
+
stopped = true;
|
|
230
|
+
clearTimeout(timer);
|
|
231
|
+
timer = null;
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Browser view layer for dsh-scheduled-send.
|
|
237
|
+
// This file is CONCATENATED into lib/client.js by scripts/build-client.mjs
|
|
238
|
+
// together with src/client-core.js — it must stay import/export-free plain JS.
|
|
239
|
+
// Inside the bundle it references: React (platform module) and the client-core
|
|
240
|
+
// helpers (sortTasks/collapseState/formatLocalTime/formatCountdown/…).
|
|
241
|
+
|
|
242
|
+
/* ==== view ==== */
|
|
243
|
+
function createClientPluginBody(React) {
|
|
244
|
+
const h = React.createElement;
|
|
245
|
+
const TOUCH_MIN = 40; // FIX 5: touch target height on mobile
|
|
246
|
+
|
|
247
|
+
function pad2(n) { return String(n).padStart(2, "0"); }
|
|
248
|
+
|
|
249
|
+
function toDatetimeLocalValue(ms) {
|
|
250
|
+
const d = new Date(ms);
|
|
251
|
+
return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate())
|
|
252
|
+
+ "T" + pad2(d.getHours()) + ":" + pad2(d.getMinutes());
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isMobile() {
|
|
256
|
+
return isMobileViewport(typeof window !== "undefined" ? window.matchMedia : null);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* --- ⏰ composer button (conversation.input.right) -------------------- */
|
|
260
|
+
function ScheduleButton(props) {
|
|
261
|
+
const core = props.core;
|
|
262
|
+
const [open, setOpen] = React.useState(false);
|
|
263
|
+
const [timeValue, setTimeValue] = React.useState("");
|
|
264
|
+
const [busy, setBusy] = React.useState(false);
|
|
265
|
+
const [error, setError] = React.useState(null);
|
|
266
|
+
// standard session props: useInput(s => s.draft) reads the composer draft
|
|
267
|
+
const draft = typeof props.useInput === "function"
|
|
268
|
+
? (props.useInput((s) => (s == null ? "" : s.draft)) ?? "")
|
|
269
|
+
: "";
|
|
270
|
+
const mobile = isMobile();
|
|
271
|
+
|
|
272
|
+
const openPopover = () => {
|
|
273
|
+
setTimeValue(toDatetimeLocalValue(typeof core.defaultSendAt === "function" ? core.defaultSendAt() : Date.now() + 5 * 60_000));
|
|
274
|
+
setError(null);
|
|
275
|
+
setOpen(true);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const submit = async () => {
|
|
279
|
+
if (busy) return;
|
|
280
|
+
const content = String(draft ?? "");
|
|
281
|
+
if (!content.trim()) { setError("输入框内容为空,请先输入要定时发送的内容"); return; }
|
|
282
|
+
const at = timeValue ? new Date(timeValue).getTime() : NaN;
|
|
283
|
+
if (!Number.isFinite(at) || at <= Date.now()) { setError("发送时间必须是未来时间"); return; }
|
|
284
|
+
setBusy(true);
|
|
285
|
+
setError(null);
|
|
286
|
+
try {
|
|
287
|
+
const payload = { content, sendAt: at, conversationId: props.sessionId };
|
|
288
|
+
await core.scheduleMessage(payload);
|
|
289
|
+
// spec: 内容转为定时任务并清空输入框 — 绝不立即发送(不调 submit)
|
|
290
|
+
if (props.inputActions && typeof props.inputActions.setDraft === "function") props.inputActions.setDraft("");
|
|
291
|
+
setOpen(false);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
// FIX 4 failure mode: nothing was enqueued — keep the popover open
|
|
294
|
+
// with the form (time + draft) intact and surface the error.
|
|
295
|
+
setError(String(err?.message || err));
|
|
296
|
+
} finally {
|
|
297
|
+
setBusy(false);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const btnStyle = {
|
|
302
|
+
cursor: "pointer", flexShrink: 0, border: "1px solid " + (open ? "#3b82f6" : "transparent"),
|
|
303
|
+
background: open ? "rgba(59,130,246,.12)" : "transparent",
|
|
304
|
+
color: open ? "#3b82f6" : "inherit",
|
|
305
|
+
borderRadius: 999, fontSize: 12, fontWeight: 600, lineHeight: "18px", padding: "1px 8px",
|
|
306
|
+
display: "inline-flex", alignItems: "center", gap: 3,
|
|
307
|
+
opacity: open ? 1 : 0.8,
|
|
308
|
+
...(mobile ? { minHeight: TOUCH_MIN } : {}),
|
|
309
|
+
};
|
|
310
|
+
const isDark = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
311
|
+
// FIX 5: on ≤480px the popover spans (nearly) the full viewport width.
|
|
312
|
+
const popCard = {
|
|
313
|
+
position: "absolute", bottom: "100%", right: 0, marginBottom: 6, zIndex: 30,
|
|
314
|
+
minWidth: 280, padding: "10px 12px", fontSize: 12,
|
|
315
|
+
borderRadius: 12, border: "1px solid " + (isDark ? "rgba(255,255,255,.14)" : "rgba(0,0,0,.10)"),
|
|
316
|
+
background: isDark ? "#1c1c1e" : "#fff",
|
|
317
|
+
boxShadow: "0 8px 24px rgba(0,0,0,.18)",
|
|
318
|
+
color: isDark ? "#eee" : "#111",
|
|
319
|
+
...(mobile ? { left: 0, right: 0, minWidth: 0, width: "calc(100vw - 16px)", maxWidth: "calc(100vw - 16px)", boxSizing: "border-box" } : {}),
|
|
320
|
+
};
|
|
321
|
+
const fieldLbl = { display: "block", fontSize: 11, opacity: .65, margin: "6px 0 3px" };
|
|
322
|
+
const fieldIn = {
|
|
323
|
+
width: "100%", boxSizing: "border-box",
|
|
324
|
+
border: "1px solid " + (isDark ? "rgba(255,255,255,.2)" : "rgba(0,0,0,.18)"),
|
|
325
|
+
borderRadius: 8, padding: "5px 8px", fontSize: 12,
|
|
326
|
+
background: isDark ? "rgba(255,255,255,.05)" : "#fff", color: "inherit",
|
|
327
|
+
...(mobile ? { minHeight: TOUCH_MIN } : {}),
|
|
328
|
+
};
|
|
329
|
+
const actionBtn = (extra) => ({
|
|
330
|
+
cursor: "pointer", borderRadius: 999, padding: "5px 16px", fontSize: 12, ...(extra || {}),
|
|
331
|
+
...(mobile ? { minHeight: TOUCH_MIN, boxSizing: "border-box" } : {}),
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
return h("div", { "data-plugin": "dsh-scheduled-send-button", style: { position: "relative", display: "inline-flex", alignItems: "center", gap: 6 } }, [
|
|
335
|
+
open
|
|
336
|
+
? h("span", { key: "badge", style: { flexShrink: 0 } },
|
|
337
|
+
h("span", { style: {
|
|
338
|
+
display: "inline-flex", alignItems: "center", borderRadius: 999,
|
|
339
|
+
padding: "1px 8px", fontSize: 11, fontWeight: 700,
|
|
340
|
+
background: "rgba(59,130,246,.16)", color: "#3b82f6",
|
|
341
|
+
} }, "⏳ 定时发送中"))
|
|
342
|
+
: null,
|
|
343
|
+
h("button", {
|
|
344
|
+
key: "alarm", type: "button", onClick: open ? () => setOpen(false) : openPopover,
|
|
345
|
+
title: "定时发送", "aria-label": "定时发送", style: btnStyle,
|
|
346
|
+
}, "⏰ 定时"),
|
|
347
|
+
open
|
|
348
|
+
? h("div", { key: "pop", style: popCard }, [
|
|
349
|
+
h("div", { key: "title", style: { fontWeight: 700, fontSize: 13 } }, "定时发送"),
|
|
350
|
+
h("label", { key: "lt", style: fieldLbl }, "发送时间(默认当前 +5 分钟)"),
|
|
351
|
+
h("input", {
|
|
352
|
+
key: "t", type: "datetime-local", value: timeValue,
|
|
353
|
+
onChange: (e) => setTimeValue(e?.target?.value ?? e), style: fieldIn,
|
|
354
|
+
}),
|
|
355
|
+
h("div", { key: "act", style: { display: "flex", gap: 8, marginTop: 10, alignItems: "center", flexWrap: "wrap" } }, [
|
|
356
|
+
h("button", {
|
|
357
|
+
key: "ok", type: "button", disabled: busy, onClick: submit,
|
|
358
|
+
style: actionBtn({ border: "none", fontWeight: 600, color: "#fff", background: "#3b82f6", opacity: busy ? .6 : 1, cursor: busy ? "wait" : "pointer" }),
|
|
359
|
+
}, busy ? "提交中…" : "确认"),
|
|
360
|
+
h("button", {
|
|
361
|
+
key: "no", type: "button", onClick: () => setOpen(false),
|
|
362
|
+
style: actionBtn({ border: "1px solid " + (isDark ? "rgba(255,255,255,.2)" : "rgba(0,0,0,.18)"), background: "transparent", color: "inherit", cursor: "pointer" }),
|
|
363
|
+
}, "取消"),
|
|
364
|
+
error ? h("span", { key: "e", style: { color: "#dc2626", wordBreak: "break-word" } }, error) : null,
|
|
365
|
+
]),
|
|
366
|
+
])
|
|
367
|
+
: null,
|
|
368
|
+
]);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/* --- pending-task dock (conversation.input.dock) ---------------------- */
|
|
372
|
+
function ScheduledDock(props) {
|
|
373
|
+
const core = props.core;
|
|
374
|
+
const [, setTick] = React.useState(0);
|
|
375
|
+
// FIX 2: tri-state user override — null = default policy, 'expanded',
|
|
376
|
+
// 'collapsed' (manual collapse-all works for ANY count).
|
|
377
|
+
const [user, setUser] = React.useState(null);
|
|
378
|
+
const mobile = isMobile();
|
|
379
|
+
|
|
380
|
+
// FIX 1: react to sessionId changes — bind the core to the NEW session
|
|
381
|
+
// and refresh IMMEDIATELY (no waiting for the next poll tick).
|
|
382
|
+
React.useEffect(() => {
|
|
383
|
+
let stopped = false;
|
|
384
|
+
let timer = null;
|
|
385
|
+
core.setSession(props.sessionId);
|
|
386
|
+
const loop = async () => {
|
|
387
|
+
if (stopped) return;
|
|
388
|
+
await core.refresh().catch(() => {});
|
|
389
|
+
if (!stopped) setTick((n) => n + 1);
|
|
390
|
+
if (!stopped) { timer = setTimeout(loop, 3000); timer.unref?.(); }
|
|
391
|
+
};
|
|
392
|
+
void loop();
|
|
393
|
+
return () => {
|
|
394
|
+
stopped = true;
|
|
395
|
+
clearTimeout(timer);
|
|
396
|
+
};
|
|
397
|
+
}, [props.sessionId]);
|
|
398
|
+
// countdown ticker
|
|
399
|
+
React.useEffect(() => {
|
|
400
|
+
const t = setInterval(() => setTick((n) => n + 1), 1000);
|
|
401
|
+
t.unref?.(); // never hold the host loop for a UI countdown
|
|
402
|
+
return () => clearInterval(t);
|
|
403
|
+
}, []);
|
|
404
|
+
|
|
405
|
+
const tasks = core.visibleTasks();
|
|
406
|
+
const err = core.lastError();
|
|
407
|
+
const st = collapseState(tasks, { user, mobile });
|
|
408
|
+
const visible = tasks.slice(0, st.visibleCount);
|
|
409
|
+
const showToggle = tasks.length > 0;
|
|
410
|
+
if (!tasks.length && !err) return null;
|
|
411
|
+
|
|
412
|
+
const rowStyle = {
|
|
413
|
+
display: "flex", flexDirection: "column", gap: 2, width: "100%", maxWidth: "48rem",
|
|
414
|
+
margin: "2px auto 0", fontSize: 12, lineHeight: "18px",
|
|
415
|
+
boxSizing: "border-box",
|
|
416
|
+
};
|
|
417
|
+
const entryStyle = {
|
|
418
|
+
display: "flex", flexDirection: "column", gap: 2, padding: "4px 8px", borderRadius: 8,
|
|
419
|
+
background: "rgba(59,130,246,.10)", border: "1px solid rgba(59,130,246,.22)",
|
|
420
|
+
maxWidth: "100%", width: "100%", boxSizing: "border-box", overflowWrap: "break-word",
|
|
421
|
+
};
|
|
422
|
+
const srcStyle = {
|
|
423
|
+
margin: 0, whiteSpace: "pre-wrap", fontFamily: "monospace", fontSize: 12,
|
|
424
|
+
wordBreak: "break-word", maxWidth: "100%",
|
|
425
|
+
};
|
|
426
|
+
const metaStyle = { display: "flex", alignItems: "center", gap: 8, color: "rgba(128,128,128,1)", flexWrap: "wrap", maxWidth: "100%" };
|
|
427
|
+
const cancelBtn = (id) => h("button", {
|
|
428
|
+
key: "x", type: "button", onClick: () => { core.cancelTask(id); },
|
|
429
|
+
style: {
|
|
430
|
+
cursor: "pointer", border: "1px solid rgba(128,128,128,.4)", borderRadius: 999, padding: "0 8px",
|
|
431
|
+
fontSize: 11, background: "transparent", color: "inherit", flexShrink: 0,
|
|
432
|
+
...(mobile ? { minHeight: TOUCH_MIN } : {}),
|
|
433
|
+
},
|
|
434
|
+
}, "取消");
|
|
435
|
+
|
|
436
|
+
return h("div", { "data-plugin": "dsh-scheduled-send-dock", style: rowStyle }, [
|
|
437
|
+
err
|
|
438
|
+
? h("div", {
|
|
439
|
+
key: "err",
|
|
440
|
+
style: { padding: "3px 8px", borderRadius: 8, background: "rgba(220,38,38,.10)", color: "#dc2626", wordBreak: "break-word" },
|
|
441
|
+
}, "⚠ 加载失败,显示上一次列表:" + err)
|
|
442
|
+
: null,
|
|
443
|
+
showToggle && st.summary
|
|
444
|
+
? h("button", {
|
|
445
|
+
key: "sum", type: "button",
|
|
446
|
+
onClick: () => { setUser(st.display === "expanded" ? "collapsed" : "expanded"); setTick((n) => n + 1); },
|
|
447
|
+
style: {
|
|
448
|
+
cursor: "pointer", alignSelf: "center", border: "1px solid rgba(59,130,246,.35)", borderRadius: 999,
|
|
449
|
+
padding: "1px 10px", fontSize: 11, fontWeight: 600, background: "rgba(59,130,246,.10)", color: "inherit",
|
|
450
|
+
...(mobile ? { minHeight: TOUCH_MIN, boxSizing: "border-box" } : {}),
|
|
451
|
+
},
|
|
452
|
+
}, st.summary)
|
|
453
|
+
: null,
|
|
454
|
+
visible.map((t) => h("div", { key: t.id, style: entryStyle }, [
|
|
455
|
+
h("div", { key: "src", style: srcStyle }, t.content),
|
|
456
|
+
h("div", { key: "meta", style: metaStyle }, [
|
|
457
|
+
h("span", { key: "at" }, formatLocalTime(t.sendAt)),
|
|
458
|
+
h("span", { key: "cd" }, formatCountdown(t.sendAt, Date.now())),
|
|
459
|
+
h("span", { key: "sp", style: { marginLeft: "auto" } }, cancelBtn(t.id)),
|
|
460
|
+
]),
|
|
461
|
+
])),
|
|
462
|
+
]);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Client plugin body. Returns the cordis plugin ({inject, apply}). */
|
|
466
|
+
return function buildPlugin({ stateRoutePath, fetchImpl }) {
|
|
467
|
+
const schedulePath = stateRoutePath.replace(/\/state$/, "/schedule");
|
|
468
|
+
const doFetch = fetchImpl || ((...a) => fetch(...a));
|
|
469
|
+
|
|
470
|
+
let currentSessionId = null;
|
|
471
|
+
|
|
472
|
+
const core = createScheduledClientState({
|
|
473
|
+
fetchState: async () => {
|
|
474
|
+
// ask the host for THIS conversation's view (the core's bound session
|
|
475
|
+
// is authoritative — the view binds it before every refresh); the
|
|
476
|
+
// core still filters strictly client-side against stale caches.
|
|
477
|
+
const sid = core.currentSession();
|
|
478
|
+
const q = sid ? "?conversationId=" + encodeURIComponent(sid) : "";
|
|
479
|
+
const res = await doFetch(stateRoutePath + q, { headers: { accept: "application/json" } });
|
|
480
|
+
if (!res.ok) throw new Error("state HTTP " + res.status);
|
|
481
|
+
return res.json();
|
|
482
|
+
},
|
|
483
|
+
postSchedule: async (payload) => {
|
|
484
|
+
const res = await doFetch(schedulePath, {
|
|
485
|
+
method: "POST", headers: { "content-type": "application/json", accept: "application/json" },
|
|
486
|
+
body: JSON.stringify(payload),
|
|
487
|
+
});
|
|
488
|
+
const body = await res.json().catch(() => ({}));
|
|
489
|
+
if (!res.ok) throw new Error(body.error || ("HTTP " + res.status));
|
|
490
|
+
return body;
|
|
491
|
+
},
|
|
492
|
+
cancelSchedule: async (id) => {
|
|
493
|
+
const res = await doFetch(schedulePath + "?id=" + encodeURIComponent(id), { method: "DELETE" });
|
|
494
|
+
return res.ok;
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
core.defaultSendAt = () => defaultSendAt();
|
|
498
|
+
|
|
499
|
+
const inject = ["slots"];
|
|
500
|
+
function apply(ctx) {
|
|
501
|
+
ctx.inject(inject, (scope) => {
|
|
502
|
+
scope.slots.inject("conversation.input.right", () => scope.slots.register({
|
|
503
|
+
name: "conversation.input.right",
|
|
504
|
+
id: "dsh-scheduled-send",
|
|
505
|
+
order: 100,
|
|
506
|
+
inject: (sessionId) => {
|
|
507
|
+
currentSessionId = sessionId;
|
|
508
|
+
core.setSession(sessionId); // dock/button are conversation-scoped
|
|
509
|
+
return { sessionId, core };
|
|
510
|
+
},
|
|
511
|
+
}, ScheduleButton));
|
|
512
|
+
scope.slots.inject("conversation.input.dock", () => scope.slots.register({
|
|
513
|
+
name: "conversation.input.dock",
|
|
514
|
+
id: "dsh-scheduled-send",
|
|
515
|
+
order: 30,
|
|
516
|
+
inject: (sessionId) => {
|
|
517
|
+
currentSessionId = sessionId;
|
|
518
|
+
core.setSession(sessionId);
|
|
519
|
+
return { sessionId, core };
|
|
520
|
+
},
|
|
521
|
+
}, ScheduledDock));
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
return { core, inject, apply };
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
var built = createClientPluginBody(react)(/* config */ {
|
|
529
|
+
stateRoutePath: "/plugin-data/dsh-scheduled-send/state",
|
|
530
|
+
});
|
|
531
|
+
exports.core = built.core;
|
|
532
|
+
exports.apply = built.apply;
|
|
533
|
+
exports.inject = built.inject;
|
|
534
|
+
return module.exports;
|
|
535
|
+
}
|
|
536
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ottttto/dsh-scheduled-send",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "DSH plugin: scheduled sending (⏰ composer button, dock list of pending tasks, due-time delivery as a normal user bubble)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"lib",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"package.json",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/OTTTTTO/dsh-scheduled-send"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"dsh",
|
|
26
|
+
"deepseek-harness",
|
|
27
|
+
"scheduled",
|
|
28
|
+
"timer",
|
|
29
|
+
"定时发送"
|
|
30
|
+
],
|
|
31
|
+
"author": "ottttto",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dsh": {
|
|
34
|
+
"bundle": {
|
|
35
|
+
"patch": "./cordis.patch.yml"
|
|
36
|
+
},
|
|
37
|
+
"client": {
|
|
38
|
+
"platform": "web",
|
|
39
|
+
"inject": [
|
|
40
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
41
|
+
],
|
|
42
|
+
"external": []
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "npm run build:client",
|
|
47
|
+
"build:client": "node scripts/build-client.mjs",
|
|
48
|
+
"test": "node --test \"test/*.test.js\""
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=18"
|
|
52
|
+
}
|
|
53
|
+
}
|