@master0071/dsh-systemone 0.4.0 → 0.4.2

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/cordis.patch.yml CHANGED
@@ -1,7 +1,8 @@
1
1
  # @master0071/dsh-systemone bundle patch:把 SystemOne 决策插件插入任何将本包列入
2
2
  # `dsh.profile.bundles` 的 profile。插件依赖 harness 的 `tools`(注册 Agent
3
- # 工具)与 `logger`;无 Web 面。配置项全部有默认值,可在插件设置面板中覆盖。
3
+ # 工具)与 `logger`;浏览器侧配置页由 client.js 提供。配置项全部有默认值,
4
+ # 可在插件设置面板中覆盖。
4
5
  - insert:
5
6
  - id: systemone
6
7
  name: '@master0071/dsh-systemone'
7
- config: {}
8
+ config: {}
package/lib/auto.js CHANGED
@@ -36,6 +36,22 @@ import { readBoolean, readNumber, readString } from './volatile.js'
36
36
  /** 宿主运行时上下文使用的 source.kind(见 dsh-agent-loop 的 SOURCE 常量)。 */
37
37
  const RUNTIME_CONTEXT_SOURCE = 'runtime-context'
38
38
 
39
+ /** 自动决策结果缓存的最大条目数;达到后先清过期再按插入序淘汰最旧。 */
40
+ const MAX_AUTO_CACHE = 200
41
+
42
+ /** 当前请求注入 state 的最大字符数(历史逐条限 2000,这里限制本轮原文)。 */
43
+ const MAX_CURRENT_CHARS = 8000
44
+
45
+ /** 自动决策数字配置的兜底默认值(与 lib/index.js Config schema 的 default 保持同一份来源)。 */
46
+ export const AUTO_DEFAULTS = {
47
+ autoMaxMessages: 6,
48
+ autoTimeoutMs: 8000,
49
+ autoCacheTtlMs: 60000,
50
+ autoRouteMinConfidence: 0.35,
51
+ autoMinConfidence: 0.6,
52
+ autoMinChars: 4,
53
+ }
54
+
39
55
  /**
40
56
  * 安装自动决策。返回卸载函数。
41
57
  *
@@ -56,9 +72,8 @@ export function installAutoDecide(ctx, deps) {
56
72
  const cache = new Map()
57
73
  /** stateHash → Promise:并发相同 state 只发一次上游请求。 */
58
74
  const inFlight = new Map()
59
- // 每次都经 unwrap 读取,配置页里的改动无需重启即生效
60
- const minChars = readNumber(config, 'autoMinChars', 0)
61
- const routeFloor = readNumber(config, 'autoRouteMinConfidence', 0)
75
+ /** 已告警过的无效固定场景 id:同一错误只提示一次,避免每步刷日志。 */
76
+ const warnedScenarios = new Set()
62
77
 
63
78
  const log = (level, message) => {
64
79
  try {
@@ -79,40 +94,44 @@ export function installAutoDecide(ctx, deps) {
79
94
  }))
80
95
 
81
96
  // ── context 通道:入箱预计算 + 动态上下文渲染(不写历史,第二步起稳定可见) ──
