@boyingliu01/xp-gate 0.32.0 → 0.32.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/adapters/powershell.sh +33 -7
- package/gate-3.sh +54 -5
- package/hooks/gate-3.sh +54 -5
- package/hooks/lib/jscpd-run.sh +92 -0
- package/hooks/pre-commit +111 -56
- package/lib/mattpocock-license-verdict.json +1 -1
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/README.md +51 -2
- package/plugins/dsh/client/client.js +760 -0
- package/plugins/dsh/lib/client.js +760 -0
- package/plugins/dsh/lib/delphi-settings.d.ts +52 -0
- package/plugins/dsh/lib/delphi-settings.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-settings.js +76 -0
- package/plugins/dsh/lib/hooks.d.ts +14 -1
- package/plugins/dsh/lib/hooks.d.ts.map +1 -1
- package/plugins/dsh/lib/hooks.js +44 -5
- package/plugins/dsh/lib/index.d.ts +63 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -1
- package/plugins/dsh/lib/index.js +126 -21
- package/plugins/dsh/package.json +13 -3
- package/plugins/dsh/scripts/copy-client.cjs +32 -0
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/client.test.ts +231 -0
- package/plugins/dsh/src/delphi-settings.test.ts +101 -0
- package/plugins/dsh/src/delphi-settings.ts +111 -0
- package/plugins/dsh/src/hooks.test.ts +48 -0
- package/plugins/dsh/src/hooks.ts +45 -5
- package/plugins/dsh/src/index.test.ts +3 -2
- package/plugins/dsh/src/index.ts +147 -25
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delphi model configuration page — the plugin's browser half.
|
|
3
|
+
*
|
|
4
|
+
* Rendered by the DSH Web client from the Plugins page: open
|
|
5
|
+
* **Plugins → @boyingliu01/dsh-plugin-xp-gate → tool-xp-gate → Configure**.
|
|
6
|
+
*
|
|
7
|
+
* Why hand-written CommonJS instead of tsdown: the DSH client module system
|
|
8
|
+
* (`dsh-client-modules`) evaluates exactly this shape — one
|
|
9
|
+
* `window.__ModuleLoader__.load({ id, factory })` call per package, receiving a
|
|
10
|
+
* `require` that resolves the shared platform table (React, Cordis, primitives).
|
|
11
|
+
* A bundler that adds a prelude or exports ESM would register nothing.
|
|
12
|
+
*
|
|
13
|
+
* Contract details worth stating, because a shipped third-party plugin got them
|
|
14
|
+
* wrong and every save failed silently:
|
|
15
|
+
*
|
|
16
|
+
* - settings writes are `{ op: 'set', path: [name], value }` (`SettingsPathOpView`),
|
|
17
|
+
* NOT `{ type, key, value }`
|
|
18
|
+
* - a row's configuration page is keyed `<package name>#<row id>`
|
|
19
|
+
* - `plugins.item` belongs to the official settings pages, so this registers only
|
|
20
|
+
* `plugins.row.config`
|
|
21
|
+
*
|
|
22
|
+
* The page cannot import the plugin's host code: `src/delphi.ts` and friends import
|
|
23
|
+
* `node:fs` and `@deepseek-ai/dsh-llm`, neither of which the browser has. The one
|
|
24
|
+
* rule it must agree with — three distinct models — is therefore re-applied here
|
|
25
|
+
* from the same three-seat invariant, and the Host re-checks it on every review
|
|
26
|
+
* run (`seatsFromSettings`), so a disagreement can never leak money.
|
|
27
|
+
*/
|
|
28
|
+
window.__ModuleLoader__.load({
|
|
29
|
+
id: '@boyingliu01/dsh-plugin-xp-gate',
|
|
30
|
+
factory: (require) => {
|
|
31
|
+
var module = { exports: {} }
|
|
32
|
+
var exports = module.exports
|
|
33
|
+
|
|
34
|
+
/** Plugin package name, and the Loader row id its bundle patch declares. */
|
|
35
|
+
var PKG = '@boyingliu01/dsh-plugin-xp-gate'
|
|
36
|
+
var ROW_ID = 'tool-xp-gate'
|
|
37
|
+
/** The settings namespace is the row id: DSH keys a served form by entry id. */
|
|
38
|
+
var NS = ROW_ID
|
|
39
|
+
/** Dictionary namespace owned by this page. */
|
|
40
|
+
var LOCALE_NS = 'xp-gate-delphi'
|
|
41
|
+
|
|
42
|
+
var React = require('react')
|
|
43
|
+
|
|
44
|
+
/** Exactly three seats, matching the Delphi invariant the Host enforces. */
|
|
45
|
+
var SEAT_COUNT = 3
|
|
46
|
+
/** The roles xp-gate writes into `.delphi-config.json`; kept as the defaults. */
|
|
47
|
+
var DEFAULT_ROLES = ['architecture', 'technical', 'feasibility']
|
|
48
|
+
|
|
49
|
+
var en = {
|
|
50
|
+
summary: 'Choose the three Delphi reviewer models. Saving here overrides .delphi-config.json.',
|
|
51
|
+
seats: 'Reviewer seats',
|
|
52
|
+
seatsHint: 'Exactly three distinct models. A repeated model, or a model shared with another seat, is rejected before any request is sent.',
|
|
53
|
+
seat: 'Seat {n}',
|
|
54
|
+
role: 'Role',
|
|
55
|
+
model: 'Model',
|
|
56
|
+
pickModel: 'Choose from configured providers',
|
|
57
|
+
gateway: 'Gateway',
|
|
58
|
+
gatewayHint: 'OpenAI-compatible base URL, e.g. https://host/gpt-proxy. All seats share it.',
|
|
59
|
+
apiKey: 'API key reference',
|
|
60
|
+
apiKeyHint: 'A credential reference such as ${WHALECLOUD_API_KEY}. A literal key is refused: it would be written to the profile file.',
|
|
61
|
+
consensus: 'Consensus',
|
|
62
|
+
threshold: 'Approval threshold (%)',
|
|
63
|
+
maxRounds: 'Maximum rounds',
|
|
64
|
+
catalogLoading: 'Loading models…',
|
|
65
|
+
catalogFailed: 'Could not load the model catalog.',
|
|
66
|
+
catalogUnavailable: 'No providers configured; type model ids directly.',
|
|
67
|
+
invalid: 'The three seats need three distinct, non-empty models.',
|
|
68
|
+
invalidGateway: 'A gateway URL is required when seats are configured.',
|
|
69
|
+
save: 'Save',
|
|
70
|
+
saving: 'Saving…',
|
|
71
|
+
saveFailed: 'The Host did not accept these values.',
|
|
72
|
+
readOnly: 'This deployment stores settings read-only.',
|
|
73
|
+
unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
|
|
74
|
+
fallbackNote: 'No seats saved here yet, so reviews use .delphi-config.json.',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
var zh = {
|
|
78
|
+
summary: '选择三个 Delphi 评审模型。在此保存会覆盖 .delphi-config.json。',
|
|
79
|
+
seats: '评审席位',
|
|
80
|
+
seatsHint: '必须恰好三个互不相同的模型。重复或与其它席位相同,会在发出任何请求之前被拒绝。',
|
|
81
|
+
seat: '席位 {n}',
|
|
82
|
+
role: '角色',
|
|
83
|
+
model: '模型',
|
|
84
|
+
pickModel: '从已配置的提供方选择',
|
|
85
|
+
gateway: '网关',
|
|
86
|
+
gatewayHint: 'OpenAI 兼容的 base URL,例如 https://host/gpt-proxy。三个席位共用。',
|
|
87
|
+
apiKey: '密钥引用',
|
|
88
|
+
apiKeyHint: '使用凭据引用,如 ${WHALECLOUD_API_KEY}。拒绝明文密钥——它会写进 profile 文件。',
|
|
89
|
+
consensus: '共识',
|
|
90
|
+
threshold: '通过阈值(%)',
|
|
91
|
+
maxRounds: '最大轮次',
|
|
92
|
+
catalogLoading: '正在加载模型…',
|
|
93
|
+
catalogFailed: '无法加载模型目录。',
|
|
94
|
+
catalogUnavailable: '未配置提供方,请直接填写模型 ID。',
|
|
95
|
+
invalid: '三个席位需要三个互不相同的非空模型。',
|
|
96
|
+
invalidGateway: '配置了席位就必填网关地址。',
|
|
97
|
+
save: '保存',
|
|
98
|
+
saving: '保存中…',
|
|
99
|
+
saveFailed: '宿主未接受这些值。',
|
|
100
|
+
readOnly: '本部署的设置是只读的。',
|
|
101
|
+
unavailable: '插件未加载,暂时无法配置。',
|
|
102
|
+
fallbackNote: '这里还没有保存席位上,评审仍使用 .delphi-config.json。',
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The active locale's BCP-47 tag, read defensively: a missing API is not an error. */
|
|
106
|
+
function activeLocale(ctx) {
|
|
107
|
+
try {
|
|
108
|
+
var snap = ctx && ctx.locale && ctx.locale.getSnapshot ? ctx.locale.getSnapshot() : null
|
|
109
|
+
if (snap && typeof snap.active === 'string') return snap.active
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.warn('[xp-gate] locale read failed', error && error.message)
|
|
112
|
+
}
|
|
113
|
+
return ''
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Pick the dictionary for the active locale, defaulting to English. */
|
|
117
|
+
function translator(ctx) {
|
|
118
|
+
var dict = activeLocale(ctx).indexOf('zh') === 0 ? zh : en
|
|
119
|
+
return function t(key, params) {
|
|
120
|
+
var text = dict[key] !== undefined ? dict[key] : en[key] !== undefined ? en[key] : key
|
|
121
|
+
if (params) {
|
|
122
|
+
Object.keys(params).forEach(function (name) {
|
|
123
|
+
text = text.split('{' + name + '}').join(String(params[name]))
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
return text
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** One provider group from the Host's catalog, in the shape the select renders. */
|
|
131
|
+
function catalogGroup(group) {
|
|
132
|
+
var models = (group.models || []).map(function (model) { return model.id })
|
|
133
|
+
return { provider: group.id, providerName: group.name, models: models }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The Host's session handle that can list models, or null when unreachable. */
|
|
137
|
+
function catalogSession(ctx) {
|
|
138
|
+
if (!ctx || !ctx.remote || !ctx.remote.session) return null
|
|
139
|
+
return ctx.remote.session
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The non-empty provider groups a catalog response offers, or null on failure.
|
|
144
|
+
*
|
|
145
|
+
* Grouping here rather than in the effect keeps the promise handler a
|
|
146
|
+
* two-branch decision: one shape or an error.
|
|
147
|
+
*/
|
|
148
|
+
function healthyCatalogGroups(response) {
|
|
149
|
+
if (!response || !response.ok) return null
|
|
150
|
+
var groups = (response.value.groups || []).map(catalogGroup)
|
|
151
|
+
return groups.filter(function (group) { return group.models.length > 0 })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build the editable draft from the Host's accepted values.
|
|
156
|
+
*
|
|
157
|
+
* Lives outside the component on purpose: the page's effect has to stay a
|
|
158
|
+
* subscription, not a place where six defaulting ternaries live.
|
|
159
|
+
*/
|
|
160
|
+
function draftFrom(value) {
|
|
161
|
+
var delphi = (value && value.delphi) || {}
|
|
162
|
+
return {
|
|
163
|
+
seats: seatsOf(delphi),
|
|
164
|
+
baseUrl: delphi.baseUrl || '',
|
|
165
|
+
apiKey: delphi.apiKey || '',
|
|
166
|
+
thresholdPercent: typeof delphi.thresholdPercent === 'number' ? delphi.thresholdPercent : 90,
|
|
167
|
+
maxRounds: typeof delphi.maxRounds === 'number' ? delphi.maxRounds : 5,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** One draft seat: the stored strings, with the role defaulting to this seat's slot. */
|
|
172
|
+
function draftSeat(stored, index) {
|
|
173
|
+
var seat = stored || {}
|
|
174
|
+
var role = typeof seat.role === 'string' && seat.role ? seat.role : DEFAULT_ROLES[index]
|
|
175
|
+
return { role: role, model: typeof seat.model === 'string' ? seat.model : '' }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Read a seat list from the section value, always returning exactly three drafts. */
|
|
179
|
+
function seatsOf(value) {
|
|
180
|
+
var stored = value && Array.isArray(value.seats) ? value.seats : []
|
|
181
|
+
var seats = []
|
|
182
|
+
for (var i = 0; i < SEAT_COUNT; i++) seats.push(draftSeat(stored[i], i))
|
|
183
|
+
return seats
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Every distinctness rule the Host will re-check, applied to the draft. */
|
|
187
|
+
function validationError(seats, baseUrl) {
|
|
188
|
+
var models = seats.map(function (s) {
|
|
189
|
+
return (s.model || '').trim()
|
|
190
|
+
})
|
|
191
|
+
if (models.some(function (m) { return m === '' })) return 'empty'
|
|
192
|
+
var unique = models.filter(function (m, i) { return models.indexOf(m) === i })
|
|
193
|
+
if (unique.length !== SEAT_COUNT) return 'duplicate'
|
|
194
|
+
if (!baseUrl || !String(baseUrl).trim()) return 'gateway'
|
|
195
|
+
return ''
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Build the ordered `mutate` operations one save sends. */
|
|
199
|
+
function saveOperations(draft) {
|
|
200
|
+
var delphi = {
|
|
201
|
+
baseUrl: (draft.baseUrl || '').trim(),
|
|
202
|
+
apiKey: (draft.apiKey || '').trim(),
|
|
203
|
+
seats: draft.seats.map(function (seat, index) {
|
|
204
|
+
return { role: (seat.role || DEFAULT_ROLES[index]).trim(), model: (seat.model || '').trim() }
|
|
205
|
+
}),
|
|
206
|
+
thresholdPercent: Number(draft.thresholdPercent),
|
|
207
|
+
maxRounds: Number(draft.maxRounds),
|
|
208
|
+
}
|
|
209
|
+
// DSH's SettingsPathOpView: path-addressed, one op per field. The whole
|
|
210
|
+
// block is one op so the sections can never be saved half-way.
|
|
211
|
+
return [{ op: 'set', path: ['delphi'], value: delphi }]
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A labelled text input. */
|
|
215
|
+
function Field(props) {
|
|
216
|
+
return React.createElement(
|
|
217
|
+
'label',
|
|
218
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 4, flex: 1, minWidth: 0 } },
|
|
219
|
+
React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } }, props.label),
|
|
220
|
+
React.createElement('input', {
|
|
221
|
+
type: 'text',
|
|
222
|
+
value: props.value,
|
|
223
|
+
disabled: props.disabled,
|
|
224
|
+
onChange: function (event) { props.onChange(event.target.value) },
|
|
225
|
+
style: {
|
|
226
|
+
padding: '6px 10px',
|
|
227
|
+
borderRadius: 8,
|
|
228
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
229
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
230
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
231
|
+
fontSize: 13,
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
props.hint
|
|
235
|
+
? React.createElement('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, props.hint)
|
|
236
|
+
: null,
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** One provider's models as an `<optgroup>`, so the select body stays shallow. */
|
|
241
|
+
function CatalogGroup(props) {
|
|
242
|
+
return React.createElement(
|
|
243
|
+
'optgroup',
|
|
244
|
+
{ label: props.group.providerName },
|
|
245
|
+
props.group.models.map(function (model) {
|
|
246
|
+
return React.createElement('option', { key: model, value: model }, model)
|
|
247
|
+
}),
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The catalog picker beside the model text input.
|
|
253
|
+
*
|
|
254
|
+
* Blank means "keep whatever was typed": an empty option is only the initial
|
|
255
|
+
* state, and selecting it must not clear a model the operator pinned by hand.
|
|
256
|
+
*/
|
|
257
|
+
function CatalogPicker(props) {
|
|
258
|
+
if (props.catalogs.length === 0) return null
|
|
259
|
+
return React.createElement(
|
|
260
|
+
'label',
|
|
261
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 4 } },
|
|
262
|
+
React.createElement(
|
|
263
|
+
'span',
|
|
264
|
+
{ style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
265
|
+
props.t('pickModel'),
|
|
266
|
+
),
|
|
267
|
+
React.createElement(
|
|
268
|
+
'select',
|
|
269
|
+
{
|
|
270
|
+
value: props.value,
|
|
271
|
+
disabled: props.disabled,
|
|
272
|
+
onChange: function (event) {
|
|
273
|
+
if (event.target.value) props.onChange(event.target.value)
|
|
274
|
+
},
|
|
275
|
+
style: {
|
|
276
|
+
padding: '6px 10px',
|
|
277
|
+
borderRadius: 8,
|
|
278
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
279
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
280
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
281
|
+
fontSize: 13,
|
|
282
|
+
maxWidth: 260,
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
React.createElement('option', { value: '' }, '—'),
|
|
286
|
+
props.catalogs.map(function (group) {
|
|
287
|
+
return React.createElement(CatalogGroup, { key: group.provider, group: group })
|
|
288
|
+
}),
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Render one seat row: role, model text input, and a catalog select when available. */
|
|
294
|
+
function SeatRow(props) {
|
|
295
|
+
var seat = props.seat
|
|
296
|
+
return React.createElement(
|
|
297
|
+
'div',
|
|
298
|
+
{ style: { display: 'flex', gap: 8, alignItems: 'flex-end', padding: '4px 0' } },
|
|
299
|
+
React.createElement(Field, {
|
|
300
|
+
label: props.t('role'),
|
|
301
|
+
value: seat.role,
|
|
302
|
+
disabled: props.disabled,
|
|
303
|
+
onChange: function (v) { props.onChange('role', v) },
|
|
304
|
+
}),
|
|
305
|
+
React.createElement(Field, {
|
|
306
|
+
label: props.t('model'),
|
|
307
|
+
value: seat.model,
|
|
308
|
+
disabled: props.disabled,
|
|
309
|
+
onChange: function (v) { props.onChange('model', v) },
|
|
310
|
+
}),
|
|
311
|
+
React.createElement(CatalogPicker, {
|
|
312
|
+
t: props.t,
|
|
313
|
+
value: seat.model,
|
|
314
|
+
disabled: props.disabled,
|
|
315
|
+
catalogs: props.catalogs,
|
|
316
|
+
onChange: function (v) { props.onChange('model', v) },
|
|
317
|
+
}),
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The page's section list, in render order.
|
|
323
|
+
*
|
|
324
|
+
* Split out of the component so the component stays a state machine — the
|
|
325
|
+
* markup is a pure function of the draft and the flags, which is also what
|
|
326
|
+
* makes it readable without scrolling through a hook order.
|
|
327
|
+
*/
|
|
328
|
+
function pageSections(f) {
|
|
329
|
+
var t = f.t
|
|
330
|
+
var children = []
|
|
331
|
+
|
|
332
|
+
if (!f.writable) children.push(React.createElement('p', { key: 'ro', role: 'status' }, t('readOnly')))
|
|
333
|
+
children.push(
|
|
334
|
+
React.createElement(
|
|
335
|
+
'p',
|
|
336
|
+
{ key: 'sum', style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
337
|
+
t('summary'),
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
if (f.noSeats) {
|
|
341
|
+
children.push(React.createElement('p', { key: 'fb', style: { fontSize: 12 } }, t('fallbackNote')))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
children.push(React.createElement(seatsSection, { key: 'seats', f: f }))
|
|
345
|
+
children.push(React.createElement(gatewaySection, { key: 'gateway', f: f }))
|
|
346
|
+
children.push(React.createElement(consensusSection, { key: 'consensus', f: f }))
|
|
347
|
+
|
|
348
|
+
if (f.problem && !f.noSeats) {
|
|
349
|
+
var message = f.problem === 'gateway' ? t('invalidGateway') : t('invalid')
|
|
350
|
+
children.push(errorLine('inv', message))
|
|
351
|
+
}
|
|
352
|
+
if (f.failed) children.push(errorLine('fail', t('saveFailed')))
|
|
353
|
+
|
|
354
|
+
children.push(React.createElement(saveAction, { key: 'actions', f: f }))
|
|
355
|
+
return children
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** One red line of form feedback. */
|
|
359
|
+
function errorLine(key, message) {
|
|
360
|
+
var style = { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' }
|
|
361
|
+
return React.createElement('p', { key: key, role: 'alert', style: style }, message)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The three seat editors plus the catalog failure note. */
|
|
365
|
+
function seatsSection(props) {
|
|
366
|
+
var f = props.f
|
|
367
|
+
var t = f.t
|
|
368
|
+
return React.createElement(
|
|
369
|
+
'section',
|
|
370
|
+
null,
|
|
371
|
+
React.createElement('h4', { style: { margin: '12px 0 4px', fontSize: 13 } }, t('seats')),
|
|
372
|
+
React.createElement(
|
|
373
|
+
'p',
|
|
374
|
+
{ style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)', margin: 0 } },
|
|
375
|
+
t('seatsHint'),
|
|
376
|
+
),
|
|
377
|
+
f.draft.seats.map(function (seat, index) {
|
|
378
|
+
return React.createElement(SeatRow, {
|
|
379
|
+
key: index,
|
|
380
|
+
t: t,
|
|
381
|
+
seat: seat,
|
|
382
|
+
catalogs: f.catalogs,
|
|
383
|
+
disabled: f.disabled,
|
|
384
|
+
onChange: function (key, value) { f.editSeat(index, key, value) },
|
|
385
|
+
})
|
|
386
|
+
}),
|
|
387
|
+
f.catalogs.length === 0 && f.catalogError
|
|
388
|
+
? React.createElement('p', { style: { fontSize: 12 } }, t('catalogFailed'))
|
|
389
|
+
: null,
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Gateway URL and the api key reference. */
|
|
394
|
+
function gatewaySection(props) {
|
|
395
|
+
var f = props.f
|
|
396
|
+
var t = f.t
|
|
397
|
+
return React.createElement(
|
|
398
|
+
'section',
|
|
399
|
+
{ style: { display: 'flex', gap: 8, marginTop: 12 } },
|
|
400
|
+
React.createElement(Field, {
|
|
401
|
+
label: t('gateway'),
|
|
402
|
+
value: f.draft.baseUrl,
|
|
403
|
+
hint: t('gatewayHint'),
|
|
404
|
+
disabled: f.disabled,
|
|
405
|
+
onChange: function (v) { f.editField('baseUrl', v) },
|
|
406
|
+
}),
|
|
407
|
+
React.createElement(Field, {
|
|
408
|
+
label: t('apiKey'),
|
|
409
|
+
value: f.draft.apiKey,
|
|
410
|
+
hint: t('apiKeyHint'),
|
|
411
|
+
disabled: f.disabled,
|
|
412
|
+
onChange: function (v) { f.editField('apiKey', v) },
|
|
413
|
+
}),
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Consensus threshold and the round cap. */
|
|
418
|
+
function consensusSection(props) {
|
|
419
|
+
var f = props.f
|
|
420
|
+
var t = f.t
|
|
421
|
+
return React.createElement(
|
|
422
|
+
'section',
|
|
423
|
+
{ style: { display: 'flex', gap: 8, marginTop: 12 } },
|
|
424
|
+
React.createElement(Field, {
|
|
425
|
+
label: t('threshold'),
|
|
426
|
+
value: String(f.draft.thresholdPercent),
|
|
427
|
+
disabled: f.disabled,
|
|
428
|
+
onChange: function (v) { f.editField('thresholdPercent', v) },
|
|
429
|
+
}),
|
|
430
|
+
React.createElement(Field, {
|
|
431
|
+
label: t('maxRounds'),
|
|
432
|
+
value: String(f.draft.maxRounds),
|
|
433
|
+
disabled: f.disabled,
|
|
434
|
+
onChange: function (v) { f.editField('maxRounds', v) },
|
|
435
|
+
}),
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** The single save button; disabled while a write is not possible. */
|
|
440
|
+
function saveAction(props) {
|
|
441
|
+
var f = props.f
|
|
442
|
+
var t = f.t
|
|
443
|
+
var blockedStyle = {
|
|
444
|
+
padding: '6px 14px',
|
|
445
|
+
borderRadius: 8,
|
|
446
|
+
border: '1px solid transparent',
|
|
447
|
+
cursor: f.writing ? 'not-allowed' : 'pointer',
|
|
448
|
+
background: 'var(--dsw-alias-brand-primary)',
|
|
449
|
+
color: 'var(--dsw-alias-brand-primary-invert, #fff)',
|
|
450
|
+
fontSize: 13,
|
|
451
|
+
opacity: f.writing ? 0.6 : 1,
|
|
452
|
+
}
|
|
453
|
+
return React.createElement(
|
|
454
|
+
'div',
|
|
455
|
+
{ style: { marginTop: 12 } },
|
|
456
|
+
React.createElement(
|
|
457
|
+
'button',
|
|
458
|
+
{ type: 'button', disabled: f.writing, onClick: f.save, style: blockedStyle },
|
|
459
|
+
f.saving ? t('saving') : t('save'),
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The three "nothing to show yet" states, decided once.
|
|
466
|
+
*
|
|
467
|
+
* `refused` carries the element to render; the caller returns it verbatim.
|
|
468
|
+
*/
|
|
469
|
+
function pageGate(t, scope, snapshot, draft) {
|
|
470
|
+
if (!scope) return { refused: React.createElement('p', null, t('unavailable')) }
|
|
471
|
+
if (snapshot && snapshot.status === 'unavailable') {
|
|
472
|
+
return { refused: React.createElement('p', { role: 'status' }, t('unavailable')) }
|
|
473
|
+
}
|
|
474
|
+
if (!draft) return { refused: React.createElement('p', { role: 'status' }, t('catalogLoading')) }
|
|
475
|
+
return { refused: null }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Subscribe to the Host's accepted values and seed the draft from them. */
|
|
479
|
+
function useDraftSubscription(scope, setSnapshot, setDraft) {
|
|
480
|
+
React.useEffect(
|
|
481
|
+
function () {
|
|
482
|
+
if (!scope) return function () {}
|
|
483
|
+
var cancelled = false
|
|
484
|
+
function sync() {
|
|
485
|
+
var next = scope.getSnapshot()
|
|
486
|
+
if (cancelled) return
|
|
487
|
+
setSnapshot(next)
|
|
488
|
+
setDraft(function (current) {
|
|
489
|
+
// Never clobber what the user is typing: seed once, and re-seed only
|
|
490
|
+
// after a save lands (current === null).
|
|
491
|
+
if (current !== null) return current
|
|
492
|
+
return draftFrom(next && next.value)
|
|
493
|
+
})
|
|
494
|
+
}
|
|
495
|
+
sync()
|
|
496
|
+
var unsubscribe = scope.subscribe ? scope.subscribe(sync) : null
|
|
497
|
+
return function () {
|
|
498
|
+
cancelled = true
|
|
499
|
+
if (typeof unsubscribe === 'function') unsubscribe()
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
[scope],
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Offer the Host's configured models, grouped by provider, when reachable. */
|
|
507
|
+
function useCatalogSubscription(ctx, setCatalogs, setCatalogError) {
|
|
508
|
+
React.useEffect(
|
|
509
|
+
function () {
|
|
510
|
+
var cancelled = false
|
|
511
|
+
var session = catalogSession(ctx)
|
|
512
|
+
if (!session) return function () {}
|
|
513
|
+
Promise.resolve(session.modelCatalog())
|
|
514
|
+
.then(function (response) {
|
|
515
|
+
if (cancelled) return
|
|
516
|
+
var groups = healthyCatalogGroups(response)
|
|
517
|
+
if (!groups) setCatalogError('failed')
|
|
518
|
+
else setCatalogs(groups)
|
|
519
|
+
})
|
|
520
|
+
.catch(function () {
|
|
521
|
+
if (!cancelled) setCatalogError('failed')
|
|
522
|
+
})
|
|
523
|
+
return function () {
|
|
524
|
+
cancelled = true
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
[ctx],
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** One seat updated in place: the edited key replaced, the other kept. */
|
|
532
|
+
function withSeatEdit(seats, index, key, value) {
|
|
533
|
+
var next = seats.slice()
|
|
534
|
+
var edited = Object.assign({}, next[index])
|
|
535
|
+
edited[key] = value
|
|
536
|
+
next[index] = edited
|
|
537
|
+
return next
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Hand the action handlers live getters, so a save always reads current state. */
|
|
541
|
+
function pageActionParams(state, scope, facts) {
|
|
542
|
+
return {
|
|
543
|
+
setDraft: state.setDraft,
|
|
544
|
+
setFailed: state.setFailed,
|
|
545
|
+
setSaving: state.setSaving,
|
|
546
|
+
getScope: function () { return scope },
|
|
547
|
+
getDraft: function () { return state.draft },
|
|
548
|
+
getSnapshot: function () { return state.snapshot },
|
|
549
|
+
getSaving: function () { return state.saving },
|
|
550
|
+
getDisabled: function () { return facts.disabled },
|
|
551
|
+
getNoSeats: function () { return facts.noSeats },
|
|
552
|
+
getProblem: function () { return facts.problem },
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** The props the section list renders from. */
|
|
557
|
+
function pageSectionProps(t, state, facts, actions) {
|
|
558
|
+
return {
|
|
559
|
+
t: t,
|
|
560
|
+
draft: state.draft,
|
|
561
|
+
catalogs: state.catalogs,
|
|
562
|
+
catalogError: state.catalogError,
|
|
563
|
+
disabled: facts.disabled,
|
|
564
|
+
writing: facts.disabled || Boolean(facts.problem),
|
|
565
|
+
noSeats: facts.noSeats,
|
|
566
|
+
writable: facts.writable,
|
|
567
|
+
problem: facts.problem,
|
|
568
|
+
failed: state.failed,
|
|
569
|
+
saving: state.saving,
|
|
570
|
+
editSeat: actions.editSeat,
|
|
571
|
+
editField: actions.editField,
|
|
572
|
+
save: actions.save,
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Why a save cannot proceed, or null when it can.
|
|
578
|
+
*
|
|
579
|
+
* Two distinct refusals: `busy` (already saving, or the form is read-only)
|
|
580
|
+
* is silent, anything else is the operator's error and gets a banner.
|
|
581
|
+
*/
|
|
582
|
+
function saveRefused(params) {
|
|
583
|
+
if (!params.getScope() || params.getSaving() || params.getDisabled()) return 'busy'
|
|
584
|
+
// A form with no seats has nothing to save: clearing seats is what makes
|
|
585
|
+
// the JSON file apply again, and that is an explicit act, not a blank save.
|
|
586
|
+
if (params.getNoSeats() || params.getProblem()) return 'invalid'
|
|
587
|
+
return null
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** POST the draft and reconcile the form with what the Host accepted. */
|
|
591
|
+
function commitDraft(params) {
|
|
592
|
+
var snapshot = params.getSnapshot()
|
|
593
|
+
params.setSaving(true)
|
|
594
|
+
params.setFailed(false)
|
|
595
|
+
Promise.resolve(params.getScope().mutate(saveOperations(params.getDraft()), snapshot ? snapshot.revision : undefined))
|
|
596
|
+
.then(function (accepted) {
|
|
597
|
+
if (!accepted) params.setFailed(true)
|
|
598
|
+
else params.setDraft(null)
|
|
599
|
+
})
|
|
600
|
+
.catch(function (error) {
|
|
601
|
+
console.error('[xp-gate] delphi save failed', error)
|
|
602
|
+
params.setFailed(true)
|
|
603
|
+
})
|
|
604
|
+
.then(function () {
|
|
605
|
+
params.setSaving(false)
|
|
606
|
+
})
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* The page's mutation handlers and the save sequence.
|
|
611
|
+
*
|
|
612
|
+
* Busy and invalid are separate refusals: one is "do nothing", the other is
|
|
613
|
+
* "tell the user why". Folding them into one predicate is what made the
|
|
614
|
+
* handler a nest of early returns.
|
|
615
|
+
*/
|
|
616
|
+
function pageActions(params) {
|
|
617
|
+
/** Apply a draft change and drop any stale "save failed" banner with it. */
|
|
618
|
+
function amend(change) {
|
|
619
|
+
params.setFailed(false)
|
|
620
|
+
params.setDraft(function (current) {
|
|
621
|
+
return Object.assign({}, current, change(current))
|
|
622
|
+
})
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function editSeat(index, key, value) {
|
|
626
|
+
amend(function (current) {
|
|
627
|
+
return { seats: withSeatEdit(current.seats, index, key, value) }
|
|
628
|
+
})
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function editField(key, value) {
|
|
632
|
+
amend(function () {
|
|
633
|
+
return { [key]: value }
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function save() {
|
|
638
|
+
var refusal = saveRefused(params)
|
|
639
|
+
if (refusal === null) commitDraft(params)
|
|
640
|
+
else if (refusal === 'invalid') params.setFailed(true)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return { editSeat: editSeat, editField: editField, save: save }
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** The six pieces of state the page keeps, unwrapped for readability. */
|
|
647
|
+
function usePageState() {
|
|
648
|
+
var snapshotState = React.useState(null)
|
|
649
|
+
var draftState = React.useState(null)
|
|
650
|
+
var catalogsState = React.useState([])
|
|
651
|
+
var catalogErrorState = React.useState('')
|
|
652
|
+
var savingState = React.useState(false)
|
|
653
|
+
var failedState = React.useState(false)
|
|
654
|
+
return {
|
|
655
|
+
snapshot: snapshotState[0],
|
|
656
|
+
draft: draftState[0],
|
|
657
|
+
setDraft: draftState[1],
|
|
658
|
+
setSnapshot: snapshotState[1],
|
|
659
|
+
catalogs: catalogsState[0],
|
|
660
|
+
setCatalogs: catalogsState[1],
|
|
661
|
+
catalogError: catalogErrorState[0],
|
|
662
|
+
setCatalogError: catalogErrorState[1],
|
|
663
|
+
saving: savingState[0],
|
|
664
|
+
setSaving: savingState[1],
|
|
665
|
+
failed: failedState[0],
|
|
666
|
+
setFailed: failedState[1],
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** The page's derived flags: is the form writable, and what is wrong with it. */
|
|
671
|
+
function pageFacts(state) {
|
|
672
|
+
var draft = state.draft
|
|
673
|
+
var writable = state.snapshot ? state.snapshot.writable !== false : false
|
|
674
|
+
return {
|
|
675
|
+
writable: writable,
|
|
676
|
+
problem: validationError(draft.seats, draft.baseUrl),
|
|
677
|
+
disabled: !writable || state.saving,
|
|
678
|
+
noSeats: !draft.seats.some(function (seat) { return (seat.model || '').trim() }),
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** The configuration page: seat editors, gateway, and one save. */
|
|
683
|
+
function DelphiConfigPage(props) {
|
|
684
|
+
var t = props.t
|
|
685
|
+
var scope = props.scope
|
|
686
|
+
var state = usePageState()
|
|
687
|
+
|
|
688
|
+
useDraftSubscription(scope, state.setSnapshot, state.setDraft)
|
|
689
|
+
useCatalogSubscription(props.ctx, state.setCatalogs, state.setCatalogError)
|
|
690
|
+
|
|
691
|
+
var gate = pageGate(t, scope, state.snapshot, state.draft)
|
|
692
|
+
if (gate.refused) return gate.refused
|
|
693
|
+
|
|
694
|
+
var facts = pageFacts(state)
|
|
695
|
+
var actions = pageActions(pageActionParams(state, scope, facts))
|
|
696
|
+
var children = pageSections(pageSectionProps(t, state, facts, actions))
|
|
697
|
+
return React.createElement('div', { style: { padding: '4px 0 8px' } }, children)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* What the page renders for a slot dispatch.
|
|
702
|
+
*
|
|
703
|
+
* `summary` is the row's one-liner when the package declares no description;
|
|
704
|
+
* `page` is the configuration form.
|
|
705
|
+
*/
|
|
706
|
+
/**
|
|
707
|
+
* The Host's live settings scope for this plugin, or null.
|
|
708
|
+
*
|
|
709
|
+
* A Host without the forms service is not a failure: the page then renders
|
|
710
|
+
* its "read-only / unavailable" branch instead of throwing at mount.
|
|
711
|
+
*/
|
|
712
|
+
function configScope(ctx) {
|
|
713
|
+
if (!ctx || !ctx.configForms || typeof ctx.configForms.get !== 'function') return null
|
|
714
|
+
try {
|
|
715
|
+
return ctx.configForms.get(NS)
|
|
716
|
+
} catch (error) {
|
|
717
|
+
console.warn('[xp-gate] configForms.get failed', error && error.message)
|
|
718
|
+
return null
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function DelphiConfigEntry(props) {
|
|
723
|
+
var ctx = props.ctx
|
|
724
|
+
var t = props.t || translator(ctx)
|
|
725
|
+
if (props.view === 'summary') return t('summary')
|
|
726
|
+
return React.createElement(DelphiConfigPage, { ctx: ctx, t: t, scope: configScope(ctx) })
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
module.exports.inject = ['slots', 'locale', 'configForms']
|
|
730
|
+
module.exports.NS = NS
|
|
731
|
+
module.exports.PKG = PKG
|
|
732
|
+
module.exports.ROW_ID = ROW_ID
|
|
733
|
+
module.exports.saveOperations = saveOperations
|
|
734
|
+
module.exports.seatsOf = seatsOf
|
|
735
|
+
module.exports.validationError = validationError
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Mount the page.
|
|
739
|
+
*
|
|
740
|
+
* Registered on `plugins.row.config` only. The page's lifetime follows the
|
|
741
|
+
* Loader row, so switching the row off removes the page with it.
|
|
742
|
+
*/
|
|
743
|
+
module.exports.apply = function apply(ctx) {
|
|
744
|
+
if (typeof ctx.effect === 'function') {
|
|
745
|
+
ctx.effect(function () {
|
|
746
|
+
return ctx.locale.register(LOCALE_NS, { en: en, zh: zh })
|
|
747
|
+
}, 'xp-gate: dictionaries')
|
|
748
|
+
}
|
|
749
|
+
if (!ctx.slots || typeof ctx.slots.inject !== 'function') return
|
|
750
|
+
ctx.slots.inject('plugins.row.config', function () {
|
|
751
|
+
return ctx.slots.register(
|
|
752
|
+
{ name: 'plugins.row.config', key: PKG + '#' + ROW_ID, locale: LOCALE_NS },
|
|
753
|
+
DelphiConfigEntry,
|
|
754
|
+
)
|
|
755
|
+
})
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return module.exports
|
|
759
|
+
},
|
|
760
|
+
})
|