@master0071/dsh-systemone 0.6.1 → 0.7.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/README.md +23 -11
- package/lib/auto.js +211 -13
- package/lib/client.js +170 -23
- package/lib/index.d.ts +23 -2
- package/lib/index.js +38 -1
- package/lib/usage.js +75 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -128,7 +128,7 @@ systemone_decide(
|
|
|
128
128
|
- **提供商无关**:所有工具只依赖统一的 `provider.decide()` 接口。切换厂商只改一行配置,业务工具零改动。
|
|
129
129
|
- **图形配置页**:侧栏「插件 → @master0071/dsh-systemone → systemone」里可直接改提供商、Key、模型与自动决策参数,保存即生效(volatile 热更新,无需重启)。
|
|
130
130
|
- **场景可扩展**:内置 11 大场景(含软件开发与需求预检),并可在配置页里用 JSON 追加或覆盖场景,**无需改代码、无需重启**
|
|
131
|
-
- **可自动决策**:开启后挂 `agent/pre-step`,每一步推理前自动捕获上下文并注入判断(fail-open
|
|
131
|
+
- **可自动决策**:开启后挂 `agent/pre-step`,每一步推理前自动捕获上下文并注入判断(fail-open、硬超时、可缓存、路由复用、失败熔断)。
|
|
132
132
|
- **概率化输出**:返回每个选项的概率分布与置信度,而非单一答案。
|
|
133
133
|
- **复核兜底(分布平坦度)**:`needs_human_review` 的**主要判据是概率分布是否平坦**——最大概率相对均匀分布不足 1.5 倍即视为"模型在猜"(不依赖标定,跨后端可比)。`minConfidence`(默认 0.6)保留为**次要**信号以兼容既有配置。判定理由放在 `review_reasons` 字段与摘要里,不只给一个布尔值(见「复核判定」)。
|
|
134
134
|
- **可编程调用**:注册 `ctx.systemone` 服务,其他插件可直接 `ctx.systemone.decide({ state, questions })`。
|
|
@@ -271,7 +271,7 @@ const decision = await waterfall('agent/pre-step', { // ③ 再跑瀑布
|
|
|
271
271
|
场景:金融风控 · 交易异常与风险等级(来源:自动路由,路由置信度 87.0%)
|
|
272
272
|
结论:异常评分=高度可疑;风险等级=高风险;人工复核=是
|
|
273
273
|
建议:交易异常评分 3,风险等级「高风险」。需转人工复核
|
|
274
|
-
|
|
274
|
+
注意:本次判断的可能分布不够集中(风险等级:最大概率 0.52,相对均匀仅 1.56 倍),请人工确认后再执行。
|
|
275
275
|
```
|
|
276
276
|
|
|
277
277
|
### 安全约束
|
|
@@ -283,6 +283,7 @@ const decision = await waterfall('agent/pre-step', { // ③ 再跑瀑布
|
|
|
283
283
|
| 默认关闭 | `autoDecide: false`,需显式开启 |
|
|
284
284
|
| 硬超时 | `autoTimeoutMs`(默认 8s)。既给上游传 `AbortSignal`,也用 Promise race 兜底——**即使提供商忽略 signal 也不会阻塞推理** |
|
|
285
285
|
| 失败放行 | 任何错误只记 warning,原样返回宿主决策 |
|
|
286
|
+
| 失败熔断 | 连续 **2 次真失败**(请求异常/超时/场景执行失败)后暂停 `autoFailureCooldownMs`(默认 60s):期间直接放行、不再请求上游。没有它,上游故障时每条消息都要等满 `autoTimeoutMs` 才继续。成功一次即清零,`0` 关闭 |
|
|
286
287
|
| 结果缓存 | 相同 state **且决策参数未变**时命中 `autoCacheTtlMs`(默认 60s)缓存,不重复请求;并发相同 state 自动合并。决策参数(固定场景 / provider / model / 两个置信度门槛)变化会使缓存立即失效 |
|
|
287
288
|
| 注入去重 | `message` 通道对同一决策文本只注入一次,避免每步重复追加 |
|
|
288
289
|
| 精准触发 | 仅当本轮有新用户消息时触发;工具结果步骤不触发 |
|
|
@@ -296,6 +297,9 @@ const decision = await waterfall('agent/pre-step', { // ③ 再跑瀑布
|
|
|
296
297
|
- **近期对话**:从 `agent.session.log` 倒序取最近 `autoMaxMessages`(默认 6)条 user/assistant 消息,单条截断 2000 字符;
|
|
297
298
|
- **当前请求**:本轮 `pre-step` 的新用户消息。
|
|
298
299
|
|
|
300
|
+
自动**路由**用的 state 只带最近 2 条历史:路由只回答"这句话属于哪个业务域",
|
|
301
|
+
不值得为一次分类付决策级的 token(默认 6×2000 + 8000 字符会随路由请求再发一遍)。
|
|
302
|
+
|
|
299
303
|
### 配置
|
|
300
304
|
|
|
301
305
|
| 配置项 | 默认值 | 说明 |
|
|
@@ -307,6 +311,8 @@ const decision = await waterfall('agent/pre-step', { // ③ 再跑瀑布
|
|
|
307
311
|
| `autoTimeoutMs` | `8000` | 自动决策硬超时(毫秒) |
|
|
308
312
|
| `autoMaxMessages` | `6` | 捕获的历史消息条数 |
|
|
309
313
|
| `autoCacheTtlMs` | `60000` | 相同内容的结果缓存时长,`0` 表示不缓存。缓存键含决策参数,改场景/模型/门槛会立即失效 |
|
|
314
|
+
| `autoRouteCacheTtlMs` | `180000` | 同一会话内复用上次自动路由结果的时长。复用时只发场景决策请求(少一次上游调用、少一份 state),注入文本标成「自动路由·复用」;决策参数一变立即失效,`0` 表示每条消息都重新路由 |
|
|
315
|
+
| `autoFailureCooldownMs` | `60000` | 连续 2 次失败后的熔断时长(毫秒),期间直接放行、不再请求上游;`0` 关闭熔断 |
|
|
310
316
|
| `autoMinConfidence` | `0.6` | 自动决策的置信度阈值 |
|
|
311
317
|
| `autoRouteMinConfidence` | `0.35` | 自动路由的置信度门槛,低于该值宁可不注入 |
|
|
312
318
|
| `autoMinChars` | `4` | **寒暄判定的长度上界**:只有"短于此长度**且**整体是寒暄"才跳过。`退款`/`报错`/`闪退` 这类中文短请求是完整业务请求,会正常触发;设为 `0` 表示不做长度限制、只按寒暄名单判定 |
|
|
@@ -319,7 +325,8 @@ const decision = await waterfall('agent/pre-step', { // ③ 再跑瀑布
|
|
|
319
325
|
| **`message` 通道仍会写入会话历史** | `dsh-agent-loop` 对 `pre-step` 返回的 messages 执行无条件 `session.append('user/message', …, {surfaceOp:'append'})`。插件已做**内容去重**:同一决策文本只注入一次,把「每步一条」降为「每个不同决策一条」;但长对话中决策多次变化时仍会累积。需要绝对零历史写入时请用 `autoInject: "context"`。 |
|
|
320
326
|
| **注入消息用自己的 `source.kind`** | 注入消息标记为 `source.kind = "systemone-decision"`,**不复用宿主的 `runtime-context`**。宿主 `RuntimeContextProjection` 仅凭该 kind 认定"这是我自己写的快照"并维护去重状态,复用会让宿主每步多追加一条冗余的自身快照。副作用(正向):读历史时现在会排除自己的决策消息、但**保留**宿主真实的动态上下文快照。 |
|
|
321
327
|
| **`context` 通道第一步通常看不到结论** | `systemPrompt.context` 的 `text` 是同步求值,而决策是异步 HTTP 调用,第一步装配时通常尚未返回。插件在 `agent/inbox/inserted` 时预计算,因此**第二步起稳定可见**;单步问答(模型不调工具)时,结论会落到下一轮。 |
|
|
322
|
-
| **自动路由会多一次请求** | 场景数 >1 且 `autoScenario`
|
|
328
|
+
| **自动路由会多一次请求** | 场景数 >1 且 `autoScenario` 为空时,需先路由再决策;同一会话内 `autoRouteCacheTtlMs`(默认 3 分钟)内复用上次路由结果,连续追问只多第一次。固定场景可完全省掉。场景数超过 26 时自动拆成多道 choice 并行问出(每题仍在限制内),仍只是一次请求。 |
|
|
329
|
+
| **路由复用会沿用上一次的场景判定** | 复用窗口内话题切换(从「订单没到账」跳到「看看这段代码」)仍会用旧场景做决策,可能注入一条不相关的参考。介意就把 `autoRouteCacheTtlMs` 设为 `0`;注入本身标着"仅供参考",低置信度还会附人工复核提示。 |
|
|
323
330
|
| **延迟直接叠加** | 自动决策在关键路径上同步等待,SystemOne 延迟会加到首字延迟。`autoTimeoutMs` 是硬上限,超时即放行。 |
|
|
324
331
|
|
|
325
332
|
建议:先用 `autoScenario` 固定场景省掉路由请求;把 `autoTimeoutMs` 设成你能接受的最大延迟;若只是想让模型"知道该用哪个场景",用提示词引导即可,不必开自动决策。
|
|
@@ -352,7 +359,7 @@ const decision = await waterfall('agent/pre-step', { // ③ 再跑瀑布
|
|
|
352
359
|
| --- | --- | --- | --- |
|
|
353
360
|
| `provider` | `unisound` | 热更新 | `unisound`(官方)/ `http`(SystemOne 兼容端点)/ `mock`(本地模拟) |
|
|
354
361
|
| `apiKeyRef` | 空 | 热更新 | **推荐的凭证方式**:从 `$DSH_HOME/.credentials.yaml` 的 `refs.<名字>` 读密钥,配置文件里只留这个名字。留空时依次尝试 `SYSTEMONE_API_KEY` → `UNISOUND_API_KEY` → `TYPESAFE_API_KEY` |
|
|
355
|
-
| `apiKey` | 空 | 热更新 | 明文密钥(备用)。声明为 secret
|
|
362
|
+
| `apiKey` | 空 | 热更新 | 明文密钥(备用)。声明为 secret,**已保存的值不会回显到界面**;要移除它用配置页的「清除已保存的 Key」(走 schema 的 `unset`,而不是写入空串)。仍以明文落在 profile 的 `cordis.patch.yml` 里——不想落明文就用 `apiKeyRef` 或环境变量 |
|
|
356
363
|
| `baseUrl` | `https://maas-api.unisound.com/v1` | 热更新 | Unisound API 基础地址 |
|
|
357
364
|
| `endpoint` | 空 | 热更新 | `provider=http` 时的完整请求端点;留空回退到 `baseUrl` |
|
|
358
365
|
| `model` | `u2-decision` | 热更新 | 决策模型名 |
|
|
@@ -362,7 +369,7 @@ const decision = await waterfall('agent/pre-step', { // ③ 再跑瀑布
|
|
|
362
369
|
| `customScenarios` | 空 | 热更新 | 自定义场景 JSON 数组(见下)。保存后**场景库立即重建**,无需重启 |
|
|
363
370
|
| `autoDecide` | `false` | 需重启 | 是否开启自动决策(挂载 `agent/pre-step`) |
|
|
364
371
|
| `auto*` | 见「自动决策」 | 热更新 | 自动决策的运行参数(钩子挂载后实时生效) |
|
|
365
|
-
| `usageLogPath` | 空 | 热更新 |
|
|
372
|
+
| `usageLogPath` | 空 | 热更新 | 台账文件路径;留空则用 `$DSH_HOME/dsh-systemone/usage.jsonl`。保存后**立即切换**:新台账从新文件恢复当日累计,不会把旧文件的账带过去 |
|
|
366
373
|
|
|
367
374
|
> 为什么 `autoDecide` 需要重启:它决定「要不要挂钩子」,属于装配期决定。其余字段每次请求/每次事件都会重新读取,所以可以热更新。
|
|
368
375
|
|
|
@@ -378,6 +385,7 @@ $DSH_HOME/dsh-systemone/usage.jsonl
|
|
|
378
385
|
|
|
379
386
|
- `source` 区分 `tool`(模型主动调用工具)与 `auto-route`(自动决策的路由请求);目标场景的那次决策也记 `tool`,**不会重复计数**。
|
|
380
387
|
- 计数按**本地日**切分,跨日自动归零,不需要定时任务。
|
|
388
|
+
- 进程启动时从文件尾部(最多 2 MB)**恢复当日累计**:宿主重启不是新的一天,重启后的「今日用量」与将来的日限判定都接着算;半行/坏行会被跳过,读盘失败就退回全 0。
|
|
381
389
|
- 台账写盘失败会**降级为仅内存统计**并只告警一次——记账失败绝不让决策失败。
|
|
382
390
|
|
|
383
391
|
查看今日用量:`systemone_scenario(action: "list")` 的 `### 用量` 小节与 `usage_today` 字段,例如 `今日:7 次判断 · 12.3k input tokens(2026-10-05),其中失败 1 次`。
|
|
@@ -578,24 +586,28 @@ SystemOne 是「结构化问题 → 概率分布」的决策模型,**不是开
|
|
|
578
586
|
|
|
579
587
|
```powershell
|
|
580
588
|
npm run check # 9 个 lib/*.js 语法检查通过
|
|
581
|
-
npm run test #
|
|
589
|
+
npm run test # 195 项测试:
|
|
582
590
|
# - 场景库完整性、11 大场景端到端(有断言强制 sample 覆盖全部内置场景)、
|
|
583
591
|
# 自定义场景合并/覆盖/patch/非法跳过
|
|
584
592
|
# - params 覆盖语义、确定性、失败路径(无网络、无 Key)
|
|
585
593
|
# - 插件入口装配(临时桩实例化:工具注册、降级、卸载)
|
|
586
594
|
# - 自动决策(开关、注入形状与自有 source.kind、fail-open、硬超时、
|
|
587
|
-
# 缓存与"改配置即失效"
|
|
595
|
+
# 缓存与"改配置即失效"、去重、跳过规则、历史捕获、自动路由、
|
|
596
|
+
# 路由复用与热失效、失败熔断、卸载)
|
|
597
|
+
# - 用量台账(累计、跨日、重启后从文件恢复、坏行容错、日限判定)
|
|
598
|
+
# - 配置页(场景 JSON 校验、数字范围、保存被拒的冲突/校验区分、
|
|
599
|
+
# 加载态与"为什么不能保存"的渲染冒烟)
|
|
588
600
|
```
|
|
589
601
|
|
|
590
602
|
## 发布(CI / CD)
|
|
591
603
|
|
|
592
|
-
仓库用 GitHub Actions
|
|
604
|
+
仓库用 GitHub Actions 做持续集成与发布,三个工作流:
|
|
593
605
|
|
|
594
606
|
| 工作流 | 触发 | 做什么 |
|
|
595
607
|
|---|---|---|
|
|
596
|
-
| [`ci.yml`](.github/workflows/ci.yml) | push / PR 到 `master`、`main`,或手动 | Node **22.19 / 24**
|
|
597
|
-
| [`release.yml`](.github/workflows/release.yml) | push `v*` tag
|
|
598
|
-
| [`probe.yml`](.github/workflows/probe.yml) | push / PR、每日定时、或手动 | **接缝探针**:对着三档真实宿主(peer 区间下限哨兵 / 当前基线 /
|
|
608
|
+
| [`ci.yml`](.github/workflows/ci.yml) | push / PR 到 `master`、`main`,或手动 | Ubuntu 上 Node **22.19 / 24** 双版本 + **Windows / Node 24**(产品面向 Windows 桌面端)跑 `npm run check` + `npm test` + 文档数字校验,再跑 `npm pack --dry-run` 确认产物完整 |
|
|
609
|
+
| [`release.yml`](.github/workflows/release.yml) | push `v*` tag,或手动(`dry_run` 默认开) | 校验 tag ↔ 版本(仅 tag 触发;手动触发按 `package.json` 版本走)→ 校验 npm 上未重名 → 测试 → `npm publish --provenance`(`dry_run=true` 时只 `--dry-run`)→ 创建 GitHub Release(仅 tag 触发) |
|
|
610
|
+
| [`probe.yml`](.github/workflows/probe.yml) | push / PR、每日定时、或手动 | **接缝探针**:对着三档真实宿主(peer 区间下限哨兵 / 当前基线 / npm `alpha` 预发布预警档)跑 `npm run probe`,在隔离的临时 profile 里真装一次本包并断言组合后的 `id: systemone` 恰好一行;预警档只告警不挡合并 |
|
|
599
611
|
|
|
600
612
|
### 发一个版本
|
|
601
613
|
|
package/lib/auto.js
CHANGED
|
@@ -73,11 +73,22 @@ export const AUTO_DEFAULTS = {
|
|
|
73
73
|
autoMaxMessages: 6,
|
|
74
74
|
autoTimeoutMs: 8000,
|
|
75
75
|
autoCacheTtlMs: 60000,
|
|
76
|
+
autoRouteCacheTtlMs: 180000,
|
|
77
|
+
autoFailureCooldownMs: 60000,
|
|
76
78
|
autoRouteMinConfidence: 0.35,
|
|
77
79
|
autoMinConfidence: 0.6,
|
|
78
80
|
autoMinChars: 4,
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
/**
|
|
84
|
+
* 连续失败多少次触发熔断。
|
|
85
|
+
*
|
|
86
|
+
* 取 2 而不是 1:单次超时/网络抖动很常见,立刻暂停 60s 会让"偶发失败"变成
|
|
87
|
+
* "一段时间完全没有自动决策",对用户是更难解释的行为。2 次连续失败已经
|
|
88
|
+
* 足以说明上游不可用。
|
|
89
|
+
*/
|
|
90
|
+
const FAILURE_TRIP_COUNT = 2
|
|
91
|
+
|
|
81
92
|
/** fail-closed 档判定:closed 只对"本应得到决策却没有"的真失败生效(见各调用点)。 */
|
|
82
93
|
function isFailClosed(config) {
|
|
83
94
|
return readString(config, 'autoFailMode', 'open') === 'closed'
|
|
@@ -107,6 +118,22 @@ export function installAutoDecide(ctx, deps) {
|
|
|
107
118
|
const inFlight = new Map()
|
|
108
119
|
/** 已告警过的无效固定场景 id:同一错误只提示一次,避免每步刷日志。 */
|
|
109
120
|
const warnedScenarios = new Set()
|
|
121
|
+
/** 已告警过的一次性提示(如"上游没给路由置信度"):同样只提示一次。 */
|
|
122
|
+
const warnedFlags = new Set()
|
|
123
|
+
/**
|
|
124
|
+
* agentId → { scenarioId, confidence, at, fingerprint }。
|
|
125
|
+
*
|
|
126
|
+
* 自动路由是"每条新消息一次"的固定开销(多一次上游请求)。同一话题连续追问时
|
|
127
|
+
* 复用它,把"每条消息两次请求"降为一次。决策指纹(固定场景 / provider / model /
|
|
128
|
+
* 门槛 / 场景库)一变就失效,改配置不会被旧结果粘住。
|
|
129
|
+
*/
|
|
130
|
+
const routeMemo = new Map()
|
|
131
|
+
/** 连续失败次数;成功一次即清零。 */
|
|
132
|
+
let consecutiveFailures = 0
|
|
133
|
+
/** 熔断截止时间戳;0 表示未熔断。 */
|
|
134
|
+
let breakerOpenUntil = 0
|
|
135
|
+
/** 已提示过的熔断窗口,避免冷区期间每条消息都刷一行日志。 */
|
|
136
|
+
let breakerLoggedUntil = 0
|
|
110
137
|
|
|
111
138
|
const log = (level, message) => {
|
|
112
139
|
try {
|
|
@@ -117,12 +144,48 @@ export function installAutoDecide(ctx, deps) {
|
|
|
117
144
|
}
|
|
118
145
|
}
|
|
119
146
|
|
|
147
|
+
/**
|
|
148
|
+
* 熔断是否打开。打开期间直接放行,绝不请求上游——
|
|
149
|
+
* 上游持续故障时,每条消息都等满 autoTimeoutMs 才是真正的用户伤害。
|
|
150
|
+
*/
|
|
151
|
+
function breakerOpen() {
|
|
152
|
+
const now = Date.now()
|
|
153
|
+
if (breakerOpenUntil <= now) return false
|
|
154
|
+
if (breakerLoggedUntil !== breakerOpenUntil) {
|
|
155
|
+
breakerLoggedUntil = breakerOpenUntil
|
|
156
|
+
log('info', `自动决策熔断中(剩余 ${Math.ceil((breakerOpenUntil - now) / 1000)}s),本轮跳过;调小 autoFailureCooldownMs 或等待冷却`)
|
|
157
|
+
}
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 记一次**真失败**(请求异常 / 超时 / 场景执行失败)。
|
|
163
|
+
*
|
|
164
|
+
* 只统计失败、不统计"主动不注入"(寒暄、低置信度、斜杠命令)——后者说明
|
|
165
|
+
* 上游是好的,不能让它们参与熔断。
|
|
166
|
+
*/
|
|
167
|
+
function recordFailure() {
|
|
168
|
+
consecutiveFailures += 1
|
|
169
|
+
const cooldown = readNumber(config, 'autoFailureCooldownMs', AUTO_DEFAULTS.autoFailureCooldownMs)
|
|
170
|
+
if (cooldown > 0 && consecutiveFailures >= FAILURE_TRIP_COUNT && breakerOpenUntil <= Date.now()) {
|
|
171
|
+
breakerOpenUntil = Date.now() + cooldown
|
|
172
|
+
log('warn', `自动决策连续 ${consecutiveFailures} 次失败,熔断 ${Math.round(cooldown / 1000)}s(配置 autoFailureCooldownMs,0 表示关闭)`)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 成功一次就清零:熔断只针对"持续失败",不能让历史失败一直卡着后面的消息。 */
|
|
177
|
+
function recordSuccess() {
|
|
178
|
+
consecutiveFailures = 0
|
|
179
|
+
breakerOpenUntil = 0
|
|
180
|
+
}
|
|
181
|
+
|
|
120
182
|
// agent 销毁时清理状态与 per-agent 监听器,避免长会话内存泄漏
|
|
121
183
|
disposers.push(ctx.on('agent/disposed', (payload) => {
|
|
122
184
|
const id = payload?.agent?.id
|
|
123
185
|
if (id !== undefined) {
|
|
124
186
|
perAgent.delete(id)
|
|
125
187
|
lastInjected.delete(id)
|
|
188
|
+
routeMemo.delete(id)
|
|
126
189
|
const list = agentDisposers.get(id)
|
|
127
190
|
if (list) {
|
|
128
191
|
for (const dispose of list) {
|
|
@@ -210,6 +273,10 @@ export function installAutoDecide(ctx, deps) {
|
|
|
210
273
|
throw error
|
|
211
274
|
}
|
|
212
275
|
|
|
276
|
+
// 别的监听器已经否决了这一步:无论决策算出什么都不可能注入,
|
|
277
|
+
// 先返回可以省掉一次路由 + 一次决策(上游是要计费的)。
|
|
278
|
+
if (decision?.kind === 'reject') return decision
|
|
279
|
+
|
|
213
280
|
// context 通道:不在这里计算(会晚于 assembly),由入箱预计算 + 动态上下文负责
|
|
214
281
|
if (readString(config, 'autoInject', 'message') !== 'message') return decision
|
|
215
282
|
|
|
@@ -219,6 +286,7 @@ export function installAutoDecide(ctx, deps) {
|
|
|
219
286
|
if (!newText) return decision // 工具结果步骤 / 无新用户消息
|
|
220
287
|
if (newText.trimStart().startsWith('/')) return decision // 斜杠命令
|
|
221
288
|
if (isTrivialInput(newText, readNumber(config, 'autoMinChars', AUTO_DEFAULTS.autoMinChars))) return decision // 寒暄/致谢等无业务含义的输入
|
|
289
|
+
if (breakerOpen()) return decision // 上游刚连续失败过:冷却期内直接放行
|
|
222
290
|
|
|
223
291
|
let injected = null
|
|
224
292
|
try {
|
|
@@ -236,9 +304,13 @@ export function installAutoDecide(ctx, deps) {
|
|
|
236
304
|
scenarios,
|
|
237
305
|
cache,
|
|
238
306
|
inFlight,
|
|
307
|
+
routeMemo,
|
|
308
|
+
onFailure: recordFailure,
|
|
309
|
+
onSuccess: recordSuccess,
|
|
239
310
|
signal: timeout.signal,
|
|
240
311
|
log,
|
|
241
312
|
warnedScenarios,
|
|
313
|
+
warnedFlags,
|
|
242
314
|
usage,
|
|
243
315
|
}),
|
|
244
316
|
timeoutMs,
|
|
@@ -247,6 +319,8 @@ export function installAutoDecide(ctx, deps) {
|
|
|
247
319
|
timeout.dispose()
|
|
248
320
|
}
|
|
249
321
|
} catch (error) {
|
|
322
|
+
// 用户/宿主主动取消不算上游失败:两次中断不该把自动决策熔断 60s
|
|
323
|
+
if (!payload?.signal?.aborted) recordFailure()
|
|
250
324
|
// fail-closed 档:决策管线失败 = 本轮没有可用决策,按 closed 语义否决
|
|
251
325
|
// (宿主把 turn 记为 blocked)。open 档维持注入语义:记 warning 放行。
|
|
252
326
|
if (isFailClosed(config)) {
|
|
@@ -281,6 +355,7 @@ export function installAutoDecide(ctx, deps) {
|
|
|
281
355
|
|
|
282
356
|
/** 入箱预计算:异步算好并存进 perAgent,供 context 通道渲染。 */
|
|
283
357
|
async function computeAndStore(agent, newText) {
|
|
358
|
+
if (breakerOpen()) return
|
|
284
359
|
try {
|
|
285
360
|
const timeoutMs = readNumber(config, 'autoTimeoutMs', AUTO_DEFAULTS.autoTimeoutMs)
|
|
286
361
|
const timeout = combineSignal(undefined, timeoutMs)
|
|
@@ -294,9 +369,13 @@ export function installAutoDecide(ctx, deps) {
|
|
|
294
369
|
scenarios,
|
|
295
370
|
cache,
|
|
296
371
|
inFlight,
|
|
372
|
+
routeMemo,
|
|
373
|
+
onFailure: recordFailure,
|
|
374
|
+
onSuccess: recordSuccess,
|
|
297
375
|
signal: timeout.signal,
|
|
298
376
|
log,
|
|
299
377
|
warnedScenarios,
|
|
378
|
+
warnedFlags,
|
|
300
379
|
usage,
|
|
301
380
|
}),
|
|
302
381
|
timeoutMs,
|
|
@@ -306,6 +385,7 @@ export function installAutoDecide(ctx, deps) {
|
|
|
306
385
|
timeout.dispose()
|
|
307
386
|
}
|
|
308
387
|
} catch (error) {
|
|
388
|
+
recordFailure()
|
|
309
389
|
log('warn', `入箱预计算失败(忽略):${messageOf(error)}`)
|
|
310
390
|
}
|
|
311
391
|
}
|
|
@@ -388,13 +468,69 @@ function scenariosFingerprint(scenarios) {
|
|
|
388
468
|
return `${list.length}:${parts.join(',')}`
|
|
389
469
|
}
|
|
390
470
|
|
|
471
|
+
/**
|
|
472
|
+
* 选出本次要用的场景:固定绑定优先;否则走自动路由,并在 TTL 内复用上次结果。
|
|
473
|
+
*
|
|
474
|
+
* 为什么要复用:自动路由是"每条新消息一次"的固定开销(多一次请求、多一份
|
|
475
|
+
* state),而同一话题的连续追问几乎必然落在同一个场景里。复用把"每条消息
|
|
476
|
+
* 两次请求"降为一次。决策指纹(固定场景 / provider / model / 两个门槛 /
|
|
477
|
+
* 场景库)一变立刻失效,改配置不会被旧结果粘住。
|
|
478
|
+
*
|
|
479
|
+
* 复用时来源标成「自动路由·复用」,注入文本与日志都能看出这不是新判定;
|
|
480
|
+
* 想每条消息都重新路由,把 autoRouteCacheTtlMs 设为 0。
|
|
481
|
+
*/
|
|
482
|
+
async function resolveRoute({ agent, newText, state, fingerprint, routeMemo, provider, config, scenarios, signal, log, warnedScenarios, usage }) {
|
|
483
|
+
// 固定场景是一次本地查找,没有请求,也就没有可复用的东西
|
|
484
|
+
if (readString(config, 'autoScenario', '').trim()) {
|
|
485
|
+
return resolveScenario({ state, provider, config, scenarios, signal, log, warnedScenarios, usage })
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const ttl = readNumber(config, 'autoRouteCacheTtlMs', AUTO_DEFAULTS.autoRouteCacheTtlMs)
|
|
489
|
+
const agentId = agent?.id ?? 'unknown'
|
|
490
|
+
if (ttl > 0 && routeMemo) {
|
|
491
|
+
const memo = routeMemo.get(agentId)
|
|
492
|
+
if (memo && memo.fingerprint === fingerprint && Date.now() - memo.at < ttl) {
|
|
493
|
+
const scenario = findScenario(scenarios, memo.scenarioId)
|
|
494
|
+
if (scenario) {
|
|
495
|
+
log('info', `复用 ${Math.round((Date.now() - memo.at) / 1000)}s 前的路由结果:${scenario.id}`)
|
|
496
|
+
// autoRouted 是结构性标记:`autoRouteMinConfidence` 只对"真的路由过"的结果有意义,
|
|
497
|
+
// 固定绑定/唯一场景的 confidence 恒为 null,不能拿去和门槛比。
|
|
498
|
+
return { scenario, source: '自动路由·复用', confidence: memo.confidence, autoRouted: true }
|
|
499
|
+
}
|
|
500
|
+
// 场景被删掉了(自定义场景热更新):丢掉这份记忆,走真路由
|
|
501
|
+
routeMemo.delete(agentId)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const routed = await resolveScenario({
|
|
506
|
+
state: captureRouteState(agent, newText),
|
|
507
|
+
provider,
|
|
508
|
+
config,
|
|
509
|
+
scenarios,
|
|
510
|
+
signal,
|
|
511
|
+
log,
|
|
512
|
+
warnedScenarios,
|
|
513
|
+
usage,
|
|
514
|
+
})
|
|
515
|
+
if (routed && ttl > 0 && routeMemo) {
|
|
516
|
+
routeMemo.set(agentId, {
|
|
517
|
+
scenarioId: routed.scenario.id,
|
|
518
|
+
confidence: routed.confidence,
|
|
519
|
+
at: Date.now(),
|
|
520
|
+
fingerprint,
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
return routed
|
|
524
|
+
}
|
|
525
|
+
|
|
391
526
|
/**
|
|
392
527
|
* 计算决策(带并发去重)。相同 state 且决策参数未变时只发一次上游请求。
|
|
393
528
|
*/
|
|
394
|
-
async function computeDecision({ agent, newText, provider, config, scenarios, cache, inFlight, signal, log, warnedScenarios, usage }) {
|
|
529
|
+
async function computeDecision({ agent, newText, provider, config, scenarios, cache, inFlight, routeMemo, onFailure, onSuccess, signal, log, warnedScenarios, warnedFlags, usage }) {
|
|
395
530
|
const state = captureState(agent, newText, readNumber(config, 'autoMaxMessages', AUTO_DEFAULTS.autoMaxMessages))
|
|
396
531
|
// key = state 文本 + 决策指纹:state 决定"问什么",指纹决定"按什么配置问"
|
|
397
|
-
const
|
|
532
|
+
const fingerprint = decisionFingerprint(config, provider, scenariosFingerprint(scenarios))
|
|
533
|
+
const key = hashString(`${state}\u0000${fingerprint}`)
|
|
398
534
|
const existing = inFlight.get(key)
|
|
399
535
|
if (existing) return existing
|
|
400
536
|
const promise = decide({
|
|
@@ -402,6 +538,10 @@ async function computeDecision({ agent, newText, provider, config, scenarios, ca
|
|
|
402
538
|
newText,
|
|
403
539
|
state,
|
|
404
540
|
key,
|
|
541
|
+
fingerprint,
|
|
542
|
+
routeMemo,
|
|
543
|
+
onFailure,
|
|
544
|
+
onSuccess,
|
|
405
545
|
provider,
|
|
406
546
|
config,
|
|
407
547
|
scenarios,
|
|
@@ -409,6 +549,7 @@ async function computeDecision({ agent, newText, provider, config, scenarios, ca
|
|
|
409
549
|
signal,
|
|
410
550
|
log,
|
|
411
551
|
warnedScenarios,
|
|
552
|
+
warnedFlags,
|
|
412
553
|
usage,
|
|
413
554
|
}).finally(() => {
|
|
414
555
|
if (inFlight.get(key) === promise) inFlight.delete(key)
|
|
@@ -417,23 +558,53 @@ async function computeDecision({ agent, newText, provider, config, scenarios, ca
|
|
|
417
558
|
return promise
|
|
418
559
|
}
|
|
419
560
|
|
|
420
|
-
async function decide({ agent, newText, state, key, provider, config, scenarios, cache, signal, log, warnedScenarios, usage }) {
|
|
561
|
+
async function decide({ agent, newText, state, key, fingerprint, routeMemo, onFailure, onSuccess, provider, config, scenarios, cache, signal, log, warnedScenarios, warnedFlags, usage }) {
|
|
421
562
|
const ttl = readNumber(config, 'autoCacheTtlMs', AUTO_DEFAULTS.autoCacheTtlMs) || 0
|
|
422
563
|
if (ttl > 0) {
|
|
423
564
|
const hit = cache.get(key)
|
|
424
565
|
if (hit && Date.now() - hit.at < ttl) return hit.result
|
|
425
566
|
}
|
|
426
567
|
|
|
427
|
-
// 1)
|
|
428
|
-
const routed = await
|
|
568
|
+
// 1) 选场景:固定绑定,或(可能复用上次结果的)自动路由
|
|
569
|
+
const routed = await resolveRoute({
|
|
570
|
+
agent,
|
|
571
|
+
newText,
|
|
572
|
+
state,
|
|
573
|
+
fingerprint,
|
|
574
|
+
routeMemo,
|
|
575
|
+
provider,
|
|
576
|
+
config,
|
|
577
|
+
scenarios,
|
|
578
|
+
signal,
|
|
579
|
+
log,
|
|
580
|
+
warnedScenarios,
|
|
581
|
+
usage,
|
|
582
|
+
})
|
|
429
583
|
if (!routed) return null
|
|
430
584
|
|
|
431
|
-
//
|
|
585
|
+
// 路由置信度不足:宁可不注入,也不要塞一个瞎猜的结论。
|
|
586
|
+
//
|
|
587
|
+
// 只对**自动路由**生效:固定场景没有"路由置信度"这个概念(confidence 恒为 null),
|
|
588
|
+
// 早期用 `Number.isFinite(confidence)` 做前置判断,副作用是"上游没返回 confidence"
|
|
589
|
+
// 也会被当成"无法判定 → 放行",等于悄悄绕过了用户设的门槛。现在缺失按 0 处理,
|
|
590
|
+
// 并提示一次该怎么恢复(把门槛设为 0,或改用固定场景)。
|
|
432
591
|
const floor = readNumber(config, 'autoRouteMinConfidence', AUTO_DEFAULTS.autoRouteMinConfidence)
|
|
433
|
-
if (Number.isFinite(floor) && floor > 0
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
592
|
+
if (routed.autoRouted === true && Number.isFinite(floor) && floor > 0) {
|
|
593
|
+
const confidenceKnown = Number.isFinite(routed.confidence)
|
|
594
|
+
if (!confidenceKnown) {
|
|
595
|
+
const flag = `route-confidence-missing:${readString(provider, 'name', 'unknown')}`
|
|
596
|
+
if (!warnedFlags?.has(flag)) {
|
|
597
|
+
warnedFlags?.add(flag)
|
|
598
|
+
log?.('warn', '上游没有返回路由置信度,已按 0 处理(不注入);若该后端本就不返回 confidence,把 autoRouteMinConfidence 设为 0,或改用固定场景 autoScenario')
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const confidence = confidenceKnown ? routed.confidence : 0
|
|
602
|
+
if (confidence < floor) {
|
|
603
|
+
// 这次上游是好的(路由拿到了回答):算健康的"主动不注入"
|
|
604
|
+
onSuccess?.()
|
|
605
|
+
log?.('info', `路由置信度 ${(confidence * 100).toFixed(1)}% 低于阈值 ${(floor * 100).toFixed(0)}%,跳过注入`)
|
|
606
|
+
return null
|
|
607
|
+
}
|
|
437
608
|
}
|
|
438
609
|
|
|
439
610
|
// 2) 执行目标场景
|
|
@@ -448,11 +619,13 @@ async function decide({ agent, newText, state, key, provider, config, scenarios,
|
|
|
448
619
|
usage,
|
|
449
620
|
})
|
|
450
621
|
if (!result?.ok) {
|
|
451
|
-
// 上游明确回答"决策失败":closed 档必须上抛,让 pre-step 的 catch
|
|
452
|
-
//
|
|
622
|
+
// 上游明确回答"决策失败":closed 档必须上抛,让 pre-step 的 catch 按语义拦截
|
|
623
|
+
// (失败也由那个 catch 计数,这里不能重复计一次);
|
|
624
|
+
// open 档维持原行为:计入熔断 + warn + 不注入(= 放行)。
|
|
453
625
|
if (isFailClosed(config)) {
|
|
454
626
|
throw new Error(`场景 ${routed.scenario.id} 执行失败:${result?.error || '未知错误'}`)
|
|
455
627
|
}
|
|
628
|
+
onFailure?.()
|
|
456
629
|
log?.('warn', `场景 ${routed.scenario.id} 执行失败,已放行:${result?.error || '未知错误'}`)
|
|
457
630
|
return null
|
|
458
631
|
}
|
|
@@ -465,6 +638,7 @@ async function decide({ agent, newText, state, key, provider, config, scenarios,
|
|
|
465
638
|
headline: result.recommendation || routed.scenario.title,
|
|
466
639
|
at: Date.now(),
|
|
467
640
|
}
|
|
641
|
+
onSuccess?.()
|
|
468
642
|
if (ttl > 0) {
|
|
469
643
|
// 缓存上限:先清过期,再按插入序淘汰最旧,防止长会话无限增长
|
|
470
644
|
if (cache.size >= MAX_AUTO_CACHE) {
|
|
@@ -579,7 +753,7 @@ async function resolveScenario({ state, provider, config, scenarios, signal, log
|
|
|
579
753
|
return bc - ac
|
|
580
754
|
})
|
|
581
755
|
const best = candidates[0]
|
|
582
|
-
return { scenario: best.scenario, source: '自动路由', confidence: best.confidence }
|
|
756
|
+
return { scenario: best.scenario, source: '自动路由', confidence: best.confidence, autoRouted: true }
|
|
583
757
|
}
|
|
584
758
|
|
|
585
759
|
/** 把决策结果渲染成注入文本。 */
|
|
@@ -660,6 +834,30 @@ function captureState(agent, newText, maxMessages) {
|
|
|
660
834
|
return parts.join('\n\n')
|
|
661
835
|
}
|
|
662
836
|
|
|
837
|
+
/** 路由用的历史条数:只为接住「那这个呢」这类短追问,不付整段历史的 token。 */
|
|
838
|
+
const ROUTE_HISTORY_MESSAGES = 2
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* 路由用的 state:最近两条历史 + 本轮请求。
|
|
842
|
+
*
|
|
843
|
+
* 自动路由只回答"这句话属于哪个业务域",却和场景决策共用同一份 state——
|
|
844
|
+
* 默认 6 条 × 2000 字的历史被发两次。分类不需要那么多上下文,砍到两条历史,
|
|
845
|
+
* 路由请求的体积就从「决策级」降到「分类级」。
|
|
846
|
+
*/
|
|
847
|
+
function captureRouteState(agent, newText) {
|
|
848
|
+
const history = readHistory(agent, ROUTE_HISTORY_MESSAGES)
|
|
849
|
+
const current = truncate(newText, MAX_CURRENT_CHARS)
|
|
850
|
+
// 当前消息通常已写进 session.log,会被 readHistory 取到:去掉这份重复
|
|
851
|
+
const tailText = truncate(current, HISTORY_CHARS_PER_MESSAGE)
|
|
852
|
+
const tail = history[history.length - 1]
|
|
853
|
+
if (tail && tail.endsWith(`:${tailText}`)) history.pop()
|
|
854
|
+
|
|
855
|
+
const parts = []
|
|
856
|
+
if (history.length > 0) parts.push(`【近期对话】\n${history.join('\n')}`)
|
|
857
|
+
parts.push(`【当前请求】\n${current}`)
|
|
858
|
+
return parts.join('\n\n')
|
|
859
|
+
}
|
|
860
|
+
|
|
663
861
|
function readHistory(agent, limit) {
|
|
664
862
|
const events = agent?.session?.log
|
|
665
863
|
if (!Array.isArray(events)) return []
|
package/lib/client.js
CHANGED
|
@@ -71,6 +71,8 @@ window.__ModuleLoader__.load({
|
|
|
71
71
|
endpointHint: '仅在「自建 / 兼容端点」时使用;留空则回退到基础地址',
|
|
72
72
|
model: '模型名',
|
|
73
73
|
modelHint: '默认 u2-decision',
|
|
74
|
+
usageLogPath: '用量台账路径',
|
|
75
|
+
usageLogPathHint: '留空则用 $DSH_HOME/dsh-systemone/usage.jsonl;保存后立即切换(新文件从当天记录恢复累计)',
|
|
74
76
|
timeoutMs: '请求超时(毫秒)',
|
|
75
77
|
timeoutMsHint: '单次决策请求的最长等待时间',
|
|
76
78
|
minConfidence: '置信度阈值',
|
|
@@ -95,22 +97,34 @@ window.__ModuleLoader__.load({
|
|
|
95
97
|
autoMaxMessagesHint: '自动决策时参考的最近消息数',
|
|
96
98
|
autoCacheTtlMs: '结果缓存时长(毫秒)',
|
|
97
99
|
autoCacheTtlMsHint: '相同内容在此时长内复用结果,0 表示不缓存',
|
|
100
|
+
autoRouteCacheTtlMs: '路由复用时长(毫秒)',
|
|
101
|
+
autoRouteCacheTtlMsHint: '同一会话内复用上次自动路由结果,期间只发场景决策请求;0 = 每条消息都重新路由',
|
|
102
|
+
autoFailureCooldownMs: '失败熔断时长(毫秒)',
|
|
103
|
+
autoFailureCooldownMsHint: '自动决策连续失败 2 次后暂停该时长(期间直接放行、不再请求上游);0 = 关闭熔断',
|
|
98
104
|
autoMinConfidence: '自动决策置信度阈值',
|
|
99
105
|
autoMinConfidenceHint: '低于该值时结论会附带「请人工确认」提示',
|
|
100
106
|
autoRouteMinConfidence: '自动路由置信度门槛',
|
|
101
107
|
autoRouteMinConfidenceHint: '低于该值时宁可不注入,避免塞入瞎猜的结论',
|
|
102
|
-
autoMinChars: '
|
|
103
|
-
autoMinCharsHint: '
|
|
108
|
+
autoMinChars: '寒暄判定上界(字符)',
|
|
109
|
+
autoMinCharsHint: '只有「短于该长度 且 整体是寒暄/致谢」才跳过;短业务请求(如「退款」)照常触发。0 = 不做长度限制',
|
|
104
110
|
save: '保存',
|
|
105
111
|
saving: '保存中…',
|
|
106
112
|
revert: '撤销修改',
|
|
113
|
+
saveBlocked: '有 {count} 处填写问题,先修正才能保存:{reason}',
|
|
114
|
+
clearApiKey: '清除已保存的 Key',
|
|
115
|
+
clearApiKeyOn: '将清除(保存后生效)',
|
|
107
116
|
saved: '已保存,立即生效',
|
|
108
117
|
saveFailed: '保存失败,请检查填写内容',
|
|
109
118
|
numberInvalid: '必须是有效数字',
|
|
110
119
|
numberRequired: '数字字段不能为空',
|
|
120
|
+
numberRange: '超出允许范围(需 {bounds})',
|
|
111
121
|
conflict: '配置已被其他地方改动,已刷新为最新值,请重新修改',
|
|
122
|
+
saveRefused: '保存被宿主拒绝:有字段没通过校验(如超出允许范围或枚举值不合法),请修正后重试',
|
|
112
123
|
readOnly: '当前不可编辑',
|
|
113
124
|
noForm: '宿主要求该条目声明 volatile 配置字段后才会显示表单。',
|
|
125
|
+
loadingConfig: '正在读取配置…',
|
|
126
|
+
unavailableConfig: '宿主没有向该页面提供这份配置(unavailable)',
|
|
127
|
+
unknownOption: '未知值',
|
|
114
128
|
summary: 'SystemOne 决策模型配置',
|
|
115
129
|
current: '当前:{provider} · {model}',
|
|
116
130
|
expand: '展开',
|
|
@@ -152,6 +166,8 @@ window.__ModuleLoader__.load({
|
|
|
152
166
|
endpointHint: 'Used only by the custom/compatible provider; falls back to the base URL',
|
|
153
167
|
model: 'Model',
|
|
154
168
|
modelHint: 'Defaults to u2-decision',
|
|
169
|
+
usageLogPath: 'Usage ledger path',
|
|
170
|
+
usageLogPathHint: 'Empty means $DSH_HOME/dsh-systemone/usage.jsonl; changing it applies immediately (today totals are restored from the new file)',
|
|
155
171
|
timeoutMs: 'Request timeout (ms)',
|
|
156
172
|
timeoutMsHint: 'Longest wait for one decision request',
|
|
157
173
|
minConfidence: 'Confidence threshold',
|
|
@@ -176,22 +192,34 @@ window.__ModuleLoader__.load({
|
|
|
176
192
|
autoMaxMessagesHint: 'How many recent messages the automatic decision reads',
|
|
177
193
|
autoCacheTtlMs: 'Result cache TTL (ms)',
|
|
178
194
|
autoCacheTtlMsHint: 'Reuse a result for identical input; 0 disables caching',
|
|
195
|
+
autoRouteCacheTtlMs: 'Route reuse window (ms)',
|
|
196
|
+
autoRouteCacheTtlMsHint: 'Reuse the previous auto-route result within a session — only the scenario request is sent; 0 re-routes every message',
|
|
197
|
+
autoFailureCooldownMs: 'Failure cooldown (ms)',
|
|
198
|
+
autoFailureCooldownMsHint: 'Pause auto decisions for this long after 2 consecutive failures (steps proceed untouched); 0 disables the breaker',
|
|
179
199
|
autoMinConfidence: 'Auto decision confidence threshold',
|
|
180
200
|
autoMinConfidenceHint: 'Below this value the conclusion carries a human-review note',
|
|
181
201
|
autoRouteMinConfidence: 'Auto routing confidence floor',
|
|
182
202
|
autoRouteMinConfidenceHint: 'Below this value nothing is injected rather than guessing',
|
|
183
|
-
autoMinChars: '
|
|
184
|
-
autoMinCharsHint: 'Skipped when shorter than this
|
|
203
|
+
autoMinChars: 'Greeting length bound (chars)',
|
|
204
|
+
autoMinCharsHint: 'Skipped only when shorter than this AND it is a bare greeting; short real requests (e.g. "refund") still trigger. 0 disables the length check',
|
|
185
205
|
save: 'Save',
|
|
186
206
|
saving: 'Saving…',
|
|
187
207
|
revert: 'Revert changes',
|
|
208
|
+
saveBlocked: '{count} field issue(s) must be fixed before saving: {reason}',
|
|
209
|
+
clearApiKey: 'Clear the saved key',
|
|
210
|
+
clearApiKeyOn: 'Will be cleared on save',
|
|
188
211
|
saved: 'Saved — applied immediately',
|
|
189
212
|
saveFailed: 'Save failed — check the values',
|
|
190
213
|
numberInvalid: 'Must be a valid number',
|
|
191
214
|
numberRequired: 'Number fields cannot be empty',
|
|
215
|
+
numberRange: 'Out of range (allowed: {bounds})',
|
|
192
216
|
conflict: 'The configuration changed elsewhere; the newest values were reloaded',
|
|
217
|
+
saveRefused: 'The Host rejected the save: a field failed validation (range or enum). Fix it and try again',
|
|
193
218
|
readOnly: 'Read-only right now',
|
|
194
219
|
noForm: 'The host exposes a form only when the entry declares volatile config fields.',
|
|
220
|
+
loadingConfig: 'Loading configuration…',
|
|
221
|
+
unavailableConfig: 'The Host does not expose this configuration to the page (unavailable)',
|
|
222
|
+
unknownOption: 'unknown',
|
|
195
223
|
summary: 'SystemOne decision model configuration',
|
|
196
224
|
current: 'Current: {provider} · {model}',
|
|
197
225
|
expand: 'Expand',
|
|
@@ -315,9 +343,13 @@ window.__ModuleLoader__.load({
|
|
|
315
343
|
{ key: 'baseUrl', kind: 'text' },
|
|
316
344
|
{ key: 'endpoint', kind: 'text' },
|
|
317
345
|
{ key: 'model', kind: 'text' },
|
|
318
|
-
|
|
319
|
-
|
|
346
|
+
// min/max 必须与 lib/index.js 的 z.number().min().max() 一致:
|
|
347
|
+
// 越界值会被宿主在保存/热更新链路里拒绝,界面不提前拦就会「显示已保存、实际不生效」。
|
|
348
|
+
{ key: 'timeoutMs', kind: 'number', min: 1000 },
|
|
349
|
+
{ key: 'minConfidence', kind: 'number', min: 0, max: 1 },
|
|
320
350
|
{ key: 'redact', kind: 'select', options: ['false', 'true'] },
|
|
351
|
+
// 台账路径也是 volatile:保存后宿主重建台账(新文件从当天记录恢复累计)
|
|
352
|
+
{ key: 'usageLogPath', kind: 'text' },
|
|
321
353
|
]
|
|
322
354
|
|
|
323
355
|
/** 自定义场景编辑区(JSON)。 */
|
|
@@ -419,12 +451,14 @@ window.__ModuleLoader__.load({
|
|
|
419
451
|
{ key: 'autoScenario', kind: 'datalist', options: SCENARIO_OPTIONS },
|
|
420
452
|
{ key: 'autoInject', kind: 'select', options: ['message', 'context'] },
|
|
421
453
|
{ key: 'autoFailMode', kind: 'select', options: ['open', 'closed'] },
|
|
422
|
-
{ key: 'autoTimeoutMs', kind: 'number' },
|
|
423
|
-
{ key: 'autoMaxMessages', kind: 'number' },
|
|
424
|
-
{ key: 'autoCacheTtlMs', kind: 'number' },
|
|
425
|
-
{ key: '
|
|
426
|
-
{ key: '
|
|
427
|
-
{ key: '
|
|
454
|
+
{ key: 'autoTimeoutMs', kind: 'number', min: 500 },
|
|
455
|
+
{ key: 'autoMaxMessages', kind: 'number', min: 1, max: 50 },
|
|
456
|
+
{ key: 'autoCacheTtlMs', kind: 'number', min: 0 },
|
|
457
|
+
{ key: 'autoRouteCacheTtlMs', kind: 'number', min: 0 },
|
|
458
|
+
{ key: 'autoFailureCooldownMs', kind: 'number', min: 0 },
|
|
459
|
+
{ key: 'autoMinConfidence', kind: 'number', min: 0, max: 1 },
|
|
460
|
+
{ key: 'autoRouteMinConfidence', kind: 'number', min: 0, max: 1 },
|
|
461
|
+
{ key: 'autoMinChars', kind: 'number', min: 0 },
|
|
428
462
|
]
|
|
429
463
|
|
|
430
464
|
/** 选项字段的显示名走字典,例如 provider 的 unisound → 「云知声(官方)」。 */
|
|
@@ -454,6 +488,50 @@ window.__ModuleLoader__.load({
|
|
|
454
488
|
return String(value)
|
|
455
489
|
}
|
|
456
490
|
|
|
491
|
+
/**
|
|
492
|
+
* 数字字段的客户端校验:空值 / 非数字 / 超出宿主 schema 的范围。
|
|
493
|
+
*
|
|
494
|
+
* 范围校验是必须的:schema 的范围约束在宿主侧才生效,界面如果只查"是不是数字",
|
|
495
|
+
* 越界值会先显示「已保存,立即生效」,随后要么被拒、要么静默不生效。
|
|
496
|
+
* 返回 undefined 表示通过。
|
|
497
|
+
*
|
|
498
|
+
* @param {{key:string, min?:number, max?:number}} field - 字段元数据
|
|
499
|
+
* @param {unknown} rawValue - 草稿里的原始值(字符串)
|
|
500
|
+
* @param {(key:string, params?:object) => string} t - 翻译函数
|
|
501
|
+
* @returns {string|undefined}
|
|
502
|
+
*/
|
|
503
|
+
function numberErrorFor(field, rawValue, t) {
|
|
504
|
+
const raw = String(rawValue ?? '').trim()
|
|
505
|
+
if (raw === '') return t('numberRequired')
|
|
506
|
+
const value = Number(raw)
|
|
507
|
+
if (!Number.isFinite(value)) return t('numberInvalid')
|
|
508
|
+
const min = Number.isFinite(field?.min) ? field.min : undefined
|
|
509
|
+
const max = Number.isFinite(field?.max) ? field.max : undefined
|
|
510
|
+
const tooSmall = min !== undefined && value < min
|
|
511
|
+
const tooLarge = max !== undefined && value > max
|
|
512
|
+
if (!tooSmall && !tooLarge) return undefined
|
|
513
|
+
const bounds = min !== undefined && max !== undefined
|
|
514
|
+
? `${min}~${max}`
|
|
515
|
+
: min !== undefined ? `≥ ${min}` : `≤ ${max}`
|
|
516
|
+
return t('numberRange', { bounds })
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* 保存被宿主拒绝时的提示文案。
|
|
521
|
+
*
|
|
522
|
+
* 宿主对 `mutate()` 返回 false 有两种含义:revision 被别人推进了(并发冲突,
|
|
523
|
+
* 草稿已过期),或值本身没通过 schema 校验。一律报「配置已被其他地方改动」
|
|
524
|
+
* 会把输入错误误导成并发问题,所以按 revision 是否变化区分。
|
|
525
|
+
*
|
|
526
|
+
* @param {{ok:boolean, revision?:number, latestRevision?:number}} outcome
|
|
527
|
+
* @param {(key:string) => string} t
|
|
528
|
+
* @returns {string|undefined} ok 为真时无文案
|
|
529
|
+
*/
|
|
530
|
+
function saveResultText({ ok, revision, latestRevision }, t) {
|
|
531
|
+
if (ok) return undefined
|
|
532
|
+
return latestRevision !== revision ? t('conflict') : t('saveRefused')
|
|
533
|
+
}
|
|
534
|
+
|
|
457
535
|
/* ────────────────────────────── 组件 ────────────────────────────── */
|
|
458
536
|
|
|
459
537
|
/**
|
|
@@ -491,15 +569,25 @@ window.__ModuleLoader__.load({
|
|
|
491
569
|
let control
|
|
492
570
|
if (field.kind === 'select') {
|
|
493
571
|
// 注意:jsx(type, props, key) 的第三个参数是 key,children 必须放进 props
|
|
572
|
+
// 值不在候选里时(例如手改 cordis.patch.yml 写成 provider: openai),
|
|
573
|
+
// 浏览器会把 select 显示成第一项,形成"看起来是云知声、实际是 openai"的脏回显。
|
|
574
|
+
// 补一个显式的"未知值"选项,让界面说的是真话。
|
|
575
|
+
const text = String(value ?? '')
|
|
576
|
+
const unknown = text !== '' && !field.options.includes(text)
|
|
494
577
|
control = h('select', {
|
|
495
578
|
id: controlId,
|
|
496
579
|
style: inputStyle,
|
|
497
580
|
value: value,
|
|
498
581
|
disabled,
|
|
499
582
|
onChange: (event) => onChange(field.key, event.target.value),
|
|
500
|
-
children:
|
|
501
|
-
|
|
502
|
-
|
|
583
|
+
children: [
|
|
584
|
+
...(unknown
|
|
585
|
+
? [h('option', { key: '__unknown__', value: text, children: `${text}(${t('unknownOption')})` })]
|
|
586
|
+
: []),
|
|
587
|
+
...field.options.map((option) =>
|
|
588
|
+
h('option', { key: option, value: option, children: optionLabel(t, field.key, option) }),
|
|
589
|
+
),
|
|
590
|
+
],
|
|
503
591
|
})
|
|
504
592
|
} else if (field.kind === 'datalist') {
|
|
505
593
|
// 带候选的输入框:可直接点选内置场景,也能手填自定义场景 id / 别名
|
|
@@ -606,6 +694,8 @@ window.__ModuleLoader__.load({
|
|
|
606
694
|
const [draft, setDraft] = react.useState(null)
|
|
607
695
|
const [busy, setBusy] = react.useState(false)
|
|
608
696
|
const [notice, setNotice] = react.useState(null)
|
|
697
|
+
// apiKey 是 secret:宿主快照里拿不到它,所以「清空」只能是独立意图,不能靠输入框清空(空值代表"不修改")
|
|
698
|
+
const [clearApiKey, setClearApiKey] = react.useState(false)
|
|
609
699
|
|
|
610
700
|
const live = form?.state?.value ?? {}
|
|
611
701
|
const revision = form?.state?.revision
|
|
@@ -627,6 +717,16 @@ window.__ModuleLoader__.load({
|
|
|
627
717
|
return h('p', { style: statusStyle, children: t('noForm') })
|
|
628
718
|
}
|
|
629
719
|
|
|
720
|
+
// 快照还没到(loading)或本部署不提供该命名空间(unavailable)时,别把空表单渲染成
|
|
721
|
+
// 一片"数字字段不能为空 + 保存按钮全灰"——那看起来像用户填错了。
|
|
722
|
+
const status = form?.state?.status
|
|
723
|
+
if (status !== undefined && status !== 'ready') {
|
|
724
|
+
return h('p', {
|
|
725
|
+
style: statusStyle,
|
|
726
|
+
children: status === 'loading' ? t('loadingConfig') : t('unavailableConfig'),
|
|
727
|
+
})
|
|
728
|
+
}
|
|
729
|
+
|
|
630
730
|
const ALL_FIELDS = [...SERVICE_FIELDS, ...CUSTOM_FIELDS, ...AUTO_FIELDS]
|
|
631
731
|
const current = {}
|
|
632
732
|
for (const field of ALL_FIELDS) current[field.key] = toDraft(live[field.key])
|
|
@@ -642,18 +742,27 @@ window.__ModuleLoader__.load({
|
|
|
642
742
|
const numberErrors = {}
|
|
643
743
|
for (const field of ALL_FIELDS) {
|
|
644
744
|
if (field.kind !== 'number') continue
|
|
645
|
-
const
|
|
646
|
-
if (
|
|
647
|
-
else if (!Number.isFinite(Number(raw))) numberErrors[field.key] = t('numberInvalid')
|
|
745
|
+
const error = numberErrorFor(field, values[field.key], t)
|
|
746
|
+
if (error) numberErrors[field.key] = error
|
|
648
747
|
}
|
|
649
748
|
const numberExtras = {}
|
|
650
749
|
for (const [key, error] of Object.entries(numberErrors)) {
|
|
651
750
|
numberExtras[key] = { error }
|
|
652
751
|
}
|
|
653
752
|
|
|
654
|
-
const dirty = draft !== null && Object.keys(draft).some((key) => draft[key] !== current[key])
|
|
753
|
+
const dirty = clearApiKey || (draft !== null && Object.keys(draft).some((key) => draft[key] !== current[key]))
|
|
655
754
|
const canSave = dirty && !busy && writable && customCheck.ok && Object.keys(numberErrors).length === 0
|
|
656
755
|
|
|
756
|
+
// 保存按钮为什么是灰的,必须写在界面上:三张卡共用一个 canSave,
|
|
757
|
+
// 而错误只出现在出问题的那张卡里(「自定义场景」卡还默认折叠)。
|
|
758
|
+
// 否则用户看到的是一个没有任何解释的灰按钮。
|
|
759
|
+
const blockers = []
|
|
760
|
+
if (!customCheck.ok && customErrorText) blockers.push(`${t('tabCustom')}:${customErrorText}`)
|
|
761
|
+
for (const [key, error] of Object.entries(numberErrors)) blockers.push(`${t(key)}:${error}`)
|
|
762
|
+
const blockedText = blockers.length > 0
|
|
763
|
+
? t('saveBlocked', { count: blockers.length, reason: blockers[0] })
|
|
764
|
+
: undefined
|
|
765
|
+
|
|
657
766
|
// 「固定场景」候选 = 11 个内置 + 当前生效的自定义场景 id(patch 条目指向已有 id,去重避免重复项)
|
|
658
767
|
const scenarioOptions = [
|
|
659
768
|
...SCENARIO_OPTIONS,
|
|
@@ -666,6 +775,8 @@ window.__ModuleLoader__.load({
|
|
|
666
775
|
|
|
667
776
|
const onFieldChange = (key, value) => {
|
|
668
777
|
setNotice(null)
|
|
778
|
+
// 用户开始输入新 Key 时,「清除」这个意图就作废了——否则会先 set 再 unset。
|
|
779
|
+
if (key === 'apiKey' && clearApiKey) setClearApiKey(false)
|
|
669
780
|
setDraft((previous) => ({ ...(previous ?? {}), [key]: value }))
|
|
670
781
|
}
|
|
671
782
|
|
|
@@ -691,6 +802,9 @@ window.__ModuleLoader__.load({
|
|
|
691
802
|
if (key === 'redact') next = value === 'true'
|
|
692
803
|
ops.push({ op: 'set', path: [key], value: next })
|
|
693
804
|
}
|
|
805
|
+
// 「清除已保存的 Key」是独立意图:schema 的 unset 才是"取消覆盖、回到默认",
|
|
806
|
+
// 用空字符串 set 只会写进一个空值。
|
|
807
|
+
if (clearApiKey) ops.push({ op: 'unset', path: ['apiKey'] })
|
|
694
808
|
if (ops.length === 0) {
|
|
695
809
|
setDraft(null)
|
|
696
810
|
return
|
|
@@ -698,9 +812,13 @@ window.__ModuleLoader__.load({
|
|
|
698
812
|
const ok = await form.mutate(ops, revision)
|
|
699
813
|
if (ok) {
|
|
700
814
|
setDraft(null)
|
|
815
|
+
setClearApiKey(false)
|
|
701
816
|
setNotice({ kind: 'ok', text: t('saved') })
|
|
702
817
|
} else {
|
|
703
|
-
setNotice({
|
|
818
|
+
setNotice({
|
|
819
|
+
kind: 'error',
|
|
820
|
+
text: saveResultText({ ok, revision, latestRevision: form?.state?.revision }, t),
|
|
821
|
+
})
|
|
704
822
|
}
|
|
705
823
|
} catch {
|
|
706
824
|
setNotice({ kind: 'error', text: t('saveFailed') })
|
|
@@ -732,6 +850,9 @@ window.__ModuleLoader__.load({
|
|
|
732
850
|
notice !== null
|
|
733
851
|
? h('span', { key: 'notice', style: notice.kind === 'ok' ? statusStyle : errorStyle, children: notice.text })
|
|
734
852
|
: null,
|
|
853
|
+
blockedText !== undefined
|
|
854
|
+
? h('span', { key: 'blocked', style: errorStyle, children: blockedText })
|
|
855
|
+
: null,
|
|
735
856
|
!writable ? h('span', { key: 'ro', style: statusStyle, children: t('readOnly') }) : null,
|
|
736
857
|
],
|
|
737
858
|
})
|
|
@@ -742,7 +863,7 @@ window.__ModuleLoader__.load({
|
|
|
742
863
|
style: footerStyle,
|
|
743
864
|
children: [
|
|
744
865
|
h('button', { key: 'save', type: 'button', style: { ...primaryButton, opacity: canSave ? 1 : 0.5 }, disabled: !canSave, onClick: onSave, children: busy ? t('saving') : t('save') }),
|
|
745
|
-
...(showRevert ? [h('button', { key: 'revert', type: 'button', style: ghostButton, disabled: !dirty || busy, onClick: () => { setDraft(null); setNotice(null) }, children: t('revert') })] : []),
|
|
866
|
+
...(showRevert ? [h('button', { key: 'revert', type: 'button', style: ghostButton, disabled: !dirty || busy, onClick: () => { setDraft(null); setNotice(null); setClearApiKey(false) }, children: t('revert') })] : []),
|
|
746
867
|
...(extra ? [extra] : []),
|
|
747
868
|
statusLine,
|
|
748
869
|
],
|
|
@@ -760,7 +881,23 @@ window.__ModuleLoader__.load({
|
|
|
760
881
|
// 注意:jsx(type, props, key) 的第三个参数是 key,正文必须作为 children 放进 props
|
|
761
882
|
children: hs(Fragment, {
|
|
762
883
|
children: [
|
|
763
|
-
...renderFields(SERVICE_FIELDS,
|
|
884
|
+
...renderFields(SERVICE_FIELDS, {
|
|
885
|
+
...numberExtras,
|
|
886
|
+
apiKey: {
|
|
887
|
+
// 已保存的 Key 不回显(secret 被宿主剔除),所以只能给一个显式的"清除"动作
|
|
888
|
+
extra: h('button', {
|
|
889
|
+
key: 'clear-key',
|
|
890
|
+
type: 'button',
|
|
891
|
+
style: { ...ghostButton, height: 24, padding: '0 8px', fontSize: 12 },
|
|
892
|
+
disabled: !writable || busy,
|
|
893
|
+
onClick: () => {
|
|
894
|
+
setClearApiKey((value) => !value)
|
|
895
|
+
setDraft((previous) => ({ ...(previous ?? {}), apiKey: '' }))
|
|
896
|
+
},
|
|
897
|
+
children: clearApiKey ? t('clearApiKeyOn') : t('clearApiKey'),
|
|
898
|
+
}),
|
|
899
|
+
},
|
|
900
|
+
}),
|
|
764
901
|
renderFooter({ showRevert: true }),
|
|
765
902
|
],
|
|
766
903
|
}),
|
|
@@ -892,7 +1029,17 @@ window.__ModuleLoader__.load({
|
|
|
892
1029
|
exports.inject = inject
|
|
893
1030
|
exports.apply = apply
|
|
894
1031
|
// 仅供测试:暴露纯函数(宿主只读 name/inject/apply,多余导出对宿主无影响)
|
|
895
|
-
exports.__internals = {
|
|
1032
|
+
exports.__internals = {
|
|
1033
|
+
// 组件也导出:UI 没有 DOM 测试环境,但「渲染一次不抛错 + 走对分支」是能测的冒烟底线
|
|
1034
|
+
SystemOneConfigPage,
|
|
1035
|
+
validateCustomScenarios,
|
|
1036
|
+
describeCustomError,
|
|
1037
|
+
numberErrorFor,
|
|
1038
|
+
saveResultText,
|
|
1039
|
+
SERVICE_FIELDS,
|
|
1040
|
+
CUSTOM_FIELDS,
|
|
1041
|
+
AUTO_FIELDS,
|
|
1042
|
+
}
|
|
896
1043
|
return module.exports
|
|
897
1044
|
},
|
|
898
1045
|
})
|
package/lib/index.d.ts
CHANGED
|
@@ -40,15 +40,19 @@ export interface Config {
|
|
|
40
40
|
/** 自动决策固定使用的场景 id;留空则自动路由 */
|
|
41
41
|
autoScenario?: string
|
|
42
42
|
/** 注入通道:message=同一步生效并去重;context=宿主动态上下文(不写历史) */
|
|
43
|
-
autoInject?:
|
|
43
|
+
autoInject?: 'message' | 'context'
|
|
44
44
|
/** 自动决策失败语义:open=放行(默认);closed=决策失败时否决本轮(供将来做闸门用,仅 message 通道) */
|
|
45
|
-
autoFailMode?:
|
|
45
|
+
autoFailMode?: 'open' | 'closed'
|
|
46
46
|
/** 自动决策超时(毫秒) */
|
|
47
47
|
autoTimeoutMs?: number
|
|
48
48
|
/** 捕获的历史消息条数 */
|
|
49
49
|
autoMaxMessages?: number
|
|
50
50
|
/** 相同内容的结果缓存时长(毫秒),0 表示不缓存 */
|
|
51
51
|
autoCacheTtlMs?: number
|
|
52
|
+
/** 自动路由结果的复用时长(毫秒):0 = 每条消息都重新路由 */
|
|
53
|
+
autoRouteCacheTtlMs?: number
|
|
54
|
+
/** 连续 2 次失败后的熔断时长(毫秒):0 = 关闭熔断 */
|
|
55
|
+
autoFailureCooldownMs?: number
|
|
52
56
|
/** 自动决策使用的置信度阈值 */
|
|
53
57
|
autoMinConfidence?: number
|
|
54
58
|
/** 自动路由的置信度门槛 */
|
|
@@ -125,10 +129,27 @@ declare module '@deepseek-ai/cordis' {
|
|
|
125
129
|
|
|
126
130
|
export default class SystemOneService extends Service {
|
|
127
131
|
static Config: any
|
|
132
|
+
/**
|
|
133
|
+
* 插件配置的原始对象。
|
|
134
|
+
*
|
|
135
|
+
* **注意**:标了 `.volatile()` 的字段在这里是 cosmokit 的引用对象,不是字符串/数字;
|
|
136
|
+
* 直接读类型是骗人的(`config.model` 会得到一个对象)。需要值时用 `lib/volatile.js`
|
|
137
|
+
* 的 `unwrap()` 解壳——插件内部所有读取都走它。
|
|
138
|
+
*/
|
|
128
139
|
readonly config: Config
|
|
140
|
+
/** 当前提供商(热切换时由代理转发到新实例,可长期持有)。 */
|
|
129
141
|
readonly provider: {
|
|
130
142
|
name: string
|
|
131
143
|
decide(options: DecideOptions): Promise<Record<string, unknown>>
|
|
144
|
+
/** 解析出的请求端点(外发声明用)。 */
|
|
145
|
+
resolveEndpoint(): string
|
|
146
|
+
}
|
|
147
|
+
/** 用量台账的转发对象;`usageLogPath` 变化时内部会换到新台账。 */
|
|
148
|
+
readonly usageLedger: {
|
|
149
|
+
record(entry: Record<string, unknown>): unknown
|
|
150
|
+
today(now?: number): { day: string, calls: number, tokens: number, failures: number }
|
|
151
|
+
check(limits?: { dailyCallLimit?: number, dailyTokenLimit?: number }): { allowed: boolean, reason?: string }
|
|
152
|
+
formatToday(now?: number): string
|
|
132
153
|
}
|
|
133
154
|
|
|
134
155
|
constructor(ctx: Context, config: Config)
|
package/lib/index.js
CHANGED
|
@@ -142,6 +142,16 @@ const Config = z.object({
|
|
|
142
142
|
autoMaxMessages: z.number().min(1).max(50).default(AUTO_DEFAULTS.autoMaxMessages).volatile(),
|
|
143
143
|
/** 相同内容的结果缓存时长(毫秒),0 表示不缓存 */
|
|
144
144
|
autoCacheTtlMs: z.number().min(0).default(AUTO_DEFAULTS.autoCacheTtlMs).volatile(),
|
|
145
|
+
/**
|
|
146
|
+
* 自动路由结果的复用时长(毫秒):同一会话内同话题连续消息只路由一次。
|
|
147
|
+
* 0 = 每条消息都重新路由(多一次上游请求)。
|
|
148
|
+
*/
|
|
149
|
+
autoRouteCacheTtlMs: z.number().min(0).default(AUTO_DEFAULTS.autoRouteCacheTtlMs).volatile(),
|
|
150
|
+
/**
|
|
151
|
+
* 连续失败 2 次后的熔断时长(毫秒):期间自动决策直接放行、不再请求上游。
|
|
152
|
+
* 0 = 关闭熔断。
|
|
153
|
+
*/
|
|
154
|
+
autoFailureCooldownMs: z.number().min(0).default(AUTO_DEFAULTS.autoFailureCooldownMs).volatile(),
|
|
145
155
|
/** 自动决策使用的置信度阈值 */
|
|
146
156
|
autoMinConfidence: z.number().min(0).max(1).default(AUTO_DEFAULTS.autoMinConfidence).volatile(),
|
|
147
157
|
/** 自动路由的置信度门槛:低于该值宁可不注入,避免塞入瞎猜的结论 */
|
|
@@ -200,7 +210,18 @@ export default class SystemOneService extends Service {
|
|
|
200
210
|
|
|
201
211
|
// 用量台账:工具路径与自动路径都记账(含路由请求),让花销可见。
|
|
202
212
|
// 台账写盘失败会降级为仅内存统计,绝不影响决策。
|
|
203
|
-
|
|
213
|
+
//
|
|
214
|
+
// usageLogPath 是 volatile 字段:路径一变就换一个台账实例(新实例会从新文件
|
|
215
|
+
// 里恢复当日累计)。调用方拿到的是一个**转发对象**而不是台账本身,
|
|
216
|
+
// 所以装配期就持有它的工具/自动决策不需要重新装配——与 provider 代理同一原则。
|
|
217
|
+
this._ledgerPath = readString(config, 'usageLogPath', '')
|
|
218
|
+
this._ledger = createUsageLedger({ path: this._ledgerPath, logger: ctx.logger })
|
|
219
|
+
this.usageLedger = {
|
|
220
|
+
record: (entry) => this.currentLedger().record(entry),
|
|
221
|
+
today: (now) => this.currentLedger().today(now),
|
|
222
|
+
check: (limits) => this.currentLedger().check(limits),
|
|
223
|
+
formatToday: (now) => this.currentLedger().formatToday(now),
|
|
224
|
+
}
|
|
204
225
|
|
|
205
226
|
// inject 保证 ctx.tools 存在;注册失败必须显式抛出,绝不静默降级
|
|
206
227
|
const tools = ctx.get('tools') ?? ctx.tools
|
|
@@ -317,6 +338,22 @@ export default class SystemOneService extends Service {
|
|
|
317
338
|
return this._provider
|
|
318
339
|
}
|
|
319
340
|
|
|
341
|
+
/**
|
|
342
|
+
* 当前用量台账;`usageLogPath` 变化时重建。
|
|
343
|
+
*
|
|
344
|
+
* 台账不是无状态服务(它持有当日累计),所以不能像 provider 那样只换引用:
|
|
345
|
+
* 换路径要造一个新实例,让新实例从**新文件**里恢复当日累计(见 usage.js 的
|
|
346
|
+
* restoreDay),否则换路径后「今日用量」会凭空归零或串到旧文件上。
|
|
347
|
+
*/
|
|
348
|
+
currentLedger() {
|
|
349
|
+
const path = readString(this.config, 'usageLogPath', '')
|
|
350
|
+
if (path !== this._ledgerPath) {
|
|
351
|
+
this._ledgerPath = path
|
|
352
|
+
this._ledger = createUsageLedger({ path, logger: this.ctx?.logger })
|
|
353
|
+
}
|
|
354
|
+
return this._ledger
|
|
355
|
+
}
|
|
356
|
+
|
|
320
357
|
/** 未知 provider 值会在 provider.js 里静默回退到 unisound,这里负责告警(每个未知值只提示一次)。 */
|
|
321
358
|
_warnUnknownProvider(kind) {
|
|
322
359
|
if (KNOWN_PROVIDERS.includes(kind) || this._warnedProviders.has(kind)) return
|
package/lib/usage.js
CHANGED
|
@@ -9,17 +9,27 @@
|
|
|
9
9
|
* - 每次调用**追加**一行 JSONL,包括失败/超时的调用(失败也可能已计费);
|
|
10
10
|
* - 日限在**发请求之前**判定并拒绝,附上该改哪个配置项;
|
|
11
11
|
* - 台账不可写时降级为「仅内存」,绝不因为记账失败而让决策失败(fail-open 记账);
|
|
12
|
-
* -
|
|
12
|
+
* - 计数按**本地日**切分,跨日自动归零,不需要定时任务;
|
|
13
|
+
* - 进程启动时从文件尾部**恢复当日累计**:重启不是新的一天,重启后的「今日用量」
|
|
14
|
+
* 与日限必须接着算(早期版本只累加内存,重启即归零)。
|
|
13
15
|
*
|
|
14
16
|
* 模块无第三方依赖,使用 node:fs / node:os / node:path。
|
|
15
17
|
*/
|
|
16
|
-
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
18
|
+
import { appendFileSync, closeSync, mkdirSync, openSync, readSync, statSync } from 'node:fs'
|
|
17
19
|
import { homedir } from 'node:os'
|
|
18
20
|
import { join } from 'node:path'
|
|
19
21
|
|
|
20
22
|
/** token 数缺失时的兜底(上游不保证返回 usage)。 */
|
|
21
23
|
const UNKNOWN_TOKENS = 0
|
|
22
24
|
|
|
25
|
+
/**
|
|
26
|
+
* 重启后恢复当日累计时最多回读的字节数。
|
|
27
|
+
*
|
|
28
|
+
* 台账是旁路设施,不能为了恢复它把一个长年累月只追加的大文件整个读进内存;
|
|
29
|
+
* 2 MB 约等于上万次调用,足够覆盖"同一天内的重启"。
|
|
30
|
+
*/
|
|
31
|
+
const RESTORE_MAX_BYTES = 2 * 1024 * 1024
|
|
32
|
+
|
|
23
33
|
/** 本地日键,形如 2026-10-05。 */
|
|
24
34
|
function localDayKey(now = Date.now()) {
|
|
25
35
|
const d = new Date(now)
|
|
@@ -54,6 +64,63 @@ export function usageLogPath(dir) {
|
|
|
54
64
|
return join(base, 'usage.jsonl')
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
/**
|
|
68
|
+
* 从台账文件尾部恢复某个本地日的累计。
|
|
69
|
+
*
|
|
70
|
+
* 为什么需要:`totals` 是进程内状态,宿主一重启就归零——而用户看到的「今日用量」
|
|
71
|
+
* 以及将来的日限判定都基于它。重启不是新的一天,账必须接着算。
|
|
72
|
+
*
|
|
73
|
+
* 三条容错:文件不存在 → 全 0;行是半截/坏 JSON → 跳过该行;读盘本身失败 → 全 0。
|
|
74
|
+
* 台账坏了绝不能让插件起不来,也不能让决策失败。
|
|
75
|
+
*
|
|
76
|
+
* @param {string} file - 台账文件绝对路径
|
|
77
|
+
* @param {string} day - 本地日键(与写入时的 `day` 同源)
|
|
78
|
+
* @returns {{calls:number, tokens:number, failures:number}}
|
|
79
|
+
*/
|
|
80
|
+
function restoreDay(file, day) {
|
|
81
|
+
const zero = { calls: 0, tokens: 0, failures: 0 }
|
|
82
|
+
let text = ''
|
|
83
|
+
let partialHead = false
|
|
84
|
+
try {
|
|
85
|
+
const stat = statSync(file, { throwIfNoEntry: false })
|
|
86
|
+
if (!stat || stat.size === 0) return zero
|
|
87
|
+
const start = Math.max(0, stat.size - RESTORE_MAX_BYTES)
|
|
88
|
+
partialHead = start > 0
|
|
89
|
+
const length = stat.size - start
|
|
90
|
+
const buffer = Buffer.alloc(length)
|
|
91
|
+
const fd = openSync(file, 'r')
|
|
92
|
+
try {
|
|
93
|
+
const read = readSync(fd, buffer, 0, length, start)
|
|
94
|
+
text = buffer.subarray(0, read).toString('utf8')
|
|
95
|
+
} finally {
|
|
96
|
+
closeSync(fd)
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
return zero
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const lines = text.split('\n')
|
|
103
|
+
// 从文件中段开始读时,第一行几乎必然是被截断的半条记录
|
|
104
|
+
if (partialHead) lines.shift()
|
|
105
|
+
|
|
106
|
+
const totals = { ...zero }
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
if (!line.trim()) continue
|
|
109
|
+
let entry
|
|
110
|
+
try {
|
|
111
|
+
entry = JSON.parse(line)
|
|
112
|
+
} catch {
|
|
113
|
+
continue // 写到一半的坏行:忽略,不因它丢掉整天的账
|
|
114
|
+
}
|
|
115
|
+
if (!entry || entry.day !== day) continue
|
|
116
|
+
totals.calls += 1
|
|
117
|
+
const tokens = Number(entry.input_tokens)
|
|
118
|
+
if (Number.isFinite(tokens) && tokens > 0) totals.tokens += Math.floor(tokens)
|
|
119
|
+
if (entry.ok === false) totals.failures += 1
|
|
120
|
+
}
|
|
121
|
+
return totals
|
|
122
|
+
}
|
|
123
|
+
|
|
57
124
|
/** 从任意形态的 usage 字段里取 input_tokens(容忍缺字段与字符串)。 */
|
|
58
125
|
function inputTokensOf(usage) {
|
|
59
126
|
const value = Number(usage?.input_tokens)
|
|
@@ -74,8 +141,12 @@ export function createUsageLedger(options = {}) {
|
|
|
74
141
|
: usageLogPath('')
|
|
75
142
|
const logger = options.logger
|
|
76
143
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
144
|
+
/**
|
|
145
|
+
* 内存累计:{ day, calls, tokens, failures } —— 读取时先按天校验再复用。
|
|
146
|
+
* 构造时从文件尾部恢复当日累计,让「今日用量」与日限跨进程重启依然连续。
|
|
147
|
+
*/
|
|
148
|
+
const initialDay = localDayKey()
|
|
149
|
+
let totals = { day: initialDay, ...restoreDay(file, initialDay) }
|
|
79
150
|
/** 写盘是否失败过(只告警一次,避免每一步刷日志) */
|
|
80
151
|
let writeFailed = false
|
|
81
152
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@master0071/dsh-systemone",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "DeepSeek Harness 插件:将 SystemOne 决策模型(默认云知声 u2-decision,可切换提供商)接入软件开发、需求预检、客服、内容审核、路由、销售线索、风控、招聘、数据治理、教育、需求变更等 11 大业务场景,为 Agent 返回带概率分布的决策与处置建议。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|