dsh-session-guard 0.1.2 → 0.2.0-beta.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.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * dsh-session-guard — 暂停/继续按钮文案与状态投影(纯函数,零依赖,可单测)。
3
+ *
4
+ * 交互(v0.2.0 修订):
5
+ * - 未暂停 → 「暂停会话」(**可点**:点击 = 在下一次 step 边界手动暂停该会话);
6
+ * - 已暂停(step 门已拉 / 已请求但未到边界)→ 「继续会话」(点击 = 放行并本峰不再拦)。
7
+ * 按钮不置灰:step 暂停虽然会自动发生,但按钮同时是**手动暂停**入口。
8
+ */
9
+
10
+ /** 按钮状态。 */
11
+ export interface PauseButtonState {
12
+ /** 是否处于暂停态(step 门已挂起,或已请求暂停但还没到边界) */
13
+ paused: boolean
14
+ /** 挂起起始时间戳(毫秒);未知为 null */
15
+ heldSince?: number | null
16
+ /** 暂停是否来自用户手动请求(而非高峰自动拉门) */
17
+ manual?: boolean
18
+ }
19
+
20
+ /** 按钮文案。 */
21
+ export function pauseButtonLabel(state: PauseButtonState): string {
22
+ return state.paused === true ? '继续会话' : '暂停会话'
23
+ }
24
+
25
+ /** 悬浮说明:写清当前状态与点击后的语义。 */
26
+ export function pauseButtonTitle(state: PauseButtonState): string {
27
+ if (state.paused !== true) {
28
+ return '暂停会话:在下一次 step 的模型请求前暂停(高峰 + 官方源时会自动暂停)'
29
+ }
30
+ const since = typeof state.heldSince === 'number' && Number.isFinite(state.heldSince) ? new Date(state.heldSince) : null
31
+ const at = since === null ? null : `${String(since.getHours()).padStart(2, '0')}:${String(since.getMinutes()).padStart(2, '0')}`
32
+ const why = state.manual === true ? '手动暂停' : '高峰自动暂停'
33
+ const base = at === null ? `已暂停(${why})` : `已暂停(${why},自 ${at})`
34
+ return `${base} · 点击继续:放行当前 step,且本高峰内不再拦该会话`
35
+ }
36
+
37
+ /** 把 `/session-guard/state` 的响应体投影为按钮状态(形状异常 → 未暂停,fail-open)。 */
38
+ export function readPauseState(body: unknown): PauseButtonState {
39
+ const b = (body ?? {}) as {
40
+ ok?: unknown
41
+ paused?: { step?: unknown; manual?: unknown }
42
+ stepGate?: { since?: unknown; manual?: unknown }
43
+ state?: { pausedStep?: unknown; stepHeldSince?: unknown; stepManual?: unknown }
44
+ }
45
+ if (b.ok !== true) return { paused: false, heldSince: null, manual: false }
46
+ const manual = b.paused?.manual === true || b.stepGate?.manual === true || b.state?.stepManual === true
47
+ const paused = b.paused?.step === true || b.state?.pausedStep === true || manual
48
+ const raw = b.stepGate?.since ?? b.state?.stepHeldSince ?? null
49
+ const heldSince = typeof raw === 'number' && Number.isFinite(raw) ? raw : null
50
+ return { paused, heldSince, manual }
51
+ }
52
+
53
+ /** 把 SSE `/session-guard/events` 的 `state` 快照投影为按钮状态。 */
54
+ export function readStepSnapshot(snapshot: unknown): PauseButtonState {
55
+ const s = (snapshot ?? {}) as { held?: unknown; since?: unknown; manual?: unknown }
56
+ const manual = s.manual === true
57
+ const paused = s.held === true || manual
58
+ const heldSince = typeof s.since === 'number' && Number.isFinite(s.since) ? s.since : null
59
+ return { paused, heldSince, manual }
60
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * dsh-session-guard — 「暂停会话 / 继续会话」按钮(`conversation.input.right`,order 20)。
3
+ *
4
+ * 交互(v0.2.0 修订):
5
+ * - 未暂停 → 「暂停会话」,**可点**:点击 POST `{action:'stepPause'}`,在**下一次 step 边界**暂停
6
+ * (不打断当前 step;step 1 也拦,不受峰谷 / provider 限制);
7
+ * - 已暂停(高峰自动拉门 **或** 手动请求已登记)→ 「继续会话」,点击 POST `{action:'stepResume'}`;
8
+ * - 状态更新双通道:① SSE `/session-guard/events?session=<id>` 即时推送;② 10s 轮询兜底
9
+ * (SSE 不可用 / 断线时仍能收敛)。全部 fail-open,绝不抛错。
10
+ *
11
+ * 与 input-traffic 的「❄ 冻结追加 / 恢复追加」并列(order 30 在其右侧),互不取代:
12
+ * 本按钮控 step 门,冻结按钮控回合级冻结 + 队列摘除。
13
+ */
14
+ import { useEffect, useState } from 'react'
15
+ import {
16
+ pauseButtonLabel,
17
+ pauseButtonTitle,
18
+ readPauseState,
19
+ readStepSnapshot,
20
+ type PauseButtonState,
21
+ } from './pause-button-text'
22
+ import { injectClientCss } from './styles'
23
+
24
+ /** slot 运行时注入的会话级 props。 */
25
+ export interface PauseButtonProps {
26
+ sessionId?: string
27
+ }
28
+
29
+ /** SSE 不可用时的兜底轮询间隔(正常路径由事件驱动,几乎不触发)。 */
30
+ const POLL_MS = 10_000
31
+
32
+ const IDLE: PauseButtonState = { paused: false, heldSince: null, manual: false }
33
+
34
+ /** 暂停 / 继续会话按钮。 */
35
+ export function PauseButton({ sessionId }: PauseButtonProps) {
36
+ const [state, setState] = useState<PauseButtonState>(IDLE)
37
+ const [busy, setBusy] = useState(false)
38
+
39
+ injectClientCss()
40
+
41
+ useEffect(() => {
42
+ if (sessionId === undefined || sessionId === '') return
43
+ let cancelled = false
44
+
45
+ const poll = async (): Promise<void> => {
46
+ try {
47
+ const res = await fetch(`/session-guard/state?session=${encodeURIComponent(sessionId)}`)
48
+ const body = await res.json().catch(() => null)
49
+ if (cancelled) return
50
+ setState(readPauseState(body))
51
+ } catch {
52
+ // fail-open:路由不存在 / 网络失败 → 保持上一次状态。
53
+ }
54
+ }
55
+ void poll()
56
+ const timer = setInterval(() => {
57
+ void poll()
58
+ }, POLL_MS)
59
+
60
+ let source: EventSource | undefined
61
+ try {
62
+ source = new EventSource(`/session-guard/events?session=${encodeURIComponent(sessionId)}`)
63
+ source.onmessage = (ev: MessageEvent<string>) => {
64
+ try {
65
+ const msg = JSON.parse(ev.data) as { type?: string; state?: unknown }
66
+ if (cancelled || msg?.type !== 'step') return
67
+ setState(readStepSnapshot(msg.state))
68
+ } catch {
69
+ /* 非法负载:忽略,等下一次推送或轮询 */
70
+ }
71
+ }
72
+ // onerror:EventSource 会自行重连;轮询同时兜底。
73
+ source.onerror = () => undefined
74
+ } catch {
75
+ // 浏览器不支持 EventSource → 只靠轮询。
76
+ }
77
+
78
+ return () => {
79
+ cancelled = true
80
+ clearInterval(timer)
81
+ try {
82
+ source?.close()
83
+ } catch {
84
+ /* ignore */
85
+ }
86
+ }
87
+ }, [sessionId])
88
+
89
+ if (sessionId === undefined || sessionId === '') return null
90
+
91
+ const paused = state.paused === true
92
+ const onClick = async (): Promise<void> => {
93
+ if (busy) return
94
+ setBusy(true)
95
+ try {
96
+ const action = paused ? 'stepResume' : 'stepPause'
97
+ const res = await fetch('/session-guard/rpc', {
98
+ method: 'POST',
99
+ headers: { 'content-type': 'application/json' },
100
+ body: JSON.stringify({ sessionId, action }),
101
+ })
102
+ const body = await res.json().catch(() => null)
103
+ if (body?.ok === true) {
104
+ // 立即乐观更新;SSE / 轮询随后校正。
105
+ setState(paused ? IDLE : { paused: true, heldSince: null, manual: true })
106
+ }
107
+ } catch {
108
+ // fail-open:下一轮轮询会纠正显示。
109
+ } finally {
110
+ setBusy(false)
111
+ }
112
+ }
113
+
114
+ return (
115
+ <button
116
+ type="button"
117
+ className="sg-pause"
118
+ disabled={busy}
119
+ aria-pressed={paused || undefined}
120
+ title={pauseButtonTitle(state)}
121
+ data-sg-step-paused={paused ? 'on' : 'off'}
122
+ onClick={() => {
123
+ void onClick()
124
+ }}
125
+ >
126
+ {pauseButtonLabel(state)}
127
+ </button>
128
+ )
129
+ }