@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 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
@@ -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
+ }