@master0071/dsh-systemone 0.0.0-stage → 0.4.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 +542 -2
- package/cordis.patch.yml +7 -0
- package/lib/auto.js +527 -0
- package/lib/client.js +879 -0
- package/lib/format.js +197 -0
- package/lib/index.d.ts +84 -0
- package/lib/index.js +262 -0
- package/lib/provider.js +247 -0
- package/lib/scenarios.js +526 -0
- package/lib/tools.js +421 -0
- package/lib/volatile.js +50 -0
- package/package.json +57 -4
package/lib/provider.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider.js — SystemOne 提供商抽象层。
|
|
3
|
+
*
|
|
4
|
+
* 所有业务工具只依赖统一的 `decide()` 接口,不感知具体厂商:
|
|
5
|
+
*
|
|
6
|
+
* {
|
|
7
|
+
* name: string,
|
|
8
|
+
* async decide({ state, questions, model, signal }): Promise<SystemOneResponse>
|
|
9
|
+
* }
|
|
10
|
+
*
|
|
11
|
+
* 切换厂商 = 修改插件配置的 `provider` 字段,业务工具代码零改动。
|
|
12
|
+
* 内置三种提供商:
|
|
13
|
+
* - "unisound":官方 HTTP API(默认)
|
|
14
|
+
* - "http" :任意实现了相同 SystemOne 协议的 HTTP 端点(自建网关/其他厂商)
|
|
15
|
+
* - "mock" :本地确定性模拟(无需 API Key,用于测试与演示)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readNumber, readString } from './volatile.js'
|
|
19
|
+
|
|
20
|
+
const DEFAULT_BASE_URL = 'https://maas-api.unisound.com/v1'
|
|
21
|
+
const DEFAULT_MODEL = 'u2-decision'
|
|
22
|
+
|
|
23
|
+
/** 创建提供商实例。config 来自插件配置(已由 Config schema 校验)。 */
|
|
24
|
+
export function createProvider(ctx, config) {
|
|
25
|
+
const kind = readString(config, 'provider', 'unisound')
|
|
26
|
+
if (kind === 'mock') return new MockProvider(config)
|
|
27
|
+
if (kind === 'http') return new HttpProvider(ctx, config, 'http')
|
|
28
|
+
return new UnisoundProvider(ctx, config)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 通用 HTTP 提供商:请求 SystemOne 兼容端点,Bearer 认证。 */
|
|
32
|
+
export class HttpProvider {
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} ctx - Cordis 上下文
|
|
35
|
+
* @param {object} config - 插件配置
|
|
36
|
+
* @param {string} [name] - 提供商显示名
|
|
37
|
+
*/
|
|
38
|
+
constructor(ctx, config, name = 'http') {
|
|
39
|
+
this.ctx = ctx
|
|
40
|
+
this.config = config || {}
|
|
41
|
+
this.name = name
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 解析完整请求端点(每次调用都读最新配置,支持热更新)。 */
|
|
45
|
+
resolveEndpoint() {
|
|
46
|
+
const provider = readString(this.config, 'provider', 'unisound')
|
|
47
|
+
const baseUrl = readString(this.config, 'baseUrl', DEFAULT_BASE_URL)
|
|
48
|
+
const endpoint = readString(this.config, 'endpoint', '')
|
|
49
|
+
if (provider === 'http') {
|
|
50
|
+
const ep = String(endpoint || baseUrl || '').trim()
|
|
51
|
+
if (!ep) {
|
|
52
|
+
throw new Error('SystemOne: provider=http 需要配置 endpoint 或 baseUrl')
|
|
53
|
+
}
|
|
54
|
+
return ep
|
|
55
|
+
}
|
|
56
|
+
const base = String(baseUrl || DEFAULT_BASE_URL).trim().replace(/\/+$/, '')
|
|
57
|
+
return `${base}/systemone`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 解析 API Key:配置优先,其次环境变量。 */
|
|
61
|
+
resolveApiKey() {
|
|
62
|
+
const key = readString(this.config, 'apiKey', '')
|
|
63
|
+
|| process.env.UNISOUND_API_KEY
|
|
64
|
+
|| process.env.SYSTEMONE_API_KEY
|
|
65
|
+
|| ''
|
|
66
|
+
if (!key) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'SystemOne: 未配置 apiKey(请在插件配置中填写,或设置环境变量 UNISOUND_API_KEY / SYSTEMONE_API_KEY)',
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
return key
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 调用决策接口。
|
|
76
|
+
* @param {{ state: unknown, questions: object, model?: string, signal?: AbortSignal }} options
|
|
77
|
+
* @returns {Promise<object>} SystemOne 响应对象(model/request_id/latency_ms/answers/usage)
|
|
78
|
+
*/
|
|
79
|
+
async decide({ state, questions, model, signal }) {
|
|
80
|
+
const endpoint = this.resolveEndpoint()
|
|
81
|
+
const apiKey = this.resolveApiKey()
|
|
82
|
+
const timeoutMs = readNumber(this.config, 'timeoutMs', 30000) || 30000
|
|
83
|
+
|
|
84
|
+
const controller = new AbortController()
|
|
85
|
+
const timer = setTimeout(() => controller.abort(new Error(`SystemOne: 请求超时(${timeoutMs}ms)`)), timeoutMs)
|
|
86
|
+
const onOuterAbort = () => controller.abort()
|
|
87
|
+
if (signal && typeof signal.addEventListener === 'function') {
|
|
88
|
+
signal.addEventListener('abort', onOuterAbort, { once: true })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(endpoint, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
Authorization: `Bearer ${apiKey}`,
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
model: model || readString(this.config, 'model', DEFAULT_MODEL) || DEFAULT_MODEL,
|
|
100
|
+
state,
|
|
101
|
+
questions,
|
|
102
|
+
}),
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const text = await res.text().catch(() => '')
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`SystemOne: HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''} — ${text.slice(0, 500) || '无响应体'}`,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let data
|
|
114
|
+
try {
|
|
115
|
+
data = JSON.parse(text)
|
|
116
|
+
} catch {
|
|
117
|
+
throw new Error(`SystemOne: 响应不是合法 JSON — ${text.slice(0, 300) || '空响应'}`)
|
|
118
|
+
}
|
|
119
|
+
return data
|
|
120
|
+
} finally {
|
|
121
|
+
clearTimeout(timer)
|
|
122
|
+
if (signal && typeof signal.removeEventListener === 'function') {
|
|
123
|
+
signal.removeEventListener('abort', onOuterAbort)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Unisound 官方提供商(默认)。 */
|
|
130
|
+
export class UnisoundProvider extends HttpProvider {
|
|
131
|
+
constructor(ctx, config) {
|
|
132
|
+
super(ctx, { ...config, provider: 'unisound' }, 'unisound')
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 本地确定性模拟提供商:无网络、无 Key,便于测试与演示。 */
|
|
137
|
+
export class MockProvider {
|
|
138
|
+
/**
|
|
139
|
+
* @param {object} config - 插件配置
|
|
140
|
+
*/
|
|
141
|
+
constructor(config) {
|
|
142
|
+
this.config = config || {}
|
|
143
|
+
this.name = 'mock'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 用输入内容 + 问题 ID 做种子,生成确定性的概率分布。
|
|
148
|
+
* 响应结构与 Unisound 完全一致。
|
|
149
|
+
*/
|
|
150
|
+
async decide({ state, questions, model, signal }) {
|
|
151
|
+
void signal
|
|
152
|
+
const seedSource = `${model || 'mock-decision'}|${typeof state === 'string' ? state : JSON.stringify(state)}`
|
|
153
|
+
const answers = {}
|
|
154
|
+
for (const [id, q] of Object.entries(questions || {})) {
|
|
155
|
+
answers[id] = mockAnswer(id, q, seedSource)
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
model: model || 'mock-decision',
|
|
159
|
+
request_id: `mock-${hashString(seedSource).toString(16)}`,
|
|
160
|
+
latency_ms: 1,
|
|
161
|
+
answers,
|
|
162
|
+
usage: { input_tokens: 0 },
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ─── 确定性模拟工具 ───────────────────────────────────────────────────────── */
|
|
168
|
+
|
|
169
|
+
function mockAnswer(id, q, seedSource) {
|
|
170
|
+
if (!q || typeof q !== 'object') {
|
|
171
|
+
return { type: 'unknown' }
|
|
172
|
+
}
|
|
173
|
+
const rand = mulberry32(hashString(`${seedSource}|${id}`))
|
|
174
|
+
|
|
175
|
+
switch (q.type) {
|
|
176
|
+
case 'choice': {
|
|
177
|
+
const keys = Object.keys(q.criteria || {})
|
|
178
|
+
if (!keys.length) {
|
|
179
|
+
return { type: 'choice', choice: null, probabilities: {}, confidence: 0 }
|
|
180
|
+
}
|
|
181
|
+
const weights = keys.map(() => rand() + 0.1)
|
|
182
|
+
const sum = weights.reduce((a, b) => a + b, 0)
|
|
183
|
+
const probabilities = {}
|
|
184
|
+
keys.forEach((k, i) => {
|
|
185
|
+
probabilities[k] = weights[i] / sum
|
|
186
|
+
})
|
|
187
|
+
const top = keys.reduce((a, b) => (probabilities[a] >= probabilities[b] ? a : b))
|
|
188
|
+
return {
|
|
189
|
+
type: 'choice',
|
|
190
|
+
choice: top,
|
|
191
|
+
probabilities,
|
|
192
|
+
confidence: probabilities[top],
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
case 'noul':
|
|
196
|
+
return {
|
|
197
|
+
type: 'noul',
|
|
198
|
+
noul: Math.round((rand() * 0.6 + 0.2) * 1000) / 1000,
|
|
199
|
+
}
|
|
200
|
+
case 'score': {
|
|
201
|
+
const list = Array.isArray(q.criteria) ? q.criteria : []
|
|
202
|
+
const len = Math.max(list.length, 1)
|
|
203
|
+
const weights = Array.from({ length: len }, () => rand() + 0.1)
|
|
204
|
+
const sum = weights.reduce((a, b) => a + b, 0)
|
|
205
|
+
const probabilities = {}
|
|
206
|
+
let score = 0
|
|
207
|
+
weights.forEach((w, i) => {
|
|
208
|
+
probabilities[String(i)] = w / sum
|
|
209
|
+
score += i * (w / sum)
|
|
210
|
+
})
|
|
211
|
+
const legend = {}
|
|
212
|
+
list.forEach((label, i) => {
|
|
213
|
+
legend[String(i)] = label
|
|
214
|
+
})
|
|
215
|
+
const topIdx = weights.indexOf(Math.max(...weights))
|
|
216
|
+
return {
|
|
217
|
+
type: 'score',
|
|
218
|
+
legend,
|
|
219
|
+
probabilities,
|
|
220
|
+
score: Math.round(score * 1000) / 1000,
|
|
221
|
+
confidence: probabilities[String(topIdx)],
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
default:
|
|
225
|
+
return { type: q.type || 'unknown' }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function hashString(str) {
|
|
230
|
+
let h = 2166136261 >>> 0
|
|
231
|
+
for (let i = 0; i < str.length; i++) {
|
|
232
|
+
h ^= str.charCodeAt(i)
|
|
233
|
+
h = Math.imul(h, 16777619)
|
|
234
|
+
}
|
|
235
|
+
return h >>> 0
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function mulberry32(seed) {
|
|
239
|
+
let a = seed >>> 0
|
|
240
|
+
return function () {
|
|
241
|
+
a |= 0
|
|
242
|
+
a = (a + 0x6d2b79f5) | 0
|
|
243
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
244
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
245
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
246
|
+
}
|
|
247
|
+
}
|