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.
@@ -1,191 +1,391 @@
1
- /**
2
- * dsh-session-guard — 插件配置卡片(settings.plugin.item 面)。
3
- *
4
- * 对齐 dsh-thinking-levels / dsh-tidychat 的卡片语法:一个可展开的 `<li>`,
5
- * header 按钮(插件名 + 描述 + chevron)切换字段体;开关为 pill switch
6
- * (track + thumb),不是复选框对勾。
7
- *
8
- * 通过 `settingsScope.bind({ namespace: NS })` 绑定 host 已注册的
9
- * `session-guard` 命名空间;每次变更立即经 scope 提交(无 staged form)。
10
- * 仅依赖 react;CSS 经 `<style data-plugin-css>` 注入一次,控件为原生 HTML,
11
- * 客户端 bundle 无 value-import @deepseek-ai/* 平台包(类型导入被构建擦除)。
12
- */
13
- import { useState, useSyncExternalStore } from 'react'
14
- import type { JSX } from 'react'
15
- import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
16
-
17
- /** 命名空间值形状(与 host 的 DEFAULT_SETTINGS 对应)。 */
18
- export interface SessionGuardConfig {
19
- enabled: boolean
20
- offPeakAutoResume: boolean
21
- weekendMode: boolean
22
- queueFallback: boolean
23
- retryEnabled: boolean
24
- timezone: string
25
- pauseMode: 'safe' | 'force'
26
- pauseReason: 'wait' | 'stop'
27
- }
28
-
29
- /** 注入给卡片的面:绑定到 session-guard 命名空间的 settings scope。 */
30
- export interface SettingsCardInjected {
31
- scope: SettingsScope<SessionGuardConfig>
32
- }
33
-
34
- export type SettingsCardProps = SettingsCardInjected
35
-
36
- /** 卡片样式,注入一次(保持 bundle CSS-free)。 */
37
- const CARD_CSS = `
38
- .sgCard{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}
39
- .sgCard:hover{border-color:var(--dsw-alias-label-dimmed)}
40
- .sgCard-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
41
- .sgCardHeader{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:transparent;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
42
- .sgCardHeadtext{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
43
- .sgCardName{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}
44
- .sgCardDesc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
45
- .sgCardChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}
46
- .sgCardChevron-open{transform:rotate(180deg)}
47
- .sgCardBody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:4px 0 12px}
48
- .sgRow{border-bottom:1px solid var(--dsw-alias-border-l2);align-items:center;gap:8px;padding:16px 0;display:flex}
49
- .sgRow:last-child{border-bottom:0}
50
- .sgRowText{flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px;display:flex}
51
- .sgTitle{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}
52
- .sgDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}
53
- .sgSwitch{position:relative;width:40px;height:22px;flex:none}
54
- .sgSwitch>input{position:absolute;inset:0;width:100%;height:100%;opacity:0;margin:0;cursor:pointer}
55
- .sgSwitch>input:disabled{cursor:not-allowed}
56
- .sgSwitchTrack{position:absolute;inset:0;border-radius:22px;background:var(--dsw-alias-interactive-bg-hover);transition:background .16s}
57
- .sgSwitch>input:checked+.sgSwitchTrack{background:var(--dsw-alias-button-primary-fill)}
58
- .sgSwitchThumb{position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .16s}
59
- .sgSwitch>input:checked~.sgSwitchThumb{transform:translateX(18px)}
60
- .sgReadonly{color:var(--dsw-alias-label-tertiary);font-size:12px;margin:8px 0 0}
61
- `
62
-
63
- /** 注入一次卡片样式。 */
64
- function injectCss(): void {
65
- if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="session-guard-card"]') === null) {
66
- const tag = document.createElement('style')
67
- tag.dataset.plugin = 'dsh-session-guard'
68
- tag.dataset.pluginCss = 'session-guard-card'
69
- tag.textContent = CARD_CSS
70
- document.head.appendChild(tag)
71
- }
72
- }
73
-
74
- /** 一行 pill switch(滑块开关,绑定 scope)。 */
75
- function SwitchRow(props: {
76
- label: string
77
- description?: string
78
- checked: boolean
79
- disabled: boolean
80
- onChange: (_next: boolean) => void
81
- }): JSX.Element {
82
- const { label, description, checked, disabled, onChange } = props
83
- return (
84
- <div className="sgRow">
85
- <div className="sgRowText">
86
- <div className="sgTitle">{label}</div>
87
- {description !== undefined && <div className="sgDesc">{description}</div>}
88
- </div>
89
- <label className="sgSwitch">
90
- <input
91
- type="checkbox"
92
- checked={checked}
93
- disabled={disabled}
94
- onChange={(e) => onChange(e.currentTarget.checked)}
95
- />
96
- <span className="sgSwitchTrack" />
97
- <span className="sgSwitchThumb" />
98
- </label>
99
- </div>
100
- )
101
- }
102
-
103
- /** 插件配置卡片主体:可展开的 <li> + header 按钮 + pill switch 字段体。 */
104
- export function SessionGuardCard({ scope }: SettingsCardProps): JSX.Element {
105
- const snapshot = useSyncExternalStore(
106
- (listener) => scope.subscribe(listener),
107
- () => scope.getSnapshot(),
108
- )
109
- const unavailable = snapshot.status === 'unavailable'
110
- const readonly = unavailable || !snapshot.writable
111
- const value = (snapshot.value ?? {}) as Partial<SessionGuardConfig>
112
- const [open, setOpen] = useState(false)
113
-
114
- injectCss()
115
-
116
- const toggle = (field: keyof SessionGuardConfig, next: boolean) => { void scope.set(field, next) }
117
-
118
- return (
119
- <li className={'sgCard' + (open ? ' sgCard-open' : '')}>
120
- <button
121
- type="button"
122
- className="sgCardHeader"
123
- aria-expanded={open}
124
- onClick={() => setOpen(!open)}
125
- >
126
- <span className="sgCardHeadtext">
127
- <span className="sgCardName">会话守护门禁</span>
128
- <span className="sgCardDesc">高峰自动暂停运行会话,周末模式无视峰谷畅快跑</span>
129
- </span>
130
- <svg
131
- className={'sgCardChevron' + (open ? ' sgCardChevron-open' : '')}
132
- viewBox="0 0 14 14"
133
- width={14}
134
- height={14}
135
- fill="none"
136
- aria-hidden="true"
137
- >
138
- <path d="M3.5 5.5L7 9l3.5-3.5" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
139
- </svg>
140
- </button>
141
- {open && (
142
- <div className="sgCardBody">
143
- {unavailable ? (
144
- <p style={{ margin: '0', padding: '12px 0', fontSize: '13px', color: 'var(--dsw-alias-label-tertiary)' }}>
145
- 设置命名空间不可用:请确认 dsh-session-guard 已装配进此 profile。
146
- </p>
147
- ) : (
148
- <>
149
- <SwitchRow
150
- label="高峰自动暂停冻结会话"
151
- description="高峰时段自动暂停运行会话"
152
- checked={value.enabled ?? true}
153
- disabled={readonly}
154
- onChange={(next) => toggle('enabled', next)}
155
- />
156
- <SwitchRow
157
- label="低谷自动恢复"
158
- description="低峰时段自动恢复被暂停的会话"
159
- checked={value.offPeakAutoResume ?? true}
160
- disabled={readonly}
161
- onChange={(next) => toggle('offPeakAutoResume', next)}
162
- />
163
- <SwitchRow
164
- label="周末模式"
165
- description="识别周末,无视峰谷畅快跑"
166
- checked={value.weekendMode ?? true}
167
- disabled={readonly}
168
- onChange={(next) => toggle('weekendMode', next)}
169
- />
170
- <SwitchRow
171
- label="回退锁队列"
172
- description="无会话门时锁等待队列"
173
- checked={value.queueFallback ?? true}
174
- disabled={readonly}
175
- onChange={(next) => toggle('queueFallback', next)}
176
- />
177
- <SwitchRow
178
- label="自动重试"
179
- description="后端重试,默认关(保守)"
180
- checked={value.retryEnabled ?? false}
181
- disabled={readonly}
182
- onChange={(next) => toggle('retryEnabled', next)}
183
- />
184
- {!snapshot.writable && <p className="sgReadonly">当前只读,无法修改。</p>}
185
- </>
186
- )}
187
- </div>
188
- )}
189
- </li>
190
- )
191
- }
1
+ /**
2
+ * dsh-session-guard — 插件配置卡片(settings.plugin.item 面)。
3
+ *
4
+ * 对齐 dsh-thinking-levels / dsh-tidychat 的卡片语法:一个可展开的 `<li>`,
5
+ * header 按钮(插件名 + 描述 + chevron)切换字段体;开关为 pill switch
6
+ * (track + thumb),不是复选框对勾。
7
+ *
8
+ * 通过 `settingsScope.bind({ namespace: NS })` 绑定 host 已注册的
9
+ * `session-guard` 命名空间;每次变更立即经 scope 提交(无 staged form)。
10
+ * 仅依赖 react;CSS 经 `<style data-plugin-css>` 注入一次,控件为原生 HTML,
11
+ * 客户端 bundle 无 value-import @deepseek-ai/* 平台包(类型导入被构建擦除)。
12
+ */
13
+ import { useEffect, useState, useSyncExternalStore } from 'react'
14
+ import type { JSX } from 'react'
15
+ import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
16
+
17
+ /** 命名空间值形状(与 host 的 DEFAULT_SETTINGS 对应)。 */
18
+ export interface SessionGuardConfig {
19
+ enabled: boolean
20
+ offPeakAutoResume: boolean
21
+ weekendMode: boolean
22
+ queueFallback: boolean
23
+ retryEnabled: boolean
24
+ timezone: string
25
+ pauseMode: 'safe' | 'force'
26
+ pauseReason: 'wait' | 'stop'
27
+ // ── step 级门控(v0.2.0)──
28
+ stepLevelPause: boolean
29
+ stepGateTimeoutMs: number
30
+ // ── 官方 provider 二维判定(高峰 × 目标源)──
31
+ providerGuard: boolean
32
+ officialProviders: string[]
33
+ officialBaseURLs: string[]
34
+ deferredResume: boolean
35
+ deferredResumeText: string
36
+ deferredMode: 'hold' | 'error'
37
+ deferredMaxHoldMs: number
38
+ guardSubagents: boolean
39
+ }
40
+
41
+ /** 注入给卡片的面:绑定到 session-guard 命名空间的 settings scope。 */
42
+ export interface SettingsCardInjected {
43
+ scope: SettingsScope<SessionGuardConfig>
44
+ }
45
+
46
+ export type SettingsCardProps = SettingsCardInjected
47
+
48
+ /** 卡片样式,注入一次(保持 bundle CSS-free)。 */
49
+ const CARD_CSS = `
50
+ .sgCard{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}
51
+ .sgCard:hover{border-color:var(--dsw-alias-label-dimmed)}
52
+ .sgCard-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
53
+ .sgCardHeader{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:transparent;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
54
+ .sgCardHeadtext{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
55
+ .sgCardName{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}
56
+ .sgCardDesc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
57
+ .sgCardChevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}
58
+ .sgCardChevron-open{transform:rotate(180deg)}
59
+ .sgCardBody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:4px 0 12px}
60
+ .sgRow{border-bottom:1px solid var(--dsw-alias-border-l2);align-items:center;gap:8px;padding:16px 0;display:flex}
61
+ .sgRow:last-child{border-bottom:0}
62
+ .sgRowText{flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px;display:flex}
63
+ .sgRowText-wide{padding-right:0}
64
+ .sgTitle{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}
65
+ .sgDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}
66
+ .sgSwitch{position:relative;width:40px;height:22px;flex:none}
67
+ .sgSwitch>input{position:absolute;inset:0;width:100%;height:100%;opacity:0;margin:0;cursor:pointer}
68
+ .sgSwitch>input:disabled{cursor:not-allowed}
69
+ .sgSwitchTrack{position:absolute;inset:0;border-radius:22px;background:var(--dsw-alias-interactive-bg-hover);transition:background .16s}
70
+ .sgSwitch>input:checked+.sgSwitchTrack{background:var(--dsw-alias-button-primary-fill)}
71
+ .sgSwitchThumb{position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .16s}
72
+ .sgSwitch>input:checked~.sgSwitchThumb{transform:translateX(18px)}
73
+ .sgInput{appearance:none;min-width:0;width:100%;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:7px 10px;margin-top:8px}
74
+ .sgInput:disabled{opacity:.6;cursor:not-allowed}
75
+ .sgSelect{appearance:none;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:7px 10px;min-width:160px;flex:none}
76
+ .sgSelect:disabled{opacity:.6;cursor:not-allowed}
77
+ .sgHint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;margin:6px 0 0}
78
+ .sgReadonly{color:var(--dsw-alias-label-tertiary);font-size:12px;margin:8px 0 0}
79
+ `
80
+
81
+ /** 注入一次卡片样式。 */
82
+ function injectCss(): void {
83
+ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="session-guard-card"]') === null) {
84
+ const tag = document.createElement('style')
85
+ tag.dataset.plugin = 'dsh-session-guard'
86
+ tag.dataset.pluginCss = 'session-guard-card'
87
+ tag.textContent = CARD_CSS
88
+ document.head.appendChild(tag)
89
+ }
90
+ }
91
+
92
+ /** 一行 pill switch(滑块开关,绑定 scope)。 */
93
+ function SwitchRow(props: {
94
+ label: string
95
+ description?: string
96
+ checked: boolean
97
+ disabled: boolean
98
+ onChange: (_next: boolean) => void
99
+ }): JSX.Element {
100
+ const { label, description, checked, disabled, onChange } = props
101
+ return (
102
+ <div className="sgRow">
103
+ <div className="sgRowText">
104
+ <div className="sgTitle">{label}</div>
105
+ {description !== undefined && <div className="sgDesc">{description}</div>}
106
+ </div>
107
+ <label className="sgSwitch">
108
+ <input
109
+ type="checkbox"
110
+ checked={checked}
111
+ disabled={disabled}
112
+ onChange={(e) => onChange(e.currentTarget.checked)}
113
+ />
114
+ <span className="sgSwitchTrack" />
115
+ <span className="sgSwitchThumb" />
116
+ </label>
117
+ </div>
118
+ )
119
+ }
120
+
121
+ /** 一行文本输入(失焦提交;外部值变化时同步草稿)。 */
122
+ function TextRow(props: {
123
+ label: string
124
+ description?: string
125
+ value: string
126
+ placeholder?: string
127
+ disabled: boolean
128
+ onCommit: (_next: string) => void
129
+ }): JSX.Element {
130
+ const { label, description, value, placeholder, disabled, onCommit } = props
131
+ const [draft, setDraft] = useState(value)
132
+ useEffect(() => {
133
+ setDraft(value)
134
+ }, [value])
135
+ return (
136
+ <div className="sgRow">
137
+ <div className="sgRowText sgRowText-wide">
138
+ <div className="sgTitle">{label}</div>
139
+ {description !== undefined && <div className="sgDesc">{description}</div>}
140
+ <input
141
+ className="sgInput"
142
+ type="text"
143
+ value={draft}
144
+ placeholder={placeholder}
145
+ disabled={disabled}
146
+ onChange={(e) => setDraft(e.currentTarget.value)}
147
+ onBlur={() => onCommit(draft)}
148
+ />
149
+ </div>
150
+ </div>
151
+ )
152
+ }
153
+
154
+ /** 一行「逗号/换行分隔」的字符串列表(失焦提交为数组)。 */
155
+ function ListRow(props: {
156
+ label: string
157
+ description?: string
158
+ values: string[]
159
+ placeholder?: string
160
+ disabled: boolean
161
+ onCommit: (_next: string[]) => void
162
+ }): JSX.Element {
163
+ const { label, description, values, placeholder, disabled, onCommit } = props
164
+ const joined = values.join(', ')
165
+ const [draft, setDraft] = useState(joined)
166
+ useEffect(() => {
167
+ setDraft(joined)
168
+ }, [joined])
169
+ return (
170
+ <div className="sgRow">
171
+ <div className="sgRowText sgRowText-wide">
172
+ <div className="sgTitle">{label}</div>
173
+ {description !== undefined && <div className="sgDesc">{description}</div>}
174
+ <input
175
+ className="sgInput"
176
+ type="text"
177
+ value={draft}
178
+ placeholder={placeholder}
179
+ disabled={disabled}
180
+ onChange={(e) => setDraft(e.currentTarget.value)}
181
+ onBlur={() => {
182
+ const next = draft
183
+ .split(/[,\n]/)
184
+ .map((s) => s.trim())
185
+ .filter((s) => s !== '')
186
+ onCommit(next)
187
+ }}
188
+ />
189
+ </div>
190
+ </div>
191
+ )
192
+ }
193
+
194
+ /** 一行下拉选择。 */
195
+ function SelectRow<T extends string>(props: {
196
+ label: string
197
+ description?: string
198
+ value: T
199
+ options: readonly { value: T; label: string }[]
200
+ disabled: boolean
201
+ onChange: (_next: T) => void
202
+ }): JSX.Element {
203
+ const { label, description, value, options, disabled, onChange } = props
204
+ return (
205
+ <div className="sgRow">
206
+ <div className="sgRowText">
207
+ <div className="sgTitle">{label}</div>
208
+ {description !== undefined && <div className="sgDesc">{description}</div>}
209
+ </div>
210
+ <select
211
+ className="sgSelect"
212
+ value={value}
213
+ disabled={disabled}
214
+ onChange={(e) => onChange(e.currentTarget.value as T)}
215
+ >
216
+ {options.map((o) => (
217
+ <option key={o.value} value={o.value}>
218
+ {o.label}
219
+ </option>
220
+ ))}
221
+ </select>
222
+ </div>
223
+ )
224
+ }
225
+
226
+ /** 插件配置卡片主体:可展开的 <li> + header 按钮 + pill switch 字段体。 */
227
+ export function SessionGuardCard({ scope }: SettingsCardProps): JSX.Element {
228
+ const snapshot = useSyncExternalStore(
229
+ (listener) => scope.subscribe(listener),
230
+ () => scope.getSnapshot(),
231
+ )
232
+ const unavailable = snapshot.status === 'unavailable'
233
+ const readonly = unavailable || !snapshot.writable
234
+ const value = (snapshot.value ?? {}) as Partial<SessionGuardConfig>
235
+ const [open, setOpen] = useState(false)
236
+
237
+ injectCss()
238
+
239
+ const toggle = (field: keyof SessionGuardConfig, next: boolean) => { void scope.set(field, next) }
240
+ return (
241
+ <li className={'sgCard' + (open ? ' sgCard-open' : '')}>
242
+ <button
243
+ type="button"
244
+ className="sgCardHeader"
245
+ aria-expanded={open}
246
+ onClick={() => setOpen(!open)}
247
+ >
248
+ <span className="sgCardHeadtext">
249
+ <span className="sgCardName">会话守护门禁</span>
250
+ <span className="sgCardDesc">高峰自动暂停运行会话,周末模式无视峰谷畅快跑</span>
251
+ </span>
252
+ <svg
253
+ className={'sgCardChevron' + (open ? ' sgCardChevron-open' : '')}
254
+ viewBox="0 0 14 14"
255
+ width={14}
256
+ height={14}
257
+ fill="none"
258
+ aria-hidden="true"
259
+ >
260
+ <path d="M3.5 5.5L7 9l3.5-3.5" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
261
+ </svg>
262
+ </button>
263
+ {open && (
264
+ <div className="sgCardBody">
265
+ {unavailable ? (
266
+ <p style={{ margin: '0', padding: '12px 0', fontSize: '13px', color: 'var(--dsw-alias-label-tertiary)' }}>
267
+ 设置命名空间不可用:请确认 dsh-session-guard 已装配进此 profile。
268
+ </p>
269
+ ) : (
270
+ <>
271
+ <SwitchRow
272
+ label="高峰自动暂停冻结会话"
273
+ description="高峰时段自动暂停运行会话"
274
+ checked={value.enabled ?? true}
275
+ disabled={readonly}
276
+ onChange={(next) => toggle('enabled', next)}
277
+ />
278
+ <SwitchRow
279
+ label="step 级门控"
280
+ description="高峰在下一个 step 的模型请求前拉门(省 token 更彻底);关闭则回退为回合级暂停"
281
+ checked={value.stepLevelPause ?? true}
282
+ disabled={readonly}
283
+ onChange={(next) => toggle('stepLevelPause', next)}
284
+ />
285
+ <TextRow
286
+ label="step 门控超时(毫秒)"
287
+ description="到期释放 step 门并升级为回合级暂停(防死锁);0 或非法值用默认 300000"
288
+ value={String(value.stepGateTimeoutMs ?? 300000)}
289
+ placeholder="300000"
290
+ disabled={readonly}
291
+ onCommit={(next) => {
292
+ const n = Number(String(next).trim())
293
+ void scope.set('stepGateTimeoutMs', Number.isFinite(n) && n > 0 ? n : 300000)
294
+ }}
295
+ />
296
+ <SwitchRow
297
+ label="官方源二维判定"
298
+ description="高峰期只拦 DeepSeek 官方源;本地/第三方 provider 照常跑"
299
+ checked={value.providerGuard ?? true}
300
+ disabled={readonly}
301
+ onChange={(next) => toggle('providerGuard', next)}
302
+ />
303
+ <ListRow
304
+ label="追加官方 provider id"
305
+ description="精确匹配,优先级最高(逗号分隔)"
306
+ values={value.officialProviders ?? []}
307
+ placeholder="deepseek-official"
308
+ disabled={readonly}
309
+ onCommit={(next) => { void scope.set('officialProviders', next) }}
310
+ />
311
+ <ListRow
312
+ label="官方端点名单"
313
+ description="baseURL 归一化后的 host(逗号分隔)"
314
+ values={value.officialBaseURLs ?? []}
315
+ placeholder="api.deepseek.com"
316
+ disabled={readonly}
317
+ onCommit={(next) => { void scope.set('officialBaseURLs', next) }}
318
+ />
319
+ <SelectRow<'hold' | 'error'>
320
+ label="拦截方式"
321
+ description="挂起等待不报错;或报错并记入延后队列"
322
+ value={value.deferredMode ?? 'hold'}
323
+ options={[
324
+ { value: 'hold', label: '挂起等待(退峰自动放行)' },
325
+ { value: 'error', label: '报错并延后(退峰续跑)' },
326
+ ]}
327
+ disabled={readonly}
328
+ onChange={(next) => { void scope.set('deferredMode', next) }}
329
+ />
330
+ <SwitchRow
331
+ label="退峰自动继续"
332
+ description="关闭后延后的请求/会话不自动续跑,需手动 /resume"
333
+ checked={value.deferredResume ?? true}
334
+ disabled={readonly}
335
+ onChange={(next) => toggle('deferredResume', next)}
336
+ />
337
+ <TextRow
338
+ label="退峰续跑文案"
339
+ description="error 模式退峰时发送的消息内容"
340
+ value={value.deferredResumeText ?? ''}
341
+ placeholder="继续(高峰已过,自动继续)"
342
+ disabled={readonly}
343
+ onCommit={(next) => { void scope.set('deferredResumeText', next) }}
344
+ />
345
+ <SwitchRow
346
+ label="纳入子代理请求"
347
+ description="子代理请求同样计费,默认一并拦截"
348
+ checked={value.guardSubagents ?? true}
349
+ disabled={readonly}
350
+ onChange={(next) => toggle('guardSubagents', next)}
351
+ />
352
+ <SwitchRow
353
+ label="低谷自动恢复"
354
+ description="低峰时段自动恢复被暂停的会话"
355
+ checked={value.offPeakAutoResume ?? true}
356
+ disabled={readonly}
357
+ onChange={(next) => toggle('offPeakAutoResume', next)}
358
+ />
359
+ <SwitchRow
360
+ label="周末模式"
361
+ description="识别周末,无视峰谷畅快跑"
362
+ checked={value.weekendMode ?? true}
363
+ disabled={readonly}
364
+ onChange={(next) => toggle('weekendMode', next)}
365
+ />
366
+ <SwitchRow
367
+ label="回退锁队列"
368
+ description="无会话门时锁等待队列"
369
+ checked={value.queueFallback ?? true}
370
+ disabled={readonly}
371
+ onChange={(next) => toggle('queueFallback', next)}
372
+ />
373
+ <SwitchRow
374
+ label="自动重试"
375
+ description="后端重试,默认关(保守)"
376
+ checked={value.retryEnabled ?? false}
377
+ disabled={readonly}
378
+ onChange={(next) => toggle('retryEnabled', next)}
379
+ />
380
+ <p className="sgHint">
381
+ 判定口径:显式 id 名单 → baseURL 端点 → catalog 默认端点 → 内置 id。
382
+ 排查误判访问 /session-guard/provider?provider=&lt;id&gt; 看 matchedBy。
383
+ </p>
384
+ {!snapshot.writable && <p className="sgReadonly">当前只读,无法修改。</p>}
385
+ </>
386
+ )}
387
+ </div>
388
+ )}
389
+ </li>
390
+ )
391
+ }