82
- if (readString(config, 'autoInject', 'message') === 'context') {
83
- disposers.push(ctx.on('agent/created', (payload) => {
84
- const agent = payload?.agent
85
- const agentCtx = agent?.ctx
86
- if (!agentCtx) return
87
- // ① 消息一入箱就异步预计算,让决策尽可能在 assembly 之前就绪
88
- if (typeof agentCtx.on === 'function') {
89
- try {
90
- disposers.push(agentCtx.on('agent/inbox/inserted', (event) => {
91
- const newText = extractUserText([event?.message])
92
- if (!newText) return
93
- if (newText.trimStart().startsWith('/')) return
94
- if (isTrivialInput(newText, minChars)) return
95
- // 返回 promise 便于测试等待;宿主事件总线忽略返回值
96
- return computeAndStore(agent, newText)
97
- }))
98
- } catch (error) {
99
- log('warn', `注册入箱预计算失败:${messageOf(error)}`)
100
- }
97
+ // 监听器无条件注册,autoInject 的门控放在事件回调里按当前配置判断:
98
+ // autoInject 是 volatile 字段,只有事件期判断才能让 message ↔ context
99
+ // 在运行期双向切换都立即生效(在装配期固化只支持一个方向)。
100
+ disposers.push(ctx.on('agent/created', (payload) => {
101
+ const agent = payload?.agent
102
+ const agentCtx = agent?.ctx
103
+ if (!agentCtx) return
104
+ // ① 消息一入箱就异步预计算,让决策尽可能在 assembly 之前就绪
105
+ if (typeof agentCtx.on === 'function') {
106
+ try {
107
+ disposers.push(agentCtx.on('agent/inbox/inserted', (event) => {
108
+ if (readString(config, 'autoInject', 'message') !== 'context') return
109
+ const newText = extractUserText([event?.message])
110
+ if (!newText) return
111
+ if (newText.trimStart().startsWith('/')) return
112
+ if (isTrivialInput(newText, readNumber(config, 'autoMinChars', AUTO_DEFAULTS.autoMinChars))) return
113
+ // 返回 promise 便于测试等待;宿主事件总线忽略返回值
114
+ return computeAndStore(agent, newText)
115
+ }))
116
+ } catch (error) {
117
+ log('warn', `注册入箱预计算失败:${messageOf(error)}`)
101
118
  }
102
- // ② 把最新决策渲染进作用域动态上下文
103
- if (agentCtx?.systemPrompt?.context) {
104
- try {
105
- disposers.push(agentCtx.systemPrompt.context({
106
- name: 'systemone-auto-decision',
107
- order: 900,
108
- text: () => perAgent.get(agent?.id)?.text || '',
109
- }))
110
- } catch (error) {
111
- log('warn', `注册动态上下文失败:${messageOf(error)}`)
112
- }
119
+ }
120
+ // ② 把最新决策渲染进作用域动态上下文
121
+ if (agentCtx?.systemPrompt?.context) {
122
+ try {
123
+ disposers.push(agentCtx.systemPrompt.context({
124
+ name: 'systemone-auto-decision',
125
+ order: 900,
126
+ text: () => (readString(config, 'autoInject', 'message') === 'context')
127
+ ? (perAgent.get(agent?.id)?.text || '')
128
+ : '',
129
+ }))
130
+ } catch (error) {
131
+ log('warn', `注册动态上下文失败:${messageOf(error)}`)
113
132
  }
114
- }))
115
- }
133
+ }
134
+ }))
116
135
 
117
136
  // ── pre-step:同一步生效(仅 message 通道) ──
118
137
  disposers.push(ctx.on('agent/pre-step', async (payload, next) => {
@@ -132,13 +151,14 @@ export function installAutoDecide(ctx, deps) {
132
151
  const newText = extractUserText(claimed)
133
152
  if (!newText) return decision // 工具结果步骤 / 无新用户消息
134
153
  if (newText.trimStart().startsWith('/')) return decision // 斜杠命令
135
- if (isTrivialInput(newText, minChars)) return decision // 寒暄/致谢等无业务含义的输入
154
+ if (isTrivialInput(newText, readNumber(config, 'autoMinChars', AUTO_DEFAULTS.autoMinChars))) return decision // 寒暄/致谢等无业务含义的输入
136
155
 
137
156
  let injected = null
138
157
  try {
139
158
  // 双保险:既给上游传 abort signal,也用 race 兜底,
140
159
  // 即使提供商忽略 signal 也不会让这一步的推理被无限阻塞。
141
- const timeout = combineSignal(payload?.signal, readNumber(config, 'autoTimeoutMs', 8000))
160
+ const timeoutMs = readNumber(config, 'autoTimeoutMs', AUTO_DEFAULTS.autoTimeoutMs)
161
+ const timeout = combineSignal(payload?.signal, timeoutMs)
142
162
  try {
143
163
  injected = await raceTimeout(
144
164
  computeDecision({
@@ -151,8 +171,9 @@ export function installAutoDecide(ctx, deps) {
151
171
  inFlight,
152
172
  signal: timeout.signal,
153
173
  log,
174
+ warnedScenarios,
154
175
  }),
155
- readNumber(config, 'autoTimeoutMs', 8000),
176
+ timeoutMs,
156
177
  )
157
178
  } finally {
158
179
  timeout.dispose()
@@ -187,7 +208,8 @@ export function installAutoDecide(ctx, deps) {
187
208
  /** 入箱预计算:异步算好并存进 perAgent,供 context 通道渲染。 */
188
209
  async function computeAndStore(agent, newText) {
189
210
  try {
190
- const timeout = combineSignal(undefined, readNumber(config, 'autoTimeoutMs', 8000))
211
+ const timeoutMs = readNumber(config, 'autoTimeoutMs', AUTO_DEFAULTS.autoTimeoutMs)
212
+ const timeout = combineSignal(undefined, timeoutMs)
191
213
  try {
192
214
  const injected = await raceTimeout(
193
215
  computeDecision({
@@ -200,8 +222,9 @@ export function installAutoDecide(ctx, deps) {
200
222
  inFlight,
201
223
  signal: timeout.signal,
202
224
  log,
225
+ warnedScenarios,
203
226
  }),
204
- readNumber(config, 'autoTimeoutMs', 8000),
227
+ timeoutMs,
205
228
  )
206
229
  if (injected) perAgent.set(agent?.id ?? 'unknown', injected)
207
230
  } finally {
@@ -232,8 +255,8 @@ export function installAutoDecide(ctx, deps) {
232
255
  /**
233
256
  * 计算决策(带并发去重)。相同 state 只发一次上游请求。
234
257
  */
235
- async function computeDecision({ agent, newText, provider, config, scenarios, cache, inFlight, signal, log }) {
236
- const state = captureState(agent, newText, readNumber(config, 'autoMaxMessages', 6))
258
+ async function computeDecision({ agent, newText, provider, config, scenarios, cache, inFlight, signal, log, warnedScenarios }) {
259
+ const state = captureState(agent, newText, readNumber(config, 'autoMaxMessages', AUTO_DEFAULTS.autoMaxMessages))
237
260
  const key = hashString(state)
238
261
  const existing = inFlight.get(key)
239
262
  if (existing) return existing
@@ -248,6 +271,7 @@ async function computeDecision({ agent, newText, provider, config, scenarios, ca
248
271
  cache,
249
272
  signal,
250
273
  log,
274
+ warnedScenarios,
251
275
  }).finally(() => {
252
276
  if (inFlight.get(key) === promise) inFlight.delete(key)
253
277
  })
@@ -255,19 +279,19 @@ async function computeDecision({ agent, newText, provider, config, scenarios, ca
255
279
  return promise
256
280
  }
257
281
 
258
- async function decide({ agent, newText, state, key, provider, config, scenarios, cache, signal, log }) {
259
- const ttl = readNumber(config, 'autoCacheTtlMs', 0) || 0
282
+ async function decide({ agent, newText, state, key, provider, config, scenarios, cache, signal, log, warnedScenarios }) {
283
+ const ttl = readNumber(config, 'autoCacheTtlMs', AUTO_DEFAULTS.autoCacheTtlMs) || 0
260
284
  if (ttl > 0) {
261
285
  const hit = cache.get(key)
262
286
  if (hit && Date.now() - hit.at < ttl) return hit.result
263
287
  }
264
288
 
265
289
  // 1) 选场景:固定绑定,或让模型做一次自动路由
266
- const routed = await resolveScenario({ state, provider, config, scenarios, signal })
290
+ const routed = await resolveScenario({ state, provider, config, scenarios, signal, log, warnedScenarios })
267
291
  if (!routed) return null
268
292
 
269
293
  // 路由置信度不足:宁可不注入,也不要塞一个瞎猜的结论
270
- const floor = readNumber(config, 'autoRouteMinConfidence', 0)
294
+ const floor = readNumber(config, 'autoRouteMinConfidence', AUTO_DEFAULTS.autoRouteMinConfidence)
271
295
  if (Number.isFinite(floor) && floor > 0
272
296
  && Number.isFinite(routed.confidence) && routed.confidence < floor) {
273
297
  log?.('info', `路由置信度 ${(routed.confidence * 100).toFixed(1)}% 低于阈值 ${(floor * 100).toFixed(0)}%,跳过注入`)
@@ -282,7 +306,7 @@ async function decide({ agent, newText, state, key, provider, config, scenarios,
282
306
  scenario: routed.scenario.id,
283
307
  state,
284
308
  exec: { signal },
285
- minConfidence: readNumber(config, 'autoMinConfidence', 0.6),
309
+ minConfidence: readNumber(config, 'autoMinConfidence', AUTO_DEFAULTS.autoMinConfidence),
286
310
  })
287
311
  if (!result?.ok) {
288
312
  log?.('warn', `场景 ${routed.scenario.id} 执行失败,已放行:${result?.error || '未知错误'}`)
@@ -297,16 +321,35 @@ async function decide({ agent, newText, state, key, provider, config, scenarios,
297
321
  headline: result.recommendation || routed.scenario.title,
298
322
  at: Date.now(),
299
323
  }
300
- if (ttl > 0) cache.set(key, { at: Date.now(), result: injected })
324
+ if (ttl > 0) {
325
+ // 缓存上限:先清过期,再按插入序淘汰最旧,防止长会话无限增长
326
+ if (cache.size >= MAX_AUTO_CACHE) {
327
+ const now = Date.now()
328
+ for (const [k, v] of cache) {
329
+ if (now - v.at >= ttl) cache.delete(k)
330
+ }
331
+ }
332
+ while (cache.size >= MAX_AUTO_CACHE) {
333
+ cache.delete(cache.keys().next().value)
334
+ }
335
+ cache.set(key, { at: Date.now(), result: injected })
336
+ }
301
337
  return injected
302
338
  }
303
339
 
304
340
  /** 选择场景:固定绑定优先,否则用一次 choice 问句自动路由。 */
305
- async function resolveScenario({ state, provider, config, scenarios, signal }) {
341
+ async function resolveScenario({ state, provider, config, scenarios, signal, log, warnedScenarios }) {
306
342
  const fixed = readString(config, 'autoScenario', '').trim()
307
343
  if (fixed) {
308
344
  const scenario = findScenario(scenarios, fixed)
309
- if (!scenario) return null
345
+ if (!scenario) {
346
+ // 配置错误不能静默:同一 id 只告警一次,避免每步刷日志
347
+ if (!warnedScenarios.has(fixed)) {
348
+ warnedScenarios.add(fixed)
349
+ log?.('warn', `固定场景 "${fixed}" 不在场景库中,自动决策已停用(请检查 autoScenario 配置;可用:${scenarios.map((s) => s.id).join('、')})`)
350
+ }
351
+ return null
352
+ }
310
353
  return { scenario, source: '固定绑定', confidence: null }
311
354
  }
312
355
  if (scenarios.length === 0) return null
@@ -365,8 +408,9 @@ function questionLabel(scenario, qid) {
365
408
  /**
366
409
  * 判断是否为"无业务含义"的输入,用于跳过自动决策。
367
410
  *
368
- * 只用长度阈值会误伤真实请求("退款失败" 只有 4 个字),所以改成
369
- * 「长度阈值 + 寒暄黑名单」:短且整体就是寒暄才跳过。
411
+ * 两条独立的跳过规则(命中任一即跳过):
412
+ * 1. 长度阈值:短于 autoMinChars 的一律跳过(默认 4,"退款失败" 恰好 4 字不被误伤);
413
+ * 2. 寒暄黑名单:整体就是一句寒暄/致谢的跳过,与长度无关。
370
414
  */
371
415
  const TRIVIAL_PATTERN = /^(你好|您好|哈喽|在吗|hi|hello|hey|ok|okay|好的|好嘞|收到|明白|嗯+|谢谢|多谢|感谢|thanks|thank you|thx|再见|拜拜|bye|测试|test)[\s!!。.~~,,、??]*$/i
372
416
 
@@ -387,7 +431,7 @@ function captureState(agent, newText, maxMessages) {
387
431
  const history = readHistory(agent, limit)
388
432
  const parts = []
389
433
  if (history.length > 0) parts.push(`【近期对话】\n${history.join('\n')}`)
390
- parts.push(`【当前请求】\n${newText}`)
434
+ parts.push(`【当前请求】\n${truncate(newText, MAX_CURRENT_CHARS)}`)
391
435
  return parts.join('\n\n')
392
436
  }
393
437
 
package/lib/client.js CHANGED
@@ -89,12 +89,14 @@ window.__ModuleLoader__.load({
89
89
  autoRouteMinConfidence: '自动路由置信度门槛',
90
90
  autoRouteMinConfidenceHint: '低于该值时宁可不注入,避免塞入瞎猜的结论',
91
91
  autoMinChars: '最小触发长度(字符)',
92
- autoMinCharsHint: '低于该长度且整体是寒暄时跳过',
92
+ autoMinCharsHint: '低于该长度、或整体就是寒暄/致谢时跳过',
93
93
  save: '保存',
94
94
  saving: '保存中…',
95
95
  revert: '撤销修改',
96
96
  saved: '已保存,立即生效',
97
97
  saveFailed: '保存失败,请检查填写内容',
98
+ numberInvalid: '必须是有效数字',
99
+ numberRequired: '数字字段不能为空',
98
100
  conflict: '配置已被其他地方改动,已刷新为最新值,请重新修改',
99
101
  readOnly: '当前不可编辑',
100
102
  noForm: '宿主要求该条目声明 volatile 配置字段后才会显示表单。',
@@ -157,12 +159,14 @@ window.__ModuleLoader__.load({
157
159
  autoRouteMinConfidence: 'Auto routing confidence floor',
158
160
  autoRouteMinConfidenceHint: 'Below this value nothing is injected rather than guessing',
159
161
  autoMinChars: 'Minimum trigger length (chars)',
160
- autoMinCharsHint: 'Shorter greetings are skipped',
162
+ autoMinCharsHint: 'Skipped when shorter than this, or when it is a bare greeting',
161
163
  save: 'Save',
162
164
  saving: 'Saving…',
163
165
  revert: 'Revert changes',
164
166
  saved: 'Saved — applied immediately',
165
167
  saveFailed: 'Save failed — check the values',
168
+ numberInvalid: 'Must be a valid number',
169
+ numberRequired: 'Number fields cannot be empty',
166
170
  conflict: 'The configuration changed elsewhere; the newest values were reloaded',
167
171
  readOnly: 'Read-only right now',
168
172
  noForm: 'The host exposes a form only when the entry declares volatile config fields.',
@@ -261,7 +265,6 @@ window.__ModuleLoader__.load({
261
265
 
262
266
  /* ────────────────────────────── 字段定义 ────────────────────────────── */
263
267
 
264
- /** 数字字段在草稿里保持字符串,保存时再转数字,避免用户输入中途被强制转换。 */
265
268
  /**
266
269
  * 内置场景候选(供「固定场景」输入框的下拉提示)。
267
270
  *
@@ -349,7 +352,7 @@ window.__ModuleLoader__.load({
349
352
  : null
350
353
  if (keys === null) issues.push(`问题 ${qid}(choice)需要 criteria 对象`)
351
354
  else if (keys.length < 2) issues.push(`问题 ${qid}(choice)至少 2 个选项`)
352
- else if (keys.length > 26) issues.push(`问题 ${qid}(choice)选项建议 ≤26(当前 ${keys.length})`)
355
+ else if (keys.length > 26) issues.push(`问题 ${qid}(choice)选项不能超过 26 个(当前 ${keys.length})`)
353
356
  }
354
357
  if (q.type === 'score'
355
358
  && (!Array.isArray(q.criteria) || q.criteria.length < 2)) {
@@ -401,7 +404,7 @@ window.__ModuleLoader__.load({
401
404
  /**
402
405
  * 一个可折叠的配置卡片:标题 + 说明,展开后是字段表单。
403
406
  */
404
- function ConfigCard({ t, title, description, open, onToggle, children }) {
407
+ function ConfigCard({ title, description, open, onToggle, children }) {
405
408
  return hs('li', {
406
409
  style: cardStyle,
407
410
  children: [
@@ -492,7 +495,7 @@ window.__ModuleLoader__.load({
492
495
  control = h('input', {
493
496
  id: controlId,
494
497
  style: inputStyle,
495
- type: field.kind === 'password' ? 'password' : field.kind === 'number' ? 'text' : 'text',
498
+ type: field.kind === 'password' ? 'password' : 'text',
496
499
  inputMode: field.kind === 'number' ? 'decimal' : undefined,
497
500
  value,
498
501
  disabled,
@@ -579,8 +582,22 @@ window.__ModuleLoader__.load({
579
582
  const customCheck = validateCustomScenarios(values.customScenarios)
580
583
  const customErrorText = customCheck.ok ? undefined : describeCustomError(customCheck.error, t)
581
584
 
585
+ // 数字字段同样先校验:非法值直接拦住保存,
586
+ // 而不是保存时静默丢弃却仍提示「已保存」
587
+ const numberErrors = {}
588
+ for (const field of ALL_FIELDS) {
589
+ if (field.kind !== 'number') continue
590
+ const raw = String(values[field.key] ?? '').trim()
591
+ if (raw === '') numberErrors[field.key] = t('numberRequired')
592
+ else if (!Number.isFinite(Number(raw))) numberErrors[field.key] = t('numberInvalid')
593
+ }
594
+ const numberExtras = {}
595
+ for (const [key, error] of Object.entries(numberErrors)) {
596
+ numberExtras[key] = { error }
597
+ }
598
+
582
599
  const dirty = draft !== null && Object.keys(draft).some((key) => draft[key] !== current[key])
583
- const canSave = dirty && !busy && writable && customCheck.ok
600
+ const canSave = dirty && !busy && writable && customCheck.ok && Object.keys(numberErrors).length === 0
584
601
 
585
602
  // 「固定场景」候选 = 10 个内置 + 当前生效的自定义场景 id
586
603
  const scenarioOptions = [
@@ -602,6 +619,7 @@ window.__ModuleLoader__.load({
602
619
  setBusy(true)
603
620
  setNotice(null)
604
621
  try {
622
+ // 数字字段在草稿里保持字符串,这里再转数字(合法性已由上方 numberErrors 拦截)
605
623
  const ops = []
606
624
  for (const [key, value] of Object.entries(draft ?? {})) {
607
625
  if (value === current[key]) continue
@@ -661,12 +679,23 @@ window.__ModuleLoader__.load({
661
679
  ],
662
680
  })
663
681
 
682
+ // 三张卡片共用同一套 footer:save(可选 revert / 额外状态)+ 状态行
683
+ const renderFooter = ({ showRevert, extra }) => hs('div', {
684
+ key: 'footer',
685
+ style: footerStyle,
686
+ children: [
687
+ h('button', { key: 'save', type: 'button', style: { ...primaryButton, opacity: canSave ? 1 : 0.5 }, disabled: !canSave, onClick: onSave, children: busy ? t('saving') : t('save') }),
688
+ ...(showRevert ? [h('button', { key: 'revert', type: 'button', style: ghostButton, disabled: !dirty || busy, onClick: () => { setDraft(null); setNotice(null) }, children: t('revert') })] : []),
689
+ ...(extra ? [extra] : []),
690
+ statusLine,
691
+ ],
692
+ })
693
+
664
694
  return hs('ul', {
665
695
  style: listStyle,
666
696
  children: [
667
697
  h(ConfigCard, {
668
698
  key: 'service',
669
- t,
670
699
  title: t('tabService'),
671
700
  description: t('tabServiceHint'),
672
701
  open: openService,
@@ -674,39 +703,13 @@ window.__ModuleLoader__.load({
674
703
  // 注意:jsx(type, props, key) 的第三个参数是 key,正文必须作为 children 放进 props
675
704
  children: hs(Fragment, {
676
705
  children: [
677
- ...renderFields(SERVICE_FIELDS),
678
- hs('div', {
679
- key: 'footer',
680
- style: footerStyle,
681
- children: [
682
- h('button', {
683
- key: 'save',
684
- type: 'button',
685
- style: { ...primaryButton, opacity: canSave ? 1 : 0.5 },
686
- disabled: !canSave,
687
- onClick: onSave,
688
- children: busy ? t('saving') : t('save'),
689
- }),
690
- h('button', {
691
- key: 'revert',
692
- type: 'button',
693
- style: ghostButton,
694
- disabled: !dirty || busy,
695
- onClick: () => {
696
- setDraft(null)
697
- setNotice(null)
698
- },
699
- children: t('revert'),
700
- }),
701
- statusLine,
702
- ],
703
- }),
706
+ ...renderFields(SERVICE_FIELDS, numberExtras),
707
+ renderFooter({ showRevert: true }),
704
708
  ],
705
709
  }),
706
710
  }),
707
711
  h(ConfigCard, {
708
712
  key: 'custom',
709
- t,
710
713
  title: t('tabCustom'),
711
714
  description: t('tabCustomHint'),
712
715
  open: openCustom,
@@ -763,69 +766,31 @@ window.__ModuleLoader__.load({
763
766
  }),
764
767
  },
765
768
  }),
766
- hs('div', {
767
- key: 'footer',
768
- style: footerStyle,
769
- children: [
770
- h('button', {
771
- key: 'save',
772
- type: 'button',
773
- style: { ...primaryButton, opacity: canSave ? 1 : 0.5 },
774
- disabled: !canSave,
775
- onClick: onSave,
776
- children: busy ? t('saving') : t('save'),
777
- }),
778
- h('button', {
779
- key: 'revert',
780
- type: 'button',
781
- style: ghostButton,
782
- disabled: !dirty || busy,
783
- onClick: () => {
784
- setDraft(null)
785
- setNotice(null)
786
- },
787
- children: t('revert'),
788
- }),
789
- customCheck.ok
790
- ? h('span', {
791
- key: 'ok',
792
- style: statusStyle,
793
- children: customCheck.count === 0
794
- ? t('jsonEmpty')
795
- : `${t('jsonOk', { count: customCheck.count })} ${t('customIdsUsed')}`,
796
- })
797
- : null,
798
- statusLine,
799
- ],
769
+ renderFooter({
770
+ showRevert: true,
771
+ extra: customCheck.ok
772
+ ? h('span', {
773
+ key: 'ok',
774
+ style: statusStyle,
775
+ children: customCheck.count === 0
776
+ ? t('jsonEmpty')
777
+ : `${t('jsonOk', { count: customCheck.count })} ${t('customIdsUsed')}`,
778
+ })
779
+ : null,
800
780
  }),
801
781
  ],
802
782
  }),
803
783
  }),
804
784
  h(ConfigCard, {
805
785
  key: 'auto',
806
- t,
807
786
  title: t('tabAuto'),
808
787
  description: t('tabAutoHint'),
809
788
  open: openAuto,
810
789
  onToggle: () => setOpenAuto((value) => !value),
811
790
  children: hs(Fragment, {
812
791
  children: [
813
- ...renderFields(AUTO_FIELDS),
814
- hs('div', {
815
- key: 'footer',
816
- style: footerStyle,
817
- children: [
818
- h('button', {
819
- key: 'save',
820
- type: 'button',
821
- style: { ...primaryButton, opacity: canSave ? 1 : 0.5 },
822
- disabled: !canSave,
823
- onClick: onSave,
824
- children: busy ? t('saving') : t('save'),
825
- }),
826
- statusLine,
827
- ],
828
- }),
792
+ ...renderFields(AUTO_FIELDS, numberExtras),
793
+ renderFooter({ showRevert: false }),
829
794
  ],
830
795
  }),
831
796
  }),
@@ -872,8 +837,3 @@ window.__ModuleLoader__.load({
872
837
  return module.exports
873
838
  },
874
839
  })
875
-
876
-
877
-
878
-
879
-
package/lib/format.js CHANGED
@@ -11,6 +11,7 @@ export function formatSummary({
11
11
  latencyMs,
12
12
  lines = [],
13
13
  recommendation,
14
+ notes = [],
14
15
  needsHumanReview = false,
15
16
  }) {
16
17
  const out = [
@@ -18,10 +19,11 @@ export function formatSummary({
18
19
  `- **决策服务**:${provider} / ${model}`,
19
20
  ...lines,
20
21
  ]
22
+ for (const note of notes) out.push(`- **注意**:${note}`)
21
23
  if (recommendation) out.push(`- **建议**:${recommendation}`)
22
24
  out.push(`- **建议复核**:${needsHumanReview ? '需要人工复核' : '无需人工复核'}`)
23
25
  if (requestId) out.push(`- **请求 ID**:${requestId}(延迟 ${latencyMs}ms)`)
24
- return out.filter(Boolean).join('\n')
26
+ return out.join('\n')
25
27
  }
26
28
 
27
29
  /**
@@ -53,7 +55,7 @@ export function normalizeAnswers(answers, questions = {}) {
53
55
  decision[qid] = Number.isFinite(score) ? idx : null
54
56
  const legend = raw.legend || {}
55
57
  const scale = Array.isArray(question?.criteria) ? question.criteria : []
56
- labels[qid] = idx != null ? (legend[String(idx)] ?? scale[Number(raw.score ?? 0)] ?? String(idx)) : '无法判断'
58
+ labels[qid] = idx != null ? (legend[String(idx)] ?? scale[idx] ?? String(idx)) : '无法判断'
57
59
  if (Number.isFinite(Number(raw.confidence))) confidences[qid] = Number(raw.confidence)
58
60
  continue
59
61
  }
@@ -150,13 +152,21 @@ export function noulLine(label, answer) {
150
152
  return `- **${label}**:${v >= 0.5 ? '是' : '否'}(${(v * 100).toFixed(1)}%)`
151
153
  }
152
154
 
155
+ /** noul 落在该区间视为「摇摆不定」(接近 0.5),建议人工复核。 */
156
+ const NOUL_UNCERTAIN_LOW = 0.45
157
+ const NOUL_UNCERTAIN_HIGH = 0.55
158
+
159
+ /** choice 返回这些取值时视为「无法判断」。 */
160
+ const UNCERTAIN_CHOICES = new Set(['uncertain', 'unknown'])
161
+
153
162
  /** 是否需要人工复核:置信度低于阈值,或出现不确定项。 */
154
163
  export function computeNeedsHumanReview(answers, minConfidence) {
155
164
  for (const a of Object.values(answers || {})) {
156
165
  if (!a || typeof a !== 'object') continue
157
166
  if (typeof a.confidence === 'number' && a.confidence < minConfidence) return true
158
- if (a.type === 'choice' && (a.choice == null || a.choice === 'uncertain' || a.choice === 'unknown')) return true
159
- if (a.type === 'noul' && typeof a.noul === 'number' && a.noul > 0.45 && a.noul < 0.55) return true
167
+ if (a.type === 'choice' && (a.choice == null || UNCERTAIN_CHOICES.has(a.choice))) return true
168
+ if (a.type === 'noul' && typeof a.noul === 'number'
169
+ && a.noul > NOUL_UNCERTAIN_LOW && a.noul < NOUL_UNCERTAIN_HIGH) return true
160
170
  }
161
171
  return false
162
172
  }
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * @master0071/dsh-systemone 插件入口。
3
3
  *
4
4
  * 架构:Cordis 插件(Service 形态)。依赖 harness 的 `tools`(工具注册表)
5
- * 与 `logger`;无 Web 面。
5
+ * 与 `logger`;浏览器侧配置页由 client.js 提供(经 dsh.client 注入)。
6
6
  *
7
7
  * - 注册 2 个 Agent 工具:
8
8
  * systemone_scenario — 统一场景调度(list / describe / run)
@@ -16,8 +16,11 @@ import z from '@deepseek-ai/schemastery'
16
16
  import { createProvider } from './provider.js'
17
17
  import { createTools } from './tools.js'
18
18
  import { resolveScenarios } from './scenarios.js'
19
- import { installAutoDecide } from './auto.js'
20
- import { readBoolean, readString } from './volatile.js'
19
+ import { installAutoDecide, AUTO_DEFAULTS } from './auto.js'
20
+ import { readBoolean, readString, unwrap } from './volatile.js'
21
+
22
+ /** 合法的提供商类型;其他值会按 unisound 处理并告警一次。 */
23
+ const KNOWN_PROVIDERS = ['unisound', 'http', 'mock']
21
24
 
22
25
  /**
23
26
  * 把「当前提供商」包成一个稳定引用的对象,内部每次调用都转发到最新实例。
@@ -79,17 +82,17 @@ const Config = z.object({
79
82
  /** 注入通道:message=本步生效(pre-step 注入);context=官方动态上下文(晚一步生效) */
80
83
  autoInject: z.string().default('message').volatile(),
81
84
  /** 自动决策超时(毫秒),超时即放行,绝不阻塞会话 */
82
- autoTimeoutMs: z.number().min(500).default(8000).volatile(),
85
+ autoTimeoutMs: z.number().min(500).default(AUTO_DEFAULTS.autoTimeoutMs).volatile(),
83
86
  /** 捕获的历史消息条数(不含本轮新消息) */
84
- autoMaxMessages: z.number().min(1).max(50).default(6).volatile(),
87
+ autoMaxMessages: z.number().min(1).max(50).default(AUTO_DEFAULTS.autoMaxMessages).volatile(),
85
88
  /** 相同内容的结果缓存时长(毫秒),0 表示不缓存 */
86
- autoCacheTtlMs: z.number().min(0).default(60000).volatile(),
89
+ autoCacheTtlMs: z.number().min(0).default(AUTO_DEFAULTS.autoCacheTtlMs).volatile(),
87
90
  /** 自动决策使用的置信度阈值 */
88
- autoMinConfidence: z.number().min(0).max(1).default(0.6).volatile(),
91
+ autoMinConfidence: z.number().min(0).max(1).default(AUTO_DEFAULTS.autoMinConfidence).volatile(),
89
92
  /** 自动路由的置信度门槛:低于该值宁可不注入,避免塞入瞎猜的结论 */
90
- autoRouteMinConfidence: z.number().min(0).max(1).default(0.35).volatile(),
93
+ autoRouteMinConfidence: z.number().min(0).max(1).default(AUTO_DEFAULTS.autoRouteMinConfidence).volatile(),
91
94
  /** 触发自动决策的最小输入长度(字符);低于该长度且整体是寒暄才跳过 */
92
- autoMinChars: z.number().min(0).default(4).volatile(),
95
+ autoMinChars: z.number().min(0).default(AUTO_DEFAULTS.autoMinChars).volatile(),
93
96
  })
94
97
 
95
98
  export default class SystemOneService extends Service {
@@ -105,7 +108,7 @@ export default class SystemOneService extends Service {
105
108
 
106
109
  /**
107
110
  * @param {import('@deepseek-ai/cordis').Context} ctx
108
- * @param {import('zod').infer<typeof Config>} config
111
+ * @param {object} config - 经 Config schema 校验的插件配置(volatile 字段是引用对象,读取须经 volatile.js 脱壳)
109
112
  */
110
113
  constructor(ctx, config) {
111
114
  super(ctx, 'systemone')
@@ -114,13 +117,16 @@ export default class SystemOneService extends Service {
114
117
 
115
118
  // provider 字段是 volatile:配置页里切换 unisound / http / mock 时,
116
119
  // 下面这个代理会按需重建真正的提供商,工具与自动决策无需改动即可跟随。
117
- this._provider = createProvider(ctx, config)
118
- this._providerKind = readString(config, 'provider', 'unisound')
120
+ this._warnedProviders = new Set()
121
+ this._providerKind = null
122
+ this._provider = null
119
123
  this.provider = createProviderProxy(() => this.currentProvider())
124
+ this.currentProvider()
120
125
 
121
126
  // 场景库同样是热更新的:customScenarios 一变就原地重建,
122
127
  // 工具与自动决策持有的是同一个 liveScenarios 引用,因此无需重新装配。
123
128
  this._scenarioSource = null
129
+ this._scenarioRawValue = undefined
124
130
  this.scenarioProblems = []
125
131
  this._scenarioArray = []
126
132
  this._refreshScenarios()
@@ -164,12 +170,22 @@ export default class SystemOneService extends Service {
164
170
  currentProvider() {
165
171
  const kind = readString(this.config, 'provider', 'unisound')
166
172
  if (kind !== this._providerKind) {
167
- this._provider = createProvider(this.ctx, this.config)
173
+ this._warnUnknownProvider(kind)
174
+ this._provider = createProvider(this.config)
168
175
  this._providerKind = kind
169
176
  }
170
177
  return this._provider
171
178
  }
172
179
 
180
+ /** 未知 provider 值会在 provider.js 里静默回退到 unisound,这里负责告警(每个未知值只提示一次)。 */
181
+ _warnUnknownProvider(kind) {
182
+ if (KNOWN_PROVIDERS.includes(kind) || this._warnedProviders.has(kind)) return
183
+ this._warnedProviders.add(kind)
184
+ this.ctx?.logger?.warn?.(
185
+ `@master0071/dsh-systemone: 未知 provider "${kind}",将按 unisound 处理(可选:${KNOWN_PROVIDERS.join(' / ')})`,
186
+ )
187
+ }
188
+
173
189
  /**
174
190
  * 场景库的「活引用」。
175
191
  *
@@ -208,7 +224,13 @@ export default class SystemOneService extends Service {
208
224
  * 因为工具与自动决策捕获的是那个数组本身。
209
225
  */
210
226
  _refreshScenarios() {
211
- const source = readString(this.config, 'customScenarios', '').trim()
227
+ // 先做引用比较快速返回:Proxy 的每次属性访问都会走到这里,
228
+ // 字符串不可变,volatile 热更新换值时会换引用,引用相同即配置未变,
229
+ // 避免对大段自定义场景 JSON 反复做全量 String() + trim + 比较。
230
+ const value = unwrap(this.config?.customScenarios) ?? ''
231
+ if (value === this._scenarioRawValue) return
232
+ this._scenarioRawValue = value
233
+ const source = String(value).trim()
212
234
  if (source === this._scenarioSource) return
213
235
  const { scenarios, problems } = resolveScenarios(source)
214
236
  this._scenarioArray.length = 0
package/lib/provider.js CHANGED
@@ -20,23 +20,21 @@ import { readNumber, readString } from './volatile.js'
20
20
  const DEFAULT_BASE_URL = 'https://maas-api.unisound.com/v1'
21
21
  const DEFAULT_MODEL = 'u2-decision'
22
22
 
23
- /** 创建提供商实例。config 来自插件配置(已由 Config schema 校验)。 */
24
- export function createProvider(ctx, config) {
23
+ /** 创建提供商实例。config 来自插件配置(已由 Config schema 校验)。未知 provider 值回退到 unisound(入口层负责告警)。 */
24
+ export function createProvider(config) {
25
25
  const kind = readString(config, 'provider', 'unisound')
26
26
  if (kind === 'mock') return new MockProvider(config)
27
- if (kind === 'http') return new HttpProvider(ctx, config, 'http')
28
- return new UnisoundProvider(ctx, config)
27
+ if (kind === 'http') return new HttpProvider(config, 'http')
28
+ return new UnisoundProvider(config)
29
29
  }
30
30
 
31
31
  /** 通用 HTTP 提供商:请求 SystemOne 兼容端点,Bearer 认证。 */
32
32
  export class HttpProvider {
33
33
  /**
34
- * @param {object} ctx - Cordis 上下文
35
34
  * @param {object} config - 插件配置
36
35
  * @param {string} [name] - 提供商显示名
37
36
  */
38
- constructor(ctx, config, name = 'http') {
39
- this.ctx = ctx
37
+ constructor(config, name = 'http') {
40
38
  this.config = config || {}
41
39
  this.name = name
42
40
  }
@@ -83,6 +81,7 @@ export class HttpProvider {
83
81
 
84
82
  const controller = new AbortController()
85
83
  const timer = setTimeout(() => controller.abort(new Error(`SystemOne: 请求超时(${timeoutMs}ms)`)), timeoutMs)
84
+ timer.unref?.()
86
85
  const onOuterAbort = () => controller.abort()
87
86
  if (signal && typeof signal.addEventListener === 'function') {
88
87
  signal.addEventListener('abort', onOuterAbort, { once: true })
@@ -128,8 +127,8 @@ export class HttpProvider {
128
127
 
129
128
  /** Unisound 官方提供商(默认)。 */
130
129
  export class UnisoundProvider extends HttpProvider {
131
- constructor(ctx, config) {
132
- super(ctx, { ...config, provider: 'unisound' }, 'unisound')
130
+ constructor(config) {
131
+ super({ ...config, provider: 'unisound' }, 'unisound')
133
132
  }
134
133
  }
135
134
 
package/lib/tools.js CHANGED
@@ -79,7 +79,15 @@ function scenarioTool(provider, config, scenarios) {
79
79
  if (action === 'describe') {
80
80
  return describeScenario(scenarios, args.scenario)
81
81
  }
82
- return runScenario({ provider, config, scenarios, args, exec })
82
+ return runScenarioDecision({
83
+ provider,
84
+ config,
85
+ scenarios,
86
+ scenario: args.scenario,
87
+ state: args.state,
88
+ params: args.params,
89
+ exec,
90
+ })
83
91
  },
84
92
  presentCall: (args) => ({
85
93
  card: 'generic',
@@ -156,18 +164,6 @@ function describeScenario(scenarios, id) {
156
164
  return { ok: true, action: 'describe', scenario: scenario.id, title: scenario.title, questions, summary }
157
165
  }
158
166
 
159
- async function runScenario({ provider, config, scenarios, args, exec }) {
160
- return runScenarioDecision({
161
- provider,
162
- config,
163
- scenarios,
164
- scenario: args.scenario,
165
- state: args.state,
166
- params: args.params,
167
- exec,
168
- })
169
- }
170
-
171
167
  /**
172
168
  * 执行一次场景决策(工具与自动决策共用的核心入口)。
173
169
  *
@@ -179,7 +175,6 @@ async function runScenario({ provider, config, scenarios, args, exec }) {
179
175
  * @param {unknown} options.state - 业务上下文
180
176
  * @param {object} [options.params] - 按问题 id 覆盖问题定义
181
177
  * @param {object} [options.exec] - 工具执行上下文(取 signal)
182
- * @param {string} [options.model] - 覆盖模型
183
178
  * @param {number} [options.minConfidence] - 覆盖置信度阈值
184
179
  * @returns {Promise<object>} 与工具输出同构的决策结果
185
180
  */
@@ -191,7 +186,6 @@ export async function runScenarioDecision({
191
186
  state,
192
187
  params,
193
188
  exec,
194
- model,
195
189
  minConfidence,
196
190
  }) {
197
191
  const scenario = findScenario(scenarios, scenarioRef)
@@ -212,7 +206,7 @@ export async function runScenarioDecision({
212
206
  }
213
207
  }
214
208
 
215
- const questions = applyParams(scenario.questions, params)
209
+ const { questions, ignored } = applyParams(scenario.questions, params)
216
210
  const configThreshold = readNumber(config, 'minConfidence', NaN)
217
211
  const threshold = Number.isFinite(Number(minConfidence))
218
212
  ? Number(minConfidence)
@@ -231,7 +225,9 @@ export async function runScenarioDecision({
231
225
  config,
232
226
  minConfidence: threshold,
233
227
  exec,
234
- model,
228
+ warnings: ignored.length > 0
229
+ ? [`以下 params 覆盖无效,已忽略:${ignored.join(';')}`]
230
+ : [],
235
231
  finalize(answers) {
236
232
  const { decision, labels, confidences } = normalizeAnswers(answers, questions)
237
233
  const derived = deriveFields(scenario.derive, decision)
@@ -243,10 +239,16 @@ export async function runScenarioDecision({
243
239
  })
244
240
  }
245
241
 
246
- /** 按 params 覆盖问题定义。 */
242
+ /**
243
+ * 按 params 覆盖问题定义,并收集无法应用的覆盖项(类型不匹配、未知问题 id),
244
+ * 让调用方能把「传了但没生效」反馈给模型,而不是静默丢弃。
245
+ *
246
+ * @returns {{ questions: object, ignored: string[] }}
247
+ */
247
248
  function applyParams(questions, params) {
248
- if (!params || typeof params !== 'object') return questions
249
+ if (!params || typeof params !== 'object') return { questions, ignored: [] }
249
250
  const merged = {}
251
+ const ignored = []
250
252
  for (const [qid, question] of Object.entries(questions)) {
251
253
  const override = params[qid]
252
254
  if (!override || typeof override !== 'object') {
@@ -268,14 +270,23 @@ function applyParams(questions, params) {
268
270
  )
269
271
  } else if (question.type === 'score' && Array.isArray(override.criteria) && override.criteria.length >= 2) {
270
272
  next.criteria = override.criteria.map((v) => String(v))
273
+ } else {
274
+ ignored.push(`${qid}.criteria(与问题类型 ${question.type} 不匹配)`)
271
275
  }
272
276
  }
273
- if (override.addCriteria && question.type === 'choice' && !Array.isArray(override.addCriteria)) {
274
- next.criteria = { ...(next.criteria || {}), ...override.addCriteria }
277
+ if (override.addCriteria) {
278
+ if (question.type === 'choice' && !Array.isArray(override.addCriteria)) {
279
+ next.criteria = { ...(next.criteria || {}), ...override.addCriteria }
280
+ } else {
281
+ ignored.push(`${qid}.addCriteria(仅支持 choice 问题)`)
282
+ }
275
283
  }
276
284
  merged[qid] = next
277
285
  }
278
- return merged
286
+ for (const qid of Object.keys(params)) {
287
+ if (!(qid in questions)) ignored.push(`${qid}(未知问题 id)`)
288
+ }
289
+ return { questions: merged, ignored }
279
290
  }
280
291
 
281
292
  /* ─── 2. 通用决策工具 ─────────────────────────────────────────────────────── */
@@ -357,6 +368,7 @@ async function executeDecision({
357
368
  exec,
358
369
  model,
359
370
  finalize,
371
+ warnings = [],
360
372
  }) {
361
373
  try {
362
374
  const started = Date.now()
@@ -383,6 +395,7 @@ async function executeDecision({
383
395
  latencyMs,
384
396
  lines,
385
397
  recommendation,
398
+ notes: warnings,
386
399
  needsHumanReview,
387
400
  })
388
401
  return {
@@ -399,6 +412,7 @@ async function executeDecision({
399
412
  confidences,
400
413
  needs_human_review: needsHumanReview,
401
414
  recommendation,
415
+ warnings,
402
416
  summary,
403
417
  }
404
418
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@master0071/dsh-systemone",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "DeepSeek Harness 插件:将 SystemOne 决策模型(默认云知声 u2-decision,可切换提供商)接入软件开发、客服、内容审核、路由、销售线索、风控、招聘、数据治理、教育、需求变更等 10 大业务场景,为 Agent 返回带概率分布的决策与处置建议。",
5
5
  "license": "MIT",
6
6
  "type": "module",