@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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Client-layer core for dsh-scheduled-send (browser side, framework-free and
|
|
2
|
+
// testable in Node). The lib/client.js bundle wraps this with React/slots.
|
|
3
|
+
|
|
4
|
+
/** Media query marking the mobile layout (FIX 5). */
|
|
5
|
+
export const MOBILE_QUERY = '(max-width: 480px)';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Grace window during which a freshly POSTed task survives a refresh whose
|
|
9
|
+
* server snapshot was taken BEFORE the task existed (FIX 4 race).
|
|
10
|
+
*/
|
|
11
|
+
export const LOCAL_ADD_GRACE_MS = 10_000;
|
|
12
|
+
|
|
13
|
+
/** Safe matchMedia probe: never throws, false when unavailable. */
|
|
14
|
+
export function isMobileViewport(matchMedia) {
|
|
15
|
+
try {
|
|
16
|
+
if (typeof matchMedia !== 'function') return false;
|
|
17
|
+
return !!matchMedia(MOBILE_QUERY)?.matches;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Sort a task list by sendAt ascending (display order). */
|
|
24
|
+
export function sortTasks(tasks) {
|
|
25
|
+
return [...(tasks || [])].sort((a, b) => (a.sendAt || 0) - (b.sendAt || 0));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Collapse rule for the dock (FIX 2):
|
|
30
|
+
* - default (desktop): >1 entry → show ONLY the soonest (sendAt-min) entry
|
|
31
|
+
* plus a 「其余 N 条定时任务 ⌄」 summary toggle; ≤1 entry → expanded.
|
|
32
|
+
* - default (mobile): fully collapsed (summary only).
|
|
33
|
+
* - user 'expanded' → all entries; user 'collapsed' → nothing but the
|
|
34
|
+
* summary (manual collapse-all works for ANY count, including 1).
|
|
35
|
+
* @param {Array} tasks already sorted ascending by sendAt
|
|
36
|
+
* @param {{user?: 'expanded'|'collapsed'|null, mobile?: boolean}} [opts]
|
|
37
|
+
*/
|
|
38
|
+
export function collapseState(tasks, { user = null, mobile = false } = {}) {
|
|
39
|
+
const list = tasks || [];
|
|
40
|
+
if (!list.length) {
|
|
41
|
+
return { display: 'expanded', visibleCount: 0, hiddenCount: 0, summary: null, collapsed: false };
|
|
42
|
+
}
|
|
43
|
+
let display = mobile ? 'summary' : (list.length > 1 ? 'one' : 'expanded');
|
|
44
|
+
if (user === 'expanded') display = 'expanded';
|
|
45
|
+
else if (user === 'collapsed') display = list.length > 1 ? 'one' : 'summary';
|
|
46
|
+
if (display === 'one') {
|
|
47
|
+
return { display, visibleCount: 1, hiddenCount: list.length - 1, summary: `其余 ${list.length - 1} 条定时任务 ⌄`, collapsed: true };
|
|
48
|
+
}
|
|
49
|
+
if (display === 'summary') {
|
|
50
|
+
return { display, visibleCount: 0, hiddenCount: list.length, summary: `${list.length} 条定时任务 ⌄`, collapsed: true };
|
|
51
|
+
}
|
|
52
|
+
return { display: 'expanded', visibleCount: list.length, hiddenCount: 0, summary: '收起 ⌃', collapsed: false };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Local-timezone "YYYY-MM-DD HH:mm" for a planned send time. */
|
|
56
|
+
export function formatLocalTime(ms) {
|
|
57
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms)) return '';
|
|
58
|
+
const d = new Date(ms);
|
|
59
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
60
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Countdown until sendAt ("N分N秒后" / "N小时N分后" / "即将发送"). */
|
|
64
|
+
export function formatCountdown(sendAt, now) {
|
|
65
|
+
const ms = sendAt - now;
|
|
66
|
+
if (ms <= 0) return '即将发送';
|
|
67
|
+
const totalSec = Math.floor(ms / 1000);
|
|
68
|
+
const h = Math.floor(totalSec / 3600);
|
|
69
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
70
|
+
const s = totalSec % 60;
|
|
71
|
+
if (h > 0) return `${h}小时${m}分后`;
|
|
72
|
+
if (m > 0) return `${m}分${s}秒后`;
|
|
73
|
+
return `${s}秒后`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Default confirmation time = now + 5 minutes. */
|
|
77
|
+
export function defaultSendAt(now = Date.now(), offsetMs = 5 * 60_000) {
|
|
78
|
+
return now + offsetMs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Stateful client controller: polls the host state route, holds the visible
|
|
83
|
+
* task list (extended with the server-confirmed task right after POST so new
|
|
84
|
+
* entries show WITHOUT a refresh), and exposes cancel.
|
|
85
|
+
*
|
|
86
|
+
* FIX 1: setSession() reports session changes so the view can refresh
|
|
87
|
+
* immediately; refresh failures keep the LAST data and surface an error
|
|
88
|
+
* instead of flashing empty.
|
|
89
|
+
*
|
|
90
|
+
* FIX 4: refresh() merges by id — a recently POSTed task (within
|
|
91
|
+
* LOCAL_ADD_GRACE_MS) survives a stale server snapshot that predates it.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} deps
|
|
94
|
+
* @param {() => Promise<object>} deps.fetchState GET the host state route
|
|
95
|
+
* @param {(payload:object)=>Promise<{task:object}>} deps.postSchedule
|
|
96
|
+
* @param {(id:string)=>Promise<boolean>} deps.cancelSchedule
|
|
97
|
+
* @param {() => number} [deps.now]
|
|
98
|
+
*/
|
|
99
|
+
export function createScheduledClientState({ fetchState, postSchedule, cancelSchedule, now = () => Date.now() } = {}) {
|
|
100
|
+
let tasks = [];
|
|
101
|
+
let error = null;
|
|
102
|
+
let timer = null;
|
|
103
|
+
let stopped = false;
|
|
104
|
+
let sessionId = null; // this dock belongs to exactly one conversation
|
|
105
|
+
const recentAdds = new Map(); // id → addedAt (FIX 4 grace window)
|
|
106
|
+
// per-conversation cache: switching sessions shows the cached list
|
|
107
|
+
// INSTANTLY (no ~1s wait for the network), then refresh() revalidates.
|
|
108
|
+
const sessionCache = new Map(); // sid → task[]
|
|
109
|
+
|
|
110
|
+
// strict conversation filter: with a session bound, only entries belonging
|
|
111
|
+
// to THIS conversation are ever visible; other-session tasks are dropped
|
|
112
|
+
// from the local list on every refresh.
|
|
113
|
+
const own = (it) => !sessionId || !it?.conversationId || it.conversationId === sessionId;
|
|
114
|
+
const dedupeById = (list) => {
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
return list.filter((t) => (t?.id && !seen.has(t.id) ? (seen.add(t.id), true) : false));
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
/**
|
|
121
|
+
* Bind this client to one conversation. Returns true when the session
|
|
122
|
+
* actually CHANGED (the view refreshes immediately in that case — FIX 1).
|
|
123
|
+
*/
|
|
124
|
+
setSession(sid) {
|
|
125
|
+
const next = sid || null;
|
|
126
|
+
const changed = next !== sessionId;
|
|
127
|
+
if (sessionId !== null) sessionCache.set(sessionId, tasks.filter(own));
|
|
128
|
+
sessionId = next;
|
|
129
|
+
if (changed) {
|
|
130
|
+
// instant swap: show the cached list for the target session first
|
|
131
|
+
tasks = sessionId !== null && sessionCache.has(sessionId)
|
|
132
|
+
? [...sessionCache.get(sessionId)]
|
|
133
|
+
: [];
|
|
134
|
+
error = null;
|
|
135
|
+
}
|
|
136
|
+
return changed;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
/** Session this client is currently bound to (view fetch uses it). */
|
|
140
|
+
currentSession() {
|
|
141
|
+
return sessionId;
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
/** Pending tasks sorted ascending by sendAt, own session only. */
|
|
145
|
+
visibleTasks() {
|
|
146
|
+
return sortTasks(tasks).filter(own);
|
|
147
|
+
},
|
|
148
|
+
/** Last refresh error, or null (view shows old data + this line). */
|
|
149
|
+
lastError() {
|
|
150
|
+
return error;
|
|
151
|
+
},
|
|
152
|
+
snapshot() {
|
|
153
|
+
return { tasks: sortTasks(tasks).filter(own), error, fetchedAt: now() };
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
async refresh() {
|
|
157
|
+
let s;
|
|
158
|
+
try {
|
|
159
|
+
s = await fetchState();
|
|
160
|
+
} catch (err) {
|
|
161
|
+
// FIX 1 failure mode: keep the previous list, surface the error —
|
|
162
|
+
// never flash an empty dock on a transient failure.
|
|
163
|
+
error = String(err?.message || err);
|
|
164
|
+
return this.snapshot();
|
|
165
|
+
}
|
|
166
|
+
error = null;
|
|
167
|
+
const server = (s?.tasks ?? []).filter(own);
|
|
168
|
+
// FIX 4: keep freshly POSTed tasks whose id the (possibly stale) server
|
|
169
|
+
// snapshot does not know yet; the server list stays authoritative for
|
|
170
|
+
// everything else (cancellations included).
|
|
171
|
+
const t = now();
|
|
172
|
+
const freshLocal = tasks.filter(
|
|
173
|
+
(it) => it?.id && !server.some((x) => x.id === it.id) && own(it) && t - (recentAdds.get(it.id) ?? -Infinity) < LOCAL_ADD_GRACE_MS,
|
|
174
|
+
);
|
|
175
|
+
for (const id of [...recentAdds.keys()]) {
|
|
176
|
+
if (t - recentAdds.get(id) >= LOCAL_ADD_GRACE_MS || server.some((x) => x.id === id)) recentAdds.delete(id);
|
|
177
|
+
}
|
|
178
|
+
tasks = dedupeById([...server, ...freshLocal]);
|
|
179
|
+
if (sessionId !== null) sessionCache.set(sessionId, tasks); // keep the instant-swap cache fresh
|
|
180
|
+
return this.snapshot();
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* User-facing schedule entry: POST to the host route and locally enqueue
|
|
185
|
+
* the SERVER-RETURNED task so it is visible IMMEDIATELY (无需刷新立即显示,
|
|
186
|
+
* id-deduped). Nothing is added on failure, so the view keeps the form
|
|
187
|
+
* with its content intact (失败回滚保留表单).
|
|
188
|
+
*/
|
|
189
|
+
async scheduleMessage(payload) {
|
|
190
|
+
if (!postSchedule) throw new Error('postSchedule 未配置');
|
|
191
|
+
const result = await postSchedule(payload);
|
|
192
|
+
const task = result?.task ?? result;
|
|
193
|
+
if (task?.id && own(task)) {
|
|
194
|
+
tasks = dedupeById([...tasks, task]);
|
|
195
|
+
recentAdds.set(task.id, now());
|
|
196
|
+
}
|
|
197
|
+
return task;
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
/** Cancel a pending task: DELETE on the host + drop locally. */
|
|
201
|
+
async cancelTask(id) {
|
|
202
|
+
if (cancelSchedule) await cancelSchedule(id);
|
|
203
|
+
tasks = tasks.filter((t) => t.id !== id);
|
|
204
|
+
recentAdds.delete(id);
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
start(intervalMs = 4_000) {
|
|
208
|
+
if (timer) return;
|
|
209
|
+
stopped = false;
|
|
210
|
+
const loop = async () => {
|
|
211
|
+
if (stopped) return;
|
|
212
|
+
await this.refresh().catch(() => {});
|
|
213
|
+
timer = setTimeout(loop, intervalMs);
|
|
214
|
+
timer.unref?.();
|
|
215
|
+
};
|
|
216
|
+
void loop();
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
stop() {
|
|
220
|
+
stopped = true;
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
timer = null;
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Browser view layer for dsh-scheduled-send.
|
|
2
|
+
// This file is CONCATENATED into lib/client.js by scripts/build-client.mjs
|
|
3
|
+
// together with src/client-core.js — it must stay import/export-free plain JS.
|
|
4
|
+
// Inside the bundle it references: React (platform module) and the client-core
|
|
5
|
+
// helpers (sortTasks/collapseState/formatLocalTime/formatCountdown/…).
|
|
6
|
+
|
|
7
|
+
/* ==== view ==== */
|
|
8
|
+
function createClientPluginBody(React) {
|
|
9
|
+
const h = React.createElement;
|
|
10
|
+
const TOUCH_MIN = 40; // FIX 5: touch target height on mobile
|
|
11
|
+
|
|
12
|
+
function pad2(n) { return String(n).padStart(2, "0"); }
|
|
13
|
+
|
|
14
|
+
function toDatetimeLocalValue(ms) {
|
|
15
|
+
const d = new Date(ms);
|
|
16
|
+
return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate())
|
|
17
|
+
+ "T" + pad2(d.getHours()) + ":" + pad2(d.getMinutes());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isMobile() {
|
|
21
|
+
return isMobileViewport(typeof window !== "undefined" ? window.matchMedia : null);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* --- ⏰ composer button (conversation.input.right) -------------------- */
|
|
25
|
+
function ScheduleButton(props) {
|
|
26
|
+
const core = props.core;
|
|
27
|
+
const [open, setOpen] = React.useState(false);
|
|
28
|
+
const [timeValue, setTimeValue] = React.useState("");
|
|
29
|
+
const [busy, setBusy] = React.useState(false);
|
|
30
|
+
const [error, setError] = React.useState(null);
|
|
31
|
+
// standard session props: useInput(s => s.draft) reads the composer draft
|
|
32
|
+
const draft = typeof props.useInput === "function"
|
|
33
|
+
? (props.useInput((s) => (s == null ? "" : s.draft)) ?? "")
|
|
34
|
+
: "";
|
|
35
|
+
const mobile = isMobile();
|
|
36
|
+
|
|
37
|
+
const openPopover = () => {
|
|
38
|
+
setTimeValue(toDatetimeLocalValue(typeof core.defaultSendAt === "function" ? core.defaultSendAt() : Date.now() + 5 * 60_000));
|
|
39
|
+
setError(null);
|
|
40
|
+
setOpen(true);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const submit = async () => {
|
|
44
|
+
if (busy) return;
|
|
45
|
+
const content = String(draft ?? "");
|
|
46
|
+
if (!content.trim()) { setError("输入框内容为空,请先输入要定时发送的内容"); return; }
|
|
47
|
+
const at = timeValue ? new Date(timeValue).getTime() : NaN;
|
|
48
|
+
if (!Number.isFinite(at) || at <= Date.now()) { setError("发送时间必须是未来时间"); return; }
|
|
49
|
+
setBusy(true);
|
|
50
|
+
setError(null);
|
|
51
|
+
try {
|
|
52
|
+
const payload = { content, sendAt: at, conversationId: props.sessionId };
|
|
53
|
+
await core.scheduleMessage(payload);
|
|
54
|
+
// spec: 内容转为定时任务并清空输入框 — 绝不立即发送(不调 submit)
|
|
55
|
+
if (props.inputActions && typeof props.inputActions.setDraft === "function") props.inputActions.setDraft("");
|
|
56
|
+
setOpen(false);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// FIX 4 failure mode: nothing was enqueued — keep the popover open
|
|
59
|
+
// with the form (time + draft) intact and surface the error.
|
|
60
|
+
setError(String(err?.message || err));
|
|
61
|
+
} finally {
|
|
62
|
+
setBusy(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const btnStyle = {
|
|
67
|
+
cursor: "pointer", flexShrink: 0, border: "1px solid " + (open ? "#3b82f6" : "transparent"),
|
|
68
|
+
background: open ? "rgba(59,130,246,.12)" : "transparent",
|
|
69
|
+
color: open ? "#3b82f6" : "inherit",
|
|
70
|
+
borderRadius: 999, fontSize: 12, fontWeight: 600, lineHeight: "18px", padding: "1px 8px",
|
|
71
|
+
display: "inline-flex", alignItems: "center", gap: 3,
|
|
72
|
+
opacity: open ? 1 : 0.8,
|
|
73
|
+
...(mobile ? { minHeight: TOUCH_MIN } : {}),
|
|
74
|
+
};
|
|
75
|
+
const isDark = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
76
|
+
// FIX 5: on ≤480px the popover spans (nearly) the full viewport width.
|
|
77
|
+
const popCard = {
|
|
78
|
+
position: "absolute", bottom: "100%", right: 0, marginBottom: 6, zIndex: 30,
|
|
79
|
+
minWidth: 280, padding: "10px 12px", fontSize: 12,
|
|
80
|
+
borderRadius: 12, border: "1px solid " + (isDark ? "rgba(255,255,255,.14)" : "rgba(0,0,0,.10)"),
|
|
81
|
+
background: isDark ? "#1c1c1e" : "#fff",
|
|
82
|
+
boxShadow: "0 8px 24px rgba(0,0,0,.18)",
|
|
83
|
+
color: isDark ? "#eee" : "#111",
|
|
84
|
+
...(mobile ? { left: 0, right: 0, minWidth: 0, width: "calc(100vw - 16px)", maxWidth: "calc(100vw - 16px)", boxSizing: "border-box" } : {}),
|
|
85
|
+
};
|
|
86
|
+
const fieldLbl = { display: "block", fontSize: 11, opacity: .65, margin: "6px 0 3px" };
|
|
87
|
+
const fieldIn = {
|
|
88
|
+
width: "100%", boxSizing: "border-box",
|
|
89
|
+
border: "1px solid " + (isDark ? "rgba(255,255,255,.2)" : "rgba(0,0,0,.18)"),
|
|
90
|
+
borderRadius: 8, padding: "5px 8px", fontSize: 12,
|
|
91
|
+
background: isDark ? "rgba(255,255,255,.05)" : "#fff", color: "inherit",
|
|
92
|
+
...(mobile ? { minHeight: TOUCH_MIN } : {}),
|
|
93
|
+
};
|
|
94
|
+
const actionBtn = (extra) => ({
|
|
95
|
+
cursor: "pointer", borderRadius: 999, padding: "5px 16px", fontSize: 12, ...(extra || {}),
|
|
96
|
+
...(mobile ? { minHeight: TOUCH_MIN, boxSizing: "border-box" } : {}),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return h("div", { "data-plugin": "dsh-scheduled-send-button", style: { position: "relative", display: "inline-flex", alignItems: "center", gap: 6 } }, [
|
|
100
|
+
open
|
|
101
|
+
? h("span", { key: "badge", style: { flexShrink: 0 } },
|
|
102
|
+
h("span", { style: {
|
|
103
|
+
display: "inline-flex", alignItems: "center", borderRadius: 999,
|
|
104
|
+
padding: "1px 8px", fontSize: 11, fontWeight: 700,
|
|
105
|
+
background: "rgba(59,130,246,.16)", color: "#3b82f6",
|
|
106
|
+
} }, "⏳ 定时发送中"))
|
|
107
|
+
: null,
|
|
108
|
+
h("button", {
|
|
109
|
+
key: "alarm", type: "button", onClick: open ? () => setOpen(false) : openPopover,
|
|
110
|
+
title: "定时发送", "aria-label": "定时发送", style: btnStyle,
|
|
111
|
+
}, "⏰ 定时"),
|
|
112
|
+
open
|
|
113
|
+
? h("div", { key: "pop", style: popCard }, [
|
|
114
|
+
h("div", { key: "title", style: { fontWeight: 700, fontSize: 13 } }, "定时发送"),
|
|
115
|
+
h("label", { key: "lt", style: fieldLbl }, "发送时间(默认当前 +5 分钟)"),
|
|
116
|
+
h("input", {
|
|
117
|
+
key: "t", type: "datetime-local", value: timeValue,
|
|
118
|
+
onChange: (e) => setTimeValue(e?.target?.value ?? e), style: fieldIn,
|
|
119
|
+
}),
|
|
120
|
+
h("div", { key: "act", style: { display: "flex", gap: 8, marginTop: 10, alignItems: "center", flexWrap: "wrap" } }, [
|
|
121
|
+
h("button", {
|
|
122
|
+
key: "ok", type: "button", disabled: busy, onClick: submit,
|
|
123
|
+
style: actionBtn({ border: "none", fontWeight: 600, color: "#fff", background: "#3b82f6", opacity: busy ? .6 : 1, cursor: busy ? "wait" : "pointer" }),
|
|
124
|
+
}, busy ? "提交中…" : "确认"),
|
|
125
|
+
h("button", {
|
|
126
|
+
key: "no", type: "button", onClick: () => setOpen(false),
|
|
127
|
+
style: actionBtn({ border: "1px solid " + (isDark ? "rgba(255,255,255,.2)" : "rgba(0,0,0,.18)"), background: "transparent", color: "inherit", cursor: "pointer" }),
|
|
128
|
+
}, "取消"),
|
|
129
|
+
error ? h("span", { key: "e", style: { color: "#dc2626", wordBreak: "break-word" } }, error) : null,
|
|
130
|
+
]),
|
|
131
|
+
])
|
|
132
|
+
: null,
|
|
133
|
+
]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* --- pending-task dock (conversation.input.dock) ---------------------- */
|
|
137
|
+
function ScheduledDock(props) {
|
|
138
|
+
const core = props.core;
|
|
139
|
+
const [, setTick] = React.useState(0);
|
|
140
|
+
// FIX 2: tri-state user override — null = default policy, 'expanded',
|
|
141
|
+
// 'collapsed' (manual collapse-all works for ANY count).
|
|
142
|
+
const [user, setUser] = React.useState(null);
|
|
143
|
+
const mobile = isMobile();
|
|
144
|
+
|
|
145
|
+
// FIX 1: react to sessionId changes — bind the core to the NEW session
|
|
146
|
+
// and refresh IMMEDIATELY (no waiting for the next poll tick).
|
|
147
|
+
React.useEffect(() => {
|
|
148
|
+
let stopped = false;
|
|
149
|
+
let timer = null;
|
|
150
|
+
core.setSession(props.sessionId);
|
|
151
|
+
const loop = async () => {
|
|
152
|
+
if (stopped) return;
|
|
153
|
+
await core.refresh().catch(() => {});
|
|
154
|
+
if (!stopped) setTick((n) => n + 1);
|
|
155
|
+
if (!stopped) { timer = setTimeout(loop, 3000); timer.unref?.(); }
|
|
156
|
+
};
|
|
157
|
+
void loop();
|
|
158
|
+
return () => {
|
|
159
|
+
stopped = true;
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
};
|
|
162
|
+
}, [props.sessionId]);
|
|
163
|
+
// countdown ticker
|
|
164
|
+
React.useEffect(() => {
|
|
165
|
+
const t = setInterval(() => setTick((n) => n + 1), 1000);
|
|
166
|
+
t.unref?.(); // never hold the host loop for a UI countdown
|
|
167
|
+
return () => clearInterval(t);
|
|
168
|
+
}, []);
|
|
169
|
+
|
|
170
|
+
const tasks = core.visibleTasks();
|
|
171
|
+
const err = core.lastError();
|
|
172
|
+
const st = collapseState(tasks, { user, mobile });
|
|
173
|
+
const visible = tasks.slice(0, st.visibleCount);
|
|
174
|
+
const showToggle = tasks.length > 0;
|
|
175
|
+
if (!tasks.length && !err) return null;
|
|
176
|
+
|
|
177
|
+
const rowStyle = {
|
|
178
|
+
display: "flex", flexDirection: "column", gap: 2, width: "100%", maxWidth: "48rem",
|
|
179
|
+
margin: "2px auto 0", fontSize: 12, lineHeight: "18px",
|
|
180
|
+
boxSizing: "border-box",
|
|
181
|
+
};
|
|
182
|
+
const entryStyle = {
|
|
183
|
+
display: "flex", flexDirection: "column", gap: 2, padding: "4px 8px", borderRadius: 8,
|
|
184
|
+
background: "rgba(59,130,246,.10)", border: "1px solid rgba(59,130,246,.22)",
|
|
185
|
+
maxWidth: "100%", width: "100%", boxSizing: "border-box", overflowWrap: "break-word",
|
|
186
|
+
};
|
|
187
|
+
const srcStyle = {
|
|
188
|
+
margin: 0, whiteSpace: "pre-wrap", fontFamily: "monospace", fontSize: 12,
|
|
189
|
+
wordBreak: "break-word", maxWidth: "100%",
|
|
190
|
+
};
|
|
191
|
+
const metaStyle = { display: "flex", alignItems: "center", gap: 8, color: "rgba(128,128,128,1)", flexWrap: "wrap", maxWidth: "100%" };
|
|
192
|
+
const cancelBtn = (id) => h("button", {
|
|
193
|
+
key: "x", type: "button", onClick: () => { core.cancelTask(id); },
|
|
194
|
+
style: {
|
|
195
|
+
cursor: "pointer", border: "1px solid rgba(128,128,128,.4)", borderRadius: 999, padding: "0 8px",
|
|
196
|
+
fontSize: 11, background: "transparent", color: "inherit", flexShrink: 0,
|
|
197
|
+
...(mobile ? { minHeight: TOUCH_MIN } : {}),
|
|
198
|
+
},
|
|
199
|
+
}, "取消");
|
|
200
|
+
|
|
201
|
+
return h("div", { "data-plugin": "dsh-scheduled-send-dock", style: rowStyle }, [
|
|
202
|
+
err
|
|
203
|
+
? h("div", {
|
|
204
|
+
key: "err",
|
|
205
|
+
style: { padding: "3px 8px", borderRadius: 8, background: "rgba(220,38,38,.10)", color: "#dc2626", wordBreak: "break-word" },
|
|
206
|
+
}, "⚠ 加载失败,显示上一次列表:" + err)
|
|
207
|
+
: null,
|
|
208
|
+
showToggle && st.summary
|
|
209
|
+
? h("button", {
|
|
210
|
+
key: "sum", type: "button",
|
|
211
|
+
onClick: () => { setUser(st.display === "expanded" ? "collapsed" : "expanded"); setTick((n) => n + 1); },
|
|
212
|
+
style: {
|
|
213
|
+
cursor: "pointer", alignSelf: "center", border: "1px solid rgba(59,130,246,.35)", borderRadius: 999,
|
|
214
|
+
padding: "1px 10px", fontSize: 11, fontWeight: 600, background: "rgba(59,130,246,.10)", color: "inherit",
|
|
215
|
+
...(mobile ? { minHeight: TOUCH_MIN, boxSizing: "border-box" } : {}),
|
|
216
|
+
},
|
|
217
|
+
}, st.summary)
|
|
218
|
+
: null,
|
|
219
|
+
visible.map((t) => h("div", { key: t.id, style: entryStyle }, [
|
|
220
|
+
h("div", { key: "src", style: srcStyle }, t.content),
|
|
221
|
+
h("div", { key: "meta", style: metaStyle }, [
|
|
222
|
+
h("span", { key: "at" }, formatLocalTime(t.sendAt)),
|
|
223
|
+
h("span", { key: "cd" }, formatCountdown(t.sendAt, Date.now())),
|
|
224
|
+
h("span", { key: "sp", style: { marginLeft: "auto" } }, cancelBtn(t.id)),
|
|
225
|
+
]),
|
|
226
|
+
])),
|
|
227
|
+
]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Client plugin body. Returns the cordis plugin ({inject, apply}). */
|
|
231
|
+
return function buildPlugin({ stateRoutePath, fetchImpl }) {
|
|
232
|
+
const schedulePath = stateRoutePath.replace(/\/state$/, "/schedule");
|
|
233
|
+
const doFetch = fetchImpl || ((...a) => fetch(...a));
|
|
234
|
+
|
|
235
|
+
let currentSessionId = null;
|
|
236
|
+
|
|
237
|
+
const core = createScheduledClientState({
|
|
238
|
+
fetchState: async () => {
|
|
239
|
+
// ask the host for THIS conversation's view (the core's bound session
|
|
240
|
+
// is authoritative — the view binds it before every refresh); the
|
|
241
|
+
// core still filters strictly client-side against stale caches.
|
|
242
|
+
const sid = core.currentSession();
|
|
243
|
+
const q = sid ? "?conversationId=" + encodeURIComponent(sid) : "";
|
|
244
|
+
const res = await doFetch(stateRoutePath + q, { headers: { accept: "application/json" } });
|
|
245
|
+
if (!res.ok) throw new Error("state HTTP " + res.status);
|
|
246
|
+
return res.json();
|
|
247
|
+
},
|
|
248
|
+
postSchedule: async (payload) => {
|
|
249
|
+
const res = await doFetch(schedulePath, {
|
|
250
|
+
method: "POST", headers: { "content-type": "application/json", accept: "application/json" },
|
|
251
|
+
body: JSON.stringify(payload),
|
|
252
|
+
});
|
|
253
|
+
const body = await res.json().catch(() => ({}));
|
|
254
|
+
if (!res.ok) throw new Error(body.error || ("HTTP " + res.status));
|
|
255
|
+
return body;
|
|
256
|
+
},
|
|
257
|
+
cancelSchedule: async (id) => {
|
|
258
|
+
const res = await doFetch(schedulePath + "?id=" + encodeURIComponent(id), { method: "DELETE" });
|
|
259
|
+
return res.ok;
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
core.defaultSendAt = () => defaultSendAt();
|
|
263
|
+
|
|
264
|
+
const inject = ["slots"];
|
|
265
|
+
function apply(ctx) {
|
|
266
|
+
ctx.inject(inject, (scope) => {
|
|
267
|
+
scope.slots.inject("conversation.input.right", () => scope.slots.register({
|
|
268
|
+
name: "conversation.input.right",
|
|
269
|
+
id: "dsh-scheduled-send",
|
|
270
|
+
order: 100,
|
|
271
|
+
inject: (sessionId) => {
|
|
272
|
+
currentSessionId = sessionId;
|
|
273
|
+
core.setSession(sessionId); // dock/button are conversation-scoped
|
|
274
|
+
return { sessionId, core };
|
|
275
|
+
},
|
|
276
|
+
}, ScheduleButton));
|
|
277
|
+
scope.slots.inject("conversation.input.dock", () => scope.slots.register({
|
|
278
|
+
name: "conversation.input.dock",
|
|
279
|
+
id: "dsh-scheduled-send",
|
|
280
|
+
order: 30,
|
|
281
|
+
inject: (sessionId) => {
|
|
282
|
+
currentSessionId = sessionId;
|
|
283
|
+
core.setSession(sessionId);
|
|
284
|
+
return { sessionId, core };
|
|
285
|
+
},
|
|
286
|
+
}, ScheduledDock));
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return { core, inject, apply };
|
|
290
|
+
};
|
|
291
|
+
}
|
package/src/delivery.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Real injection channel for scheduled tasks. When a task comes due, we
|
|
2
|
+
// build a REAL user message (role 'user', fresh id, source kind 'user' with a
|
|
3
|
+
// `via` tag — NOT kind 'plugin', which the UI renders as an injected line
|
|
4
|
+
// instead of a normal user bubble) and hand it to the live agent of the task's
|
|
5
|
+
// ORIGINAL conversation via agent.runMaintenance(() => agent.followup(msg)).
|
|
6
|
+
// This mirrors the host's real prompt path
|
|
7
|
+
// (@deepseek-ai/dsh-api-session-controller prompt(): createUserMessage with
|
|
8
|
+
// kind:"user" source + followup). If the session is not live or the agent is
|
|
9
|
+
// busy, delivery throws so the scheduler keeps the task queued and retries.
|
|
10
|
+
//
|
|
11
|
+
// Model switching has been REMOVED. Legacy tasks may still carry a `model`
|
|
12
|
+
// field (written by earlier versions); it is silently ignored — the task
|
|
13
|
+
// delivers on the conversation's current model.
|
|
14
|
+
|
|
15
|
+
export const PLUGIN_NAME = 'dsh-scheduled-send';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Inline equivalent of @deepseek-ai/dsh-llm createUserMessage: completes the
|
|
19
|
+
* message with role 'user' and a fresh stable UUID id, then freezes it before
|
|
20
|
+
* publication (same contract as the host's createUserMessage, which is not
|
|
21
|
+
* resolvable from a plugin directory).
|
|
22
|
+
*/
|
|
23
|
+
export function defaultCreateUserMessage(spec) {
|
|
24
|
+
const message = {
|
|
25
|
+
id: crypto.randomUUID(),
|
|
26
|
+
role: 'user',
|
|
27
|
+
content: spec.content,
|
|
28
|
+
source: spec.source,
|
|
29
|
+
};
|
|
30
|
+
return Object.freeze(message);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Track root agents published after plugin load (same install rule as
|
|
35
|
+
* dsh-schedule: agents live at load time are not adopted). agent.id IS the
|
|
36
|
+
* conversation/session id (dsh-agent: agent id must equal session id), so
|
|
37
|
+
* tasks bind back to their original conversation after restarts.
|
|
38
|
+
* @param {object} ctx host plugin context exposing ctx.on
|
|
39
|
+
* @returns {{agents:()=>any[], live:()=>any[], dispose:()=>void}}
|
|
40
|
+
*/
|
|
41
|
+
export function installAgentTracking(ctx) {
|
|
42
|
+
const agents = new Set();
|
|
43
|
+
let stopped = false;
|
|
44
|
+
const offCreated = ctx?.on?.('agent/created', ({ agent }) => {
|
|
45
|
+
if (stopped || !agent || typeof agent.followup !== 'function') return;
|
|
46
|
+
agents.add(agent);
|
|
47
|
+
});
|
|
48
|
+
const offDisposed = ctx?.on?.('agent/disposed', ({ agent }) => {
|
|
49
|
+
agents.delete(agent);
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
agents: () => [...agents],
|
|
53
|
+
live: () => [...agents].filter((a) => typeof a.followup === 'function'),
|
|
54
|
+
dispose: () => {
|
|
55
|
+
stopped = true; // stop accepting new ones
|
|
56
|
+
if (typeof offCreated === 'function') offCreated();
|
|
57
|
+
if (typeof offDisposed === 'function') offDisposed();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the delivery callback for the scheduler: inject one due task into the
|
|
64
|
+
* live session of its conversation as a follow-up user message.
|
|
65
|
+
* @param {object} p
|
|
66
|
+
* @param {{live:()=>any[]}} p.tracking agent tracking from installAgentTracking
|
|
67
|
+
* @param {(spec:object)=>object} [p.createUserMessage] message factory (injectable)
|
|
68
|
+
* @param {string} [p.pluginName]
|
|
69
|
+
* @returns {(item:{id:string, content:string, conversationId?:string, model?:object}) => Promise<true>}
|
|
70
|
+
*/
|
|
71
|
+
export function createFollowupDelivery({
|
|
72
|
+
tracking,
|
|
73
|
+
createUserMessage = defaultCreateUserMessage,
|
|
74
|
+
pluginName = PLUGIN_NAME,
|
|
75
|
+
} = {}) {
|
|
76
|
+
return async function deliver(item) {
|
|
77
|
+
const live = tracking.live();
|
|
78
|
+
// bind to the task's conversation (agent.id === conversationId): a task
|
|
79
|
+
// bound to a conversation NEVER falls through to another live session
|
|
80
|
+
// (会话绑定); only legacy tasks without a conversationId may use any agent.
|
|
81
|
+
const agent = item.conversationId
|
|
82
|
+
? live.find((a) => a.id === item.conversationId) ?? null
|
|
83
|
+
: live[0] ?? null;
|
|
84
|
+
if (!agent) {
|
|
85
|
+
const err = new Error('当前无活跃会话代理,任务保留在队列等待会话恢复后补发');
|
|
86
|
+
err.code = 'NO_LIVE_AGENT';
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
const message = createUserMessage({
|
|
90
|
+
content: [{ type: 'text', text: item.content }],
|
|
91
|
+
source: { kind: 'user', via: pluginName },
|
|
92
|
+
});
|
|
93
|
+
// runMaintenance throws when the agent is mid-turn (busy) — the throw
|
|
94
|
+
// propagates so the scheduler re-queues with backoff.
|
|
95
|
+
await agent.runMaintenance(async () => {
|
|
96
|
+
agent.followup(message);
|
|
97
|
+
return true;
|
|
98
|
+
});
|
|
99
|
+
return true;
|
|
100
|
+
};
|
|
101
|
+
}
|
package/src/deps.js
ADDED