@ottttto/dsh-scheduled-send 0.2.3 → 0.3.1
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/README.md +3 -1
- package/lib/client.js +214 -3
- package/package.json +5 -4
- package/src/client-core.js +29 -0
- package/src/client-view.js +185 -3
package/README.md
CHANGED
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
- 🔁 **断电补发**:任务持久化在本地(`<dataDir>/tasks.json`),重启 DSH 后恢复队列并向原会话补发;会话不在线时保留任务,会话恢复即补发。
|
|
14
14
|
- ✕ **一键取消**:列表中每条任务均可随时取消。
|
|
15
15
|
- 📱 **移动端适配**:近全宽弹窗、≥40px 触控高度、防横向溢出。
|
|
16
|
+
- 🗂️ **侧边栏「定时任务」面板**(v0.3.0):侧边栏底部入口(含全库未发送任务数角标)→ 独立面板列出**所有会话**的未发送任务(sendAt 升序、内容摘要、计划时间本地格式、倒计时、会话名);点击条目跳转到对应会话;条目取消按钮可跨会话取消(复用既有 DELETE 路由);会话已删除的条目置灰标注「会话不存在」仍可取消;空列表显示友好空态。挂载机制与 `@ychris12138/dsh-usage-stats` 同型(`sidebar.footer.action` 槽位),跳转使用 `sessions.open(sessionId)` 会话导航服务。
|
|
16
17
|
|
|
17
18
|
## 安装
|
|
18
19
|
|
|
19
20
|
```bash
|
|
20
|
-
dsh plugin --profile web add @ottttto/dsh-scheduled-send@0.
|
|
21
|
+
dsh plugin --profile web add @ottttto/dsh-scheduled-send@0.3.0
|
|
21
22
|
```
|
|
22
23
|
|
|
23
24
|
然后重启 DSH Web GUI。
|
|
@@ -28,6 +29,7 @@ dsh plugin --profile web add @ottttto/dsh-scheduled-send@0.2.0
|
|
|
28
29
|
2. 点输入框工具行的 **⏰** → 确认时间(默认 +5 分钟)→ 确认。
|
|
29
30
|
3. 输入框被清空;上方出现已排程条目(含计划时间与倒计时),可随时点取消。
|
|
30
31
|
4. 到点后消息以你的正常用户气泡出现在**创建时的会话**并触发模型响应。
|
|
32
|
+
5. 侧边栏底部「⏰ 定时任务」:查看/跳转/取消**所有会话**的定时任务。
|
|
31
33
|
|
|
32
34
|
## 隐私
|
|
33
35
|
|
package/lib/client.js
CHANGED
|
@@ -87,6 +87,35 @@ function defaultSendAt(now = Date.now(), offsetMs = 5 * 60_000) {
|
|
|
87
87
|
return now + offsetMs;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* One-line content summary for the sidebar panel list (0.3.0): first line,
|
|
92
|
+
* trimmed; capped with an ellipsis past `max` chars. Empty input → ''.
|
|
93
|
+
*/
|
|
94
|
+
function summarizeContent(text, max = 50) {
|
|
95
|
+
if (typeof text !== 'string') return '';
|
|
96
|
+
const first = text.split(/\r?\n/, 1)[0].trim();
|
|
97
|
+
if (!first) return '';
|
|
98
|
+
return first.length > max ? first.slice(0, max - 1) + '…' : first;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Attach session identity to panel rows (0.3.0): `sessionTitle` from the
|
|
103
|
+
* client session list's displayTitle, `sessionExists` false when the session
|
|
104
|
+
* is gone (deleted / not in the list) — those rows gray out but stay
|
|
105
|
+
* cancellable. Never throws on a missing/empty map.
|
|
106
|
+
*/
|
|
107
|
+
function annotateSessions(tasks, sessionsById) {
|
|
108
|
+
const byId = sessionsById || {};
|
|
109
|
+
return (tasks || []).map((t) => {
|
|
110
|
+
const s = t?.conversationId ? byId[t.conversationId] : null;
|
|
111
|
+
return {
|
|
112
|
+
...t,
|
|
113
|
+
sessionExists: !!s,
|
|
114
|
+
sessionTitle: s ? (s.displayTitle || s.title || t.conversationId) : '',
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
90
119
|
/**
|
|
91
120
|
* Stateful client controller: polls the host state route, holds the visible
|
|
92
121
|
* task list (extended with the server-confirmed task right after POST so new
|
|
@@ -286,6 +315,9 @@ function createClientPluginBody(React) {
|
|
|
286
315
|
try {
|
|
287
316
|
const payload = { content, sendAt: at, conversationId: props.sessionId };
|
|
288
317
|
await core.scheduleMessage(payload);
|
|
318
|
+
// sidebar panel has its own state: refresh it NOW (its 8s poll would
|
|
319
|
+
// otherwise lag ~10s behind a fresh task)
|
|
320
|
+
if (props.onTaskCreated) void props.onTaskCreated();
|
|
289
321
|
// spec: 内容转为定时任务并清空输入框 — 绝不立即发送(不调 submit)
|
|
290
322
|
if (props.inputActions && typeof props.inputActions.setDraft === "function") props.inputActions.setDraft("");
|
|
291
323
|
setOpen(false);
|
|
@@ -462,6 +494,148 @@ function createClientPluginBody(React) {
|
|
|
462
494
|
]);
|
|
463
495
|
}
|
|
464
496
|
|
|
497
|
+
/* --- sidebar footer「定时任务」panel (0.3.0) ---------------------------- */
|
|
498
|
+
function ScheduledTasksPanel(props) {
|
|
499
|
+
const core = props.panelCore;
|
|
500
|
+
const [open, setOpen] = React.useState(false);
|
|
501
|
+
const [tick, setTick] = React.useState(0);
|
|
502
|
+
const mobile = isMobile();
|
|
503
|
+
|
|
504
|
+
// keep the badge alive even while the panel is closed: poll the FULL
|
|
505
|
+
// (unfiltered) state — every conversation's pending tasks, 8s cadence.
|
|
506
|
+
React.useEffect(() => {
|
|
507
|
+
let stopped = false;
|
|
508
|
+
let timer = null;
|
|
509
|
+
const loop = async () => {
|
|
510
|
+
if (stopped) return;
|
|
511
|
+
await core.refresh().catch(() => {});
|
|
512
|
+
if (!stopped) setTick((n) => n + 1);
|
|
513
|
+
if (!stopped) { timer = setTimeout(loop, 8000); timer.unref?.(); }
|
|
514
|
+
};
|
|
515
|
+
void loop();
|
|
516
|
+
return () => { stopped = true; clearTimeout(timer); };
|
|
517
|
+
}, []);
|
|
518
|
+
|
|
519
|
+
const all = core.visibleTasks(); // ALL sessions, sendAt ascending
|
|
520
|
+
const err = core.lastError();
|
|
521
|
+
const MAX_ROWS = 100;
|
|
522
|
+
const annotated = annotateSessions(all.slice(0, MAX_ROWS), props.sessionById ? props.sessionById() : null);
|
|
523
|
+
const overflow = Math.max(0, all.length - annotated.length);
|
|
524
|
+
|
|
525
|
+
const isDark = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
526
|
+
const entryBtn = {
|
|
527
|
+
cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 6,
|
|
528
|
+
border: "none", background: "transparent", color: "inherit",
|
|
529
|
+
fontSize: 12, fontWeight: 600, padding: "4px 8px", borderRadius: 8, whiteSpace: "nowrap",
|
|
530
|
+
...(mobile ? { minHeight: TOUCH_MIN } : {}),
|
|
531
|
+
};
|
|
532
|
+
const badge = (n) => h("span", {
|
|
533
|
+
key: "b", "data-badge": n,
|
|
534
|
+
style: {
|
|
535
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
536
|
+
minWidth: 16, height: 16, padding: "0 4px", borderRadius: 999,
|
|
537
|
+
background: "#3b82f6", color: "#fff", fontSize: 10, fontWeight: 700,
|
|
538
|
+
},
|
|
539
|
+
}, String(n));
|
|
540
|
+
|
|
541
|
+
const jump = (t) => {
|
|
542
|
+
// failure mode: jump unavailable/unknown session → keep the panel open,
|
|
543
|
+
// keep the row (still cancellable); never throw into the click handler.
|
|
544
|
+
const ok = props.openSession ? props.openSession(t.conversationId) : false;
|
|
545
|
+
if (ok) setOpen(false);
|
|
546
|
+
};
|
|
547
|
+
const cancelBtn = (t) => h("button", {
|
|
548
|
+
key: "x", type: "button",
|
|
549
|
+
onClick: (e) => {
|
|
550
|
+
if (e && typeof e.stopPropagation === "function") e.stopPropagation();
|
|
551
|
+
core.cancelTask(t.id).catch(() => {});
|
|
552
|
+
setTick((n) => n + 1);
|
|
553
|
+
},
|
|
554
|
+
style: {
|
|
555
|
+
cursor: "pointer", border: "1px solid rgba(128,128,128,.4)", borderRadius: 999,
|
|
556
|
+
padding: "0 8px", fontSize: 11, background: "transparent", color: "inherit", flexShrink: 0,
|
|
557
|
+
...(mobile ? { minHeight: TOUCH_MIN, boxSizing: "border-box" } : {}),
|
|
558
|
+
},
|
|
559
|
+
}, "取消");
|
|
560
|
+
|
|
561
|
+
const footer = open
|
|
562
|
+
? h("div", {
|
|
563
|
+
key: "panel", "data-plugin": "dsh-scheduled-send-sidebar-panel",
|
|
564
|
+
style: {
|
|
565
|
+
position: "fixed", bottom: 56, left: 12, zIndex: 50,
|
|
566
|
+
width: 340, maxWidth: "calc(100vw - 24px)", maxHeight: "70vh", overflowY: "auto",
|
|
567
|
+
boxSizing: "border-box", padding: "10px 12px", fontSize: 12,
|
|
568
|
+
borderRadius: 12, border: "1px solid " + (isDark ? "rgba(255,255,255,.14)" : "rgba(0,0,0,.10)"),
|
|
569
|
+
background: isDark ? "#1c1c1e" : "#fff",
|
|
570
|
+
boxShadow: "0 8px 24px rgba(0,0,0,.18)",
|
|
571
|
+
color: isDark ? "#eee" : "#111",
|
|
572
|
+
...(mobile ? { left: 8, width: "calc(100vw - 16px)", maxWidth: "calc(100vw - 16px)" } : {}),
|
|
573
|
+
},
|
|
574
|
+
}, [
|
|
575
|
+
h("div", {
|
|
576
|
+
key: "head", style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6 },
|
|
577
|
+
}, [
|
|
578
|
+
h("span", { key: "t", style: { fontWeight: 700, fontSize: 13 } }, "⏰ 定时任务"),
|
|
579
|
+
h("span", { key: "n", style: { opacity: .6 } }, all.length ? `${all.length} 条待发送` : ""),
|
|
580
|
+
h("button", {
|
|
581
|
+
key: "close", type: "button", onClick: () => setOpen(false),
|
|
582
|
+
style: { cursor: "pointer", marginLeft: "auto", border: "none", background: "transparent", color: "inherit", fontSize: 14, lineHeight: 1 },
|
|
583
|
+
}, "✕"),
|
|
584
|
+
]),
|
|
585
|
+
err
|
|
586
|
+
? h("div", {
|
|
587
|
+
key: "err",
|
|
588
|
+
style: { padding: "3px 8px", borderRadius: 8, background: "rgba(220,38,38,.10)", color: "#dc2626", wordBreak: "break-word" },
|
|
589
|
+
}, "⚠ 加载失败,显示上一次列表:" + err)
|
|
590
|
+
: null,
|
|
591
|
+
!all.length && !err
|
|
592
|
+
? h("div", { key: "empty", style: { padding: "18px 0", textAlign: "center", opacity: .6 } }, [
|
|
593
|
+
h("div", { key: "l1" }, "暂无定时任务"),
|
|
594
|
+
h("div", { key: "l2", style: { marginTop: 4 } }, "在会话输入框点击 ⏰ 定时 即可创建"),
|
|
595
|
+
])
|
|
596
|
+
: null,
|
|
597
|
+
annotated.map((t) => h("div", {
|
|
598
|
+
key: t.id, "data-task": t.id,
|
|
599
|
+
onClick: () => { if (t.sessionExists) jump(t); },
|
|
600
|
+
title: t.sessionExists ? "打开对应会话" : "会话不存在,仅可取消",
|
|
601
|
+
style: {
|
|
602
|
+
display: "flex", flexDirection: "column", gap: 2, padding: "6px 8px", marginBottom: 4,
|
|
603
|
+
borderRadius: 8, background: "rgba(59,130,246,.10)", border: "1px solid rgba(59,130,246,.22)",
|
|
604
|
+
cursor: t.sessionExists ? "pointer" : "default",
|
|
605
|
+
opacity: t.sessionExists ? 1 : 0.5,
|
|
606
|
+
wordBreak: "break-word", maxWidth: "100%", boxSizing: "border-box",
|
|
607
|
+
},
|
|
608
|
+
}, [
|
|
609
|
+
h("div", { key: "c", style: { whiteSpace: "pre-wrap", fontFamily: "monospace" } },
|
|
610
|
+
summarizeContent(t.content) || "(空内容)"),
|
|
611
|
+
h("div", { key: "m", style: { display: "flex", alignItems: "center", gap: 8, color: "rgba(128,128,128,1)", flexWrap: "wrap" } }, [
|
|
612
|
+
h("span", { key: "s", style: { maxWidth: "60%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
|
|
613
|
+
t.sessionExists ? t.sessionTitle : "会话不存在"),
|
|
614
|
+
h("span", { key: "at" }, formatLocalTime(t.sendAt)),
|
|
615
|
+
h("span", { key: "cd" }, formatCountdown(t.sendAt, Date.now())),
|
|
616
|
+
h("span", { key: "sp", style: { marginLeft: "auto" } }, cancelBtn(t)),
|
|
617
|
+
]),
|
|
618
|
+
])),
|
|
619
|
+
overflow > 0
|
|
620
|
+
? h("div", { key: "more", style: { textAlign: "center", opacity: .6, padding: "4px 0" } },
|
|
621
|
+
`仅显示最近 ${annotated.length} 条(共 ${all.length} 条)`)
|
|
622
|
+
: null,
|
|
623
|
+
])
|
|
624
|
+
: null;
|
|
625
|
+
|
|
626
|
+
return h("div", { "data-plugin": "dsh-scheduled-send-sidebar", style: { display: "inline-flex", alignItems: "center" } }, [
|
|
627
|
+
h("button", {
|
|
628
|
+
key: "btn", type: "button",
|
|
629
|
+
onClick: () => { setOpen(!open); setTick((n) => n + 1); },
|
|
630
|
+
title: "定时任务", "aria-label": "定时任务", style: entryBtn,
|
|
631
|
+
}, [
|
|
632
|
+
h("span", { key: "l" }, "⏰ 定时任务"),
|
|
633
|
+
all.length ? badge(all.length) : null,
|
|
634
|
+
]),
|
|
635
|
+
footer,
|
|
636
|
+
]);
|
|
637
|
+
}
|
|
638
|
+
|
|
465
639
|
/** Client plugin body. Returns the cordis plugin ({inject, apply}). */
|
|
466
640
|
return function buildPlugin({ stateRoutePath, fetchImpl }) {
|
|
467
641
|
const schedulePath = stateRoutePath.replace(/\/state$/, "/schedule");
|
|
@@ -469,6 +643,24 @@ function createClientPluginBody(React) {
|
|
|
469
643
|
|
|
470
644
|
let currentSessionId = null;
|
|
471
645
|
|
|
646
|
+
const doCancel = async (id) => {
|
|
647
|
+
const res = await doFetch(schedulePath + "?id=" + encodeURIComponent(id), { method: "DELETE" });
|
|
648
|
+
return res.ok;
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
// 0.3.0 panel core: UNBOUND (session null) — fetches the FULL task list
|
|
652
|
+
// (no conversationId filter) for the sidebar badge + panel; cancel reuses
|
|
653
|
+
// the same DELETE route (cross-conversation).
|
|
654
|
+
const panelCore = createScheduledClientState({
|
|
655
|
+
fetchState: async () => {
|
|
656
|
+
const res = await doFetch(stateRoutePath, { headers: { accept: "application/json" } });
|
|
657
|
+
if (!res.ok) throw new Error("state HTTP " + res.status);
|
|
658
|
+
return res.json();
|
|
659
|
+
},
|
|
660
|
+
cancelSchedule: doCancel,
|
|
661
|
+
});
|
|
662
|
+
panelCore.setSession(null);
|
|
663
|
+
|
|
472
664
|
const core = createScheduledClientState({
|
|
473
665
|
fetchState: async () => {
|
|
474
666
|
// ask the host for THIS conversation's view (the core's bound session
|
|
@@ -496,9 +688,28 @@ function createClientPluginBody(React) {
|
|
|
496
688
|
});
|
|
497
689
|
core.defaultSendAt = () => defaultSendAt();
|
|
498
690
|
|
|
499
|
-
const inject = ["slots"];
|
|
691
|
+
const inject = ["slots", "sessions"]; // sessions: sidebar panel jump (0.3.0)
|
|
500
692
|
function apply(ctx) {
|
|
501
693
|
ctx.inject(inject, (scope) => {
|
|
694
|
+
// 0.3.0 session navigation, sourced from the sessions service:
|
|
695
|
+
// - open(id) selects a session as current (unknown ids throw → false)
|
|
696
|
+
// - list.getSnapshot().byId maps id → {displayTitle} for row labels
|
|
697
|
+
const svc = scope.sessions;
|
|
698
|
+
const openSession = (sid) => {
|
|
699
|
+
try {
|
|
700
|
+
if (svc && typeof svc.open === "function" && sid) { svc.open(sid); return true; }
|
|
701
|
+
} catch (err) { /* unknown session — degrade, row stays cancellable */ }
|
|
702
|
+
return false;
|
|
703
|
+
};
|
|
704
|
+
const sessionById = () => {
|
|
705
|
+
try { return (svc && svc.list && svc.list.getSnapshot && svc.list.getSnapshot().byId) || {}; }
|
|
706
|
+
catch (err) { return {}; }
|
|
707
|
+
};
|
|
708
|
+
scope.slots.inject("sidebar.footer.action", () => scope.slots.register({
|
|
709
|
+
name: "sidebar.footer.action",
|
|
710
|
+
id: "@ottttto/dsh-scheduled-send",
|
|
711
|
+
order: 20,
|
|
712
|
+
}, (slotProps) => ScheduledTasksPanel({ ...(slotProps || {}), panelCore, openSession, sessionById })));
|
|
502
713
|
scope.slots.inject("conversation.input.right", () => scope.slots.register({
|
|
503
714
|
name: "conversation.input.right",
|
|
504
715
|
id: "@ottttto/dsh-scheduled-send",
|
|
@@ -506,7 +717,7 @@ function createClientPluginBody(React) {
|
|
|
506
717
|
inject: (sessionId) => {
|
|
507
718
|
currentSessionId = sessionId;
|
|
508
719
|
core.setSession(sessionId); // dock/button are conversation-scoped
|
|
509
|
-
return { sessionId, core };
|
|
720
|
+
return { sessionId, core, onTaskCreated: () => panelCore.refresh() };
|
|
510
721
|
},
|
|
511
722
|
}, ScheduleButton));
|
|
512
723
|
scope.slots.inject("conversation.input.dock", () => scope.slots.register({
|
|
@@ -516,7 +727,7 @@ function createClientPluginBody(React) {
|
|
|
516
727
|
inject: (sessionId) => {
|
|
517
728
|
currentSessionId = sessionId;
|
|
518
729
|
core.setSession(sessionId);
|
|
519
|
-
return { sessionId, core };
|
|
730
|
+
return { sessionId, core, onTaskCreated: () => panelCore.refresh() };
|
|
520
731
|
},
|
|
521
732
|
}, ScheduledDock));
|
|
522
733
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ottttto/dsh-scheduled-send",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH plugin: scheduled sending (⏰ composer button, dock list of pending tasks, due-time delivery as a normal user bubble)",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "DSH plugin: scheduled sending (⏰ composer button, dock list of pending tasks, sidebar 定时任务 panel with cross-session cancel, due-time delivery as a normal user bubble)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"client": {
|
|
39
39
|
"platform": "web",
|
|
40
40
|
"inject": [
|
|
41
|
-
"@deepseek-ai/dsh-client-ui-conversation"
|
|
41
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
42
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
42
43
|
],
|
|
43
44
|
"external": []
|
|
44
45
|
}
|
|
@@ -51,4 +52,4 @@
|
|
|
51
52
|
"engines": {
|
|
52
53
|
"node": ">=18"
|
|
53
54
|
}
|
|
54
|
-
}
|
|
55
|
+
}
|
package/src/client-core.js
CHANGED
|
@@ -78,6 +78,35 @@ export function defaultSendAt(now = Date.now(), offsetMs = 5 * 60_000) {
|
|
|
78
78
|
return now + offsetMs;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* One-line content summary for the sidebar panel list (0.3.0): first line,
|
|
83
|
+
* trimmed; capped with an ellipsis past `max` chars. Empty input → ''.
|
|
84
|
+
*/
|
|
85
|
+
export function summarizeContent(text, max = 50) {
|
|
86
|
+
if (typeof text !== 'string') return '';
|
|
87
|
+
const first = text.split(/\r?\n/, 1)[0].trim();
|
|
88
|
+
if (!first) return '';
|
|
89
|
+
return first.length > max ? first.slice(0, max - 1) + '…' : first;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Attach session identity to panel rows (0.3.0): `sessionTitle` from the
|
|
94
|
+
* client session list's displayTitle, `sessionExists` false when the session
|
|
95
|
+
* is gone (deleted / not in the list) — those rows gray out but stay
|
|
96
|
+
* cancellable. Never throws on a missing/empty map.
|
|
97
|
+
*/
|
|
98
|
+
export function annotateSessions(tasks, sessionsById) {
|
|
99
|
+
const byId = sessionsById || {};
|
|
100
|
+
return (tasks || []).map((t) => {
|
|
101
|
+
const s = t?.conversationId ? byId[t.conversationId] : null;
|
|
102
|
+
return {
|
|
103
|
+
...t,
|
|
104
|
+
sessionExists: !!s,
|
|
105
|
+
sessionTitle: s ? (s.displayTitle || s.title || t.conversationId) : '',
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
81
110
|
/**
|
|
82
111
|
* Stateful client controller: polls the host state route, holds the visible
|
|
83
112
|
* task list (extended with the server-confirmed task right after POST so new
|
package/src/client-view.js
CHANGED
|
@@ -51,6 +51,9 @@ function createClientPluginBody(React) {
|
|
|
51
51
|
try {
|
|
52
52
|
const payload = { content, sendAt: at, conversationId: props.sessionId };
|
|
53
53
|
await core.scheduleMessage(payload);
|
|
54
|
+
// sidebar panel has its own state: refresh it NOW (its 8s poll would
|
|
55
|
+
// otherwise lag ~10s behind a fresh task)
|
|
56
|
+
if (props.onTaskCreated) void props.onTaskCreated();
|
|
54
57
|
// spec: 内容转为定时任务并清空输入框 — 绝不立即发送(不调 submit)
|
|
55
58
|
if (props.inputActions && typeof props.inputActions.setDraft === "function") props.inputActions.setDraft("");
|
|
56
59
|
setOpen(false);
|
|
@@ -227,6 +230,148 @@ function createClientPluginBody(React) {
|
|
|
227
230
|
]);
|
|
228
231
|
}
|
|
229
232
|
|
|
233
|
+
/* --- sidebar footer「定时任务」panel (0.3.0) ---------------------------- */
|
|
234
|
+
function ScheduledTasksPanel(props) {
|
|
235
|
+
const core = props.panelCore;
|
|
236
|
+
const [open, setOpen] = React.useState(false);
|
|
237
|
+
const [tick, setTick] = React.useState(0);
|
|
238
|
+
const mobile = isMobile();
|
|
239
|
+
|
|
240
|
+
// keep the badge alive even while the panel is closed: poll the FULL
|
|
241
|
+
// (unfiltered) state — every conversation's pending tasks, 8s cadence.
|
|
242
|
+
React.useEffect(() => {
|
|
243
|
+
let stopped = false;
|
|
244
|
+
let timer = null;
|
|
245
|
+
const loop = async () => {
|
|
246
|
+
if (stopped) return;
|
|
247
|
+
await core.refresh().catch(() => {});
|
|
248
|
+
if (!stopped) setTick((n) => n + 1);
|
|
249
|
+
if (!stopped) { timer = setTimeout(loop, 8000); timer.unref?.(); }
|
|
250
|
+
};
|
|
251
|
+
void loop();
|
|
252
|
+
return () => { stopped = true; clearTimeout(timer); };
|
|
253
|
+
}, []);
|
|
254
|
+
|
|
255
|
+
const all = core.visibleTasks(); // ALL sessions, sendAt ascending
|
|
256
|
+
const err = core.lastError();
|
|
257
|
+
const MAX_ROWS = 100;
|
|
258
|
+
const annotated = annotateSessions(all.slice(0, MAX_ROWS), props.sessionById ? props.sessionById() : null);
|
|
259
|
+
const overflow = Math.max(0, all.length - annotated.length);
|
|
260
|
+
|
|
261
|
+
const isDark = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
262
|
+
const entryBtn = {
|
|
263
|
+
cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 6,
|
|
264
|
+
border: "none", background: "transparent", color: "inherit",
|
|
265
|
+
fontSize: 12, fontWeight: 600, padding: "4px 8px", borderRadius: 8, whiteSpace: "nowrap",
|
|
266
|
+
...(mobile ? { minHeight: TOUCH_MIN } : {}),
|
|
267
|
+
};
|
|
268
|
+
const badge = (n) => h("span", {
|
|
269
|
+
key: "b", "data-badge": n,
|
|
270
|
+
style: {
|
|
271
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
272
|
+
minWidth: 16, height: 16, padding: "0 4px", borderRadius: 999,
|
|
273
|
+
background: "#3b82f6", color: "#fff", fontSize: 10, fontWeight: 700,
|
|
274
|
+
},
|
|
275
|
+
}, String(n));
|
|
276
|
+
|
|
277
|
+
const jump = (t) => {
|
|
278
|
+
// failure mode: jump unavailable/unknown session → keep the panel open,
|
|
279
|
+
// keep the row (still cancellable); never throw into the click handler.
|
|
280
|
+
const ok = props.openSession ? props.openSession(t.conversationId) : false;
|
|
281
|
+
if (ok) setOpen(false);
|
|
282
|
+
};
|
|
283
|
+
const cancelBtn = (t) => h("button", {
|
|
284
|
+
key: "x", type: "button",
|
|
285
|
+
onClick: (e) => {
|
|
286
|
+
if (e && typeof e.stopPropagation === "function") e.stopPropagation();
|
|
287
|
+
core.cancelTask(t.id).catch(() => {});
|
|
288
|
+
setTick((n) => n + 1);
|
|
289
|
+
},
|
|
290
|
+
style: {
|
|
291
|
+
cursor: "pointer", border: "1px solid rgba(128,128,128,.4)", borderRadius: 999,
|
|
292
|
+
padding: "0 8px", fontSize: 11, background: "transparent", color: "inherit", flexShrink: 0,
|
|
293
|
+
...(mobile ? { minHeight: TOUCH_MIN, boxSizing: "border-box" } : {}),
|
|
294
|
+
},
|
|
295
|
+
}, "取消");
|
|
296
|
+
|
|
297
|
+
const footer = open
|
|
298
|
+
? h("div", {
|
|
299
|
+
key: "panel", "data-plugin": "dsh-scheduled-send-sidebar-panel",
|
|
300
|
+
style: {
|
|
301
|
+
position: "fixed", bottom: 56, left: 12, zIndex: 50,
|
|
302
|
+
width: 340, maxWidth: "calc(100vw - 24px)", maxHeight: "70vh", overflowY: "auto",
|
|
303
|
+
boxSizing: "border-box", padding: "10px 12px", fontSize: 12,
|
|
304
|
+
borderRadius: 12, border: "1px solid " + (isDark ? "rgba(255,255,255,.14)" : "rgba(0,0,0,.10)"),
|
|
305
|
+
background: isDark ? "#1c1c1e" : "#fff",
|
|
306
|
+
boxShadow: "0 8px 24px rgba(0,0,0,.18)",
|
|
307
|
+
color: isDark ? "#eee" : "#111",
|
|
308
|
+
...(mobile ? { left: 8, width: "calc(100vw - 16px)", maxWidth: "calc(100vw - 16px)" } : {}),
|
|
309
|
+
},
|
|
310
|
+
}, [
|
|
311
|
+
h("div", {
|
|
312
|
+
key: "head", style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6 },
|
|
313
|
+
}, [
|
|
314
|
+
h("span", { key: "t", style: { fontWeight: 700, fontSize: 13 } }, "⏰ 定时任务"),
|
|
315
|
+
h("span", { key: "n", style: { opacity: .6 } }, all.length ? `${all.length} 条待发送` : ""),
|
|
316
|
+
h("button", {
|
|
317
|
+
key: "close", type: "button", onClick: () => setOpen(false),
|
|
318
|
+
style: { cursor: "pointer", marginLeft: "auto", border: "none", background: "transparent", color: "inherit", fontSize: 14, lineHeight: 1 },
|
|
319
|
+
}, "✕"),
|
|
320
|
+
]),
|
|
321
|
+
err
|
|
322
|
+
? h("div", {
|
|
323
|
+
key: "err",
|
|
324
|
+
style: { padding: "3px 8px", borderRadius: 8, background: "rgba(220,38,38,.10)", color: "#dc2626", wordBreak: "break-word" },
|
|
325
|
+
}, "⚠ 加载失败,显示上一次列表:" + err)
|
|
326
|
+
: null,
|
|
327
|
+
!all.length && !err
|
|
328
|
+
? h("div", { key: "empty", style: { padding: "18px 0", textAlign: "center", opacity: .6 } }, [
|
|
329
|
+
h("div", { key: "l1" }, "暂无定时任务"),
|
|
330
|
+
h("div", { key: "l2", style: { marginTop: 4 } }, "在会话输入框点击 ⏰ 定时 即可创建"),
|
|
331
|
+
])
|
|
332
|
+
: null,
|
|
333
|
+
annotated.map((t) => h("div", {
|
|
334
|
+
key: t.id, "data-task": t.id,
|
|
335
|
+
onClick: () => { if (t.sessionExists) jump(t); },
|
|
336
|
+
title: t.sessionExists ? "打开对应会话" : "会话不存在,仅可取消",
|
|
337
|
+
style: {
|
|
338
|
+
display: "flex", flexDirection: "column", gap: 2, padding: "6px 8px", marginBottom: 4,
|
|
339
|
+
borderRadius: 8, background: "rgba(59,130,246,.10)", border: "1px solid rgba(59,130,246,.22)",
|
|
340
|
+
cursor: t.sessionExists ? "pointer" : "default",
|
|
341
|
+
opacity: t.sessionExists ? 1 : 0.5,
|
|
342
|
+
wordBreak: "break-word", maxWidth: "100%", boxSizing: "border-box",
|
|
343
|
+
},
|
|
344
|
+
}, [
|
|
345
|
+
h("div", { key: "c", style: { whiteSpace: "pre-wrap", fontFamily: "monospace" } },
|
|
346
|
+
summarizeContent(t.content) || "(空内容)"),
|
|
347
|
+
h("div", { key: "m", style: { display: "flex", alignItems: "center", gap: 8, color: "rgba(128,128,128,1)", flexWrap: "wrap" } }, [
|
|
348
|
+
h("span", { key: "s", style: { maxWidth: "60%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
|
|
349
|
+
t.sessionExists ? t.sessionTitle : "会话不存在"),
|
|
350
|
+
h("span", { key: "at" }, formatLocalTime(t.sendAt)),
|
|
351
|
+
h("span", { key: "cd" }, formatCountdown(t.sendAt, Date.now())),
|
|
352
|
+
h("span", { key: "sp", style: { marginLeft: "auto" } }, cancelBtn(t)),
|
|
353
|
+
]),
|
|
354
|
+
])),
|
|
355
|
+
overflow > 0
|
|
356
|
+
? h("div", { key: "more", style: { textAlign: "center", opacity: .6, padding: "4px 0" } },
|
|
357
|
+
`仅显示最近 ${annotated.length} 条(共 ${all.length} 条)`)
|
|
358
|
+
: null,
|
|
359
|
+
])
|
|
360
|
+
: null;
|
|
361
|
+
|
|
362
|
+
return h("div", { "data-plugin": "dsh-scheduled-send-sidebar", style: { display: "inline-flex", alignItems: "center" } }, [
|
|
363
|
+
h("button", {
|
|
364
|
+
key: "btn", type: "button",
|
|
365
|
+
onClick: () => { setOpen(!open); setTick((n) => n + 1); },
|
|
366
|
+
title: "定时任务", "aria-label": "定时任务", style: entryBtn,
|
|
367
|
+
}, [
|
|
368
|
+
h("span", { key: "l" }, "⏰ 定时任务"),
|
|
369
|
+
all.length ? badge(all.length) : null,
|
|
370
|
+
]),
|
|
371
|
+
footer,
|
|
372
|
+
]);
|
|
373
|
+
}
|
|
374
|
+
|
|
230
375
|
/** Client plugin body. Returns the cordis plugin ({inject, apply}). */
|
|
231
376
|
return function buildPlugin({ stateRoutePath, fetchImpl }) {
|
|
232
377
|
const schedulePath = stateRoutePath.replace(/\/state$/, "/schedule");
|
|
@@ -234,6 +379,24 @@ function createClientPluginBody(React) {
|
|
|
234
379
|
|
|
235
380
|
let currentSessionId = null;
|
|
236
381
|
|
|
382
|
+
const doCancel = async (id) => {
|
|
383
|
+
const res = await doFetch(schedulePath + "?id=" + encodeURIComponent(id), { method: "DELETE" });
|
|
384
|
+
return res.ok;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// 0.3.0 panel core: UNBOUND (session null) — fetches the FULL task list
|
|
388
|
+
// (no conversationId filter) for the sidebar badge + panel; cancel reuses
|
|
389
|
+
// the same DELETE route (cross-conversation).
|
|
390
|
+
const panelCore = createScheduledClientState({
|
|
391
|
+
fetchState: async () => {
|
|
392
|
+
const res = await doFetch(stateRoutePath, { headers: { accept: "application/json" } });
|
|
393
|
+
if (!res.ok) throw new Error("state HTTP " + res.status);
|
|
394
|
+
return res.json();
|
|
395
|
+
},
|
|
396
|
+
cancelSchedule: doCancel,
|
|
397
|
+
});
|
|
398
|
+
panelCore.setSession(null);
|
|
399
|
+
|
|
237
400
|
const core = createScheduledClientState({
|
|
238
401
|
fetchState: async () => {
|
|
239
402
|
// ask the host for THIS conversation's view (the core's bound session
|
|
@@ -261,9 +424,28 @@ function createClientPluginBody(React) {
|
|
|
261
424
|
});
|
|
262
425
|
core.defaultSendAt = () => defaultSendAt();
|
|
263
426
|
|
|
264
|
-
const inject = ["slots"];
|
|
427
|
+
const inject = ["slots", "sessions"]; // sessions: sidebar panel jump (0.3.0)
|
|
265
428
|
function apply(ctx) {
|
|
266
429
|
ctx.inject(inject, (scope) => {
|
|
430
|
+
// 0.3.0 session navigation, sourced from the sessions service:
|
|
431
|
+
// - open(id) selects a session as current (unknown ids throw → false)
|
|
432
|
+
// - list.getSnapshot().byId maps id → {displayTitle} for row labels
|
|
433
|
+
const svc = scope.sessions;
|
|
434
|
+
const openSession = (sid) => {
|
|
435
|
+
try {
|
|
436
|
+
if (svc && typeof svc.open === "function" && sid) { svc.open(sid); return true; }
|
|
437
|
+
} catch (err) { /* unknown session — degrade, row stays cancellable */ }
|
|
438
|
+
return false;
|
|
439
|
+
};
|
|
440
|
+
const sessionById = () => {
|
|
441
|
+
try { return (svc && svc.list && svc.list.getSnapshot && svc.list.getSnapshot().byId) || {}; }
|
|
442
|
+
catch (err) { return {}; }
|
|
443
|
+
};
|
|
444
|
+
scope.slots.inject("sidebar.footer.action", () => scope.slots.register({
|
|
445
|
+
name: "sidebar.footer.action",
|
|
446
|
+
id: "@ottttto/dsh-scheduled-send",
|
|
447
|
+
order: 20,
|
|
448
|
+
}, (slotProps) => ScheduledTasksPanel({ ...(slotProps || {}), panelCore, openSession, sessionById })));
|
|
267
449
|
scope.slots.inject("conversation.input.right", () => scope.slots.register({
|
|
268
450
|
name: "conversation.input.right",
|
|
269
451
|
id: "@ottttto/dsh-scheduled-send",
|
|
@@ -271,7 +453,7 @@ function createClientPluginBody(React) {
|
|
|
271
453
|
inject: (sessionId) => {
|
|
272
454
|
currentSessionId = sessionId;
|
|
273
455
|
core.setSession(sessionId); // dock/button are conversation-scoped
|
|
274
|
-
return { sessionId, core };
|
|
456
|
+
return { sessionId, core, onTaskCreated: () => panelCore.refresh() };
|
|
275
457
|
},
|
|
276
458
|
}, ScheduleButton));
|
|
277
459
|
scope.slots.inject("conversation.input.dock", () => scope.slots.register({
|
|
@@ -281,7 +463,7 @@ function createClientPluginBody(React) {
|
|
|
281
463
|
inject: (sessionId) => {
|
|
282
464
|
currentSessionId = sessionId;
|
|
283
465
|
core.setSession(sessionId);
|
|
284
|
-
return { sessionId, core };
|
|
466
|
+
return { sessionId, core, onTaskCreated: () => panelCore.refresh() };
|
|
285
467
|
},
|
|
286
468
|
}, ScheduledDock));
|
|
287
469
|
});
|