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.
- package/CHANGELOG.ja.md +75 -29
- package/CHANGELOG.ko.md +75 -29
- package/CHANGELOG.md +146 -29
- package/INSTALL.ja.md +83 -42
- package/INSTALL.ko.md +83 -42
- package/INSTALL.md +86 -44
- package/INSTALL.zh.md +82 -42
- package/README.en.md +303 -170
- package/README.ja.md +177 -106
- package/README.ko.md +177 -106
- package/README.md +159 -20
- package/dsh.plugin.json +13 -0
- package/lib/client.js +397 -11
- package/lib/client.js.map +1 -1
- package/package.json +101 -78
- package/src/bridge.js +61 -1
- package/src/client/badge-text.ts +49 -0
- package/src/client/index.ts +11 -1
- package/src/client/locales.ts +163 -95
- package/src/client/pause-button-text.ts +60 -0
- package/src/client/pause-button.tsx +129 -0
- package/src/client/settings-card.tsx +391 -191
- package/src/client/status-badge.tsx +59 -66
- package/src/client/styles.ts +30 -0
- package/src/deferrals.js +197 -0
- package/src/index.js +405 -293
- package/src/pause-gate.js +472 -416
- package/src/provider-directory.js +101 -0
- package/src/provider.js +139 -0
- package/src/request-guard.js +191 -0
- package/src/retry.js +227 -202
- package/src/settings.js +118 -97
- package/src/step-gate.js +399 -0
- package/src/targets.js +70 -0
- package/src/time.js +195 -104
- package/src/tool-call-id.js +44 -0
- package/src/wiring.js +305 -0
|
@@ -1,66 +1,59 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-session-guard — 状态徽标(纯展示,fail-open)。
|
|
3
|
-
*
|
|
4
|
-
* 轮询 host 的 /session-guard/status(全局当前阶段),显示
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
title={`${LABELS[status.phase]} · ${status.timezone}${status.weekendMode ? ' · 周末模式' : ''}`}
|
|
61
|
-
data-sg-phase={status.phase}
|
|
62
|
-
>
|
|
63
|
-
{LABELS[status.phase]}
|
|
64
|
-
</span>
|
|
65
|
-
)
|
|
66
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 状态徽标(纯展示,fail-open)。
|
|
3
|
+
*
|
|
4
|
+
* 轮询 host 的 /session-guard/status(全局当前阶段),显示 高峰/谷时/周末;
|
|
5
|
+
* 高峰期按二维判定区分「只拦官方」与「全部暂停」(文案见 badge-text.ts)。
|
|
6
|
+
* 仅展示,不做任何队列/会话动作;冻结按钮由 input-traffic 经桥接管(D6/D8)。
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useState } from 'react'
|
|
9
|
+
import { badgeTitle, peakLabel, type Status } from './badge-text'
|
|
10
|
+
import { injectClientCss } from './styles'
|
|
11
|
+
|
|
12
|
+
/** slot 运行时注入的会话级 props(sessionId 由 dsh 的 SessionStandardProps 提供)。 */
|
|
13
|
+
export interface StatusBadgeProps {
|
|
14
|
+
sessionId?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const POLL_MS = 15_000
|
|
18
|
+
|
|
19
|
+
/** 状态徽标:轮询全局阶段,显示 高峰/谷时/周末(enabled 关闭或请求失败时静默隐藏)。 */
|
|
20
|
+
export function StatusBadge({ sessionId }: StatusBadgeProps) {
|
|
21
|
+
const [status, setStatus] = useState<Status | null>(null)
|
|
22
|
+
|
|
23
|
+
injectClientCss()
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!sessionId) return
|
|
27
|
+
let cancelled = false
|
|
28
|
+
const poll = async () => {
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch('/session-guard/status')
|
|
31
|
+
const body = await res.json().catch(() => null)
|
|
32
|
+
if (!cancelled && body?.ok && body.status) setStatus(body.status)
|
|
33
|
+
} catch {
|
|
34
|
+
// fail-open:路由/网络不可达 → 静默,徽标隐藏。
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
void poll()
|
|
38
|
+
const timer = setInterval(poll, POLL_MS)
|
|
39
|
+
return () => {
|
|
40
|
+
cancelled = true
|
|
41
|
+
clearInterval(timer)
|
|
42
|
+
}
|
|
43
|
+
}, [sessionId])
|
|
44
|
+
|
|
45
|
+
if (!status || !status.enabled) return null
|
|
46
|
+
|
|
47
|
+
const cls = status.phase === 'peak' ? 'sg-peak' : status.phase === 'weekend' ? 'sg-weekend' : 'sg-off'
|
|
48
|
+
return (
|
|
49
|
+
<span
|
|
50
|
+
className={`sg-status ${cls}`}
|
|
51
|
+
title={badgeTitle(status)}
|
|
52
|
+
data-sg-phase={status.phase}
|
|
53
|
+
data-sg-provider-guard={status.providerGuard === true ? 'on' : 'off'}
|
|
54
|
+
data-sg-step-held={String(status.stepHeld ?? 0)}
|
|
55
|
+
>
|
|
56
|
+
{peakLabel(status)}
|
|
57
|
+
</span>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 客户端样式(与 composer 右侧 input-traffic 冻结按钮同一视觉语言)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么注入 `<style>` 而不是 CSS Modules:本插件的 tsdown 配置没有 CSS Modules 管线
|
|
5
|
+
* (input-traffic 有),而 settings-card 已经用 `<style data-plugin-css>` 的既有约定。
|
|
6
|
+
* 这里只做一件事:把「暂停会话 / 继续会话」按钮与状态徽标对齐到同一行的其它控件
|
|
7
|
+
* (高度 24px、圆角 6px、12px 字号、同样的 border / hover / pressed 令牌)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** 与 `dsh-input-traffic/src/client/freeze-button.module.css` 对齐的控件外观。 */
|
|
11
|
+
export const CLIENT_CSS = `
|
|
12
|
+
.sg-pause{display:inline-flex;align-items:center;gap:4px;height:24px;padding:0 8px;border:1px solid var(--dsw-alias-border-l3,rgba(0,0,0,.12));border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#6b7280);font-size:12px;line-height:1;cursor:pointer;white-space:nowrap;flex:none}
|
|
13
|
+
.sg-pause:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.04))}
|
|
14
|
+
.sg-pause:disabled{opacity:.55;cursor:default}
|
|
15
|
+
.sg-pause[aria-pressed='true']{border-color:var(--dsw-alias-state-warning-primary,#d97706);color:var(--dsw-alias-state-warning-primary,#d97706);background:color-mix(in srgb,var(--dsw-alias-state-warning-primary,#d97706) 8%,transparent)}
|
|
16
|
+
.sg-status{display:inline-flex;align-items:center;height:24px;padding:0 8px;border:1px solid var(--dsw-alias-border-l3,rgba(0,0,0,.12));border-radius:6px;font-size:12px;line-height:1;color:var(--dsw-alias-label-secondary,#6b7280);white-space:nowrap;flex:none}
|
|
17
|
+
.sg-status.sg-peak{border-color:var(--dsw-alias-state-warning-primary,#d97706);color:var(--dsw-alias-state-warning-primary,#d97706)}
|
|
18
|
+
.sg-status.sg-weekend{border-color:var(--dsw-alias-state-success-primary,#30a46c);color:var(--dsw-alias-state-success-primary,#30a46c)}
|
|
19
|
+
`
|
|
20
|
+
|
|
21
|
+
/** 注入一次(幂等;无 document 时静默跳过)。 */
|
|
22
|
+
export function injectClientCss(): void {
|
|
23
|
+
if (typeof document === 'undefined') return
|
|
24
|
+
if (document.querySelector('style[data-plugin-css="session-guard-client"]') !== null) return
|
|
25
|
+
const tag = document.createElement('style')
|
|
26
|
+
tag.dataset.plugin = 'dsh-session-guard'
|
|
27
|
+
tag.dataset.pluginCss = 'session-guard-client'
|
|
28
|
+
tag.textContent = CLIENT_CSS
|
|
29
|
+
document.head.appendChild(tag)
|
|
30
|
+
}
|
package/src/deferrals.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — 延后登记表(host,进程内)。
|
|
3
|
+
*
|
|
4
|
+
* 两类记录:
|
|
5
|
+
* - **挂起(hold)**:请求被挂在 `agent/request` 上,promise 待退峰 resolve。
|
|
6
|
+
* 退峰 → `resolve(config)` 让请求照常发出;abort / 超限 / 被新 hold 顶替 → reject。
|
|
7
|
+
* - **延后(deferred)**:error 模式下已经抛错的会话,记下来供退峰 `followup` 续跑。
|
|
8
|
+
*
|
|
9
|
+
* 纪律:
|
|
10
|
+
* - **不做磁盘持久化**(挂起态是进程内 promise,重启即消失;写盘无收益)。
|
|
11
|
+
* - 同一会话重复 hold → 先结算旧记录(reject `superseded`),再登记新记录。
|
|
12
|
+
* - `releaseAll` / `rejectAll` / `settle` 幂等:对不存在的会话是 no-op。
|
|
13
|
+
* - 定时器 `unref()`,不拖住进程退出。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 机器可路由的延后失败码(`retry.js` 只按这个精确码短路)。 */
|
|
17
|
+
export const PEAK_DEFERRED_CODE = 'PEAK_DEFERRED'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 延后错误:`code` 走结构化字段;`message` 以精确哨兵 `PEAK_DEFERRED:` 开头。
|
|
21
|
+
*
|
|
22
|
+
* 为什么要带哨兵:DSH 回合循环只对 `error instanceof LlmError` 保留结构化
|
|
23
|
+
* `failure`,其它一律压成 `{ message: errorChain(error), code: 'UNKNOWN' }`
|
|
24
|
+
* (findings 8.3)。本插件不能 value-import `@deepseek-ai/dsh-llm`,
|
|
25
|
+
* 因此让 `code` 与 message 前缀同时携带同一个精确哨兵,两条路径都能识别。
|
|
26
|
+
*/
|
|
27
|
+
export class PeakDeferredError extends Error {
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} [detail] 人类可读补充说明
|
|
30
|
+
* @param {object} [facts] { provider, model, sessionId }
|
|
31
|
+
*/
|
|
32
|
+
constructor(detail = 'peak hours, request deferred', facts = {}) {
|
|
33
|
+
super(`${PEAK_DEFERRED_CODE}: ${detail}`)
|
|
34
|
+
this.name = 'PeakDeferredError'
|
|
35
|
+
this.code = PEAK_DEFERRED_CODE
|
|
36
|
+
this.facts = facts
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {object} [deps]
|
|
42
|
+
* @param {number|(()=>number)} [deps.maxHoldMs] 挂起上限(毫秒),到期转 error;<=0 表示不设上限。
|
|
43
|
+
* 传函数时每次 hold 实时读取(设置热改即时生效)。
|
|
44
|
+
* @param {()=>number} [deps.now] 时钟(测试注入)
|
|
45
|
+
* @param {boolean} [deps.unrefTimers] 定时器是否 unref(生产 true,测试可关以便等待定时器)
|
|
46
|
+
* @returns {object} 延后登记表
|
|
47
|
+
*/
|
|
48
|
+
export function createDeferrals({ maxHoldMs = 6 * 60 * 60 * 1000, now = () => Date.now(), unrefTimers = true } = {}) {
|
|
49
|
+
const resolveMaxHold = typeof maxHoldMs === 'function' ? maxHoldMs : () => maxHoldMs
|
|
50
|
+
/** @type {Map<string, object>} 活跃挂起 */
|
|
51
|
+
const holds = new Map()
|
|
52
|
+
/** @type {Map<string, object>} error 模式的延后记录 */
|
|
53
|
+
const deferred = new Map()
|
|
54
|
+
|
|
55
|
+
function clearTimer(entry) {
|
|
56
|
+
if (entry && entry.timer !== null && entry.timer !== undefined) {
|
|
57
|
+
clearTimeout(entry.timer)
|
|
58
|
+
entry.timer = null
|
|
59
|
+
}
|
|
60
|
+
if (entry && entry.signal && entry.onAbort) {
|
|
61
|
+
try {
|
|
62
|
+
entry.signal.removeEventListener('abort', entry.onAbort)
|
|
63
|
+
} catch {
|
|
64
|
+
/* 信号实现不完整时忽略 */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 结算一个挂起记录。
|
|
71
|
+
* @param {string} sessionId
|
|
72
|
+
* @param {string} reason
|
|
73
|
+
* @param {'release'|'reject'} outcome
|
|
74
|
+
* @returns {boolean} 是否真的结算了一条
|
|
75
|
+
*/
|
|
76
|
+
function settle(sessionId, reason, outcome = 'release') {
|
|
77
|
+
const key = String(sessionId ?? '')
|
|
78
|
+
const entry = holds.get(key)
|
|
79
|
+
if (!entry) return false
|
|
80
|
+
holds.delete(key)
|
|
81
|
+
clearTimer(entry)
|
|
82
|
+
if (outcome === 'reject') {
|
|
83
|
+
entry.reject(new PeakDeferredError(reason, { sessionId: key, provider: entry.provider, model: entry.model }))
|
|
84
|
+
} else {
|
|
85
|
+
entry.resolve(entry.config)
|
|
86
|
+
}
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 挂起一次请求。
|
|
92
|
+
* @param {string} sessionId
|
|
93
|
+
* @param {{provider?:string, model?:string, config?:unknown}} info
|
|
94
|
+
* @param {AbortSignal} [signal]
|
|
95
|
+
* @returns {Promise<unknown>} 退峰 resolve 原 config
|
|
96
|
+
*/
|
|
97
|
+
function hold(sessionId, info = {}, signal) {
|
|
98
|
+
const key = String(sessionId ?? '')
|
|
99
|
+
// 重复 hold:先结算旧的(新请求代表同一会话的最新意图)
|
|
100
|
+
if (holds.has(key)) settle(key, 'superseded', 'reject')
|
|
101
|
+
|
|
102
|
+
let resolveFn
|
|
103
|
+
let rejectFn
|
|
104
|
+
const promise = new Promise((resolve, reject) => {
|
|
105
|
+
resolveFn = resolve
|
|
106
|
+
rejectFn = reject
|
|
107
|
+
})
|
|
108
|
+
const entry = {
|
|
109
|
+
sessionId: key,
|
|
110
|
+
provider: info.provider ?? null,
|
|
111
|
+
model: info.model ?? null,
|
|
112
|
+
config: info.config,
|
|
113
|
+
since: now(),
|
|
114
|
+
resolve: resolveFn,
|
|
115
|
+
reject: rejectFn,
|
|
116
|
+
promise,
|
|
117
|
+
timer: null,
|
|
118
|
+
signal: null,
|
|
119
|
+
onAbort: null,
|
|
120
|
+
}
|
|
121
|
+
holds.set(key, entry)
|
|
122
|
+
|
|
123
|
+
if (Number.isFinite(resolveMaxHold()) && resolveMaxHold() > 0) {
|
|
124
|
+
entry.timer = setTimeout(() => {
|
|
125
|
+
if (holds.get(key) !== entry) return
|
|
126
|
+
settle(key, 'max-hold-exceeded', 'reject')
|
|
127
|
+
}, resolveMaxHold())
|
|
128
|
+
if (entry.timer && unrefTimers && typeof entry.timer.unref === 'function') entry.timer.unref()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (signal && typeof signal.addEventListener === 'function') {
|
|
132
|
+
entry.signal = signal
|
|
133
|
+
if (signal.aborted) {
|
|
134
|
+
settle(key, 'aborted', 'reject')
|
|
135
|
+
} else {
|
|
136
|
+
entry.onAbort = () => {
|
|
137
|
+
if (holds.get(key) === entry) settle(key, 'aborted', 'reject')
|
|
138
|
+
}
|
|
139
|
+
signal.addEventListener('abort', entry.onAbort, { once: true })
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return promise
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 放行全部挂起(退峰):resolve 原 config,并清空延后记录。 */
|
|
147
|
+
function releaseAll(reason = 'off-peak') {
|
|
148
|
+
const released = []
|
|
149
|
+
for (const key of [...holds.keys()]) {
|
|
150
|
+
if (settle(key, reason, 'release')) released.push(key)
|
|
151
|
+
}
|
|
152
|
+
const cleared = [...deferred.keys()]
|
|
153
|
+
deferred.clear()
|
|
154
|
+
return { released, cleared }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 拒绝全部挂起(插件卸载 / 关闭自动续跑)。 */
|
|
158
|
+
function rejectAll(reason = 'disposed') {
|
|
159
|
+
const rejected = []
|
|
160
|
+
for (const key of [...holds.keys()]) {
|
|
161
|
+
if (settle(key, reason, 'reject')) rejected.push(key)
|
|
162
|
+
}
|
|
163
|
+
return rejected
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** error 模式:登记一条延后记录(退峰时按 deferredResume 决定是否 followup)。 */
|
|
167
|
+
function remember(sessionId, info = {}) {
|
|
168
|
+
const key = String(sessionId ?? '')
|
|
169
|
+
if (key === '') return null
|
|
170
|
+
const rec = {
|
|
171
|
+
sessionId: key,
|
|
172
|
+
provider: info.provider ?? null,
|
|
173
|
+
model: info.model ?? null,
|
|
174
|
+
reason: info.reason ?? 'peak',
|
|
175
|
+
since: now(),
|
|
176
|
+
}
|
|
177
|
+
deferred.set(key, rec)
|
|
178
|
+
return rec
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
hold,
|
|
183
|
+
settle,
|
|
184
|
+
releaseAll,
|
|
185
|
+
rejectAll,
|
|
186
|
+
remember,
|
|
187
|
+
/** 活跃挂起快照。 */
|
|
188
|
+
list: () => [...holds.values()].map((e) => ({ sessionId: e.sessionId, provider: e.provider, model: e.model, since: e.since })),
|
|
189
|
+
/** 延后记录快照。 */
|
|
190
|
+
deferredList: () => [...deferred.values()],
|
|
191
|
+
isDeferred: (sessionId) => deferred.has(String(sessionId ?? '')),
|
|
192
|
+
clearDeferred: (sessionId) => deferred.delete(String(sessionId ?? '')),
|
|
193
|
+
has: (sessionId) => holds.has(String(sessionId ?? '')),
|
|
194
|
+
size: () => holds.size,
|
|
195
|
+
deferredSize: () => deferred.size,
|
|
196
|
+
}
|
|
197
|
+
}
|