dsh-scheduled-message 0.1.1 → 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/README.md +27 -8
- package/client.js +184 -3
- package/docs/DETAIL.md +13 -0
- package/lib/core.js +80 -0
- package/locale/en.json +2 -1
- package/locale/zh.json +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,23 +19,42 @@ their current meaning.
|
|
|
19
19
|
| **Dialog** | Date, hour 1–12, minute, AM/PM, quick picks, a "Sends …" summary, and an optional **Repeat** row. Backdrop/Escape close, focus returns to the composer. |
|
|
20
20
|
| **Strip** | Above the composer: `2 scheduled · next Fri, 14 Nov, 9:00 am`, expandable to rows with **Send now**, **Change time**, **Cancel**, and **Retry** for a failed delivery. |
|
|
21
21
|
| **Repeat** | Creates a task in DSH's own Automation service and opens its task page. Blocked when the draft has images or files. |
|
|
22
|
+
| **Peak-price hint** | When the chosen time lands in DeepSeek's peak window and the session runs a DeepSeek model, the dialog adds one orange line naming the window in your own time zone. Weekends and off-peak hours are half price. |
|
|
22
23
|
| **Phone** | Bottom sheet under 640 px, 44 px targets, 16 px inputs (no zoom-on-focus), safe-area padding. |
|
|
23
24
|
| **Theme** | Host tokens for every surface and text colour; the accent orange is the only literal colour. English and Chinese dictionaries ship complete. |
|
|
24
25
|
|
|
25
26
|
## Install
|
|
26
27
|
|
|
27
|
-
The package is a normal DSH plugin bundle: a Host half
|
|
28
|
-
(`client.js`), and one patch entry.
|
|
28
|
+
Published on npm as `dsh-scheduled-message`. The package is a normal DSH plugin bundle: a Host half
|
|
29
|
+
(`index.js`), a browser half (`client.js`), and one patch entry.
|
|
29
30
|
|
|
30
31
|
```sh
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
dsh plugin --profile <profile> add dsh-scheduled-message
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Replace `<profile>` with the profile your host runs — `web` for a plain `dsh web`. DSH Desktop keeps
|
|
36
|
+
its own profile, so install there from **Settings → Plugins** instead of the command line.
|
|
37
|
+
|
|
38
|
+
The command forwards to pnpm in the profile directory and, because the package declares `dsh.bundle`,
|
|
39
|
+
adds it to `dsh.profile.bundles` as well. Then restart DSH.
|
|
40
|
+
|
|
41
|
+
The market card does the same thing in one click: **Plugins** → **Discover**, search *scheduled
|
|
42
|
+
message*.
|
|
43
|
+
|
|
44
|
+
Needs DSH `0.2.2-rc.2` or newer (`engines.dsh`).
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
### Without a package manager
|
|
47
|
+
|
|
48
|
+
Put the folder where the profile can resolve it, list it in that profile's `package.json`, and
|
|
49
|
+
restart:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
cp -r dsh-scheduled-message ~/.dsh/profiles/<profile>/node_modules/dsh-scheduled-message
|
|
53
|
+
```
|
|
37
54
|
|
|
38
|
-
|
|
55
|
+
```jsonc
|
|
56
|
+
"dependencies": { "dsh-scheduled-message": "file:./node_modules/dsh-scheduled-message" },
|
|
57
|
+
"dsh": { "profile": { "bundles": [ …, "dsh-scheduled-message" ] } }
|
|
39
58
|
```
|
|
40
59
|
|
|
41
60
|
With the plugin manager available, `install_bundle` with this directory as the target does the same
|
package/client.js
CHANGED
|
@@ -475,8 +475,88 @@ function describeZone(instantMs, locale, timeZone) {
|
|
|
475
475
|
return timeZone
|
|
476
476
|
}
|
|
477
477
|
}
|
|
478
|
+
|
|
479
|
+
// ---------------------------------------------------------------- DeepSeek price clock
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* DeepSeek's peak-price windows as UTC minutes since midnight.
|
|
483
|
+
*
|
|
484
|
+
* DeepSeek documents the rule verbatim: "Off-peak rates are half of the peak
|
|
485
|
+
* rates. Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through
|
|
486
|
+
* Friday, excluding Chinese public holidays. All other hours are off-peak,
|
|
487
|
+
* including weekends and Chinese public holidays in full."
|
|
488
|
+
* (https://api-docs.deepseek.com/quick_start/pricing, read 2026-11-14.)
|
|
489
|
+
*
|
|
490
|
+
* Chinese public holidays are not modelled: the notice speaks about the
|
|
491
|
+
* weekday clock, which is right for every ordinary working week.
|
|
492
|
+
*/
|
|
493
|
+
const DEEPSEEK_PEAK_UTC_MINUTES = Object.freeze([[60, 240], [360, 600]])
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Whether one instant is billed at DeepSeek's standard (peak) rate.
|
|
497
|
+
* @param instantMs - epoch milliseconds.
|
|
498
|
+
* @returns whether the instant sits inside a weekday peak window.
|
|
499
|
+
*/
|
|
500
|
+
function isDeepSeekPeak(instantMs) {
|
|
501
|
+
if (!Number.isFinite(instantMs)) return false
|
|
502
|
+
const instant = new Date(instantMs)
|
|
503
|
+
const weekday = instant.getUTCDay()
|
|
504
|
+
if (weekday === 0 || weekday === 6) return false
|
|
505
|
+
const minutes = instant.getUTCHours() * 60 + instant.getUTCMinutes()
|
|
506
|
+
return DEEPSEEK_PEAK_UTC_MINUTES.some(([start, end]) => minutes >= start && minutes < end)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Whether a model id belongs to the DeepSeek family, whose clock is priced.
|
|
511
|
+
* @param modelName - provider model id, e.g. `deepseek-v4.1-flash`.
|
|
512
|
+
* @returns whether the name contains `deepseek`, case-insensitively.
|
|
513
|
+
*/
|
|
514
|
+
function isDeepSeekModel(modelName) {
|
|
515
|
+
return typeof modelName === 'string' && /deepseek/i.test(modelName)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Render both peak windows in the reader's zone, e.g. `09:00–12:00 / 14:00–18:00`.
|
|
520
|
+
* A zone west of UTC shows a window on the neighbouring date, which is
|
|
521
|
+
* correct: the boundaries are instants, not wall-clock times.
|
|
522
|
+
* @param instantMs - the instant whose UTC date anchors the windows.
|
|
523
|
+
* @param timeZone - explicit IANA zone used for display.
|
|
524
|
+
* @returns the two windows joined by ` / `.
|
|
525
|
+
*/
|
|
526
|
+
function describePeakWindows(instantMs, timeZone) {
|
|
527
|
+
const at = Number.isFinite(instantMs) ? instantMs : Date.now()
|
|
528
|
+
const stamp = new Date(at)
|
|
529
|
+
const dayStart = Date.UTC(stamp.getUTCFullYear(), stamp.getUTCMonth(), stamp.getUTCDate())
|
|
530
|
+
const zone = isValidTimeZone(timeZone) ? timeZone : 'UTC'
|
|
531
|
+
const clock = (ms) => {
|
|
532
|
+
try {
|
|
533
|
+
return new Intl.DateTimeFormat('en-GB', {
|
|
534
|
+
hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: zone,
|
|
535
|
+
}).format(new Date(ms))
|
|
536
|
+
} catch {
|
|
537
|
+
return new Date(ms).toISOString().slice(11, 16)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return DEEPSEEK_PEAK_UTC_MINUTES
|
|
541
|
+
.map(([start, end]) => `${clock(dayStart + start * 60_000)}–${clock(dayStart + end * 60_000)}`)
|
|
542
|
+
.join(' / ')
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* The peak-window notice the dialog shows, or `null` when it does not apply.
|
|
547
|
+
*
|
|
548
|
+
* One rule, decided in one place: only a DeepSeek model is billed on this
|
|
549
|
+
* clock, and only a chosen instant inside a peak window is worth mentioning.
|
|
550
|
+
* @param options - chosen instant, model id and display zone.
|
|
551
|
+
* @returns `{ windows }` with the windows in the reader's zone, or `null`.
|
|
552
|
+
*/
|
|
553
|
+
function deepSeekPeakNotice({ instantMs, modelName, timeZone } = {}) {
|
|
554
|
+
if (!isDeepSeekModel(modelName)) return null
|
|
555
|
+
if (!isDeepSeekPeak(instantMs)) return null
|
|
556
|
+
return { windows: describePeakWindows(instantMs, timeZone) }
|
|
557
|
+
}
|
|
478
558
|
|
|
479
|
-
return { NS, DOMAIN_NAME, MIN_LEAD_MS, MAX_TEXT_CHARS, MAX_IMAGES, MAX_PENDING, MAX_TITLE_CHARS, MAX_TIMER_DELAY_MS, LATE_THRESHOLD_MS, RETENTION_MS, REPEAT_FREQUENCIES, pad2, toMeridiem, to24Hour, formatClock, parseLocalDate, isValidTimeZone, localParts, zoneOffsetMs, wallClockToInstant, buildRrule, skipsShortMonths, summariseTitle, validateCreateRequest, earliestPending, timerDelay, isLate, pruneTargets, pendingSummary, describeDue, describeZone }
|
|
559
|
+
return { NS, DOMAIN_NAME, MIN_LEAD_MS, MAX_TEXT_CHARS, MAX_IMAGES, MAX_PENDING, MAX_TITLE_CHARS, MAX_TIMER_DELAY_MS, LATE_THRESHOLD_MS, RETENTION_MS, REPEAT_FREQUENCIES, pad2, toMeridiem, to24Hour, formatClock, parseLocalDate, isValidTimeZone, localParts, zoneOffsetMs, wallClockToInstant, buildRrule, skipsShortMonths, summariseTitle, validateCreateRequest, earliestPending, timerDelay, isLate, pruneTargets, pendingSummary, describeDue, describeZone, DEEPSEEK_PEAK_UTC_MINUTES, isDeepSeekPeak, isDeepSeekModel, describePeakWindows, deepSeekPeakNotice }
|
|
480
560
|
})()
|
|
481
561
|
|
|
482
562
|
const EN = {
|
|
@@ -500,6 +580,7 @@ const EN = {
|
|
|
500
580
|
"dialog.meridiem": "AM or PM",
|
|
501
581
|
"dialog.summary": "Sends {time} ({zone})",
|
|
502
582
|
"dialog.pastWarning": "That time has already passed, or is less than 30 seconds away.",
|
|
583
|
+
"dialog.peakHint": "DeepSeek's busy window is {windows} — a send there bills at 2× the off-peak rate. An evening or weekend slot is half price, so it's worth nudging this one later.",
|
|
503
584
|
"dialog.repeat": "Repeat",
|
|
504
585
|
"dialog.repeatHint": "Create an automation task that sends this text again on a rule.",
|
|
505
586
|
"dialog.repeatLocked": "Repeating messages can't include images or files.",
|
|
@@ -583,6 +664,7 @@ const ZH = {
|
|
|
583
664
|
"dialog.meridiem": "上午或下午",
|
|
584
665
|
"dialog.summary": "将于 {time}({zone})发送",
|
|
585
666
|
"dialog.pastWarning": "这个时间已经过去,或距现在不足 30 秒。",
|
|
667
|
+
"dialog.peakHint": "DeepSeek 的高峰时段是 {windows}——此时段的价格是平时的 2 倍;挪到晚上或周末就是半价,晚一点发更划算。",
|
|
586
668
|
"dialog.repeat": "重复",
|
|
587
669
|
"dialog.repeatHint": "创建自动化任务,按规则重复发送这段文字。",
|
|
588
670
|
"dialog.repeatLocked": "重复消息不能包含图片或文件。",
|
|
@@ -749,6 +831,56 @@ function createClientPlugin(require) {
|
|
|
749
831
|
return useSyncExternalStore(subscribeRefresh, () => refreshSeq, () => 0)
|
|
750
832
|
}
|
|
751
833
|
|
|
834
|
+
/**
|
|
835
|
+
* The session's current model id, read from the same shared directory the
|
|
836
|
+
* composer's model seat renders, so the dialog follows a model switch live.
|
|
837
|
+
* @param sessionId - owning conversation, or undefined outside a seat.
|
|
838
|
+
* @returns the model id, or null when the profile has no model selection.
|
|
839
|
+
*/
|
|
840
|
+
/**
|
|
841
|
+
* The Session's durable model selection, read straight from its projection.
|
|
842
|
+
*
|
|
843
|
+
* A fallback for the first paint, or a profile whose model catalog failed to
|
|
844
|
+
* load, where the shared directory has no `current` yet. Every step is
|
|
845
|
+
* optional-chained: an unexpected shape answers null instead of throwing.
|
|
846
|
+
* @param sessionId - owning conversation.
|
|
847
|
+
* @returns the model id, or null when the projection cannot be read.
|
|
848
|
+
*/
|
|
849
|
+
function projectSessionModel(sessionId) {
|
|
850
|
+
try {
|
|
851
|
+
const face = services.sessions?.binding?.(sessionId)?.session?.projections?.faceOf?.('modelSelection')
|
|
852
|
+
const projected = face?.getSnapshot?.()
|
|
853
|
+
const selection = projected?.next ?? projected?.lastUsed ?? null
|
|
854
|
+
return typeof selection?.model === 'string' ? selection.model : null
|
|
855
|
+
} catch {
|
|
856
|
+
return null
|
|
857
|
+
}
|
|
858
|
+
} function useSessionModelName(sessionId) {
|
|
859
|
+
const store = useMemo(() => {
|
|
860
|
+
const resolver = services.modelDirectories
|
|
861
|
+
if (resolver === undefined || sessionId === undefined || typeof resolver.directoryFor !== 'function') return null
|
|
862
|
+
try {
|
|
863
|
+
return resolver.directoryFor(sessionId)?.store ?? null
|
|
864
|
+
} catch {
|
|
865
|
+
return null
|
|
866
|
+
}
|
|
867
|
+
}, [sessionId])
|
|
868
|
+
const subscribe = useCallback(
|
|
869
|
+
(listener) => (store === null ? () => {} : store.subscribe(() => listener())),
|
|
870
|
+
[store],
|
|
871
|
+
)
|
|
872
|
+
const state = useSyncExternalStore(
|
|
873
|
+
subscribe,
|
|
874
|
+
() => (store === null ? null : store.getSnapshot()),
|
|
875
|
+
() => null,
|
|
876
|
+
)
|
|
877
|
+
const selection = state?.current ?? state?.pending ?? null
|
|
878
|
+
if (typeof selection?.model === 'string') return selection.model
|
|
879
|
+
// The directory only reports a selection once the shared catalog has
|
|
880
|
+
// loaded; the Session's own durable projection answers before that.
|
|
881
|
+
return projectSessionModel(sessionId)
|
|
882
|
+
}
|
|
883
|
+
|
|
752
884
|
/** Mounted composers, used by the shortcut to find the focused conversation. */
|
|
753
885
|
const composers = new Map()
|
|
754
886
|
let lastTouchedSession = null
|
|
@@ -851,7 +983,7 @@ function createClientPlugin(require) {
|
|
|
851
983
|
* service the scope never runs and the feature that needs it degrades to a
|
|
852
984
|
* no-op.
|
|
853
985
|
*/
|
|
854
|
-
const services = { sessions: undefined, layout: undefined }
|
|
986
|
+
const services = { sessions: undefined, layout: undefined, modelDirectories: undefined }
|
|
855
987
|
|
|
856
988
|
function errorText(t, error) {
|
|
857
989
|
const code = error?.code
|
|
@@ -914,6 +1046,19 @@ function createClientPlugin(require) {
|
|
|
914
1046
|
h('path', { d: 'm4.5 4.5 7 7m0-7-7 7', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' }))
|
|
915
1047
|
}
|
|
916
1048
|
|
|
1049
|
+
/** Small upward-trend mark for the peak-window notice. */
|
|
1050
|
+
function PeakIcon() {
|
|
1051
|
+
return h('svg', { viewBox: '0 0 16 16', width: 13, height: 13, 'aria-hidden': 'true', focusable: 'false' },
|
|
1052
|
+
h('path', {
|
|
1053
|
+
d: 'M2.4 11.6 6 7.6l2.6 2.2 3.9-5',
|
|
1054
|
+
fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
1055
|
+
}),
|
|
1056
|
+
h('path', {
|
|
1057
|
+
d: 'M10.6 4.4h2.6v2.6',
|
|
1058
|
+
fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
1059
|
+
}))
|
|
1060
|
+
}
|
|
1061
|
+
|
|
917
1062
|
// ---------------------------------------------------------------- dialog
|
|
918
1063
|
|
|
919
1064
|
/**
|
|
@@ -924,7 +1069,7 @@ function createClientPlugin(require) {
|
|
|
924
1069
|
* keep the dialog open with an inline error).
|
|
925
1070
|
*/
|
|
926
1071
|
function ScheduleDialog(props) {
|
|
927
|
-
const { t, zone, mode, initial, repeatLocked, previewText, attachmentCount, onSubmit, onClose } = props
|
|
1072
|
+
const { t, zone, mode, initial, repeatLocked, previewText, attachmentCount, modelName, onSubmit, onClose } = props
|
|
928
1073
|
const locale = currentLocale()
|
|
929
1074
|
const titleId = useId()
|
|
930
1075
|
const panel = useRef(null)
|
|
@@ -941,6 +1086,16 @@ function createClientPlugin(require) {
|
|
|
941
1086
|
|
|
942
1087
|
const quickPicks = useMemo(() => listQuickPicks(t, zone, now), [t, zone, Math.floor(now / 60_000)])
|
|
943
1088
|
|
|
1089
|
+
/** Peak-price notice: DeepSeek models only, and only inside a peak window. */
|
|
1090
|
+
const peak = useMemo(
|
|
1091
|
+
() => core.deepSeekPeakNotice({
|
|
1092
|
+
instantMs: resolved.ok === true ? resolved.instantMs : null,
|
|
1093
|
+
modelName,
|
|
1094
|
+
timeZone: zone,
|
|
1095
|
+
}) ?? null,
|
|
1096
|
+
[resolved, modelName, zone],
|
|
1097
|
+
)
|
|
1098
|
+
|
|
944
1099
|
useEffect(() => {
|
|
945
1100
|
restore.current = typeof document !== 'undefined' ? document.activeElement : null
|
|
946
1101
|
const first = panel.current?.querySelector('button, select, input')
|
|
@@ -1083,6 +1238,11 @@ function createClientPlugin(require) {
|
|
|
1083
1238
|
}, meridiem)))),
|
|
1084
1239
|
|
|
1085
1240
|
h('p', { className: `dsm-summary${tooSoon ? ' dsm-summary-bad' : ''}`, role: 'status' }, summary),
|
|
1241
|
+
peak === null
|
|
1242
|
+
? null
|
|
1243
|
+
: h('p', { className: 'dsm-peak' },
|
|
1244
|
+
h(PeakIcon),
|
|
1245
|
+
h('span', null, t('dialog.peakHint', { windows: peak.windows }))),
|
|
1086
1246
|
|
|
1087
1247
|
mode === 'create'
|
|
1088
1248
|
? h('div', { className: 'dsm-repeat' },
|
|
@@ -1275,6 +1435,7 @@ function createClientPlugin(require) {
|
|
|
1275
1435
|
const attachmentIds = Array.isArray(input?.attachmentIds) ? input.attachmentIds : []
|
|
1276
1436
|
const phase = typeof input?.phase === 'string' ? input.phase : 'plain'
|
|
1277
1437
|
const openSeq = useOpenRequest(sessionId)
|
|
1438
|
+
const modelName = useSessionModelName(sessionId)
|
|
1278
1439
|
const [open, setOpen] = useState(false)
|
|
1279
1440
|
const zone = useMemo(currentZone, [])
|
|
1280
1441
|
|
|
@@ -1324,6 +1485,7 @@ function createClientPlugin(require) {
|
|
|
1324
1485
|
t,
|
|
1325
1486
|
zone,
|
|
1326
1487
|
mode: 'create',
|
|
1488
|
+
modelName,
|
|
1327
1489
|
initial: defaultSelection(zone),
|
|
1328
1490
|
repeatLocked: attachmentIds.length > 0,
|
|
1329
1491
|
previewText: draft,
|
|
@@ -1346,6 +1508,7 @@ function createClientPlugin(require) {
|
|
|
1346
1508
|
const refresh = useRefreshSignal()
|
|
1347
1509
|
const zone = useMemo(currentZone, [])
|
|
1348
1510
|
const locale = useMemo(currentLocale, [])
|
|
1511
|
+
const modelName = useSessionModelName(sessionId)
|
|
1349
1512
|
const [items, setItems] = useState([])
|
|
1350
1513
|
const [failure, setFailure] = useState(null)
|
|
1351
1514
|
const [expanded, setExpanded] = useState(false)
|
|
@@ -1474,6 +1637,7 @@ function createClientPlugin(require) {
|
|
|
1474
1637
|
: defaultSelection(zone),
|
|
1475
1638
|
repeatLocked: true,
|
|
1476
1639
|
previewText: '',
|
|
1640
|
+
modelName,
|
|
1477
1641
|
attachmentCount: 0,
|
|
1478
1642
|
onClose: () => setEditing(null),
|
|
1479
1643
|
onSubmit: async (plan) => {
|
|
@@ -1596,6 +1760,13 @@ function createClientPlugin(require) {
|
|
|
1596
1760
|
.dsm-seg-on{border-color:${ORANGE};color:#fff;background:${ORANGE}}
|
|
1597
1761
|
.dsm-summary{margin:0;font-size:12px;line-height:1.45;color:var(--dsw-alias-label-secondary,#52525b)}
|
|
1598
1762
|
.dsm-summary-bad{color:var(--dsw-alias-state-error-primary,#dc2626)}
|
|
1763
|
+
.dsm-peak{display:flex;align-items:flex-start;gap:6px;margin:0;padding:7px 9px;border-radius:8px;
|
|
1764
|
+
font-size:12px;line-height:1.45;color:#C2410C;background:rgba(249,115,22,.10);
|
|
1765
|
+
border:1px solid rgba(249,115,22,.32)}
|
|
1766
|
+
.dsm-peak svg{flex:none;margin-top:1px}
|
|
1767
|
+
@media (prefers-color-scheme:dark){
|
|
1768
|
+
.dsm-peak{color:#FDBA74;background:rgba(249,115,22,.16);border-color:rgba(253,186,116,.34)}
|
|
1769
|
+
}
|
|
1599
1770
|
.dsm-repeat{display:flex;flex-direction:column;gap:8px;padding-top:4px;border-top:1px solid var(--dsw-alias-border-l1,#f0f0f1)}
|
|
1600
1771
|
.dsm-check{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:600;cursor:pointer;min-height:32px}
|
|
1601
1772
|
.dsm-check-off{opacity:.55;cursor:not-allowed}
|
|
@@ -1698,6 +1869,16 @@ function createClientPlugin(require) {
|
|
|
1698
1869
|
}
|
|
1699
1870
|
})
|
|
1700
1871
|
|
|
1872
|
+
// The composer's model seat and the /model popup share one directory; the
|
|
1873
|
+
// dialog reads that same selection to know whether the DeepSeek price clock
|
|
1874
|
+
// applies. A profile without it simply never shows the notice.
|
|
1875
|
+
ctx.inject(['modelDirectories'], (scope) => {
|
|
1876
|
+
services.modelDirectories = scope.modelDirectories
|
|
1877
|
+
return () => {
|
|
1878
|
+
services.modelDirectories = undefined
|
|
1879
|
+
}
|
|
1880
|
+
})
|
|
1881
|
+
|
|
1701
1882
|
ctx.inject(['shortcuts'], (scope) => {
|
|
1702
1883
|
scope.effect(() => scope.shortcuts.register({
|
|
1703
1884
|
id: SHORTCUT_ID,
|
package/docs/DETAIL.md
CHANGED
|
@@ -257,6 +257,19 @@ overlays, so a portal is required, not cosmetic. It holds:
|
|
|
257
257
|
shorter months are skipped;
|
|
258
258
|
- footer with Cancel and the primary action, which becomes **Create automation** when Repeat is on.
|
|
259
259
|
|
|
260
|
+
**Peak-price notice.** The dialog also reads the Session's model selection —
|
|
261
|
+
`ctx.modelDirectories.directoryFor(sessionId).store`, the per-session directory the composer's model
|
|
262
|
+
seat and the `/model` popup already share — and, when that model id contains `deepseek` *and* the
|
|
263
|
+
chosen instant falls inside DeepSeek's published peak window, adds one orange line naming both
|
|
264
|
+
windows in the reader's zone. The published rule (`api-docs.deepseek.com/quick_start/pricing`, read
|
|
265
|
+
2026-11-14) is: off-peak rates are half the peak rates, peak hours are 01:00–04:00 and 06:00–10:00
|
|
266
|
+
UTC Monday to Friday excluding Chinese public holidays, and every other hour is off-peak. The
|
|
267
|
+
decision is `core.deepSeekPeakNotice` — model family, instant, zone in, notice or `null` out — so it
|
|
268
|
+
is unit-tested without a browser; the seat only renders what it returns. Two honest limits: Chinese
|
|
269
|
+
public holidays are not modelled (the copy speaks about the weekday clock rather than promising a
|
|
270
|
+
price), and a scheduled message carries no model of its own, so the note reflects the model selected
|
|
271
|
+
when the time was chosen.
|
|
272
|
+
|
|
260
273
|
Escape, the backdrop and the header button close it; Tab is trapped; focus returns to whatever was
|
|
261
274
|
focused before it opened. Submitting converts the selection with `core.wallClockToInstant` in the
|
|
262
275
|
browser's zone and sends one absolute instant, so the Host never has to guess a zone.
|
package/lib/core.js
CHANGED
|
@@ -464,3 +464,83 @@ export function describeZone(instantMs, locale, timeZone) {
|
|
|
464
464
|
return timeZone
|
|
465
465
|
}
|
|
466
466
|
}
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------- DeepSeek price clock
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* DeepSeek's peak-price windows as UTC minutes since midnight.
|
|
472
|
+
*
|
|
473
|
+
* DeepSeek documents the rule verbatim: "Off-peak rates are half of the peak
|
|
474
|
+
* rates. Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through
|
|
475
|
+
* Friday, excluding Chinese public holidays. All other hours are off-peak,
|
|
476
|
+
* including weekends and Chinese public holidays in full."
|
|
477
|
+
* (https://api-docs.deepseek.com/quick_start/pricing, read 2026-11-14.)
|
|
478
|
+
*
|
|
479
|
+
* Chinese public holidays are not modelled: the notice speaks about the
|
|
480
|
+
* weekday clock, which is right for every ordinary working week.
|
|
481
|
+
*/
|
|
482
|
+
export const DEEPSEEK_PEAK_UTC_MINUTES = Object.freeze([[60, 240], [360, 600]])
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Whether one instant is billed at DeepSeek's standard (peak) rate.
|
|
486
|
+
* @param instantMs - epoch milliseconds.
|
|
487
|
+
* @returns whether the instant sits inside a weekday peak window.
|
|
488
|
+
*/
|
|
489
|
+
export function isDeepSeekPeak(instantMs) {
|
|
490
|
+
if (!Number.isFinite(instantMs)) return false
|
|
491
|
+
const instant = new Date(instantMs)
|
|
492
|
+
const weekday = instant.getUTCDay()
|
|
493
|
+
if (weekday === 0 || weekday === 6) return false
|
|
494
|
+
const minutes = instant.getUTCHours() * 60 + instant.getUTCMinutes()
|
|
495
|
+
return DEEPSEEK_PEAK_UTC_MINUTES.some(([start, end]) => minutes >= start && minutes < end)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Whether a model id belongs to the DeepSeek family, whose clock is priced.
|
|
500
|
+
* @param modelName - provider model id, e.g. `deepseek-v4.1-flash`.
|
|
501
|
+
* @returns whether the name contains `deepseek`, case-insensitively.
|
|
502
|
+
*/
|
|
503
|
+
export function isDeepSeekModel(modelName) {
|
|
504
|
+
return typeof modelName === 'string' && /deepseek/i.test(modelName)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Render both peak windows in the reader's zone, e.g. `09:00–12:00 / 14:00–18:00`.
|
|
509
|
+
* A zone west of UTC shows a window on the neighbouring date, which is
|
|
510
|
+
* correct: the boundaries are instants, not wall-clock times.
|
|
511
|
+
* @param instantMs - the instant whose UTC date anchors the windows.
|
|
512
|
+
* @param timeZone - explicit IANA zone used for display.
|
|
513
|
+
* @returns the two windows joined by ` / `.
|
|
514
|
+
*/
|
|
515
|
+
export function describePeakWindows(instantMs, timeZone) {
|
|
516
|
+
const at = Number.isFinite(instantMs) ? instantMs : Date.now()
|
|
517
|
+
const stamp = new Date(at)
|
|
518
|
+
const dayStart = Date.UTC(stamp.getUTCFullYear(), stamp.getUTCMonth(), stamp.getUTCDate())
|
|
519
|
+
const zone = isValidTimeZone(timeZone) ? timeZone : 'UTC'
|
|
520
|
+
const clock = (ms) => {
|
|
521
|
+
try {
|
|
522
|
+
return new Intl.DateTimeFormat('en-GB', {
|
|
523
|
+
hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: zone,
|
|
524
|
+
}).format(new Date(ms))
|
|
525
|
+
} catch {
|
|
526
|
+
return new Date(ms).toISOString().slice(11, 16)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return DEEPSEEK_PEAK_UTC_MINUTES
|
|
530
|
+
.map(([start, end]) => `${clock(dayStart + start * 60_000)}–${clock(dayStart + end * 60_000)}`)
|
|
531
|
+
.join(' / ')
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The peak-window notice the dialog shows, or `null` when it does not apply.
|
|
536
|
+
*
|
|
537
|
+
* One rule, decided in one place: only a DeepSeek model is billed on this
|
|
538
|
+
* clock, and only a chosen instant inside a peak window is worth mentioning.
|
|
539
|
+
* @param options - chosen instant, model id and display zone.
|
|
540
|
+
* @returns `{ windows }` with the windows in the reader's zone, or `null`.
|
|
541
|
+
*/
|
|
542
|
+
export function deepSeekPeakNotice({ instantMs, modelName, timeZone } = {}) {
|
|
543
|
+
if (!isDeepSeekModel(modelName)) return null
|
|
544
|
+
if (!isDeepSeekPeak(instantMs)) return null
|
|
545
|
+
return { windows: describePeakWindows(instantMs, timeZone) }
|
|
546
|
+
}
|
package/locale/en.json
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"dialog.minute": "Minute",
|
|
22
22
|
"dialog.meridiem": "AM or PM",
|
|
23
23
|
"dialog.summary": "Sends {time} ({zone})",
|
|
24
|
-
"dialog.pastWarning": "That time has already passed, or is less than 30 seconds away.",
|
|
24
|
+
"dialog.pastWarning": "That time has already passed, or is less than 30 seconds away.",
|
|
25
|
+
"dialog.peakHint": "DeepSeek's busy window is {windows} — a send there bills at 2× the off-peak rate. An evening or weekend slot is half price, so it's worth nudging this one later.",
|
|
25
26
|
"dialog.repeat": "Repeat",
|
|
26
27
|
"dialog.repeatHint": "Create an automation task that sends this text again on a rule.",
|
|
27
28
|
"dialog.repeatLocked": "Repeating messages can't include images or files.",
|
package/locale/zh.json
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"dialog.minute": "分钟",
|
|
22
22
|
"dialog.meridiem": "上午或下午",
|
|
23
23
|
"dialog.summary": "将于 {time}({zone})发送",
|
|
24
|
-
"dialog.pastWarning": "这个时间已经过去,或距现在不足 30 秒。",
|
|
24
|
+
"dialog.pastWarning": "这个时间已经过去,或距现在不足 30 秒。",
|
|
25
|
+
"dialog.peakHint": "DeepSeek 的高峰时段是 {windows}——此时段的价格是平时的 2 倍;挪到晚上或周末就是半价,晚一点发更划算。",
|
|
25
26
|
"dialog.repeat": "重复",
|
|
26
27
|
"dialog.repeatHint": "创建自动化任务,按规则重复发送这段文字。",
|
|
27
28
|
"dialog.repeatLocked": "重复消息不能包含图片或文件。",
|
package/package.json
CHANGED