@leonhardthust/dsh-decision-engine 0.0.0-stage → 1.9.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/LICENSE +21 -0
- package/README.md +55 -2
- package/README.zh.md +57 -0
- package/config.example.yml +436 -0
- package/cordis.patch.yml +14 -0
- package/docs/assets/config-page.png +0 -0
- package/docs/assets/decisions-tab.png +0 -0
- package/docs/assets/hero.jpeg +0 -0
- package/docs/assets/stats-charts-preview.png +0 -0
- package/docs/assets/stats-donut-toggle.png +0 -0
- package/docs/assets/stats-interactive-charts.png +0 -0
- package/docs/assets/stats-point-donut.png +0 -0
- package/docs/assets/stats-ranking-bars.png +0 -0
- package/docs/lessons/cloudflare-builds-root-directory.md +51 -0
- package/docs/lessons/cold-start-timeout.md +134 -0
- package/docs/lessons/endpoint-path-vs-model.md +26 -0
- package/docs/lessons/model-field-required-by-provider.md +31 -0
- package/docs/local-dev-link.md +54 -0
- package/docs/releasing.md +67 -0
- package/docs/security-revision.md +130 -0
- package/icon.svg +6 -0
- package/lib/adapters/agent.js +314 -0
- package/lib/adapters/route.js +144 -0
- package/lib/adapters/tools.js +186 -0
- package/lib/adapters/translate.js +143 -0
- package/lib/approval-detail.js +325 -0
- package/lib/client.charts.js +36 -0
- package/lib/client.js +8589 -0
- package/lib/config-file.js +136 -0
- package/lib/credentials.js +187 -0
- package/lib/decision-cache.js +188 -0
- package/lib/decision-points.js +205 -0
- package/lib/decision-store.js +774 -0
- package/lib/endpoint-fallback.js +143 -0
- package/lib/engine.js +312 -0
- package/lib/guard/advisory.js +100 -0
- package/lib/guard/audit.js +79 -0
- package/lib/guard/context.js +128 -0
- package/lib/guard/effects.js +96 -0
- package/lib/guard/fallback.js +139 -0
- package/lib/guard/listener.js +191 -0
- package/lib/guard/policy.js +28 -0
- package/lib/home-paths.js +86 -0
- package/lib/index.js +3823 -0
- package/lib/outcomes.js +169 -0
- package/lib/points/spec.js +645 -0
- package/lib/providers.js +958 -0
- package/lib/resilience.js +308 -0
- package/lib/scope.js +68 -0
- package/lib/server-config.js +351 -0
- package/lib/session-events.js +75 -0
- package/lib/stats-store.js +916 -0
- package/lib/stats.js +1293 -0
- package/lib/telemetry.js +465 -0
- package/lib/tokenizer.js +201 -0
- package/lib/tolerance.js +498 -0
- package/lib/vendor/microcharts-charts.css +47 -0
- package/lib/vendor-src/entry.js +13 -0
- package/lib/vendor-src/jsx-shim.js +34 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +101 -3
- package/scripts/migrate-profile.mjs +102 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 服务端下发的 provider 配置源(DEC-030)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要这一层:
|
|
5
|
+
*
|
|
6
|
+
* 用户的地址与 key 此前只能来自本地 config.yml / .credentials.yaml。团队或
|
|
7
|
+
* 企业部署时,管理员希望「一处改、所有客户端下次刷新即生效」,而不是逐个
|
|
8
|
+
* 指导用户改配置。授权服务因此额外下发一份 provider 配置(地址、模型、
|
|
9
|
+
* 端点风格、key),本模块负责把它取回来、校验、缓存在内存里并给出状态。
|
|
10
|
+
*
|
|
11
|
+
* 三条设计红线:
|
|
12
|
+
*
|
|
13
|
+
* 1. **不落盘**。本模块只在内存里放值:缓存是易失的,进程结束即消失,
|
|
14
|
+
* 服务端下发的明文 key 属于服务端账号,不该在用户机器上留下副本。
|
|
15
|
+
* 因此这里不读也不写任何持久化文件(连读取都交给调用方注入)。
|
|
16
|
+
* 2. **绝不抛错**。`refresh()` 是热路径上的后台动作,任何失败只能降级成
|
|
17
|
+
* 「状态变了」;守卫是 fail-open 的,把异常抛进调用方等于静默失效。
|
|
18
|
+
* 3. **绝不外泄 key**。`status()` 会进日志与界面,只给 keyHint(末四位);
|
|
19
|
+
* 明文只在 `value()` 里,调用方拿到后直接用于请求头。
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-decision-engine/server-config
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { ENDPOINT_STYLES, validateBaseUrl } from "./providers.js";
|
|
25
|
+
|
|
26
|
+
/** 授权服务的配置下发端点。换地址只改这一处。 */
|
|
27
|
+
export const SERVER_CONFIG_ENDPOINT = "https://dsh.zuos.us/api/provider-config";
|
|
28
|
+
|
|
29
|
+
/** 未给 ttl 时的缓存时长(10 分钟):够久到不必频繁请求,也够短到改动能及时生效。 */
|
|
30
|
+
export const DEFAULT_TTL_SECONDS = 600;
|
|
31
|
+
|
|
32
|
+
/** TTL 下限:太短会让每次工具调用都去刷配置,把网络往返加到决策热路径上。 */
|
|
33
|
+
export const MIN_TTL_SECONDS = 60;
|
|
34
|
+
|
|
35
|
+
/** TTL 上限:太长会让管理员改完服务端配置后,用户一小时内看不到变化。 */
|
|
36
|
+
export const MAX_TTL_SECONDS = 3600;
|
|
37
|
+
|
|
38
|
+
/** 单次请求超时:配置下发是后台动作,不能让一个卡住的连接拖住决策路径。
|
|
39
|
+
* 实测 dsh.zuos.us 下发接口在慢时会到 ~7s,原来 5s 会被判超时并静默回落到本地配置,
|
|
40
|
+
* 因此放宽到 15000ms 以容纳慢响应。仍是后台动作,失败只降级、不阻塞决策路径。 */
|
|
41
|
+
export const REQUEST_TIMEOUT_MS = 15000;
|
|
42
|
+
|
|
43
|
+
/** 失败重试下限:网络抖动时不必每次调用都补一发,30 秒后自然恢复重试。 */
|
|
44
|
+
export const RETRY_FLOOR_MS = 30000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 超时检查的轮询间隔(模块私有,不导出)。
|
|
48
|
+
*
|
|
49
|
+
* 为什么不是「一次 setTimeout(5000) 就完事」:超时必须能被注入的 clock
|
|
50
|
+
* 观测到,否则测试要么真的等满 5 秒,要么根本覆盖不到超时路径。这里每
|
|
51
|
+
* 50ms 检查一次「注入 clock 或真实时间」是否越界——生产环境两者同步前进,
|
|
52
|
+
* 行为与单个 5 秒定时器一致。
|
|
53
|
+
*/
|
|
54
|
+
const TIMEOUT_POLL_MS = 50;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 校验并归一服务端下发的配置体。
|
|
58
|
+
*
|
|
59
|
+
* 纯函数,任何输入都不抛错:下发内容来自网络,形状完全不可信。
|
|
60
|
+
* 整笔拒绝(而不是逐字段兜底)只用在「兜底会猜错方向」的地方——
|
|
61
|
+
* `endpointStyle` 非法时静默改成 auto,会去猜一条路径,猜错就是一次 404,
|
|
62
|
+
* 而 404 在本插件里表现为静默失效;宁可这一笔配置不生效。
|
|
63
|
+
*
|
|
64
|
+
* @param value - 解析后的 JSON 体(任意类型)。
|
|
65
|
+
* @returns `{ ok: true, value }` 或 `{ ok: false, error }`(中文,可直接展示)。
|
|
66
|
+
*/
|
|
67
|
+
export function parseServerConfig(value) {
|
|
68
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
69
|
+
return { ok: false, error: "服务端配置不是 JSON 对象" };
|
|
70
|
+
}
|
|
71
|
+
if (value.ok !== true) {
|
|
72
|
+
return { ok: false, error: "服务端配置未标记为成功(ok 不是 true)" };
|
|
73
|
+
}
|
|
74
|
+
// 版本号只做等值判断,刻意不回显原值:错误文案会进日志,回显等于把
|
|
75
|
+
// 一个未校验的字段(万一它就是 key)原样抄进日志。
|
|
76
|
+
if (value.v !== 1) {
|
|
77
|
+
return { ok: false, error: "不支持的服务端配置版本(期望 1)" };
|
|
78
|
+
}
|
|
79
|
+
const checked = validateBaseUrl(value.baseUrl);
|
|
80
|
+
if (!checked.ok) {
|
|
81
|
+
return { ok: false, error: `服务端下发的 base URL 不可用:${checked.error}` };
|
|
82
|
+
}
|
|
83
|
+
// key 缺失或全是空白时整笔拒绝:没有 key 的配置只会换来一次 401,
|
|
84
|
+
// 反而不如让上层继续用本地配置。
|
|
85
|
+
const key = typeof value.key === "string" ? value.key.trim() : "";
|
|
86
|
+
if (key.length === 0) {
|
|
87
|
+
return { ok: false, error: "服务端配置缺少 API key" };
|
|
88
|
+
}
|
|
89
|
+
// model 允许空串:它表示「省略 model 字段、由服务端回填默认模型」,与本地
|
|
90
|
+
// provider 的语义一致(见 providers.js 的 buildRequestBody)。
|
|
91
|
+
const model = typeof value.model === "string" ? value.model.trim() : "";
|
|
92
|
+
let endpointStyle = "auto";
|
|
93
|
+
if (value.endpointStyle !== undefined) {
|
|
94
|
+
if (!ENDPOINT_STYLES.includes(value.endpointStyle)) {
|
|
95
|
+
return { ok: false, error: "服务端下发的 endpointStyle 不是合法取值" };
|
|
96
|
+
}
|
|
97
|
+
endpointStyle = value.endpointStyle;
|
|
98
|
+
}
|
|
99
|
+
// TTL 只影响本地缓存时长,写偏了不值得整笔拒绝:夹到 [下限, 上限] 即可。
|
|
100
|
+
// 但类型不对(字符串、NaN、Infinity)无法猜出意图,按缺省处理。
|
|
101
|
+
let ttlSeconds = DEFAULT_TTL_SECONDS;
|
|
102
|
+
if (value.ttlSeconds !== undefined) {
|
|
103
|
+
if (typeof value.ttlSeconds !== "number" || !Number.isFinite(value.ttlSeconds)) {
|
|
104
|
+
return { ok: false, error: "服务端下发的 ttlSeconds 不是有穷数字" };
|
|
105
|
+
}
|
|
106
|
+
ttlSeconds = Math.min(MAX_TTL_SECONDS, Math.max(MIN_TTL_SECONDS, value.ttlSeconds));
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, value: { baseUrl: checked.value, model, endpointStyle, key, ttlSeconds } };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 一次失败的可展示文案。
|
|
113
|
+
*
|
|
114
|
+
* 只保留一行并截断:异常消息可能带换行(响应体片段),原样进日志会把
|
|
115
|
+
* 日志刷爆。注意调用方只会把它用在**自家生成的固定文案**或运行时错误上,
|
|
116
|
+
* 解析响应体的原文一律不从这里透出(见 `#request`)。
|
|
117
|
+
*/
|
|
118
|
+
function describeError(err) {
|
|
119
|
+
const raw = err instanceof Error && typeof err.message === "string" ? err.message : "";
|
|
120
|
+
const text = raw.replace(/\s+/g, " ").trim();
|
|
121
|
+
return text.length > 0 ? text.slice(0, 200) : "未知错误";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 服务端配置的内存缓存源。
|
|
126
|
+
*
|
|
127
|
+
* 生命周期:`refresh()` 取回值(失败保留未过期的旧值)→ 上层在 `usable()`
|
|
128
|
+
* 期间直接用 `value()` 里的地址与 key → 过期后 `status()` 变为 error,
|
|
129
|
+
* 上层回落到本地 provider。
|
|
130
|
+
*/
|
|
131
|
+
export class ServerConfigSource {
|
|
132
|
+
#endpoint;
|
|
133
|
+
#token;
|
|
134
|
+
#fetchImpl;
|
|
135
|
+
#clock;
|
|
136
|
+
#logger;
|
|
137
|
+
|
|
138
|
+
#value;
|
|
139
|
+
#error = "";
|
|
140
|
+
#fetchedAt = 0;
|
|
141
|
+
#expiresAt = 0;
|
|
142
|
+
#lastAttemptAt = 0;
|
|
143
|
+
#attempted = false;
|
|
144
|
+
#inFlight = null;
|
|
145
|
+
/** 换代号:clear() 时自增,用来丢弃「清空之前发出、之后才返回」的在途结果。 */
|
|
146
|
+
#generation = 0;
|
|
147
|
+
#warned = false;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param options.endpoint - 配置下发地址,默认 SERVER_CONFIG_ENDPOINT。
|
|
151
|
+
* @param options.token - 下发的鉴权令牌(可选,非空时作为 x-provider-token 发送)。
|
|
152
|
+
* @param options.fetchImpl - 注入的 fetch;缺省时**调用时**才取 globalThis.fetch,
|
|
153
|
+
* 这样测试可以在构造之后再打桩(见 test/server-config.test.js)。
|
|
154
|
+
* @param options.clock - 注入的时钟,默认 `Date.now`。
|
|
155
|
+
* @param options.logger - 注入的日志器(只用 `warn`)。
|
|
156
|
+
*/
|
|
157
|
+
constructor({ endpoint, token, fetchImpl, clock, logger } = {}) {
|
|
158
|
+
const configured = typeof endpoint === "string" ? endpoint.trim() : "";
|
|
159
|
+
this.#endpoint = configured.length > 0 ? configured : SERVER_CONFIG_ENDPOINT;
|
|
160
|
+
this.#token = typeof token === "string" ? token.trim() : "";
|
|
161
|
+
this.#fetchImpl = typeof fetchImpl === "function" ? fetchImpl : undefined;
|
|
162
|
+
this.#clock = typeof clock === "function" ? clock : Date.now;
|
|
163
|
+
this.#logger = logger;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 有值且尚未过期。上层据此决定「用服务端下发」还是「回落到本地配置」。 */
|
|
167
|
+
usable() {
|
|
168
|
+
if (this.#value === undefined) return false;
|
|
169
|
+
return this.#clock() < this.#expiresAt;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 原始配置值(**只有它含明文 key**);从未取到或被 clear() 后为 undefined。
|
|
174
|
+
*
|
|
175
|
+
* 刻意在过期后仍返回旧值:状态展示要用它回显「上一份是什么」,而
|
|
176
|
+
* 「能不能用」由 usable() 单独回答。调用方必须先用 usable() 过滤。
|
|
177
|
+
*/
|
|
178
|
+
value() {
|
|
179
|
+
return this.#value;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 供界面/日志使用的状态快照,**任何路径都不含明文 key**。
|
|
184
|
+
*
|
|
185
|
+
* 状态判定顺序即优先级:在途 → loading(旧值仍可用时也报 loading,
|
|
186
|
+
* 因为调用方更关心「正在刷新」);可用 → ready;有失败原因 → error;
|
|
187
|
+
* 只剩「有值但过期」→ error 并补一句可展示的过期文案;否则 idle。
|
|
188
|
+
*/
|
|
189
|
+
status() {
|
|
190
|
+
const hasValue = this.#value !== undefined;
|
|
191
|
+
const usable = this.usable();
|
|
192
|
+
const expired = hasValue && !usable;
|
|
193
|
+
// 过期文案是**算出来的**,不写回 #error:否则它会一直留在内存里,
|
|
194
|
+
// 下一次刷新成功时还得专门记得擦掉它。
|
|
195
|
+
let error = this.#error;
|
|
196
|
+
if (error === "" && expired) error = "服务端配置已过期";
|
|
197
|
+
const status = this.#inFlight !== null
|
|
198
|
+
? "loading"
|
|
199
|
+
: usable
|
|
200
|
+
? "ready"
|
|
201
|
+
: error !== ""
|
|
202
|
+
? "error"
|
|
203
|
+
: "idle";
|
|
204
|
+
return {
|
|
205
|
+
status,
|
|
206
|
+
usable,
|
|
207
|
+
error,
|
|
208
|
+
// 末四位是「够确认是哪把 key」与「不能反推出 key」之间的折中。
|
|
209
|
+
keyHint: hasValue ? `…${this.#value.key.slice(-4)}` : "",
|
|
210
|
+
baseUrl: hasValue ? this.#value.baseUrl : "",
|
|
211
|
+
model: hasValue ? this.#value.model : "",
|
|
212
|
+
endpointStyle: hasValue ? this.#value.endpointStyle : "",
|
|
213
|
+
fetchedAt: this.#fetchedAt,
|
|
214
|
+
expiresAt: this.#expiresAt,
|
|
215
|
+
expired,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 取一次服务端配置。**绝不抛错**:失败只体现在返回值与 status() 上。
|
|
221
|
+
*
|
|
222
|
+
* @param options.force - 忽略失败重试下限(用户手动点「刷新」时用)。
|
|
223
|
+
* @returns `{ ok, error }`;ok 为 true 时 error 为 ""。
|
|
224
|
+
*/
|
|
225
|
+
async refresh(options = {}) {
|
|
226
|
+
const force = options?.force === true;
|
|
227
|
+
// 并发去重:已有在途请求就直接复用它的 promise,同一时刻只打一发。
|
|
228
|
+
// 这里必须在任何 await 之前完成赋值,否则同一 tick 的两次调用会各打一发。
|
|
229
|
+
if (this.#inFlight !== null) return this.#inFlight;
|
|
230
|
+
const now = this.#clock();
|
|
231
|
+
// 失败重试下限:刚失败过就不再打,避免服务端故障时每次决策都补一发请求。
|
|
232
|
+
if (!force && this.#attempted && now - this.#lastAttemptAt < RETRY_FLOOR_MS) {
|
|
233
|
+
return { ok: false, error: "retry-floor" };
|
|
234
|
+
}
|
|
235
|
+
const generation = this.#generation;
|
|
236
|
+
const run = this.#run(generation);
|
|
237
|
+
this.#inFlight = run;
|
|
238
|
+
// 收尾统一走 finally:错误路径也不能让 #inFlight 永久占位,否则后续
|
|
239
|
+
// 刷新会一直复用一个已经结束的 promise(表现为「再也不刷新」)。
|
|
240
|
+
void run.finally(() => {
|
|
241
|
+
if (this.#inFlight === run) this.#inFlight = null;
|
|
242
|
+
});
|
|
243
|
+
return run;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 清空全部内存字段(用户主动断开服务端下发时用)。 */
|
|
247
|
+
clear() {
|
|
248
|
+
this.#generation += 1;
|
|
249
|
+
this.#value = undefined;
|
|
250
|
+
this.#error = "";
|
|
251
|
+
this.#fetchedAt = 0;
|
|
252
|
+
this.#expiresAt = 0;
|
|
253
|
+
this.#lastAttemptAt = 0;
|
|
254
|
+
this.#attempted = false;
|
|
255
|
+
this.#inFlight = null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 一次刷新的完整流程:取数 → 落状态。绝不 reject。 */
|
|
259
|
+
async #run(generation) {
|
|
260
|
+
let value;
|
|
261
|
+
try {
|
|
262
|
+
value = await this.#request();
|
|
263
|
+
} catch (err) {
|
|
264
|
+
// clear() 已换代:这次失败不该污染新状态(否则清空后状态会显示 error)。
|
|
265
|
+
if (generation !== this.#generation) return { ok: false, error: "cleared" };
|
|
266
|
+
// 失败**不改 #value**:未过期的旧值仍然可用,比「一失败就没配置」好得多。
|
|
267
|
+
this.#attempted = true;
|
|
268
|
+
this.#lastAttemptAt = this.#clock();
|
|
269
|
+
this.#error = describeError(err);
|
|
270
|
+
this.#warn(`服务端配置刷新失败(端点 ${this.#endpoint}):${this.#error}`);
|
|
271
|
+
return { ok: false, error: this.#error };
|
|
272
|
+
}
|
|
273
|
+
// clear() 之后才返回的结果属于上一代,直接丢弃,否则「清空」会看起来没生效。
|
|
274
|
+
if (generation !== this.#generation) return { ok: false, error: "cleared" };
|
|
275
|
+
const now = this.#clock();
|
|
276
|
+
this.#value = value;
|
|
277
|
+
this.#fetchedAt = now;
|
|
278
|
+
this.#expiresAt = now + value.ttlSeconds * 1000;
|
|
279
|
+
this.#error = "";
|
|
280
|
+
// 刻意**不**更新 #lastAttemptAt:它是「失败重试下限」的锚点,成功之后
|
|
281
|
+
// 没有理由让紧接着的一次刷新被自己的下限挡住。
|
|
282
|
+
return { ok: true, error: "" };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 真正的网络请求:超时、状态码、JSON、字段校验全部在这里收敛成异常。 */
|
|
286
|
+
async #request() {
|
|
287
|
+
const controller = new AbortController();
|
|
288
|
+
let timer;
|
|
289
|
+
// 超时用「与 fetch 赛跑」而不是只给 abort signal:真实 fetch 收到 abort
|
|
290
|
+
// 会立刻 reject,但测试桩(永不 resolve)不会,赛跑保证任何实现下
|
|
291
|
+
// refresh 都会在 REQUEST_TIMEOUT_MS 后返回,而不是永远挂着。
|
|
292
|
+
const timeout = new Promise((_, reject) => {
|
|
293
|
+
const clockDeadline = this.#clock() + REQUEST_TIMEOUT_MS;
|
|
294
|
+
const realDeadline = Date.now() + REQUEST_TIMEOUT_MS;
|
|
295
|
+
const tick = () => {
|
|
296
|
+
// 任一「时钟」越界即超时:注入 clock 让测试可以毫秒级覆盖超时路径,
|
|
297
|
+
// 真实时间兜底保证即使 clock 被冻结也一定会返回。
|
|
298
|
+
if (this.#clock() >= clockDeadline || Date.now() >= realDeadline) {
|
|
299
|
+
controller.abort();
|
|
300
|
+
reject(new Error(`请求超时(${REQUEST_TIMEOUT_MS}ms)`));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
timer = setTimeout(tick, TIMEOUT_POLL_MS);
|
|
304
|
+
};
|
|
305
|
+
timer = setTimeout(tick, TIMEOUT_POLL_MS);
|
|
306
|
+
});
|
|
307
|
+
try {
|
|
308
|
+
// 惰性解析:测试要在**构造之后**替换 globalThis.fetch 来模拟各种失败。
|
|
309
|
+
const fetchImpl = this.#fetchImpl ?? globalThis.fetch;
|
|
310
|
+
if (typeof fetchImpl !== "function") throw new Error("当前运行环境没有可用的 fetch");
|
|
311
|
+
const headers = { accept: "application/json" };
|
|
312
|
+
if (this.#token.length > 0) headers["x-provider-token"] = this.#token;
|
|
313
|
+
const response = await Promise.race([
|
|
314
|
+
fetchImpl(this.#endpoint, { method: "GET", headers, signal: controller.signal }),
|
|
315
|
+
timeout,
|
|
316
|
+
]);
|
|
317
|
+
const httpStatus = Number(response?.status ?? 0);
|
|
318
|
+
if (!(httpStatus >= 200 && httpStatus < 300)) {
|
|
319
|
+
// 只报状态码,不回显响应体:失败响应里可能就带着 key。
|
|
320
|
+
throw new Error(`服务端返回 HTTP ${httpStatus}`);
|
|
321
|
+
}
|
|
322
|
+
let body;
|
|
323
|
+
try {
|
|
324
|
+
body = await response.json();
|
|
325
|
+
} catch {
|
|
326
|
+
// 固定文案:JSON 解析错的原文会带响应体片段,而响应体正是 key
|
|
327
|
+
// 可能出现的地方——错误文案会进日志与 status(),不能从这里漏。
|
|
328
|
+
throw new Error("服务端响应不是合法 JSON");
|
|
329
|
+
}
|
|
330
|
+
const parsed = parseServerConfig(body);
|
|
331
|
+
if (!parsed.ok) throw new Error(parsed.error);
|
|
332
|
+
return parsed.value;
|
|
333
|
+
} finally {
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* 一次告警:同一实例只提示一次,避免后台反复失败把日志刷爆
|
|
340
|
+
* (与 lib/telemetry.js 的 #warn 同思路)。日志里只有状态与端点,没有 key。
|
|
341
|
+
*/
|
|
342
|
+
#warn(message) {
|
|
343
|
+
if (this.#warned) return;
|
|
344
|
+
this.#warned = true;
|
|
345
|
+
try {
|
|
346
|
+
this.#logger?.warn?.(`dsh-decision-engine: ${message}`);
|
|
347
|
+
} catch {
|
|
348
|
+
// 日志器是外部注入的,它抛错不能反过来把刷新流程打挂。
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 读会话事件的**兼容层**。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要这一层
|
|
5
|
+
*
|
|
6
|
+
* 宿主把会话日志从「公开的事件数组」改成了「只能通过快照方法读」:
|
|
7
|
+
*
|
|
8
|
+
* 旧宿主 `session.events` —— 一个可直接遍历的数组
|
|
9
|
+
* 新宿主 `session.snapshotEvents()` —— 物化一段不可变快照
|
|
10
|
+
*
|
|
11
|
+
* 宿主明确标了 `snapshotEvents` / `ownEvents` / `eventAt` 为
|
|
12
|
+
* `@deprecated`(「新调用一律禁止」),但**同时也说了「既有逻辑可以暂不迁移」**。
|
|
13
|
+
* 换句话说:新 API 是推荐用法,旧 API 在当前版本仍可用。
|
|
14
|
+
*
|
|
15
|
+
* 而 `session.events` 在新宿主上**已经不存在**——它不是抛错,是**根本没有
|
|
16
|
+
* 这个成员**。插件原来的写法是 `session.events ?? []`:`??` 兜住了
|
|
17
|
+
* `undefined`,于是**不报错、只是永远拿到空数组**。
|
|
18
|
+
*
|
|
19
|
+
* 后果是静默的:轮次延续点拿到的 `state.last` 恒为 `""`,于是模型是在
|
|
20
|
+
* 对着一句空话判断「这一轮干完了吗」;模型路由点的「有新用户输入」闸门
|
|
21
|
+
* 恒为 false。**没有任何日志说这里出了问题**——用户只会看到插件时灵时不灵。
|
|
22
|
+
* 实测(DEC-045):28 条轮次延续记录里,`last` 全是空串。
|
|
23
|
+
*
|
|
24
|
+
* 因此这一层做两件事:
|
|
25
|
+
*
|
|
26
|
+
* 1. **优先用新 API**(`snapshotEvents()`)——它是宿主推荐的读法;
|
|
27
|
+
* 2. **保留旧 API 作为回退**——插件要能跑在老宿主上(用户的 DSH 可能
|
|
28
|
+
* 还没升级),不能因为宿主升级就静默失效。
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-decision-engine/session-events
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 取会话的事件列表(尽力而为)。
|
|
35
|
+
*
|
|
36
|
+
* @param session - 宿主 Session 对象;缺省或不可用时返回空数组。
|
|
37
|
+
* @returns 事件数组(可能是新 API 的冻结快照,也可能是旧 API 的数组)。
|
|
38
|
+
* 拿不到时返回**空数组**而不是 undefined:调用方一律按「没有事件」处理,
|
|
39
|
+
* 不需要到处判空。
|
|
40
|
+
*/
|
|
41
|
+
export function sessionEventsOf(session) {
|
|
42
|
+
if (session === null || typeof session !== "object") return [];
|
|
43
|
+
// 新宿主:snapshotEvents 是宿主推荐(且未来唯一)的读法。
|
|
44
|
+
try {
|
|
45
|
+
if (typeof session.snapshotEvents === "function") {
|
|
46
|
+
const events = session.snapshotEvents();
|
|
47
|
+
if (Array.isArray(events)) return events;
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
/* 落到旧 API */
|
|
51
|
+
}
|
|
52
|
+
// 旧宿主:公开的 events 数组。
|
|
53
|
+
try {
|
|
54
|
+
if (Array.isArray(session.events)) return session.events;
|
|
55
|
+
} catch {
|
|
56
|
+
/* 读不到就是读不到 */
|
|
57
|
+
}
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 找最近一条满足条件的消息事件(从后往前找)。
|
|
63
|
+
*
|
|
64
|
+
* @param session - 宿主 Session 对象。
|
|
65
|
+
* @param predicate - `(event) => boolean`,判断这条事件是不是要找的。
|
|
66
|
+
* @returns 找到的事件,或 undefined。
|
|
67
|
+
*/
|
|
68
|
+
export function findLastEvent(session, predicate) {
|
|
69
|
+
const events = sessionEventsOf(session);
|
|
70
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
71
|
+
const event = events[index];
|
|
72
|
+
if (predicate(event)) return event;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|