dsh-session-guard 0.4.0 → 3.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-guard",
3
- "version": "0.4.0",
3
+ "version": "3.0.1",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public",
@@ -78,6 +78,7 @@
78
78
  "peerDependencies": {
79
79
  "@deepseek-ai/cordis": "^4.0.1",
80
80
  "@deepseek-ai/dsh-client-locale": ">=0.1.5-rc.2 <0.2.0-0",
81
+ "@deepseek-ai/dsh-client-ui-renderer": ">=0.1.5-rc.2 <0.2.0-0",
81
82
  "@deepseek-ai/dsh-client-ui-settings": ">=0.1.5-rc.2 <0.2.0-0",
82
83
  "@deepseek-ai/dsh-client-ui-slots": ">=0.1.5-rc.2 <0.2.0-0"
83
84
  },
@@ -88,6 +89,9 @@
88
89
  "@deepseek-ai/dsh-client-locale": {
89
90
  "optional": true
90
91
  },
92
+ "@deepseek-ai/dsh-client-ui-renderer": {
93
+ "optional": true
94
+ },
91
95
  "@deepseek-ai/dsh-client-ui-settings": {
92
96
  "optional": true
93
97
  },
package/src/bridge.js CHANGED
@@ -1,136 +1,136 @@
1
- /**
2
- * dsh-session-guard — sessionGuard 冗余端口(D5/D6/D8)。
3
- *
4
- * `ctx.provide('sessionGuard', service)`:
5
- * - stopNextTurn / resume:会话门(透传 taskControl,或回退锁队列)。
6
- * - lockQueue / unlockQueue:显式队列锁(供 input-traffic 冻结按钮桥接,
7
- * 或未来手动触发)。
8
- * - state(sessionId):{ queueLocked, lockReason, taskControlAvailable, taskControl }。
9
- *
10
- * input-traffic(方案 A,基于 feat/absorb-auto-continue 分支改造)在冻结按钮
11
- * 触发时调用 stopNextTurn;本服务不存在时 input-traffic 静默跳过(fail-open,D8)。
12
- */
13
- import { idleState } from './store.js'
14
-
15
- /**
16
- * @param {object} ctx host context
17
- * @param {ReturnType<import('./gate.js').createGate>} gate
18
- * @param {ReturnType<import('./store.js').createStore>} store
19
- * @param {ReturnType<import('./pause-gate.js').createPauseGate>} [pauseGate]
20
- * @param {ReturnType<import('./step-gate.js').createStepGate>} [stepGate] step 级门控(v0.2.0)
21
- */
22
- export function createBridge(ctx, gate, store, pauseGate, stepGate) {
23
- function taskControlState(sessionId) {
24
- let tcState = null
25
- try {
26
- const tc = ctx && typeof ctx.get === 'function' ? ctx.get('taskControl') : undefined
27
- if (tc && typeof tc.state === 'function') {
28
- const s = tc.state(sessionId)
29
- tcState = {
30
- status: s && s.status,
31
- paused: !!(s && s.paused),
32
- forced: !!(s && s.forced),
33
- }
34
- }
35
- } catch {
36
- /* 状态读取失败 → 保持 null,fail-open */
37
- }
38
- return tcState
39
- }
40
-
41
- return {
42
- /** 停掉 session 下一回合(会话门 / 回退锁队列)。 */
43
- stopNextTurn(sessionId, opts) {
44
- return gate.stopNextTurn(sessionId, opts)
45
- },
46
- /** 恢复。 */
47
- resume(sessionId, opts) {
48
- return gate.resume(sessionId, opts)
49
- },
50
- /** 显式锁队列(手动触发)。 */
51
- lockQueue(sessionId, reason = 'manual') {
52
- const next = gate.lockQueue(sessionId, reason)
53
- return { ok: true, state: next }
54
- },
55
- /** 显式解锁队列。 */
56
- unlockQueue(sessionId) {
57
- const next = gate.unlockQueue(sessionId)
58
- return { ok: true, state: next }
59
- },
60
- /**
61
- * 手动请求 step 级暂停(「暂停会话」按钮)。
62
- * 不打断当前 step:在下一次 `agent/pre-step` 边界拉门(step 1 也拦,且不受峰谷/provider 限制)。
63
- * @param {string} sessionId
64
- */
65
- stepPause(sessionId) {
66
- if (!stepGate || typeof stepGate.requestPause !== 'function') {
67
- return { requested: false, held: false, reason: 'no-step-gate' }
68
- }
69
- try {
70
- return stepGate.requestPause(sessionId)
71
- } catch {
72
- return { requested: false, held: false, reason: 'error' }
73
- }
74
- },
75
- /**
76
- * 解开 step 门(v0.2.0):放行被挂起的 step。
77
- * 默认 `bypass=true`(用户手动「继续」)——本高峰窗口内不再拦该会话;
78
- * 退峰/非官方切换等自动释放请用 `{ bypass: false }`。
79
- * @param {string} sessionId
80
- * @param {{bypass?:boolean, reason?:string}} [opts]
81
- */
82
- stepResume(sessionId, opts = {}) {
83
- if (!stepGate || typeof stepGate.release !== 'function') {
84
- return { released: false, bypass: false, reason: 'no-step-gate' }
85
- }
86
- const reason = opts.reason ?? 'manual'
87
- let r
88
- try {
89
- r = stepGate.release(sessionId, reason)
90
- } catch {
91
- return { released: false, bypass: false, reason }
92
- }
93
- const released = !!(r && r.released === true)
94
- let bypassed = false
95
- if (released && opts.bypass !== false && typeof stepGate.markBypass === 'function') {
96
- try {
97
- stepGate.markBypass(sessionId)
98
- bypassed = true
99
- } catch {
100
- /* ignore */
101
- }
102
- }
103
- return { released, bypass: bypassed, reason }
104
- },
105
- /** 读一会话状态。 */
106
- state(sessionId) {
107
- const cur = store.get(sessionId)
108
- const base = cur || idleState(sessionId)
109
- const pausedState = pauseGate ? (pauseGate.state(sessionId) || {}) : {}
110
- let stepState = {}
111
- if (stepGate && typeof stepGate.state === 'function') {
112
- try {
113
- stepState = stepGate.state(sessionId) || {}
114
- } catch {
115
- stepState = {}
116
- }
117
- }
118
- return {
119
- sessionId: String(sessionId),
120
- queueLocked: base.queueLocked === true,
121
- lockReason: base.lockReason ?? null,
122
- // 自研会话门真暂停状态(脱离 task-control)。
123
- paused: pausedState.paused === true,
124
- pausedForced: pausedState.forced === true,
125
- // step 级门控(v0.2.0):paused 保持布尔以兼容既有消费者,step 态用独立字段。
126
- pausedStep: stepState.held === true,
127
- stepHeldSince: stepState.since ?? null,
128
- stepBypass: stepState.bypass === true,
129
- stepManual: stepState.manual === true,
130
- taskControlAvailable: gate.taskControlAvailable(),
131
- taskControl: taskControlState(sessionId),
132
- updatedAt: base.updatedAt ?? null,
133
- }
134
- },
135
- }
136
- }
1
+ /**
2
+ * dsh-session-guard — sessionGuard 冗余端口(D5/D6/D8)。
3
+ *
4
+ * `ctx.provide('sessionGuard', service)`:
5
+ * - stopNextTurn / resume:会话门(透传 taskControl,或回退锁队列)。
6
+ * - lockQueue / unlockQueue:显式队列锁(供 input-traffic 冻结按钮桥接,
7
+ * 或未来手动触发)。
8
+ * - state(sessionId):{ queueLocked, lockReason, taskControlAvailable, taskControl }。
9
+ *
10
+ * input-traffic(方案 A,基于 feat/absorb-auto-continue 分支改造)在冻结按钮
11
+ * 触发时调用 stopNextTurn;本服务不存在时 input-traffic 静默跳过(fail-open,D8)。
12
+ */
13
+ import { idleState } from './store.js'
14
+
15
+ /**
16
+ * @param {object} ctx host context
17
+ * @param {ReturnType<import('./gate.js').createGate>} gate
18
+ * @param {ReturnType<import('./store.js').createStore>} store
19
+ * @param {ReturnType<import('./pause-gate.js').createPauseGate>} [pauseGate]
20
+ * @param {ReturnType<import('./step-gate.js').createStepGate>} [stepGate] step 级门控(v0.2.0)
21
+ */
22
+ export function createBridge(ctx, gate, store, pauseGate, stepGate) {
23
+ function taskControlState(sessionId) {
24
+ let tcState = null
25
+ try {
26
+ const tc = ctx && typeof ctx.get === 'function' ? ctx.get('taskControl') : undefined
27
+ if (tc && typeof tc.state === 'function') {
28
+ const s = tc.state(sessionId)
29
+ tcState = {
30
+ status: s && s.status,
31
+ paused: !!(s && s.paused),
32
+ forced: !!(s && s.forced),
33
+ }
34
+ }
35
+ } catch {
36
+ /* 状态读取失败 → 保持 null,fail-open */
37
+ }
38
+ return tcState
39
+ }
40
+
41
+ return {
42
+ /** 停掉 session 下一回合(会话门 / 回退锁队列)。 */
43
+ stopNextTurn(sessionId, opts) {
44
+ return gate.stopNextTurn(sessionId, opts)
45
+ },
46
+ /** 恢复。 */
47
+ resume(sessionId, opts) {
48
+ return gate.resume(sessionId, opts)
49
+ },
50
+ /** 显式锁队列(手动触发)。 */
51
+ lockQueue(sessionId, reason = 'manual') {
52
+ const next = gate.lockQueue(sessionId, reason)
53
+ return { ok: true, state: next }
54
+ },
55
+ /** 显式解锁队列。 */
56
+ unlockQueue(sessionId) {
57
+ const next = gate.unlockQueue(sessionId)
58
+ return { ok: true, state: next }
59
+ },
60
+ /**
61
+ * 手动请求 step 级暂停(「暂停会话」按钮)。
62
+ * 不打断当前 step:在下一次 `agent/pre-step` 边界拉门(step 1 也拦,且不受峰谷/provider 限制)。
63
+ * @param {string} sessionId
64
+ */
65
+ stepPause(sessionId) {
66
+ if (!stepGate || typeof stepGate.requestPause !== 'function') {
67
+ return { requested: false, held: false, reason: 'no-step-gate' }
68
+ }
69
+ try {
70
+ return stepGate.requestPause(sessionId)
71
+ } catch {
72
+ return { requested: false, held: false, reason: 'error' }
73
+ }
74
+ },
75
+ /**
76
+ * 解开 step 门(v0.2.0):放行被挂起的 step。
77
+ * 默认 `bypass=true`(用户手动「继续」)——本高峰窗口内不再拦该会话;
78
+ * 退峰/非官方切换等自动释放请用 `{ bypass: false }`。
79
+ * @param {string} sessionId
80
+ * @param {{bypass?:boolean, reason?:string}} [opts]
81
+ */
82
+ stepResume(sessionId, opts = {}) {
83
+ if (!stepGate || typeof stepGate.release !== 'function') {
84
+ return { released: false, bypass: false, reason: 'no-step-gate' }
85
+ }
86
+ const reason = opts.reason ?? 'manual'
87
+ let r
88
+ try {
89
+ r = stepGate.release(sessionId, reason)
90
+ } catch {
91
+ return { released: false, bypass: false, reason }
92
+ }
93
+ const released = !!(r && r.released === true)
94
+ let bypassed = false
95
+ if (released && opts.bypass !== false && typeof stepGate.markBypass === 'function') {
96
+ try {
97
+ stepGate.markBypass(sessionId)
98
+ bypassed = true
99
+ } catch {
100
+ /* ignore */
101
+ }
102
+ }
103
+ return { released, bypass: bypassed, reason }
104
+ },
105
+ /** 读一会话状态。 */
106
+ state(sessionId) {
107
+ const cur = store.get(sessionId)
108
+ const base = cur || idleState(sessionId)
109
+ const pausedState = pauseGate ? (pauseGate.state(sessionId) || {}) : {}
110
+ let stepState = {}
111
+ if (stepGate && typeof stepGate.state === 'function') {
112
+ try {
113
+ stepState = stepGate.state(sessionId) || {}
114
+ } catch {
115
+ stepState = {}
116
+ }
117
+ }
118
+ return {
119
+ sessionId: String(sessionId),
120
+ queueLocked: base.queueLocked === true,
121
+ lockReason: base.lockReason ?? null,
122
+ // 自研会话门真暂停状态(脱离 task-control)。
123
+ paused: pausedState.paused === true,
124
+ pausedForced: pausedState.forced === true,
125
+ // step 级门控(v0.2.0):paused 保持布尔以兼容既有消费者,step 态用独立字段。
126
+ pausedStep: stepState.held === true,
127
+ stepHeldSince: stepState.since ?? null,
128
+ stepBypass: stepState.bypass === true,
129
+ stepManual: stepState.manual === true,
130
+ taskControlAvailable: gate.taskControlAvailable(),
131
+ taskControl: taskControlState(sessionId),
132
+ updatedAt: base.updatedAt ?? null,
133
+ }
134
+ },
135
+ }
136
+ }
@@ -1,66 +1,66 @@
1
- /**
2
- * dsh-session-guard — 浏览器 half。
3
- *
4
- * 职责(全部 fail-open,D8):
5
- * - 在 composer 输入区右侧注册一个**纯展示**状态徽标(高峰/谷时/周末),轮询
6
- * /session-guard/status;
7
- * - 注册 `settings.plugin.item` 设置卡片,经 `settingsScope.bind({ namespace })`
8
- * 绑定 host 已注册的 `session-guard` 命名空间——这正是“插件配置”面板显示本
9
- * 插件的**必要**机制(对齐 dsh-thinking-levels / dsh-context);
10
- * - **不做**冻结/会话动作——冻结按钮由 input-traffic 接管并经 /session-guard/rpc
11
- * 桥接 host 会话门;本插件客户端不注册任何按钮,避免与 input-traffic 冲突。
12
- *
13
- * 构建:tsdown → lib/client.js(__ModuleLoader__.load 注册,与 input-traffic 同构)。
14
- */
15
- import { StatusBadge } from './status-badge'
16
- import { SessionGuardCard } from './settings-card'
17
- import { PauseButton } from './pause-button'
18
-
19
- /** 客户端所需服务:slots(状态徽标 + 设置卡片)+ locale + settingsScope(设置卡片绑定)。 */
20
- export const inject = ['slots', 'locale', 'settingsScope']
21
-
22
- /** 轻量 ctx 类型(仅本客户端用到的方法;构建时类型被剥离)。 */
23
- interface SlotsFace {
24
- inject: (_name: string, _fn: () => unknown) => () => void
25
- register: (_options: Record<string, unknown>, _component: unknown) => () => void
26
- }
27
- interface SettingsScopeFace {
28
- bind: (_spec: { namespace: string }) => unknown
29
- }
30
- interface ClientCtx {
31
- slots: SlotsFace
32
- settingsScope: SettingsScopeFace
33
- }
34
-
35
- /** host 侧 src/settings.js 注册的命名空间(保持一致)。 */
36
- const NS = 'session-guard'
37
-
38
- export function apply(ctx: ClientCtx) {
39
- // 暂停会话按钮(v0.2.0):step 级门控的状态显示 + 手动解除。
40
- // order 20 < input-traffic 冻结按钮的 30 → 排在左侧,两者并列互不取代(ADR-001/002)。
41
- ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
42
- name: 'conversation.input.right',
43
- id: 'session-guard-pause',
44
- order: 20,
45
- locale: 'session-guard',
46
- }, PauseButton))
47
-
48
- // 状态徽标:输入区右侧,纯展示。
49
- ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
50
- name: 'conversation.input.right',
51
- id: 'session-guard-status',
52
- order: 40,
53
- locale: 'session-guard',
54
- }, StatusBadge))
55
-
56
- // 插件配置卡片:绑定 session-guard 命名空间,经 settingsScope 渲染并提交。
57
- ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
58
- name: 'settings.plugin.item',
59
- id: NS,
60
- key: NS,
61
- locale: 'session-guard',
62
- inject: () => ({
63
- scope: ctx.settingsScope.bind({ namespace: NS }),
64
- }),
65
- }, SessionGuardCard))
66
- }
1
+ /**
2
+ * dsh-session-guard — 浏览器 half。
3
+ *
4
+ * 职责(全部 fail-open,D8):
5
+ * - 在 composer 输入区右侧注册一个**纯展示**状态徽标(高峰/谷时/周末),轮询
6
+ * /session-guard/status;
7
+ * - 注册 `settings.plugin.item` 设置卡片,经 `settingsScope.bind({ namespace })`
8
+ * 绑定 host 已注册的 `session-guard` 命名空间——这正是“插件配置”面板显示本
9
+ * 插件的**必要**机制(对齐 dsh-thinking-levels / dsh-context);
10
+ * - **不做**冻结/会话动作——冻结按钮由 input-traffic 接管并经 /session-guard/rpc
11
+ * 桥接 host 会话门;本插件客户端不注册任何按钮,避免与 input-traffic 冲突。
12
+ *
13
+ * 构建:tsdown → lib/client.js(__ModuleLoader__.load 注册,与 input-traffic 同构)。
14
+ */
15
+ import { StatusBadge } from './status-badge'
16
+ import { SessionGuardCard } from './settings-card'
17
+ import { PauseButton } from './pause-button'
18
+
19
+ /** 客户端所需服务:slots(状态徽标 + 设置卡片)+ locale + settingsScope(设置卡片绑定)。 */
20
+ export const inject = ['slots', 'locale', 'settingsScope']
21
+
22
+ /** 轻量 ctx 类型(仅本客户端用到的方法;构建时类型被剥离)。 */
23
+ interface SlotsFace {
24
+ inject: (_name: string, _fn: () => unknown) => () => void
25
+ register: (_options: Record<string, unknown>, _component: unknown) => () => void
26
+ }
27
+ interface SettingsScopeFace {
28
+ bind: (_spec: { namespace: string }) => unknown
29
+ }
30
+ interface ClientCtx {
31
+ slots: SlotsFace
32
+ settingsScope: SettingsScopeFace
33
+ }
34
+
35
+ /** host 侧 src/settings.js 注册的命名空间(保持一致)。 */
36
+ const NS = 'session-guard'
37
+
38
+ export function apply(ctx: ClientCtx) {
39
+ // 暂停会话按钮(v0.2.0):step 级门控的状态显示 + 手动解除。
40
+ // order 20 < input-traffic 冻结按钮的 30 → 排在左侧,两者并列互不取代(ADR-001/002)。
41
+ ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
42
+ name: 'conversation.input.right',
43
+ id: 'session-guard-pause',
44
+ order: 20,
45
+ locale: 'session-guard',
46
+ }, PauseButton))
47
+
48
+ // 状态徽标:输入区右侧,纯展示。
49
+ ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
50
+ name: 'conversation.input.right',
51
+ id: 'session-guard-status',
52
+ order: 40,
53
+ locale: 'session-guard',
54
+ }, StatusBadge))
55
+
56
+ // 插件配置卡片:绑定 session-guard 命名空间,经 settingsScope 渲染并提交。
57
+ ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
58
+ name: 'settings.plugin.item',
59
+ id: NS,
60
+ key: NS,
61
+ locale: 'session-guard',
62
+ inject: () => ({
63
+ scope: ctx.settingsScope.bind({ namespace: NS }),
64
+ }),
65
+ }, SessionGuardCard))
66
+ }
@@ -1,60 +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
- }
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
+ }