@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/client.js
ADDED
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client.js — @master0071/dsh-systemone 的浏览器侧(DSH 桌面端 / Web 界面)。
|
|
3
|
+
*
|
|
4
|
+
* 宿主侧那半边只注册工具与服务、没有界面;这个半边负责在
|
|
5
|
+
* 「侧栏 → 插件 → @master0071/dsh-systemone → systemone 行」里渲染配置表单。
|
|
6
|
+
*
|
|
7
|
+
* 注册方式:`plugins.row.config` 槽位,key 为 `<包名>#<行 id>`。
|
|
8
|
+
* —— 这是插件管理页里**唯一**会把 `form` 传进页面的配置槽位:
|
|
9
|
+
* 页面宿主调用 `formFor(rowId)`,命中会传入
|
|
10
|
+
* `{ state: <快照>, mutate(ops, revision) }`;
|
|
11
|
+
* 没有可编辑(volatile)字段时 `form` 为 undefined,页面退化为只读展示。
|
|
12
|
+
*
|
|
13
|
+
* 表单写入的是 profile 的 cordis.patch.yml(由宿主 dsh-config-editor 落盘),
|
|
14
|
+
* 保存后经 Loader 热更新,宿主的 volatile 配置字段原地生效、无需重启。
|
|
15
|
+
*
|
|
16
|
+
* 模块格式:DSH 客户端模块加载器要求 `window.__ModuleLoader__.load({ id, factory })`,
|
|
17
|
+
* 依赖通过 factory 的 `require()` 取得(react 等由加载器提供,无需 npm 依赖)。
|
|
18
|
+
*/
|
|
19
|
+
window.__ModuleLoader__.load({
|
|
20
|
+
id: '@master0071/dsh-systemone',
|
|
21
|
+
factory: (require) => {
|
|
22
|
+
const module = { exports: {} }
|
|
23
|
+
const exports = module.exports
|
|
24
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
25
|
+
|
|
26
|
+
const react = require('react')
|
|
27
|
+
const jsxRuntime = require('react/jsx-runtime')
|
|
28
|
+
const h = jsxRuntime.jsx
|
|
29
|
+
const hs = jsxRuntime.jsxs
|
|
30
|
+
const Fragment = react.Fragment
|
|
31
|
+
|
|
32
|
+
/** 字典命名空间。 */
|
|
33
|
+
const NS = 'settings.systemone'
|
|
34
|
+
/** 本半边注册进 `plugins.row.config` 的键:包名#行 id。 */
|
|
35
|
+
const ROW_CONFIG_KEY = '@master0071/dsh-systemone#systemone'
|
|
36
|
+
|
|
37
|
+
/* ────────────────────────────── 文案 ────────────────────────────── */
|
|
38
|
+
|
|
39
|
+
const zh = {
|
|
40
|
+
tabService: '模型服务',
|
|
41
|
+
tabServiceHint: '连接哪个 SystemOne 决策服务,以及用哪个模型。保存后立即生效,无需重启。',
|
|
42
|
+
tabCustom: '自定义场景',
|
|
43
|
+
tabCustomHint: '用 JSON 定义自己的场景(含「通用意图识别」)。保存后场景库立即重建,无需重启。',
|
|
44
|
+
customScenarios: '场景定义(JSON 数组)',
|
|
45
|
+
customScenariosHint: '同 id 会覆盖内置场景;留空 [] 表示只用内置 10 个。每个场景最多 16 个问题,choice 选项 2~26 个。',
|
|
46
|
+
customScenariosPlaceholder: '[\n {\n "id": "intent",\n "title": "通用意图识别",\n "questions": {\n "intent": {\n "type": "choice",\n "instructions": "用户最想做什么?",\n "criteria": { "query": "查询", "action": "执行操作", "chat": "闲聊" }\n }\n }\n }\n]',
|
|
47
|
+
insertTemplate: '插入意图识别模板',
|
|
48
|
+
jsonEmpty: '(空 = 只用内置场景)',
|
|
49
|
+
jsonNotArray: '必须是 JSON 数组(以 [ 开头)',
|
|
50
|
+
jsonBadSyntax: 'JSON 语法错误:{message}',
|
|
51
|
+
jsonItemNotObject: '第 {index} 项不是对象',
|
|
52
|
+
jsonItemIssues: '第 {index} 项({id}):{issues}',
|
|
53
|
+
jsonOk: 'JSON 有效,共 {count} 个自定义场景',
|
|
54
|
+
customIdsUsed: '(已加入「固定场景」候选)',
|
|
55
|
+
tabAuto: '自动决策',
|
|
56
|
+
tabAutoHint: '开启自动决策后,这些参数控制每一步推理前的自动判断。',
|
|
57
|
+
provider: '提供商',
|
|
58
|
+
providerHint: 'unisound=云知声官方;http=任意 SystemOne 兼容端点;mock=本地模拟(无需 Key,用于联调)',
|
|
59
|
+
providerUnisound: '云知声(官方)',
|
|
60
|
+
providerHttp: '自建 / 兼容端点',
|
|
61
|
+
providerMock: '本地模拟',
|
|
62
|
+
apiKey: 'API Key',
|
|
63
|
+
apiKeyHint: '也可用环境变量 UNISOUND_API_KEY / SYSTEMONE_API_KEY。出于安全,已保存的 Key 不会回显。',
|
|
64
|
+
apiKeyPlaceholder: '留空表示不修改已保存的值',
|
|
65
|
+
baseUrl: 'API 基础地址',
|
|
66
|
+
baseUrlHint: '默认 https://maas-api.unisound.com/v1',
|
|
67
|
+
endpoint: '完整端点(可选)',
|
|
68
|
+
endpointHint: '仅在「自建 / 兼容端点」时使用;留空则回退到基础地址',
|
|
69
|
+
model: '模型名',
|
|
70
|
+
modelHint: '默认 u2-decision',
|
|
71
|
+
timeoutMs: '请求超时(毫秒)',
|
|
72
|
+
timeoutMsHint: '单次决策请求的最长等待时间',
|
|
73
|
+
minConfidence: '置信度阈值',
|
|
74
|
+
minConfidenceHint: '任一答案低于该值时,结果会标记为建议人工复核(0~1)',
|
|
75
|
+
autoScenario: '固定场景(可选)',
|
|
76
|
+
autoScenarioHint: '点右侧箭头可从 10 个内置场景里选,也可手填场景 id 或别名(如 software_dev / 开发);留空则每次自动选场景(多一次路由请求)',
|
|
77
|
+
autoInject: '注入通道',
|
|
78
|
+
autoInjectHint: 'message=同一步生效;context=走宿主动态上下文,不写入会话历史',
|
|
79
|
+
autoInjectMessage: '同步注入(message)',
|
|
80
|
+
autoInjectContext: '动态上下文(context)',
|
|
81
|
+
autoTimeoutMs: '自动决策超时(毫秒)',
|
|
82
|
+
autoTimeoutMsHint: '超时即放行,绝不阻塞会话',
|
|
83
|
+
autoMaxMessages: '捕获历史条数',
|
|
84
|
+
autoMaxMessagesHint: '自动决策时参考的最近消息数',
|
|
85
|
+
autoCacheTtlMs: '结果缓存时长(毫秒)',
|
|
86
|
+
autoCacheTtlMsHint: '相同内容在此时长内复用结果,0 表示不缓存',
|
|
87
|
+
autoMinConfidence: '自动决策置信度阈值',
|
|
88
|
+
autoMinConfidenceHint: '低于该值时结论会附带「请人工确认」提示',
|
|
89
|
+
autoRouteMinConfidence: '自动路由置信度门槛',
|
|
90
|
+
autoRouteMinConfidenceHint: '低于该值时宁可不注入,避免塞入瞎猜的结论',
|
|
91
|
+
autoMinChars: '最小触发长度(字符)',
|
|
92
|
+
autoMinCharsHint: '低于该长度且整体是寒暄时跳过',
|
|
93
|
+
save: '保存',
|
|
94
|
+
saving: '保存中…',
|
|
95
|
+
revert: '撤销修改',
|
|
96
|
+
saved: '已保存,立即生效',
|
|
97
|
+
saveFailed: '保存失败,请检查填写内容',
|
|
98
|
+
conflict: '配置已被其他地方改动,已刷新为最新值,请重新修改',
|
|
99
|
+
readOnly: '当前不可编辑',
|
|
100
|
+
noForm: '宿主要求该条目声明 volatile 配置字段后才会显示表单。',
|
|
101
|
+
summary: 'SystemOne 决策模型配置',
|
|
102
|
+
current: '当前:{provider} · {model}',
|
|
103
|
+
expand: '展开',
|
|
104
|
+
collapse: '收起',
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const en = {
|
|
108
|
+
tabService: 'Model service',
|
|
109
|
+
tabServiceHint: 'Which SystemOne decision service to call, and with which model. Saving applies immediately.',
|
|
110
|
+
tabCustom: 'Custom scenarios',
|
|
111
|
+
tabCustomHint: 'Define your own scenarios in JSON (including general intent recognition). Saving rebuilds the library immediately.',
|
|
112
|
+
customScenarios: 'Scenario definitions (JSON array)',
|
|
113
|
+
customScenariosHint: 'An id matching a built-in overrides it; empty [] keeps only the 10 built-ins. Max 16 questions per scenario, 2~26 choice options.',
|
|
114
|
+
customScenariosPlaceholder: '[\n {\n "id": "intent",\n "title": "General intent",\n "questions": {\n "intent": {\n "type": "choice",\n "instructions": "What does the user want?",\n "criteria": { "query": "Look up", "action": "Do something", "chat": "Small talk" }\n }\n }\n }\n]',
|
|
115
|
+
insertTemplate: 'Insert intent template',
|
|
116
|
+
jsonEmpty: '(empty = built-in scenarios only)',
|
|
117
|
+
jsonNotArray: 'Must be a JSON array (starting with [)',
|
|
118
|
+
jsonBadSyntax: 'JSON syntax error: {message}',
|
|
119
|
+
jsonItemNotObject: 'Item {index} is not an object',
|
|
120
|
+
jsonItemIssues: 'Item {index} ({id}): {issues}',
|
|
121
|
+
jsonOk: 'Valid JSON — {count} custom scenario(s)',
|
|
122
|
+
customIdsUsed: '(added to the fixed-scenario candidates)',
|
|
123
|
+
tabAuto: 'Auto decision',
|
|
124
|
+
tabAutoHint: 'These parameters drive the automatic decision taken before each reasoning step.',
|
|
125
|
+
provider: 'Provider',
|
|
126
|
+
providerHint: 'unisound=Unisound official; http=any SystemOne-compatible endpoint; mock=local mock (no key)',
|
|
127
|
+
providerUnisound: 'Unisound (official)',
|
|
128
|
+
providerHttp: 'Custom / compatible endpoint',
|
|
129
|
+
providerMock: 'Local mock',
|
|
130
|
+
apiKey: 'API key',
|
|
131
|
+
apiKeyHint: 'UNISOUND_API_KEY / SYSTEMONE_API_KEY also work. A saved key is never echoed back.',
|
|
132
|
+
apiKeyPlaceholder: 'Leave empty to keep the saved value',
|
|
133
|
+
baseUrl: 'Base URL',
|
|
134
|
+
baseUrlHint: 'Defaults to https://maas-api.unisound.com/v1',
|
|
135
|
+
endpoint: 'Full endpoint (optional)',
|
|
136
|
+
endpointHint: 'Used only by the custom/compatible provider; falls back to the base URL',
|
|
137
|
+
model: 'Model',
|
|
138
|
+
modelHint: 'Defaults to u2-decision',
|
|
139
|
+
timeoutMs: 'Request timeout (ms)',
|
|
140
|
+
timeoutMsHint: 'Longest wait for one decision request',
|
|
141
|
+
minConfidence: 'Confidence threshold',
|
|
142
|
+
minConfidenceHint: 'Below this value a result is flagged for human review (0~1)',
|
|
143
|
+
autoScenario: 'Fixed scenario (optional)',
|
|
144
|
+
autoScenarioHint: 'Pick one of the 10 built-in scenarios from the dropdown, or type a scenario id/alias (e.g. software_dev); empty routes automatically on every step (one extra request)',
|
|
145
|
+
autoInject: 'Injection channel',
|
|
146
|
+
autoInjectHint: 'message=applies in the same step; context=host dynamic context, not written to history',
|
|
147
|
+
autoInjectMessage: 'Same step (message)',
|
|
148
|
+
autoInjectContext: 'Dynamic context (context)',
|
|
149
|
+
autoTimeoutMs: 'Auto decision timeout (ms)',
|
|
150
|
+
autoTimeoutMsHint: 'On timeout the step proceeds — never blocks the session',
|
|
151
|
+
autoMaxMessages: 'Captured history messages',
|
|
152
|
+
autoMaxMessagesHint: 'How many recent messages the automatic decision reads',
|
|
153
|
+
autoCacheTtlMs: 'Result cache TTL (ms)',
|
|
154
|
+
autoCacheTtlMsHint: 'Reuse a result for identical input; 0 disables caching',
|
|
155
|
+
autoMinConfidence: 'Auto decision confidence threshold',
|
|
156
|
+
autoMinConfidenceHint: 'Below this value the conclusion carries a human-review note',
|
|
157
|
+
autoRouteMinConfidence: 'Auto routing confidence floor',
|
|
158
|
+
autoRouteMinConfidenceHint: 'Below this value nothing is injected rather than guessing',
|
|
159
|
+
autoMinChars: 'Minimum trigger length (chars)',
|
|
160
|
+
autoMinCharsHint: 'Shorter greetings are skipped',
|
|
161
|
+
save: 'Save',
|
|
162
|
+
saving: 'Saving…',
|
|
163
|
+
revert: 'Revert changes',
|
|
164
|
+
saved: 'Saved — applied immediately',
|
|
165
|
+
saveFailed: 'Save failed — check the values',
|
|
166
|
+
conflict: 'The configuration changed elsewhere; the newest values were reloaded',
|
|
167
|
+
readOnly: 'Read-only right now',
|
|
168
|
+
noForm: 'The host exposes a form only when the entry declares volatile config fields.',
|
|
169
|
+
summary: 'SystemOne decision model configuration',
|
|
170
|
+
current: 'Current: {provider} · {model}',
|
|
171
|
+
expand: 'Expand',
|
|
172
|
+
collapse: 'Collapse',
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* ────────────────────────────── 样式 ────────────────────────────── */
|
|
176
|
+
|
|
177
|
+
const listStyle = {
|
|
178
|
+
display: 'flex',
|
|
179
|
+
flexDirection: 'column',
|
|
180
|
+
gap: 12,
|
|
181
|
+
listStyle: 'none',
|
|
182
|
+
margin: 0,
|
|
183
|
+
padding: 0,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const cardStyle = {
|
|
187
|
+
border: '0.5px solid var(--dsw-alias-settings-card-stroke, #e3e3e6)',
|
|
188
|
+
borderRadius: 12,
|
|
189
|
+
background: 'var(--dsw-alias-settings-card-fill, transparent)',
|
|
190
|
+
overflow: 'hidden',
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const headStyle = {
|
|
194
|
+
display: 'flex',
|
|
195
|
+
alignItems: 'center',
|
|
196
|
+
justifyContent: 'space-between',
|
|
197
|
+
gap: 12,
|
|
198
|
+
width: '100%',
|
|
199
|
+
padding: '12px 14px',
|
|
200
|
+
background: 'transparent',
|
|
201
|
+
border: 0,
|
|
202
|
+
cursor: 'pointer',
|
|
203
|
+
textAlign: 'left',
|
|
204
|
+
color: 'inherit',
|
|
205
|
+
font: 'inherit',
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const bodyStyle = {
|
|
209
|
+
borderTop: '0.5px solid var(--dsw-alias-border-l2, #e3e3e6)',
|
|
210
|
+
padding: '12px 14px 14px',
|
|
211
|
+
display: 'flex',
|
|
212
|
+
flexDirection: 'column',
|
|
213
|
+
gap: 12,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const titleStyle = { fontSize: 14, fontWeight: 500, lineHeight: '20px' }
|
|
217
|
+
const descStyle = {
|
|
218
|
+
color: 'var(--dsw-alias-label-tertiary, #8a8a8e)',
|
|
219
|
+
fontSize: 12,
|
|
220
|
+
lineHeight: '18px',
|
|
221
|
+
marginTop: 2,
|
|
222
|
+
}
|
|
223
|
+
const fieldStyle = { display: 'flex', flexDirection: 'column', gap: 4 }
|
|
224
|
+
const labelStyle = { fontSize: 12, lineHeight: '18px', fontWeight: 500 }
|
|
225
|
+
const hintStyle = { color: 'var(--dsw-alias-label-tertiary, #8a8a8e)', fontSize: 11, lineHeight: '17px' }
|
|
226
|
+
const inputStyle = {
|
|
227
|
+
width: '100%',
|
|
228
|
+
boxSizing: 'border-box',
|
|
229
|
+
height: 32,
|
|
230
|
+
padding: '0 10px',
|
|
231
|
+
fontSize: 13,
|
|
232
|
+
borderRadius: 8,
|
|
233
|
+
color: 'var(--dsw-alias-label-primary, inherit)',
|
|
234
|
+
background: 'var(--dsw-alias-bg-layer-1, transparent)',
|
|
235
|
+
border: '0.5px solid var(--dsw-alias-border-l4, #d0d0d4)',
|
|
236
|
+
outline: 'none',
|
|
237
|
+
}
|
|
238
|
+
const footerStyle = { display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }
|
|
239
|
+
const primaryButton = {
|
|
240
|
+
height: 30,
|
|
241
|
+
padding: '0 14px',
|
|
242
|
+
borderRadius: 8,
|
|
243
|
+
border: 'none',
|
|
244
|
+
cursor: 'pointer',
|
|
245
|
+
fontSize: 13,
|
|
246
|
+
color: '#fff',
|
|
247
|
+
background: 'var(--dsw-alias-state-business-primary, #4d6bfe)',
|
|
248
|
+
}
|
|
249
|
+
const ghostButton = {
|
|
250
|
+
height: 30,
|
|
251
|
+
padding: '0 12px',
|
|
252
|
+
borderRadius: 8,
|
|
253
|
+
cursor: 'pointer',
|
|
254
|
+
fontSize: 13,
|
|
255
|
+
color: 'inherit',
|
|
256
|
+
background: 'transparent',
|
|
257
|
+
border: '0.5px solid var(--dsw-alias-border-l3, #d0d0d4)',
|
|
258
|
+
}
|
|
259
|
+
const statusStyle = { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary, #8a8a8e)' }
|
|
260
|
+
const errorStyle = { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-state-error-primary, #d64545)' }
|
|
261
|
+
|
|
262
|
+
/* ────────────────────────────── 字段定义 ────────────────────────────── */
|
|
263
|
+
|
|
264
|
+
/** 数字字段在草稿里保持字符串,保存时再转数字,避免用户输入中途被强制转换。 */
|
|
265
|
+
/**
|
|
266
|
+
* 内置场景候选(供「固定场景」输入框的下拉提示)。
|
|
267
|
+
*
|
|
268
|
+
* 用 `<datalist>` 而不是 `<select>`:既能把 10 个内置场景列出来点选,
|
|
269
|
+
* 又允许手填自定义场景 id 或别名(别名见 README 的场景表)。
|
|
270
|
+
* 这里只放 id + 中文标题;id 用别名也能命中(findScenario 会按别名匹配)。
|
|
271
|
+
*/
|
|
272
|
+
const SCENARIO_OPTIONS = [
|
|
273
|
+
{ id: 'customer_service', label: '客服运营 · 工单派单与分流' },
|
|
274
|
+
{ id: 'content_moderation', label: '内容审核 · 违规判定与处置' },
|
|
275
|
+
{ id: 'agent_routing', label: '智能体路由 · 意图分流与工具决策' },
|
|
276
|
+
{ id: 'sales_lead', label: '销售线索 · 质量评分与分配' },
|
|
277
|
+
{ id: 'risk_control', label: '金融风控 · 交易异常与风险等级' },
|
|
278
|
+
{ id: 'recruiting', label: '招聘 HR · 简历匹配与流程推进' },
|
|
279
|
+
{ id: 'data_governance', label: '数据治理 · 打标、归因与敏感识别' },
|
|
280
|
+
{ id: 'education', label: '教育内容 · 知识点、难度与合规' },
|
|
281
|
+
{ id: 'requirements', label: '需求与变更 · 优先级、风险与派发' },
|
|
282
|
+
{ id: 'software_dev', label: '软件开发 · 任务类型、复杂度与处置' },
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
const SERVICE_FIELDS = [
|
|
286
|
+
{ key: 'provider', kind: 'select', options: ['unisound', 'http', 'mock'] },
|
|
287
|
+
{ key: 'apiKey', kind: 'password' },
|
|
288
|
+
{ key: 'baseUrl', kind: 'text' },
|
|
289
|
+
{ key: 'endpoint', kind: 'text' },
|
|
290
|
+
{ key: 'model', kind: 'text' },
|
|
291
|
+
{ key: 'timeoutMs', kind: 'number' },
|
|
292
|
+
{ key: 'minConfidence', kind: 'number' },
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
/** 自定义场景编辑区(JSON)。 */
|
|
296
|
+
const CUSTOM_FIELDS = [{ key: 'customScenarios', kind: 'json' }]
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 客户端侧校验自定义场景 JSON。
|
|
300
|
+
*
|
|
301
|
+
* 只做「能立刻发现」的检查:JSON 语法 + 每条的 id/title/questions 与
|
|
302
|
+
* 问题类型/选项数量。宿主侧 `resolveScenarios` 还会做一遍同样的校验,
|
|
303
|
+
* 语义问题(非法条目)宿主会跳过并写入日志;这里提前拦住,用户不必去翻日志。
|
|
304
|
+
*
|
|
305
|
+
* @param {string} text - 编辑框里的原始文本
|
|
306
|
+
* @returns {{ ok: boolean, error?: string, ids: string[], count: number }}
|
|
307
|
+
*/
|
|
308
|
+
function validateCustomScenarios(text) {
|
|
309
|
+
const raw = typeof text === 'string' ? text.trim() : ''
|
|
310
|
+
if (raw === '') return { ok: true, ids: [], count: 0 }
|
|
311
|
+
|
|
312
|
+
let parsed
|
|
313
|
+
try {
|
|
314
|
+
parsed = JSON.parse(raw)
|
|
315
|
+
} catch (error) {
|
|
316
|
+
return { ok: false, error: `json:${error?.message || error}`, ids: [], count: 0 }
|
|
317
|
+
}
|
|
318
|
+
if (!Array.isArray(parsed)) return { ok: false, error: 'notArray', ids: [], count: 0 }
|
|
319
|
+
|
|
320
|
+
const ids = []
|
|
321
|
+
for (let i = 0; i < parsed.length; i += 1) {
|
|
322
|
+
const spec = parsed[i]
|
|
323
|
+
const at = i + 1
|
|
324
|
+
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
325
|
+
return { ok: false, error: `item:${at}`, ids, count: 0 }
|
|
326
|
+
}
|
|
327
|
+
const issues = []
|
|
328
|
+
if (typeof spec.id !== 'string' || spec.id.trim() === '') issues.push('缺少 id')
|
|
329
|
+
if (typeof spec.title !== 'string' || spec.title.trim() === '') issues.push('缺少 title')
|
|
330
|
+
const questions = spec.questions
|
|
331
|
+
if (!questions || typeof questions !== 'object' || Array.isArray(questions)) {
|
|
332
|
+
issues.push('缺少 questions 对象')
|
|
333
|
+
} else {
|
|
334
|
+
const qids = Object.keys(questions)
|
|
335
|
+
if (qids.length === 0) issues.push('questions 至少 1 个')
|
|
336
|
+
if (qids.length > 16) issues.push(`questions 最多 16 个(当前 ${qids.length})`)
|
|
337
|
+
for (const [qid, q] of Object.entries(questions)) {
|
|
338
|
+
if (!q || typeof q !== 'object') { issues.push(`问题 ${qid} 必须是对象`); continue }
|
|
339
|
+
if (!['choice', 'noul', 'score'].includes(q.type)) {
|
|
340
|
+
issues.push(`问题 ${qid} 的 type 必须是 choice/noul/score`)
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
if (typeof q.instructions !== 'string' || q.instructions.trim() === '') {
|
|
344
|
+
issues.push(`问题 ${qid} 缺少 instructions`)
|
|
345
|
+
}
|
|
346
|
+
if (q.type === 'choice') {
|
|
347
|
+
const keys = q.criteria && typeof q.criteria === 'object' && !Array.isArray(q.criteria)
|
|
348
|
+
? Object.keys(q.criteria)
|
|
349
|
+
: null
|
|
350
|
+
if (keys === null) issues.push(`问题 ${qid}(choice)需要 criteria 对象`)
|
|
351
|
+
else if (keys.length < 2) issues.push(`问题 ${qid}(choice)至少 2 个选项`)
|
|
352
|
+
else if (keys.length > 26) issues.push(`问题 ${qid}(choice)选项建议 ≤26(当前 ${keys.length})`)
|
|
353
|
+
}
|
|
354
|
+
if (q.type === 'score'
|
|
355
|
+
&& (!Array.isArray(q.criteria) || q.criteria.length < 2)) {
|
|
356
|
+
issues.push(`问题 ${qid}(score)criteria 需为长度 ≥2 的数组`)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (issues.length > 0) {
|
|
361
|
+
return { ok: false, error: `issues:${at}:${spec.id ?? '?'}:${issues.join(';')}`, ids, count: 0 }
|
|
362
|
+
}
|
|
363
|
+
if (typeof spec.id === 'string') ids.push(spec.id.trim())
|
|
364
|
+
}
|
|
365
|
+
return { ok: true, ids, count: ids.length }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const AUTO_FIELDS = [
|
|
369
|
+
{ key: 'autoScenario', kind: 'datalist', options: SCENARIO_OPTIONS },
|
|
370
|
+
{ key: 'autoInject', kind: 'select', options: ['message', 'context'] },
|
|
371
|
+
{ key: 'autoTimeoutMs', kind: 'number' },
|
|
372
|
+
{ key: 'autoMaxMessages', kind: 'number' },
|
|
373
|
+
{ key: 'autoCacheTtlMs', kind: 'number' },
|
|
374
|
+
{ key: 'autoMinConfidence', kind: 'number' },
|
|
375
|
+
{ key: 'autoRouteMinConfidence', kind: 'number' },
|
|
376
|
+
{ key: 'autoMinChars', kind: 'number' },
|
|
377
|
+
]
|
|
378
|
+
|
|
379
|
+
/** 选项字段的显示名走字典,例如 provider 的 unisound → 「云知声(官方)」。 */
|
|
380
|
+
function optionLabel(t, key, value) {
|
|
381
|
+
if (key === 'provider') {
|
|
382
|
+
if (value === 'unisound') return t('providerUnisound')
|
|
383
|
+
if (value === 'http') return t('providerHttp')
|
|
384
|
+
if (value === 'mock') return t('providerMock')
|
|
385
|
+
}
|
|
386
|
+
if (key === 'autoInject') {
|
|
387
|
+
if (value === 'message') return t('autoInjectMessage')
|
|
388
|
+
if (value === 'context') return t('autoInjectContext')
|
|
389
|
+
}
|
|
390
|
+
return value
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** 把快照里的值转成草稿字符串(表单受控值统一按字符串处理)。 */
|
|
394
|
+
function toDraft(value) {
|
|
395
|
+
if (value === undefined || value === null) return ''
|
|
396
|
+
return String(value)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* ────────────────────────────── 组件 ────────────────────────────── */
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 一个可折叠的配置卡片:标题 + 说明,展开后是字段表单。
|
|
403
|
+
*/
|
|
404
|
+
function ConfigCard({ t, title, description, open, onToggle, children }) {
|
|
405
|
+
return hs('li', {
|
|
406
|
+
style: cardStyle,
|
|
407
|
+
children: [
|
|
408
|
+
h('button', {
|
|
409
|
+
key: 'head',
|
|
410
|
+
type: 'button',
|
|
411
|
+
style: headStyle,
|
|
412
|
+
'aria-expanded': open,
|
|
413
|
+
onClick: onToggle,
|
|
414
|
+
children: hs('span', {
|
|
415
|
+
style: { display: 'flex', flexDirection: 'column', minWidth: 0 },
|
|
416
|
+
children: [
|
|
417
|
+
h('span', { key: 'title', style: titleStyle, children: title }),
|
|
418
|
+
h('span', { key: 'desc', style: descStyle, children: description }),
|
|
419
|
+
],
|
|
420
|
+
}),
|
|
421
|
+
}),
|
|
422
|
+
open ? h('div', { key: 'body', style: bodyStyle, children }) : null,
|
|
423
|
+
],
|
|
424
|
+
})
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** 单个字段:标签 + 控件 + 说明。 */
|
|
428
|
+
function Field({ t, field, value, disabled, onChange }) {
|
|
429
|
+
const label = t(field.key)
|
|
430
|
+
const hint = t(`${field.key}Hint`)
|
|
431
|
+
const controlId = `systemone-${field.key}`
|
|
432
|
+
|
|
433
|
+
let control
|
|
434
|
+
if (field.kind === 'select') {
|
|
435
|
+
// 注意:jsx(type, props, key) 的第三个参数是 key,children 必须放进 props
|
|
436
|
+
control = h('select', {
|
|
437
|
+
id: controlId,
|
|
438
|
+
style: inputStyle,
|
|
439
|
+
value: value,
|
|
440
|
+
disabled,
|
|
441
|
+
onChange: (event) => onChange(field.key, event.target.value),
|
|
442
|
+
children: field.options.map((option) =>
|
|
443
|
+
h('option', { key: option, value: option, children: optionLabel(t, field.key, option) }),
|
|
444
|
+
),
|
|
445
|
+
})
|
|
446
|
+
} else if (field.kind === 'datalist') {
|
|
447
|
+
// 带候选的输入框:可直接点选内置场景,也能手填自定义场景 id / 别名
|
|
448
|
+
const listId = `${controlId}-options`
|
|
449
|
+
control = hs(Fragment, {
|
|
450
|
+
children: [
|
|
451
|
+
h('input', {
|
|
452
|
+
key: 'input',
|
|
453
|
+
id: controlId,
|
|
454
|
+
style: inputStyle,
|
|
455
|
+
type: 'text',
|
|
456
|
+
list: listId,
|
|
457
|
+
value,
|
|
458
|
+
disabled,
|
|
459
|
+
autoComplete: 'off',
|
|
460
|
+
onChange: (event) => onChange(field.key, event.target.value),
|
|
461
|
+
}),
|
|
462
|
+
h('datalist', {
|
|
463
|
+
key: 'list',
|
|
464
|
+
id: listId,
|
|
465
|
+
children: field.options.map((option) =>
|
|
466
|
+
h('option', { key: option.id, value: option.id, children: option.label }),
|
|
467
|
+
),
|
|
468
|
+
}),
|
|
469
|
+
],
|
|
470
|
+
})
|
|
471
|
+
} else if (field.kind === 'json') {
|
|
472
|
+
// 多行 JSON 编辑框:等宽字体,保存前由调用方校验
|
|
473
|
+
control = h('textarea', {
|
|
474
|
+
id: controlId,
|
|
475
|
+
style: {
|
|
476
|
+
...inputStyle,
|
|
477
|
+
height: 'auto',
|
|
478
|
+
minHeight: 180,
|
|
479
|
+
padding: '8px 10px',
|
|
480
|
+
fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)',
|
|
481
|
+
fontSize: 12,
|
|
482
|
+
lineHeight: '18px',
|
|
483
|
+
resize: 'vertical',
|
|
484
|
+
},
|
|
485
|
+
value,
|
|
486
|
+
disabled,
|
|
487
|
+
spellCheck: false,
|
|
488
|
+
placeholder: t('customScenariosPlaceholder'),
|
|
489
|
+
onChange: (event) => onChange(field.key, event.target.value),
|
|
490
|
+
})
|
|
491
|
+
} else {
|
|
492
|
+
control = h('input', {
|
|
493
|
+
id: controlId,
|
|
494
|
+
style: inputStyle,
|
|
495
|
+
type: field.kind === 'password' ? 'password' : field.kind === 'number' ? 'text' : 'text',
|
|
496
|
+
inputMode: field.kind === 'number' ? 'decimal' : undefined,
|
|
497
|
+
value,
|
|
498
|
+
disabled,
|
|
499
|
+
placeholder: field.key === 'apiKey' ? t('apiKeyPlaceholder') : undefined,
|
|
500
|
+
autoComplete: 'off',
|
|
501
|
+
onChange: (event) => onChange(field.key, event.target.value),
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return hs('div', {
|
|
506
|
+
style: fieldStyle,
|
|
507
|
+
children: [
|
|
508
|
+
hs('div', {
|
|
509
|
+
key: 'head',
|
|
510
|
+
style: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 },
|
|
511
|
+
children: [
|
|
512
|
+
h('label', { key: 'label', htmlFor: controlId, style: labelStyle, children: label }),
|
|
513
|
+
field.extra === undefined ? null : field.extra,
|
|
514
|
+
],
|
|
515
|
+
}),
|
|
516
|
+
control,
|
|
517
|
+
h('span', { key: 'hint', style: hintStyle, children: hint }),
|
|
518
|
+
field.error === undefined
|
|
519
|
+
? null
|
|
520
|
+
: h('span', { key: 'err', style: errorStyle, children: field.error }),
|
|
521
|
+
],
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** 把校验结果的机器码翻成给用户看的一句话。 */
|
|
526
|
+
function describeCustomError(code, t) {
|
|
527
|
+
if (code === undefined) return undefined
|
|
528
|
+
if (code === 'notArray') return t('jsonNotArray')
|
|
529
|
+
const syntax = /^json:(.*)$/s.exec(code)
|
|
530
|
+
if (syntax) return t('jsonBadSyntax', { message: syntax[1] })
|
|
531
|
+
const item = /^item:(\d+)$/.exec(code)
|
|
532
|
+
if (item) return t('jsonItemNotObject', { index: item[1] })
|
|
533
|
+
const issues = /^issues:(\d+):([^:]*):(.*)$/s.exec(code)
|
|
534
|
+
if (issues) return t('jsonItemIssues', { index: issues[1], id: issues[2], issues: issues[3] })
|
|
535
|
+
return code
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 配置页主体。
|
|
540
|
+
*
|
|
541
|
+
* `form` 由插件管理页注入:`{ state, mutate }`。
|
|
542
|
+
* 没有 volatile 字段时宿主不传 form,这里退化为只读提示。
|
|
543
|
+
*/
|
|
544
|
+
function SystemOneConfigPage({ view, t, form }) {
|
|
545
|
+
const [openService, setOpenService] = react.useState(true)
|
|
546
|
+
const [openCustom, setOpenCustom] = react.useState(false)
|
|
547
|
+
const [openAuto, setOpenAuto] = react.useState(false)
|
|
548
|
+
const [draft, setDraft] = react.useState(null)
|
|
549
|
+
const [busy, setBusy] = react.useState(false)
|
|
550
|
+
const [notice, setNotice] = react.useState(null)
|
|
551
|
+
|
|
552
|
+
const live = form?.state?.value ?? {}
|
|
553
|
+
const revision = form?.state?.revision
|
|
554
|
+
const writable = form?.state?.writable !== false && form !== undefined
|
|
555
|
+
|
|
556
|
+
// 快照更新(保存成功 / 别处改动)后,重置草稿
|
|
557
|
+
const snapshotKey = `${String(revision)}|${JSON.stringify(live)}`
|
|
558
|
+
const lastKey = react.useRef(null)
|
|
559
|
+
if (lastKey.current !== snapshotKey) {
|
|
560
|
+
lastKey.current = snapshotKey
|
|
561
|
+
if (draft !== null) setDraft(null)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (view === 'summary') {
|
|
565
|
+
return t('summary')
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (form === undefined) {
|
|
569
|
+
return h('p', { style: statusStyle, children: t('noForm') })
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const ALL_FIELDS = [...SERVICE_FIELDS, ...CUSTOM_FIELDS, ...AUTO_FIELDS]
|
|
573
|
+
const current = {}
|
|
574
|
+
for (const field of ALL_FIELDS) current[field.key] = toDraft(live[field.key])
|
|
575
|
+
|
|
576
|
+
const values = { ...current, ...(draft ?? {}) }
|
|
577
|
+
|
|
578
|
+
// 自定义场景先校验:不通过就禁用保存,并把问题显示在编辑框下方
|
|
579
|
+
const customCheck = validateCustomScenarios(values.customScenarios)
|
|
580
|
+
const customErrorText = customCheck.ok ? undefined : describeCustomError(customCheck.error, t)
|
|
581
|
+
|
|
582
|
+
const dirty = draft !== null && Object.keys(draft).some((key) => draft[key] !== current[key])
|
|
583
|
+
const canSave = dirty && !busy && writable && customCheck.ok
|
|
584
|
+
|
|
585
|
+
// 「固定场景」候选 = 10 个内置 + 当前生效的自定义场景 id
|
|
586
|
+
const scenarioOptions = [
|
|
587
|
+
...SCENARIO_OPTIONS,
|
|
588
|
+
...(customCheck.ok
|
|
589
|
+
? customCheck.ids
|
|
590
|
+
.filter((id) => !SCENARIO_OPTIONS.some((option) => option.id === id))
|
|
591
|
+
.map((id) => ({ id, label: id }))
|
|
592
|
+
: []),
|
|
593
|
+
]
|
|
594
|
+
|
|
595
|
+
const onFieldChange = (key, value) => {
|
|
596
|
+
setNotice(null)
|
|
597
|
+
setDraft((previous) => ({ ...(previous ?? {}), [key]: value }))
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const onSave = async () => {
|
|
601
|
+
if (!canSave) return
|
|
602
|
+
setBusy(true)
|
|
603
|
+
setNotice(null)
|
|
604
|
+
try {
|
|
605
|
+
const ops = []
|
|
606
|
+
for (const [key, value] of Object.entries(draft ?? {})) {
|
|
607
|
+
if (value === current[key]) continue
|
|
608
|
+
// apiKey 留空表示「保持已保存的值」,不发写操作
|
|
609
|
+
if (key === 'apiKey' && value === '') continue
|
|
610
|
+
const field = ALL_FIELDS.find((item) => item.key === key)
|
|
611
|
+
let next = value
|
|
612
|
+
if (field?.kind === 'number') {
|
|
613
|
+
const parsed = Number(value)
|
|
614
|
+
if (!Number.isFinite(parsed)) continue
|
|
615
|
+
next = parsed
|
|
616
|
+
}
|
|
617
|
+
ops.push({ op: 'set', path: [key], value: next })
|
|
618
|
+
}
|
|
619
|
+
if (ops.length === 0) {
|
|
620
|
+
setDraft(null)
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
const ok = await form.mutate(ops, revision)
|
|
624
|
+
if (ok) {
|
|
625
|
+
setDraft(null)
|
|
626
|
+
setNotice({ kind: 'ok', text: t('saved') })
|
|
627
|
+
} else {
|
|
628
|
+
setNotice({ kind: 'error', text: t('conflict') })
|
|
629
|
+
}
|
|
630
|
+
} catch {
|
|
631
|
+
setNotice({ kind: 'error', text: t('saveFailed') })
|
|
632
|
+
} finally {
|
|
633
|
+
setBusy(false)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** 渲染字段;可按 key 附加错误提示、候选、右上角附加元素。 */
|
|
638
|
+
const renderFields = (fields, extras = {}) =>
|
|
639
|
+
fields.map((field) => {
|
|
640
|
+
const extra = extras[field.key]
|
|
641
|
+
const resolved = field.key === 'autoScenario'
|
|
642
|
+
? { ...field, options: scenarioOptions }
|
|
643
|
+
: field
|
|
644
|
+
return h(Field, {
|
|
645
|
+
key: field.key,
|
|
646
|
+
t,
|
|
647
|
+
field: extra === undefined ? resolved : { ...resolved, ...extra },
|
|
648
|
+
value: values[field.key],
|
|
649
|
+
disabled: !writable || busy,
|
|
650
|
+
onChange: onFieldChange,
|
|
651
|
+
})
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
const statusLine = hs(Fragment, {
|
|
655
|
+
children: [
|
|
656
|
+
h('span', { key: 'now', children: t('current', { provider: values.provider || 'unisound', model: values.model || 'u2-decision' }) }),
|
|
657
|
+
notice !== null
|
|
658
|
+
? h('span', { key: 'notice', style: notice.kind === 'ok' ? statusStyle : errorStyle, children: notice.text })
|
|
659
|
+
: null,
|
|
660
|
+
!writable ? h('span', { key: 'ro', style: statusStyle, children: t('readOnly') }) : null,
|
|
661
|
+
],
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
return hs('ul', {
|
|
665
|
+
style: listStyle,
|
|
666
|
+
children: [
|
|
667
|
+
h(ConfigCard, {
|
|
668
|
+
key: 'service',
|
|
669
|
+
t,
|
|
670
|
+
title: t('tabService'),
|
|
671
|
+
description: t('tabServiceHint'),
|
|
672
|
+
open: openService,
|
|
673
|
+
onToggle: () => setOpenService((value) => !value),
|
|
674
|
+
// 注意:jsx(type, props, key) 的第三个参数是 key,正文必须作为 children 放进 props
|
|
675
|
+
children: hs(Fragment, {
|
|
676
|
+
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
|
+
}),
|
|
704
|
+
],
|
|
705
|
+
}),
|
|
706
|
+
}),
|
|
707
|
+
h(ConfigCard, {
|
|
708
|
+
key: 'custom',
|
|
709
|
+
t,
|
|
710
|
+
title: t('tabCustom'),
|
|
711
|
+
description: t('tabCustomHint'),
|
|
712
|
+
open: openCustom,
|
|
713
|
+
onToggle: () => setOpenCustom((value) => !value),
|
|
714
|
+
children: hs(Fragment, {
|
|
715
|
+
children: [
|
|
716
|
+
...renderFields(CUSTOM_FIELDS, {
|
|
717
|
+
customScenarios: {
|
|
718
|
+
error: customErrorText,
|
|
719
|
+
extra: h('button', {
|
|
720
|
+
key: 'tpl',
|
|
721
|
+
type: 'button',
|
|
722
|
+
style: { ...ghostButton, height: 24, padding: '0 8px', fontSize: 12 },
|
|
723
|
+
disabled: !writable || busy,
|
|
724
|
+
onClick: () => {
|
|
725
|
+
// 填一个「通用意图识别」模板,用户改 criteria 即可
|
|
726
|
+
const template = JSON.stringify([
|
|
727
|
+
{
|
|
728
|
+
id: 'intent',
|
|
729
|
+
title: '通用意图识别',
|
|
730
|
+
description: '把任意输入归类到业务意图。',
|
|
731
|
+
aliases: ['意图', 'intent'],
|
|
732
|
+
questions: {
|
|
733
|
+
intent: {
|
|
734
|
+
type: 'choice',
|
|
735
|
+
label: '意图',
|
|
736
|
+
instructions: '用户这句话最想做什么?',
|
|
737
|
+
criteria: {
|
|
738
|
+
query: '查询/检索信息',
|
|
739
|
+
action: '执行一个操作',
|
|
740
|
+
create: '新建内容或文件',
|
|
741
|
+
modify: '修改已有内容',
|
|
742
|
+
analyze: '分析、对比、总结',
|
|
743
|
+
explain: '解释原理或概念',
|
|
744
|
+
debug: '排查报错或异常',
|
|
745
|
+
chat: '闲聊、寒暄',
|
|
746
|
+
other: '以上都不是',
|
|
747
|
+
},
|
|
748
|
+
},
|
|
749
|
+
urgency: {
|
|
750
|
+
type: 'score',
|
|
751
|
+
label: '紧急度',
|
|
752
|
+
instructions: '这件事有多紧急?',
|
|
753
|
+
criteria: ['不急', '可以等', '尽快', '马上'],
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
recommendation: '意图「{intent}」,紧急度 {urgency}',
|
|
757
|
+
},
|
|
758
|
+
], null, 2)
|
|
759
|
+
onFieldChange('customScenarios', template)
|
|
760
|
+
setOpenCustom(true)
|
|
761
|
+
},
|
|
762
|
+
children: t('insertTemplate'),
|
|
763
|
+
}),
|
|
764
|
+
},
|
|
765
|
+
}),
|
|
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
|
+
],
|
|
800
|
+
}),
|
|
801
|
+
],
|
|
802
|
+
}),
|
|
803
|
+
}),
|
|
804
|
+
h(ConfigCard, {
|
|
805
|
+
key: 'auto',
|
|
806
|
+
t,
|
|
807
|
+
title: t('tabAuto'),
|
|
808
|
+
description: t('tabAutoHint'),
|
|
809
|
+
open: openAuto,
|
|
810
|
+
onToggle: () => setOpenAuto((value) => !value),
|
|
811
|
+
children: hs(Fragment, {
|
|
812
|
+
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
|
+
}),
|
|
829
|
+
],
|
|
830
|
+
}),
|
|
831
|
+
}),
|
|
832
|
+
],
|
|
833
|
+
})
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/* ────────────────────────────── 注册 ────────────────────────────── */
|
|
837
|
+
|
|
838
|
+
/** 稳定插件名。 */
|
|
839
|
+
const name = 'dsh-systemone-client'
|
|
840
|
+
|
|
841
|
+
/** 需要的客户端服务:槽位注册表与字典。 */
|
|
842
|
+
const inject = ['slots', 'locale']
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* 挂载浏览器半边。
|
|
846
|
+
* @param {object} ctx - 浏览器插件上下文
|
|
847
|
+
*/
|
|
848
|
+
function apply(ctx) {
|
|
849
|
+
const t = ctx.locale.bind(NS)
|
|
850
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), '@master0071/dsh-systemone: dictionaries')
|
|
851
|
+
|
|
852
|
+
// 某一行的配置页。key 必须是「包名#行 id」,插件管理页才会把 form 传进来。
|
|
853
|
+
ctx.effect(
|
|
854
|
+
() =>
|
|
855
|
+
ctx.slots.inject('plugins.row.config', () =>
|
|
856
|
+
ctx.slots.register(
|
|
857
|
+
{
|
|
858
|
+
name: 'plugins.row.config',
|
|
859
|
+
key: ROW_CONFIG_KEY,
|
|
860
|
+
locale: NS,
|
|
861
|
+
},
|
|
862
|
+
SystemOneConfigPage,
|
|
863
|
+
),
|
|
864
|
+
),
|
|
865
|
+
'@master0071/dsh-systemone: row config page',
|
|
866
|
+
)
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
exports.name = name
|
|
870
|
+
exports.inject = inject
|
|
871
|
+
exports.apply = apply
|
|
872
|
+
return module.exports
|
|
873
|
+
},
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
|