@ottttto/dsh-scheduled-send 0.2.2 → 0.3.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/README.md +3 -1
- package/dsh.plugin.json +1 -1
- package/lib/client.js +212 -4
- package/package.json +4 -3
- package/src/client-core.js +29 -0
- package/src/client-view.js +182 -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/dsh.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "@ottttto/dsh-scheduled-send",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"main": "./src/index.js",
|
|
5
5
|
"description": "定时发送:到点以你的身份把消息作为正常用户气泡发进当前会话,无人值守、会话隔离、断电补发。Scheduled send for DSH — fires as a normal user bubble into the bound conversation, unattended, per-session isolated, restart-safe.",
|
|
6
6
|
"engines": {
|
package/lib/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GENERATED by scripts/build-client.mjs — do not edit by hand.
|
|
2
2
|
window.__ModuleLoader__.load({
|
|
3
|
-
id: "dsh-scheduled-send",
|
|
3
|
+
id: "@ottttto/dsh-scheduled-send",
|
|
4
4
|
factory: (require) => {
|
|
5
5
|
var module = { exports: {} };
|
|
6
6
|
var exports = module.exports;
|
|
@@ -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
|
|
@@ -462,6 +491,148 @@ function createClientPluginBody(React) {
|
|
|
462
491
|
]);
|
|
463
492
|
}
|
|
464
493
|
|
|
494
|
+
/* --- sidebar footer「定时任务」panel (0.3.0) ---------------------------- */
|
|
495
|
+
function ScheduledTasksPanel(props) {
|
|
496
|
+
const core = props.panelCore;
|
|
497
|
+
const [open, setOpen] = React.useState(false);
|
|
498
|
+
const [tick, setTick] = React.useState(0);
|
|
499
|
+
const mobile = isMobile();
|
|
500
|
+
|
|
501
|
+
// keep the badge alive even while the panel is closed: poll the FULL
|
|
502
|
+
// (unfiltered) state — every conversation's pending tasks, 8s cadence.
|
|
503
|
+
React.useEffect(() => {
|
|
504
|
+
let stopped = false;
|
|
505
|
+
let timer = null;
|
|
506
|
+
const loop = async () => {
|
|
507
|
+
if (stopped) return;
|
|
508
|
+
await core.refresh().catch(() => {});
|
|
509
|
+
if (!stopped) setTick((n) => n + 1);
|
|
510
|
+
if (!stopped) { timer = setTimeout(loop, 8000); timer.unref?.(); }
|
|
511
|
+
};
|
|
512
|
+
void loop();
|
|
513
|
+
return () => { stopped = true; clearTimeout(timer); };
|
|
514
|
+
}, []);
|
|
515
|
+
|
|
516
|
+
const all = core.visibleTasks(); // ALL sessions, sendAt ascending
|
|
517
|
+
const err = core.lastError();
|
|
518
|
+
const MAX_ROWS = 100;
|
|
519
|
+
const annotated = annotateSessions(all.slice(0, MAX_ROWS), props.sessionById ? props.sessionById() : null);
|
|
520
|
+
const overflow = Math.max(0, all.length - annotated.length);
|
|
521
|
+
|
|
522
|
+
const isDark = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
523
|
+
const entryBtn = {
|
|
524
|
+
cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 6,
|
|
525
|
+
border: "none", background: "transparent", color: "inherit",
|
|
526
|
+
fontSize: 12, fontWeight: 600, padding: "4px 8px", borderRadius: 8, whiteSpace: "nowrap",
|
|
527
|
+
...(mobile ? { minHeight: TOUCH_MIN } : {}),
|
|
528
|
+
};
|
|
529
|
+
const badge = (n) => h("span", {
|
|
530
|
+
key: "b", "data-badge": n,
|
|
531
|
+
style: {
|
|
532
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
533
|
+
minWidth: 16, height: 16, padding: "0 4px", borderRadius: 999,
|
|
534
|
+
background: "#3b82f6", color: "#fff", fontSize: 10, fontWeight: 700,
|
|
535
|
+
},
|
|
536
|
+
}, String(n));
|
|
537
|
+
|
|
538
|
+
const jump = (t) => {
|
|
539
|
+
// failure mode: jump unavailable/unknown session → keep the panel open,
|
|
540
|
+
// keep the row (still cancellable); never throw into the click handler.
|
|
541
|
+
const ok = props.openSession ? props.openSession(t.conversationId) : false;
|
|
542
|
+
if (ok) setOpen(false);
|
|
543
|
+
};
|
|
544
|
+
const cancelBtn = (t) => h("button", {
|
|
545
|
+
key: "x", type: "button",
|
|
546
|
+
onClick: (e) => {
|
|
547
|
+
if (e && typeof e.stopPropagation === "function") e.stopPropagation();
|
|
548
|
+
core.cancelTask(t.id).catch(() => {});
|
|
549
|
+
setTick((n) => n + 1);
|
|
550
|
+
},
|
|
551
|
+
style: {
|
|
552
|
+
cursor: "pointer", border: "1px solid rgba(128,128,128,.4)", borderRadius: 999,
|
|
553
|
+
padding: "0 8px", fontSize: 11, background: "transparent", color: "inherit", flexShrink: 0,
|
|
554
|
+
...(mobile ? { minHeight: TOUCH_MIN, boxSizing: "border-box" } : {}),
|
|
555
|
+
},
|
|
556
|
+
}, "取消");
|
|
557
|
+
|
|
558
|
+
const footer = open
|
|
559
|
+
? h("div", {
|
|
560
|
+
key: "panel", "data-plugin": "dsh-scheduled-send-sidebar-panel",
|
|
561
|
+
style: {
|
|
562
|
+
position: "fixed", bottom: 56, left: 12, zIndex: 50,
|
|
563
|
+
width: 340, maxWidth: "calc(100vw - 24px)", maxHeight: "70vh", overflowY: "auto",
|
|
564
|
+
boxSizing: "border-box", padding: "10px 12px", fontSize: 12,
|
|
565
|
+
borderRadius: 12, border: "1px solid " + (isDark ? "rgba(255,255,255,.14)" : "rgba(0,0,0,.10)"),
|
|
566
|
+
background: isDark ? "#1c1c1e" : "#fff",
|
|
567
|
+
boxShadow: "0 8px 24px rgba(0,0,0,.18)",
|
|
568
|
+
color: isDark ? "#eee" : "#111",
|
|
569
|
+
...(mobile ? { left: 8, width: "calc(100vw - 16px)", maxWidth: "calc(100vw - 16px)" } : {}),
|
|
570
|
+
},
|
|
571
|
+
}, [
|
|
572
|
+
h("div", {
|
|
573
|
+
key: "head", style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6 },
|
|
574
|
+
}, [
|
|
575
|
+
h("span", { key: "t", style: { fontWeight: 700, fontSize: 13 } }, "⏰ 定时任务"),
|
|
576
|
+
h("span", { key: "n", style: { opacity: .6 } }, all.length ? `${all.length} 条待发送` : ""),
|
|
577
|
+
h("button", {
|
|
578
|
+
key: "close", type: "button", onClick: () => setOpen(false),
|
|
579
|
+
style: { cursor: "pointer", marginLeft: "auto", border: "none", background: "transparent", color: "inherit", fontSize: 14, lineHeight: 1 },
|
|
580
|
+
}, "✕"),
|
|
581
|
+
]),
|
|
582
|
+
err
|
|
583
|
+
? h("div", {
|
|
584
|
+
key: "err",
|
|
585
|
+
style: { padding: "3px 8px", borderRadius: 8, background: "rgba(220,38,38,.10)", color: "#dc2626", wordBreak: "break-word" },
|
|
586
|
+
}, "⚠ 加载失败,显示上一次列表:" + err)
|
|
587
|
+
: null,
|
|
588
|
+
!all.length && !err
|
|
589
|
+
? h("div", { key: "empty", style: { padding: "18px 0", textAlign: "center", opacity: .6 } }, [
|
|
590
|
+
h("div", { key: "l1" }, "暂无定时任务"),
|
|
591
|
+
h("div", { key: "l2", style: { marginTop: 4 } }, "在会话输入框点击 ⏰ 定时 即可创建"),
|
|
592
|
+
])
|
|
593
|
+
: null,
|
|
594
|
+
annotated.map((t) => h("div", {
|
|
595
|
+
key: t.id, "data-task": t.id,
|
|
596
|
+
onClick: () => { if (t.sessionExists) jump(t); },
|
|
597
|
+
title: t.sessionExists ? "打开对应会话" : "会话不存在,仅可取消",
|
|
598
|
+
style: {
|
|
599
|
+
display: "flex", flexDirection: "column", gap: 2, padding: "6px 8px", marginBottom: 4,
|
|
600
|
+
borderRadius: 8, background: "rgba(59,130,246,.10)", border: "1px solid rgba(59,130,246,.22)",
|
|
601
|
+
cursor: t.sessionExists ? "pointer" : "default",
|
|
602
|
+
opacity: t.sessionExists ? 1 : 0.5,
|
|
603
|
+
wordBreak: "break-word", maxWidth: "100%", boxSizing: "border-box",
|
|
604
|
+
},
|
|
605
|
+
}, [
|
|
606
|
+
h("div", { key: "c", style: { whiteSpace: "pre-wrap", fontFamily: "monospace" } },
|
|
607
|
+
summarizeContent(t.content) || "(空内容)"),
|
|
608
|
+
h("div", { key: "m", style: { display: "flex", alignItems: "center", gap: 8, color: "rgba(128,128,128,1)", flexWrap: "wrap" } }, [
|
|
609
|
+
h("span", { key: "s", style: { maxWidth: "60%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
|
|
610
|
+
t.sessionExists ? t.sessionTitle : "会话不存在"),
|
|
611
|
+
h("span", { key: "at" }, formatLocalTime(t.sendAt)),
|
|
612
|
+
h("span", { key: "cd" }, formatCountdown(t.sendAt, Date.now())),
|
|
613
|
+
h("span", { key: "sp", style: { marginLeft: "auto" } }, cancelBtn(t)),
|
|
614
|
+
]),
|
|
615
|
+
])),
|
|
616
|
+
overflow > 0
|
|
617
|
+
? h("div", { key: "more", style: { textAlign: "center", opacity: .6, padding: "4px 0" } },
|
|
618
|
+
`仅显示最近 ${annotated.length} 条(共 ${all.length} 条)`)
|
|
619
|
+
: null,
|
|
620
|
+
])
|
|
621
|
+
: null;
|
|
622
|
+
|
|
623
|
+
return h("div", { "data-plugin": "dsh-scheduled-send-sidebar", style: { display: "inline-flex", alignItems: "center" } }, [
|
|
624
|
+
h("button", {
|
|
625
|
+
key: "btn", type: "button",
|
|
626
|
+
onClick: () => { setOpen(!open); setTick((n) => n + 1); },
|
|
627
|
+
title: "定时任务", "aria-label": "定时任务", style: entryBtn,
|
|
628
|
+
}, [
|
|
629
|
+
h("span", { key: "l" }, "⏰ 定时任务"),
|
|
630
|
+
all.length ? badge(all.length) : null,
|
|
631
|
+
]),
|
|
632
|
+
footer,
|
|
633
|
+
]);
|
|
634
|
+
}
|
|
635
|
+
|
|
465
636
|
/** Client plugin body. Returns the cordis plugin ({inject, apply}). */
|
|
466
637
|
return function buildPlugin({ stateRoutePath, fetchImpl }) {
|
|
467
638
|
const schedulePath = stateRoutePath.replace(/\/state$/, "/schedule");
|
|
@@ -469,6 +640,24 @@ function createClientPluginBody(React) {
|
|
|
469
640
|
|
|
470
641
|
let currentSessionId = null;
|
|
471
642
|
|
|
643
|
+
const doCancel = async (id) => {
|
|
644
|
+
const res = await doFetch(schedulePath + "?id=" + encodeURIComponent(id), { method: "DELETE" });
|
|
645
|
+
return res.ok;
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
// 0.3.0 panel core: UNBOUND (session null) — fetches the FULL task list
|
|
649
|
+
// (no conversationId filter) for the sidebar badge + panel; cancel reuses
|
|
650
|
+
// the same DELETE route (cross-conversation).
|
|
651
|
+
const panelCore = createScheduledClientState({
|
|
652
|
+
fetchState: async () => {
|
|
653
|
+
const res = await doFetch(stateRoutePath, { headers: { accept: "application/json" } });
|
|
654
|
+
if (!res.ok) throw new Error("state HTTP " + res.status);
|
|
655
|
+
return res.json();
|
|
656
|
+
},
|
|
657
|
+
cancelSchedule: doCancel,
|
|
658
|
+
});
|
|
659
|
+
panelCore.setSession(null);
|
|
660
|
+
|
|
472
661
|
const core = createScheduledClientState({
|
|
473
662
|
fetchState: async () => {
|
|
474
663
|
// ask the host for THIS conversation's view (the core's bound session
|
|
@@ -496,12 +685,31 @@ function createClientPluginBody(React) {
|
|
|
496
685
|
});
|
|
497
686
|
core.defaultSendAt = () => defaultSendAt();
|
|
498
687
|
|
|
499
|
-
const inject = ["slots"];
|
|
688
|
+
const inject = ["slots", "sessions"]; // sessions: sidebar panel jump (0.3.0)
|
|
500
689
|
function apply(ctx) {
|
|
501
690
|
ctx.inject(inject, (scope) => {
|
|
691
|
+
// 0.3.0 session navigation, sourced from the sessions service:
|
|
692
|
+
// - open(id) selects a session as current (unknown ids throw → false)
|
|
693
|
+
// - list.getSnapshot().byId maps id → {displayTitle} for row labels
|
|
694
|
+
const svc = scope.sessions;
|
|
695
|
+
const openSession = (sid) => {
|
|
696
|
+
try {
|
|
697
|
+
if (svc && typeof svc.open === "function" && sid) { svc.open(sid); return true; }
|
|
698
|
+
} catch (err) { /* unknown session — degrade, row stays cancellable */ }
|
|
699
|
+
return false;
|
|
700
|
+
};
|
|
701
|
+
const sessionById = () => {
|
|
702
|
+
try { return (svc && svc.list && svc.list.getSnapshot && svc.list.getSnapshot().byId) || {}; }
|
|
703
|
+
catch (err) { return {}; }
|
|
704
|
+
};
|
|
705
|
+
scope.slots.inject("sidebar.footer.action", () => scope.slots.register({
|
|
706
|
+
name: "sidebar.footer.action",
|
|
707
|
+
id: "@ottttto/dsh-scheduled-send",
|
|
708
|
+
order: 20,
|
|
709
|
+
}, (slotProps) => ScheduledTasksPanel({ ...(slotProps || {}), panelCore, openSession, sessionById })));
|
|
502
710
|
scope.slots.inject("conversation.input.right", () => scope.slots.register({
|
|
503
711
|
name: "conversation.input.right",
|
|
504
|
-
id: "dsh-scheduled-send",
|
|
712
|
+
id: "@ottttto/dsh-scheduled-send",
|
|
505
713
|
order: 100,
|
|
506
714
|
inject: (sessionId) => {
|
|
507
715
|
currentSessionId = sessionId;
|
|
@@ -511,7 +719,7 @@ function createClientPluginBody(React) {
|
|
|
511
719
|
}, ScheduleButton));
|
|
512
720
|
scope.slots.inject("conversation.input.dock", () => scope.slots.register({
|
|
513
721
|
name: "conversation.input.dock",
|
|
514
|
-
id: "dsh-scheduled-send",
|
|
722
|
+
id: "@ottttto/dsh-scheduled-send",
|
|
515
723
|
order: 30,
|
|
516
724
|
inject: (sessionId) => {
|
|
517
725
|
currentSessionId = sessionId;
|
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.0",
|
|
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
|
}
|
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
|
@@ -227,6 +227,148 @@ function createClientPluginBody(React) {
|
|
|
227
227
|
]);
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/* --- sidebar footer「定时任务」panel (0.3.0) ---------------------------- */
|
|
231
|
+
function ScheduledTasksPanel(props) {
|
|
232
|
+
const core = props.panelCore;
|
|
233
|
+
const [open, setOpen] = React.useState(false);
|
|
234
|
+
const [tick, setTick] = React.useState(0);
|
|
235
|
+
const mobile = isMobile();
|
|
236
|
+
|
|
237
|
+
// keep the badge alive even while the panel is closed: poll the FULL
|
|
238
|
+
// (unfiltered) state — every conversation's pending tasks, 8s cadence.
|
|
239
|
+
React.useEffect(() => {
|
|
240
|
+
let stopped = false;
|
|
241
|
+
let timer = null;
|
|
242
|
+
const loop = async () => {
|
|
243
|
+
if (stopped) return;
|
|
244
|
+
await core.refresh().catch(() => {});
|
|
245
|
+
if (!stopped) setTick((n) => n + 1);
|
|
246
|
+
if (!stopped) { timer = setTimeout(loop, 8000); timer.unref?.(); }
|
|
247
|
+
};
|
|
248
|
+
void loop();
|
|
249
|
+
return () => { stopped = true; clearTimeout(timer); };
|
|
250
|
+
}, []);
|
|
251
|
+
|
|
252
|
+
const all = core.visibleTasks(); // ALL sessions, sendAt ascending
|
|
253
|
+
const err = core.lastError();
|
|
254
|
+
const MAX_ROWS = 100;
|
|
255
|
+
const annotated = annotateSessions(all.slice(0, MAX_ROWS), props.sessionById ? props.sessionById() : null);
|
|
256
|
+
const overflow = Math.max(0, all.length - annotated.length);
|
|
257
|
+
|
|
258
|
+
const isDark = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
259
|
+
const entryBtn = {
|
|
260
|
+
cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 6,
|
|
261
|
+
border: "none", background: "transparent", color: "inherit",
|
|
262
|
+
fontSize: 12, fontWeight: 600, padding: "4px 8px", borderRadius: 8, whiteSpace: "nowrap",
|
|
263
|
+
...(mobile ? { minHeight: TOUCH_MIN } : {}),
|
|
264
|
+
};
|
|
265
|
+
const badge = (n) => h("span", {
|
|
266
|
+
key: "b", "data-badge": n,
|
|
267
|
+
style: {
|
|
268
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
269
|
+
minWidth: 16, height: 16, padding: "0 4px", borderRadius: 999,
|
|
270
|
+
background: "#3b82f6", color: "#fff", fontSize: 10, fontWeight: 700,
|
|
271
|
+
},
|
|
272
|
+
}, String(n));
|
|
273
|
+
|
|
274
|
+
const jump = (t) => {
|
|
275
|
+
// failure mode: jump unavailable/unknown session → keep the panel open,
|
|
276
|
+
// keep the row (still cancellable); never throw into the click handler.
|
|
277
|
+
const ok = props.openSession ? props.openSession(t.conversationId) : false;
|
|
278
|
+
if (ok) setOpen(false);
|
|
279
|
+
};
|
|
280
|
+
const cancelBtn = (t) => h("button", {
|
|
281
|
+
key: "x", type: "button",
|
|
282
|
+
onClick: (e) => {
|
|
283
|
+
if (e && typeof e.stopPropagation === "function") e.stopPropagation();
|
|
284
|
+
core.cancelTask(t.id).catch(() => {});
|
|
285
|
+
setTick((n) => n + 1);
|
|
286
|
+
},
|
|
287
|
+
style: {
|
|
288
|
+
cursor: "pointer", border: "1px solid rgba(128,128,128,.4)", borderRadius: 999,
|
|
289
|
+
padding: "0 8px", fontSize: 11, background: "transparent", color: "inherit", flexShrink: 0,
|
|
290
|
+
...(mobile ? { minHeight: TOUCH_MIN, boxSizing: "border-box" } : {}),
|
|
291
|
+
},
|
|
292
|
+
}, "取消");
|
|
293
|
+
|
|
294
|
+
const footer = open
|
|
295
|
+
? h("div", {
|
|
296
|
+
key: "panel", "data-plugin": "dsh-scheduled-send-sidebar-panel",
|
|
297
|
+
style: {
|
|
298
|
+
position: "fixed", bottom: 56, left: 12, zIndex: 50,
|
|
299
|
+
width: 340, maxWidth: "calc(100vw - 24px)", maxHeight: "70vh", overflowY: "auto",
|
|
300
|
+
boxSizing: "border-box", padding: "10px 12px", fontSize: 12,
|
|
301
|
+
borderRadius: 12, border: "1px solid " + (isDark ? "rgba(255,255,255,.14)" : "rgba(0,0,0,.10)"),
|
|
302
|
+
background: isDark ? "#1c1c1e" : "#fff",
|
|
303
|
+
boxShadow: "0 8px 24px rgba(0,0,0,.18)",
|
|
304
|
+
color: isDark ? "#eee" : "#111",
|
|
305
|
+
...(mobile ? { left: 8, width: "calc(100vw - 16px)", maxWidth: "calc(100vw - 16px)" } : {}),
|
|
306
|
+
},
|
|
307
|
+
}, [
|
|
308
|
+
h("div", {
|
|
309
|
+
key: "head", style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6 },
|
|
310
|
+
}, [
|
|
311
|
+
h("span", { key: "t", style: { fontWeight: 700, fontSize: 13 } }, "⏰ 定时任务"),
|
|
312
|
+
h("span", { key: "n", style: { opacity: .6 } }, all.length ? `${all.length} 条待发送` : ""),
|
|
313
|
+
h("button", {
|
|
314
|
+
key: "close", type: "button", onClick: () => setOpen(false),
|
|
315
|
+
style: { cursor: "pointer", marginLeft: "auto", border: "none", background: "transparent", color: "inherit", fontSize: 14, lineHeight: 1 },
|
|
316
|
+
}, "✕"),
|
|
317
|
+
]),
|
|
318
|
+
err
|
|
319
|
+
? h("div", {
|
|
320
|
+
key: "err",
|
|
321
|
+
style: { padding: "3px 8px", borderRadius: 8, background: "rgba(220,38,38,.10)", color: "#dc2626", wordBreak: "break-word" },
|
|
322
|
+
}, "⚠ 加载失败,显示上一次列表:" + err)
|
|
323
|
+
: null,
|
|
324
|
+
!all.length && !err
|
|
325
|
+
? h("div", { key: "empty", style: { padding: "18px 0", textAlign: "center", opacity: .6 } }, [
|
|
326
|
+
h("div", { key: "l1" }, "暂无定时任务"),
|
|
327
|
+
h("div", { key: "l2", style: { marginTop: 4 } }, "在会话输入框点击 ⏰ 定时 即可创建"),
|
|
328
|
+
])
|
|
329
|
+
: null,
|
|
330
|
+
annotated.map((t) => h("div", {
|
|
331
|
+
key: t.id, "data-task": t.id,
|
|
332
|
+
onClick: () => { if (t.sessionExists) jump(t); },
|
|
333
|
+
title: t.sessionExists ? "打开对应会话" : "会话不存在,仅可取消",
|
|
334
|
+
style: {
|
|
335
|
+
display: "flex", flexDirection: "column", gap: 2, padding: "6px 8px", marginBottom: 4,
|
|
336
|
+
borderRadius: 8, background: "rgba(59,130,246,.10)", border: "1px solid rgba(59,130,246,.22)",
|
|
337
|
+
cursor: t.sessionExists ? "pointer" : "default",
|
|
338
|
+
opacity: t.sessionExists ? 1 : 0.5,
|
|
339
|
+
wordBreak: "break-word", maxWidth: "100%", boxSizing: "border-box",
|
|
340
|
+
},
|
|
341
|
+
}, [
|
|
342
|
+
h("div", { key: "c", style: { whiteSpace: "pre-wrap", fontFamily: "monospace" } },
|
|
343
|
+
summarizeContent(t.content) || "(空内容)"),
|
|
344
|
+
h("div", { key: "m", style: { display: "flex", alignItems: "center", gap: 8, color: "rgba(128,128,128,1)", flexWrap: "wrap" } }, [
|
|
345
|
+
h("span", { key: "s", style: { maxWidth: "60%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
|
|
346
|
+
t.sessionExists ? t.sessionTitle : "会话不存在"),
|
|
347
|
+
h("span", { key: "at" }, formatLocalTime(t.sendAt)),
|
|
348
|
+
h("span", { key: "cd" }, formatCountdown(t.sendAt, Date.now())),
|
|
349
|
+
h("span", { key: "sp", style: { marginLeft: "auto" } }, cancelBtn(t)),
|
|
350
|
+
]),
|
|
351
|
+
])),
|
|
352
|
+
overflow > 0
|
|
353
|
+
? h("div", { key: "more", style: { textAlign: "center", opacity: .6, padding: "4px 0" } },
|
|
354
|
+
`仅显示最近 ${annotated.length} 条(共 ${all.length} 条)`)
|
|
355
|
+
: null,
|
|
356
|
+
])
|
|
357
|
+
: null;
|
|
358
|
+
|
|
359
|
+
return h("div", { "data-plugin": "dsh-scheduled-send-sidebar", style: { display: "inline-flex", alignItems: "center" } }, [
|
|
360
|
+
h("button", {
|
|
361
|
+
key: "btn", type: "button",
|
|
362
|
+
onClick: () => { setOpen(!open); setTick((n) => n + 1); },
|
|
363
|
+
title: "定时任务", "aria-label": "定时任务", style: entryBtn,
|
|
364
|
+
}, [
|
|
365
|
+
h("span", { key: "l" }, "⏰ 定时任务"),
|
|
366
|
+
all.length ? badge(all.length) : null,
|
|
367
|
+
]),
|
|
368
|
+
footer,
|
|
369
|
+
]);
|
|
370
|
+
}
|
|
371
|
+
|
|
230
372
|
/** Client plugin body. Returns the cordis plugin ({inject, apply}). */
|
|
231
373
|
return function buildPlugin({ stateRoutePath, fetchImpl }) {
|
|
232
374
|
const schedulePath = stateRoutePath.replace(/\/state$/, "/schedule");
|
|
@@ -234,6 +376,24 @@ function createClientPluginBody(React) {
|
|
|
234
376
|
|
|
235
377
|
let currentSessionId = null;
|
|
236
378
|
|
|
379
|
+
const doCancel = async (id) => {
|
|
380
|
+
const res = await doFetch(schedulePath + "?id=" + encodeURIComponent(id), { method: "DELETE" });
|
|
381
|
+
return res.ok;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// 0.3.0 panel core: UNBOUND (session null) — fetches the FULL task list
|
|
385
|
+
// (no conversationId filter) for the sidebar badge + panel; cancel reuses
|
|
386
|
+
// the same DELETE route (cross-conversation).
|
|
387
|
+
const panelCore = createScheduledClientState({
|
|
388
|
+
fetchState: async () => {
|
|
389
|
+
const res = await doFetch(stateRoutePath, { headers: { accept: "application/json" } });
|
|
390
|
+
if (!res.ok) throw new Error("state HTTP " + res.status);
|
|
391
|
+
return res.json();
|
|
392
|
+
},
|
|
393
|
+
cancelSchedule: doCancel,
|
|
394
|
+
});
|
|
395
|
+
panelCore.setSession(null);
|
|
396
|
+
|
|
237
397
|
const core = createScheduledClientState({
|
|
238
398
|
fetchState: async () => {
|
|
239
399
|
// ask the host for THIS conversation's view (the core's bound session
|
|
@@ -261,12 +421,31 @@ function createClientPluginBody(React) {
|
|
|
261
421
|
});
|
|
262
422
|
core.defaultSendAt = () => defaultSendAt();
|
|
263
423
|
|
|
264
|
-
const inject = ["slots"];
|
|
424
|
+
const inject = ["slots", "sessions"]; // sessions: sidebar panel jump (0.3.0)
|
|
265
425
|
function apply(ctx) {
|
|
266
426
|
ctx.inject(inject, (scope) => {
|
|
427
|
+
// 0.3.0 session navigation, sourced from the sessions service:
|
|
428
|
+
// - open(id) selects a session as current (unknown ids throw → false)
|
|
429
|
+
// - list.getSnapshot().byId maps id → {displayTitle} for row labels
|
|
430
|
+
const svc = scope.sessions;
|
|
431
|
+
const openSession = (sid) => {
|
|
432
|
+
try {
|
|
433
|
+
if (svc && typeof svc.open === "function" && sid) { svc.open(sid); return true; }
|
|
434
|
+
} catch (err) { /* unknown session — degrade, row stays cancellable */ }
|
|
435
|
+
return false;
|
|
436
|
+
};
|
|
437
|
+
const sessionById = () => {
|
|
438
|
+
try { return (svc && svc.list && svc.list.getSnapshot && svc.list.getSnapshot().byId) || {}; }
|
|
439
|
+
catch (err) { return {}; }
|
|
440
|
+
};
|
|
441
|
+
scope.slots.inject("sidebar.footer.action", () => scope.slots.register({
|
|
442
|
+
name: "sidebar.footer.action",
|
|
443
|
+
id: "@ottttto/dsh-scheduled-send",
|
|
444
|
+
order: 20,
|
|
445
|
+
}, (slotProps) => ScheduledTasksPanel({ ...(slotProps || {}), panelCore, openSession, sessionById })));
|
|
267
446
|
scope.slots.inject("conversation.input.right", () => scope.slots.register({
|
|
268
447
|
name: "conversation.input.right",
|
|
269
|
-
id: "dsh-scheduled-send",
|
|
448
|
+
id: "@ottttto/dsh-scheduled-send",
|
|
270
449
|
order: 100,
|
|
271
450
|
inject: (sessionId) => {
|
|
272
451
|
currentSessionId = sessionId;
|
|
@@ -276,7 +455,7 @@ function createClientPluginBody(React) {
|
|
|
276
455
|
}, ScheduleButton));
|
|
277
456
|
scope.slots.inject("conversation.input.dock", () => scope.slots.register({
|
|
278
457
|
name: "conversation.input.dock",
|
|
279
|
-
id: "dsh-scheduled-send",
|
|
458
|
+
id: "@ottttto/dsh-scheduled-send",
|
|
280
459
|
order: 30,
|
|
281
460
|
inject: (sessionId) => {
|
|
282
461
|
currentSessionId = sessionId;
|