dsh-switchman 1.3.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/LICENSE +21 -0
- package/README.md +79 -0
- package/README.zh.md +79 -0
- package/client.js +2441 -0
- package/cordis.patch.yml +599 -0
- package/host/config.js +116 -0
- package/host/context-watch.js +620 -0
- package/host/data/capability-default.json +1097 -0
- package/host/dispatch.js +205 -0
- package/host/lang.js +182 -0
- package/host/lib/rank.js +187 -0
- package/host/routes.js +398 -0
- package/icon.svg +7 -0
- package/index.js +154 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +70 -0
- package/skills/db-query/SKILL.md +322 -0
- package/skills/db-query/package-lock.json +251 -0
- package/skills/db-query/package.json +20 -0
- package/skills/db-query/references/mysql.md +137 -0
- package/skills/db-query/references/redis.md +213 -0
- package/skills/db-query/references/security.md +176 -0
- package/skills/db-query/scripts/lib/cli.js +118 -0
- package/skills/db-query/scripts/lib/config.js +215 -0
- package/skills/db-query/scripts/lib/mysql-readonly.js +655 -0
- package/skills/db-query/scripts/lib/output.js +322 -0
- package/skills/db-query/scripts/lib/redis-readonly.js +362 -0
- package/skills/db-query/scripts/mysql-query.js +198 -0
- package/skills/db-query/scripts/redis-query.js +181 -0
- package/skills/db-query/scripts/setup.sh +51 -0
- package/skills/git-commit-message/SKILL.md +49 -0
- package/skills/requirement-docs/SKILL.md +38 -0
- package/skills/requirement-docs/references/templates.md +187 -0
package/client.js
ADDED
|
@@ -0,0 +1,2441 @@
|
|
|
1
|
+
/** Client half of dsh-switchman.
|
|
2
|
+
*
|
|
3
|
+
* One self-contained entry (the client-module system serves exactly one
|
|
4
|
+
* bundle per package; sibling files cannot be synchronously required).
|
|
5
|
+
*
|
|
6
|
+
* Surfaces, all reading the shared "dsh-switchman" locale namespace (zh/en):
|
|
7
|
+
* - Phase 0: the team-autonomy badge in `conversation.session.header.actions`.
|
|
8
|
+
* - Phase 0: the team-autonomy badge in `conversation.session.header.actions`.
|
|
9
|
+
* - Phase 1: a `settings.section` page with a "Language" card:
|
|
10
|
+
* conversation / code-comment / document language preferences.
|
|
11
|
+
* - Phase 2: a "Dispatch pools & ranking" card on the same page: six lane
|
|
12
|
+
* pools and a capability ranking edited against the live model catalog
|
|
13
|
+
* (served by the Host route family), plus the enforcement mode. Every
|
|
14
|
+
* field shares one staged draft and one atomic fenced save.
|
|
15
|
+
* - Phase 3: a "Context watermark" card on the same page: tiered token
|
|
16
|
+
* thresholds (soft/hard/force), the per-read budget, hard-tier behavior,
|
|
17
|
+
* automatic handover, and the subagent context cap — number inputs with
|
|
18
|
+
* per-field minimum plus soft<hard<force order validation, folded into
|
|
19
|
+
* the same staged draft and one atomic save.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
window.__ModuleLoader__.load({
|
|
23
|
+
id: 'dsh-switchman',
|
|
24
|
+
factory(require) {
|
|
25
|
+
const React = require('react');
|
|
26
|
+
const h = React.createElement;
|
|
27
|
+
|
|
28
|
+
/** Locale namespace shared by every dsh-switchman client surface. */
|
|
29
|
+
const NS = 'dsh-switchman';
|
|
30
|
+
|
|
31
|
+
/** Settings namespace this bundle's Host half owns (= cordis name). */
|
|
32
|
+
const HOST_NS = 'dsh-switchman';
|
|
33
|
+
|
|
34
|
+
// ------------------------------------------------------------------
|
|
35
|
+
// Preference model (Phase 1 languages + Phase 2 dispatch)
|
|
36
|
+
// ------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/** Selectable languages. `value` is the stored settings value (BCP-style
|
|
39
|
+
* tag, ≤48 chars per the Host schema convention); `label` is the native
|
|
40
|
+
* name, identical in every UI language. */
|
|
41
|
+
const LANGUAGES = [
|
|
42
|
+
{ value: 'en', label: 'English' },
|
|
43
|
+
{ value: 'zh-CN', label: '简体中文' },
|
|
44
|
+
{ value: 'zh-TW', label: '繁體中文' },
|
|
45
|
+
{ value: 'ja', label: '日本語' },
|
|
46
|
+
{ value: 'ko', label: '한국어' },
|
|
47
|
+
{ value: 'es', label: 'Español' },
|
|
48
|
+
{ value: 'fr', label: 'Français' },
|
|
49
|
+
{ value: 'de', label: 'Deutsch' },
|
|
50
|
+
{ value: 'ru', label: 'Русский' },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** Sentinel select value standing for a hand-typed language. */
|
|
54
|
+
const CUSTOM = '__custom__';
|
|
55
|
+
|
|
56
|
+
/** Max length of one language value (Host-side convention). */
|
|
57
|
+
const LANG_MAX = 48;
|
|
58
|
+
|
|
59
|
+
/** Host-route bridge: the restricted dynamic-module runtime has no
|
|
60
|
+
* direct network beyond same-origin fetch, so every settings
|
|
61
|
+
* read/write and the model catalog go through the Host half's
|
|
62
|
+
* /api/dsh-switchman family. Document-relative on purpose — the
|
|
63
|
+
* harness serves the GUI with <base href="./">. */
|
|
64
|
+
const API_BASE = 'api/dsh-switchman/';
|
|
65
|
+
async function apiRequest(resource, options) {
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`${API_BASE}${resource}`, {
|
|
68
|
+
method: options?.method ?? 'GET',
|
|
69
|
+
headers:
|
|
70
|
+
options?.body === undefined
|
|
71
|
+
? new Headers()
|
|
72
|
+
: new Headers({ 'content-type': 'application/json' }),
|
|
73
|
+
body: options?.body === undefined ? undefined : JSON.stringify(options.body),
|
|
74
|
+
});
|
|
75
|
+
let body;
|
|
76
|
+
try {
|
|
77
|
+
body = await response.json();
|
|
78
|
+
} catch {
|
|
79
|
+
body = undefined;
|
|
80
|
+
}
|
|
81
|
+
return response.ok
|
|
82
|
+
? { ok: true, value: body }
|
|
83
|
+
: { ok: false, status: response.status, value: body };
|
|
84
|
+
} catch {
|
|
85
|
+
return { ok: false, status: 0 };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const api = {
|
|
89
|
+
get: (resource) => apiRequest(resource),
|
|
90
|
+
post: (resource, body) => apiRequest(resource, { method: 'POST', body }),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** The three language fields, in display order. */
|
|
94
|
+
const LANG_FIELDS = ['langConversation', 'langComments', 'langDocs'];
|
|
95
|
+
|
|
96
|
+
/** Capability tiers, strongest first ('' = unanchored). */
|
|
97
|
+
const TIERS = ['S', 'A', 'B', 'C'];
|
|
98
|
+
|
|
99
|
+
/** Enforcement modes (dictionary keys: enforceOff/Advice/Enforce). */
|
|
100
|
+
const ENFORCE_MODES = ['off', 'advice', 'enforce'];
|
|
101
|
+
|
|
102
|
+
/** The six dispatch lanes: settings field per lane, display order.
|
|
103
|
+
* manualField = the per-lane manual-order toggle (true = stored
|
|
104
|
+
* array order IS the dispatch priority). */
|
|
105
|
+
const POOL_FIELDS = [
|
|
106
|
+
{ field: 'poolEconomy', manualField: 'poolEconomyManual', effortsField: 'poolEconomyEfforts', lane: 'economy' },
|
|
107
|
+
{ field: 'poolMechanical', manualField: 'poolMechanicalManual', effortsField: 'poolMechanicalEfforts', lane: 'mechanical' },
|
|
108
|
+
{ field: 'poolMain', manualField: 'poolMainManual', effortsField: 'poolMainEfforts', lane: 'main' },
|
|
109
|
+
{ field: 'poolHard', manualField: 'poolHardManual', effortsField: 'poolHardEfforts', lane: 'hard' },
|
|
110
|
+
{ field: 'poolVision', manualField: 'poolVisionManual', effortsField: 'poolVisionEfforts', lane: 'vision' },
|
|
111
|
+
{ field: 'poolReview', manualField: 'poolReviewManual', effortsField: 'poolReviewEfforts', lane: 'review' },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
/** Numeric watermark fields: settings field, schema minimum, and the
|
|
115
|
+
* Host-side default (also the fallback for malformed stored values). */
|
|
116
|
+
const WM_NUMBER_FIELDS = [
|
|
117
|
+
{ field: 'wmSoftTokens', min: 1000, def: 50000 },
|
|
118
|
+
{ field: 'wmHardTokens', min: 2000, def: 90000 },
|
|
119
|
+
{ field: 'wmForceTokens', min: 3000, def: 130000 },
|
|
120
|
+
{ field: 'wmReadBudgetTokens', min: 200, def: 1500 },
|
|
121
|
+
{ field: 'wmSubagentForceTokens', min: 0, def: 0 },
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
/** The three thresholds that must keep a strict ascending order. */
|
|
125
|
+
const WM_ORDER_FIELDS = ['wmSoftTokens', 'wmHardTokens', 'wmForceTokens'];
|
|
126
|
+
|
|
127
|
+
/** Dictionary keys per numeric watermark row. */
|
|
128
|
+
const WM_ROW_KEYS = {
|
|
129
|
+
wmSoftTokens: { label: 'wmSoftLabel', help: 'wmSoftHelp' },
|
|
130
|
+
wmHardTokens: { label: 'wmHardLabel', help: 'wmHardHelp' },
|
|
131
|
+
wmForceTokens: { label: 'wmForceLabel', help: 'wmForceHelp' },
|
|
132
|
+
wmReadBudgetTokens: { label: 'wmReadBudgetLabel', help: 'wmReadBudgetHelp' },
|
|
133
|
+
wmSubagentForceTokens: {
|
|
134
|
+
label: 'wmSubagentForceLabel',
|
|
135
|
+
help: 'wmSubagentForceHelp',
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Every settings field this page edits, in mutate order. */
|
|
140
|
+
const ALL_FIELDS = [
|
|
141
|
+
...LANG_FIELDS,
|
|
142
|
+
...POOL_FIELDS.map(({ field }) => field),
|
|
143
|
+
...POOL_FIELDS.map(({ manualField }) => manualField),
|
|
144
|
+
...POOL_FIELDS.map(({ effortsField }) => effortsField),
|
|
145
|
+
'modelRank',
|
|
146
|
+
'dispatchEnforce',
|
|
147
|
+
...WM_NUMBER_FIELDS.map(({ field }) => field),
|
|
148
|
+
'wmDenyMode',
|
|
149
|
+
'wmAutoHandover',
|
|
150
|
+
'wmSubagentCap',
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
/** Stable identity of one provider/model route. */
|
|
154
|
+
const routeKey = (route) => `${route.provider}\0${route.model}`;
|
|
155
|
+
|
|
156
|
+
/** One well-formed {provider, model} route, or null. */
|
|
157
|
+
function sanitizeRoute(route) {
|
|
158
|
+
if (route === null || typeof route !== 'object') return null;
|
|
159
|
+
if (typeof route.provider !== 'string' || route.provider === '') return null;
|
|
160
|
+
if (typeof route.model !== 'string' || route.model === '') return null;
|
|
161
|
+
return { provider: route.provider, model: route.model };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One well-formed rank entry ({provider, model[, tier]}), or null;
|
|
165
|
+
* an unknown tier is dropped rather than the whole entry. */
|
|
166
|
+
function sanitizeRankEntry(entry) {
|
|
167
|
+
const route = sanitizeRoute(entry);
|
|
168
|
+
if (route === null) return null;
|
|
169
|
+
return typeof entry.tier === 'string' && TIERS.includes(entry.tier)
|
|
170
|
+
? { ...route, tier: entry.tier }
|
|
171
|
+
: route;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 思考强度 fallback 档位(模型拿不到 wire 元数据时用);
|
|
175
|
+
* 真实列表以 wire reasoning.efforts 为准。 */
|
|
176
|
+
const EFFORTS = ['low', 'medium', 'high'];
|
|
177
|
+
|
|
178
|
+
/** 一条格式良好的 effort 条目({provider, model, effort}),或 null。 */
|
|
179
|
+
function sanitizeEffortEntry(entry) {
|
|
180
|
+
const route = sanitizeRoute(entry);
|
|
181
|
+
if (route === null) return null;
|
|
182
|
+
return typeof entry.effort === 'string' && entry.effort !== ''
|
|
183
|
+
? { ...route, effort: entry.effort }
|
|
184
|
+
: null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Is `value` one of the predefined candidates? */
|
|
188
|
+
function isCandidate(value) {
|
|
189
|
+
return LANGUAGES.some((language) => language.value === value);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Human display for a stored value: "简体中文 (zh-CN)", or the raw
|
|
193
|
+
* string when it is a custom value. */
|
|
194
|
+
function formatLanguage(value) {
|
|
195
|
+
if (value === '') return '';
|
|
196
|
+
const known = LANGUAGES.find((language) => language.value === value);
|
|
197
|
+
return known === undefined ? value : `${known.label} (${known.value})`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Suggested conversation language for one UI locale id: 'zh' maps to
|
|
201
|
+
* the zh-CN candidate, a matching candidate id maps to itself, anything
|
|
202
|
+
* else falls back to English. Display-only advice — never written. */
|
|
203
|
+
function suggestFromUiLocale(id) {
|
|
204
|
+
if (id === 'zh') return 'zh-CN';
|
|
205
|
+
return isCandidate(id) ? id : 'en';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Read every field this page edits off a form snapshot, coerced to
|
|
209
|
+
* shape ('' / [] / numeric / boolean / enum defaults when unset or
|
|
210
|
+
* malformed). */
|
|
211
|
+
function readCurrent(snapshot) {
|
|
212
|
+
// Form state carries the Host snapshot under `values` (the
|
|
213
|
+
// original configForms shape used `.value`; keep a fallback so
|
|
214
|
+
// a shape regression degrades instead of silently blanking).
|
|
215
|
+
const value = snapshot.values ?? snapshot.value;
|
|
216
|
+
const string = (field) =>
|
|
217
|
+
typeof value?.[field] === 'string' ? value[field] : '';
|
|
218
|
+
const routes = (field) =>
|
|
219
|
+
Array.isArray(value?.[field])
|
|
220
|
+
? value[field].map(sanitizeRoute).filter(Boolean)
|
|
221
|
+
: [];
|
|
222
|
+
const efforts = (field) =>
|
|
223
|
+
Array.isArray(value?.[field])
|
|
224
|
+
? value[field].map(sanitizeEffortEntry).filter(Boolean)
|
|
225
|
+
: [];
|
|
226
|
+
const number = ({ field, def }) =>
|
|
227
|
+
typeof value?.[field] === 'number' && Number.isFinite(value[field])
|
|
228
|
+
? value[field]
|
|
229
|
+
: def;
|
|
230
|
+
const bool = (field, def) =>
|
|
231
|
+
typeof value?.[field] === 'boolean' ? value[field] : def;
|
|
232
|
+
return {
|
|
233
|
+
langConversation: string('langConversation'),
|
|
234
|
+
langComments: string('langComments'),
|
|
235
|
+
langDocs: string('langDocs'),
|
|
236
|
+
...Object.fromEntries(
|
|
237
|
+
POOL_FIELDS.map(({ field }) => [field, routes(field)]),
|
|
238
|
+
),
|
|
239
|
+
...Object.fromEntries(
|
|
240
|
+
POOL_FIELDS.map(({ field, manualField }) => [
|
|
241
|
+
manualField,
|
|
242
|
+
bool(manualField, false),
|
|
243
|
+
]),
|
|
244
|
+
),
|
|
245
|
+
...Object.fromEntries(
|
|
246
|
+
POOL_FIELDS.map(({ effortsField }) => [
|
|
247
|
+
effortsField,
|
|
248
|
+
efforts(effortsField),
|
|
249
|
+
]),
|
|
250
|
+
),
|
|
251
|
+
modelRank: Array.isArray(value?.modelRank)
|
|
252
|
+
? value.modelRank.map(sanitizeRankEntry).filter(Boolean)
|
|
253
|
+
: [],
|
|
254
|
+
dispatchEnforce: ENFORCE_MODES.includes(value?.dispatchEnforce)
|
|
255
|
+
? value.dispatchEnforce
|
|
256
|
+
: 'advice',
|
|
257
|
+
...Object.fromEntries(
|
|
258
|
+
WM_NUMBER_FIELDS.map((spec) => [spec.field, number(spec)]),
|
|
259
|
+
),
|
|
260
|
+
wmDenyMode: value?.wmDenyMode === 'deny' ? 'deny' : 'cap',
|
|
261
|
+
wmAutoHandover: bool('wmAutoHandover', true),
|
|
262
|
+
wmSubagentCap: bool('wmSubagentCap', true),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Canonical serialization for equality: auto pools compare as sets
|
|
267
|
+
* (order irrelevant) while manual pools compare as ordered sequences
|
|
268
|
+
* (their order IS the dispatch priority), the ranking as an ordered
|
|
269
|
+
* tier-annotated sequence, everything else verbatim (numbers as
|
|
270
|
+
* numbers — never their input-string forms). */
|
|
271
|
+
function canonical(values) {
|
|
272
|
+
return JSON.stringify({
|
|
273
|
+
langConversation: values.langConversation,
|
|
274
|
+
langComments: values.langComments,
|
|
275
|
+
langDocs: values.langDocs,
|
|
276
|
+
...Object.fromEntries(
|
|
277
|
+
POOL_FIELDS.map(({ field, manualField }) => [
|
|
278
|
+
field,
|
|
279
|
+
values[manualField]
|
|
280
|
+
? values[field].map(routeKey)
|
|
281
|
+
: values[field].map(routeKey).sort(),
|
|
282
|
+
]),
|
|
283
|
+
),
|
|
284
|
+
...Object.fromEntries(
|
|
285
|
+
POOL_FIELDS.map(({ manualField }) => [
|
|
286
|
+
manualField,
|
|
287
|
+
values[manualField],
|
|
288
|
+
]),
|
|
289
|
+
),
|
|
290
|
+
...Object.fromEntries(
|
|
291
|
+
POOL_FIELDS.map(({ effortsField }) => [
|
|
292
|
+
effortsField,
|
|
293
|
+
values[effortsField]
|
|
294
|
+
.map((entry) => `${routeKey(entry)}\0${entry.effort}`)
|
|
295
|
+
.sort()
|
|
296
|
+
.join('|'),
|
|
297
|
+
]),
|
|
298
|
+
),
|
|
299
|
+
modelRank: values.modelRank
|
|
300
|
+
.map((entry) => `${routeKey(entry)}\0${entry.tier ?? ''}`)
|
|
301
|
+
.join('|'),
|
|
302
|
+
dispatchEnforce: values.dispatchEnforce,
|
|
303
|
+
...Object.fromEntries(
|
|
304
|
+
WM_NUMBER_FIELDS.map(({ field }) => [field, values[field]]),
|
|
305
|
+
),
|
|
306
|
+
wmDenyMode: values.wmDenyMode,
|
|
307
|
+
wmAutoHandover: values.wmAutoHandover,
|
|
308
|
+
wmSubagentCap: values.wmSubagentCap,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Deep-enough equality across every edited field. */
|
|
313
|
+
function sameValues(left, right) {
|
|
314
|
+
return canonical(left) === canonical(right);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Index one catalog answer: route key → display metadata. Models are
|
|
318
|
+
* keyed by `id` (the sessionController wire shape: {id, name}) with
|
|
319
|
+
* the group id as provider; the flattened `model` shape is accepted
|
|
320
|
+
* too. Groups keep their wire order. */
|
|
321
|
+
function indexCatalog(groups) {
|
|
322
|
+
const index = new Map();
|
|
323
|
+
for (const group of Array.isArray(groups) ? groups : []) {
|
|
324
|
+
const providerName = group.name ?? group.id ?? '';
|
|
325
|
+
for (const model of Array.isArray(group.models) ? group.models : []) {
|
|
326
|
+
const provider = model.provider ?? group.id;
|
|
327
|
+
const modelId = model.model ?? model.id;
|
|
328
|
+
if (typeof provider !== 'string' || typeof modelId !== 'string')
|
|
329
|
+
continue;
|
|
330
|
+
index.set(routeKey({ provider, model: modelId }), {
|
|
331
|
+
provider,
|
|
332
|
+
model: modelId,
|
|
333
|
+
providerName: providerName || provider,
|
|
334
|
+
modelName: model.name ?? modelId,
|
|
335
|
+
reasoning:
|
|
336
|
+
model.reasoning !== null &&
|
|
337
|
+
typeof model.reasoning === 'object'
|
|
338
|
+
? model.reasoning
|
|
339
|
+
: undefined,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return index;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Checkbox rows for one pool: every live catalog model, checked or
|
|
347
|
+
* not, plus this pool's saved routes that left the catalog (still
|
|
348
|
+
* removable — mirrors the shipped model-selection page). */
|
|
349
|
+
function poolRows(pool, catalogIndex) {
|
|
350
|
+
const selected = new Set(pool.map(routeKey));
|
|
351
|
+
const rows = [];
|
|
352
|
+
let providerName = null;
|
|
353
|
+
for (const meta of catalogIndex.values()) {
|
|
354
|
+
if (meta.providerName !== providerName) {
|
|
355
|
+
providerName = meta.providerName;
|
|
356
|
+
rows.push({ header: providerName });
|
|
357
|
+
}
|
|
358
|
+
rows.push({ ...meta, selected: selected.has(routeKey(meta)) });
|
|
359
|
+
}
|
|
360
|
+
const extras = pool
|
|
361
|
+
.filter((route) => !catalogIndex.has(routeKey(route)))
|
|
362
|
+
.map((route) => ({
|
|
363
|
+
provider: route.provider,
|
|
364
|
+
model: route.model,
|
|
365
|
+
providerName: route.provider,
|
|
366
|
+
modelName: route.model,
|
|
367
|
+
available: false,
|
|
368
|
+
selected: true,
|
|
369
|
+
}));
|
|
370
|
+
return { rows, extras };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The rank editor's display list: saved ranking first (stored order),
|
|
374
|
+
* then every universe member not yet ranked in catalog order with
|
|
375
|
+
* saved-unavailable last. Universe = all pool selections ∪ the saved
|
|
376
|
+
* ranking (opencode semantics the Host side consumes). */
|
|
377
|
+
function rankDisplay(values, catalogIndex) {
|
|
378
|
+
const ranked = values.modelRank.map((entry) => ({ ...entry }));
|
|
379
|
+
const rankedKeys = new Set(ranked.map(routeKey));
|
|
380
|
+
const universe = new Map();
|
|
381
|
+
for (const { field } of POOL_FIELDS)
|
|
382
|
+
for (const route of values[field]) universe.set(routeKey(route), route);
|
|
383
|
+
for (const entry of ranked) universe.set(routeKey(entry), entry);
|
|
384
|
+
const tail = [];
|
|
385
|
+
for (const meta of catalogIndex.values()) {
|
|
386
|
+
const key = routeKey(meta);
|
|
387
|
+
if (!rankedKeys.has(key) && universe.has(key))
|
|
388
|
+
tail.push({ provider: meta.provider, model: meta.model });
|
|
389
|
+
}
|
|
390
|
+
for (const [key, route] of universe)
|
|
391
|
+
if (!rankedKeys.has(key) && !catalogIndex.has(key))
|
|
392
|
+
tail.push({ provider: route.provider, model: route.model });
|
|
393
|
+
return [...ranked, ...tail];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Serialize a display list back into the stored modelRank shape
|
|
397
|
+
* (tier omitted when unset). */
|
|
398
|
+
const rankValue = (display) =>
|
|
399
|
+
display.map((entry) => ({
|
|
400
|
+
provider: entry.provider,
|
|
401
|
+
model: entry.model,
|
|
402
|
+
...(entry.tier ? { tier: entry.tier } : {}),
|
|
403
|
+
}));
|
|
404
|
+
|
|
405
|
+
// ------------------------------------------------------------------
|
|
406
|
+
// Dictionaries
|
|
407
|
+
// ------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
/** zh/en dictionaries; keys are stable, values follow the UI language. */
|
|
410
|
+
const DICTS = {
|
|
411
|
+
zh: {
|
|
412
|
+
badge: '自主团队',
|
|
413
|
+
badgeTip:
|
|
414
|
+
'dsh-switchman:四个内置预设(standard / ptc / minimal / cordis)已注入自主智能体团队调度规程',
|
|
415
|
+
settingsTitle: 'dsh-switchman',
|
|
416
|
+
settingsDescription: '配置 dsh-switchman 的各项偏好。',
|
|
417
|
+
langSectionTitle: '语言偏好',
|
|
418
|
+
langSectionDescription:
|
|
419
|
+
'控制智能体的对话、代码注释与文档撰写语言。',
|
|
420
|
+
langConversationLabel: '对话语言',
|
|
421
|
+
langConversationHelp:
|
|
422
|
+
'智能体回复与讨论使用的语言;未设置时会在首次使用时询问一次并记住。',
|
|
423
|
+
langCommentsLabel: '代码注释语言',
|
|
424
|
+
langCommentsHelp: '智能体编写代码注释使用的语言。',
|
|
425
|
+
langDocsLabel: '文档语言',
|
|
426
|
+
langDocsHelp: '智能体撰写文档使用的语言。',
|
|
427
|
+
followConversation: '跟随对话语言',
|
|
428
|
+
notSetAsk: '未设置(首次使用时询问)',
|
|
429
|
+
customLanguage: '自定义…',
|
|
430
|
+
customLanguagePlaceholder: '语言名称或 BCP 标签',
|
|
431
|
+
customLanguageRequired: '请输入自定义语言(1–48 个字符)。',
|
|
432
|
+
currentValue: '当前:{value}',
|
|
433
|
+
currentValueUnset: '当前:未设置',
|
|
434
|
+
suggestedValue: '建议:{value}',
|
|
435
|
+
save: '保存',
|
|
436
|
+
saving: '保存中…',
|
|
437
|
+
discard: '放弃修改',
|
|
438
|
+
saveFailed: '本部署没有接受这些值,已保留供你修改。',
|
|
439
|
+
saveConflict: '设置已在其他位置更新。请放弃修改后重试。',
|
|
440
|
+
formLoading: '正在加载设置…',
|
|
441
|
+
formUnavailable: '该插件当前未加载,暂时无法配置。',
|
|
442
|
+
formReadOnly: '本部署的设置为只读。',
|
|
443
|
+
dispatchSectionTitle: '派发池与模型排序',
|
|
444
|
+
dispatchSectionDescription:
|
|
445
|
+
'按任务复杂度把模型分组进六个派发池,并维护能力排序;仅影响 dsh-switchman 的委派调度。',
|
|
446
|
+
dispatchProgress: '已配置 {pools}/6 池 · 排名 {ranked} 项 · 模式 {mode}',
|
|
447
|
+
poolEconomyTitle: '轻量池 · economy',
|
|
448
|
+
poolEconomyDesc: '轻量批量与低复杂度任务',
|
|
449
|
+
poolMechanicalTitle: '机械池 · mechanical',
|
|
450
|
+
poolMechanicalDesc: '机械改写与模板化操作',
|
|
451
|
+
poolMainTitle: '主力池 · main',
|
|
452
|
+
poolMainDesc: '常规编码与日常任务',
|
|
453
|
+
poolHardTitle: '高难池 · hard',
|
|
454
|
+
poolHardDesc: '高难推理与大规模重构',
|
|
455
|
+
poolVisionTitle: '多模态池 · vision',
|
|
456
|
+
poolVisionDesc: '图像等多模态输入任务',
|
|
457
|
+
poolReviewTitle: '复审池 · review',
|
|
458
|
+
poolReviewDesc: '只读复审与独立验证',
|
|
459
|
+
poolSelectAll: '全选',
|
|
460
|
+
poolClear: '清空',
|
|
461
|
+
poolEmpty: '暂无候选模型',
|
|
462
|
+
poolManualLabel: '手动序',
|
|
463
|
+
poolManualHelp:
|
|
464
|
+
'开启后按列表顺序派发(首个即首选);未开启按能力排序自动排。',
|
|
465
|
+
poolManualSelected: '已选 · 派发优先序',
|
|
466
|
+
poolManualAddable: '可添加',
|
|
467
|
+
poolMoveUp: '上移',
|
|
468
|
+
poolMoveDown: '下移',
|
|
469
|
+
poolRemoveOne: '移除',
|
|
470
|
+
poolEffortFollow: '跟随泳道',
|
|
471
|
+
poolEffortLabel: '思考强度',
|
|
472
|
+
poolEffortHelp:
|
|
473
|
+
'手动指定该模型的思考强度,派发时优先于泳道默认',
|
|
474
|
+
catalogLoading: '正在加载模型目录…',
|
|
475
|
+
catalogLoadFailed: '无法加载模型目录。',
|
|
476
|
+
catalogRetry: '重试',
|
|
477
|
+
catalogPartial: '部分模型提供方暂时无法加载;已保存的选择仍可移除。',
|
|
478
|
+
catalogEmpty: '当前没有模型提供方公布模型。',
|
|
479
|
+
catalogUnavailableGroup: '已保存但当前不可用',
|
|
480
|
+
catalogUnavailable: '当前不可用',
|
|
481
|
+
rankTitle: '能力排序',
|
|
482
|
+
rankDescription:
|
|
483
|
+
'序号即能力序(最强在前),可为每项锚定 S/A/B/C 档。列表为六池选中 ∪ 已有排序;任意编辑都会固化当前显示顺序。',
|
|
484
|
+
rankEmpty: '先在上方池中勾选模型,排序列表会自动出现。',
|
|
485
|
+
rankTier: '档位',
|
|
486
|
+
rankTierNone: '—',
|
|
487
|
+
rankMoveUp: '上移',
|
|
488
|
+
rankMoveDown: '下移',
|
|
489
|
+
rankRemove: '移出排序',
|
|
490
|
+
enforceLabel: '执行模式',
|
|
491
|
+
enforceOff: '关闭',
|
|
492
|
+
enforceAdvice: '建议',
|
|
493
|
+
enforceEnforce: '强制',
|
|
494
|
+
enforceHelpOff: '不向委派注入任何池子指引。',
|
|
495
|
+
enforceHelpAdvice: '池子指引作为建议注入,模型可自行偏离。',
|
|
496
|
+
enforceHelpEnforce: '池子指引作为硬约束注入,偏离会被拒绝。',
|
|
497
|
+
wmSectionTitle: '上下文水位',
|
|
498
|
+
wmSectionDescription:
|
|
499
|
+
'按 token 用量分级收紧行为:建议、限读与收尾、自动交接。',
|
|
500
|
+
wmSoftLabel: '建议水位(soft)',
|
|
501
|
+
wmSoftHelp: '达到后出现横幅提示,并建议委派与整理上下文。',
|
|
502
|
+
wmHardLabel: '硬水位(hard)',
|
|
503
|
+
wmHardHelp: '达到后读取预算收紧,并建议收尾当前工作。',
|
|
504
|
+
wmForceLabel: '强制水位(force)',
|
|
505
|
+
wmForceHelp: '达到后建议备份并移交压缩(可设为自动)。',
|
|
506
|
+
wmReadBudgetLabel: '单次读取预算',
|
|
507
|
+
wmReadBudgetHelp: '每次读取的 token 预算;每轮上限为其两倍。',
|
|
508
|
+
wmNumberInvalid: '请输入不小于 {min} 的整数。',
|
|
509
|
+
wmOrderInvalid: '三档水位需严格递增:soft < hard < force。',
|
|
510
|
+
wmDenyModeLabel: '硬档行为',
|
|
511
|
+
wmDenyModeCap: '限流放行',
|
|
512
|
+
wmDenyModeDeny: '直接拒绝',
|
|
513
|
+
wmDenyModeHelpCap: '超出读预算的调用放行,但记录警示。',
|
|
514
|
+
wmDenyModeHelpDeny: '超出读预算的调用直接拒绝。',
|
|
515
|
+
wmAutoHandoverLabel: '自动交接',
|
|
516
|
+
wmAutoHandoverHelp: '达到强制水位时自动备份并移交压缩,无需手动确认。',
|
|
517
|
+
wmSubagentTitle: '子智能体',
|
|
518
|
+
wmSubagentCapLabel: '子智能体上下文上限',
|
|
519
|
+
wmSubagentCapHelp:
|
|
520
|
+
'开启后子智能体可用下方独立强制阈值;关闭则完全跟随上方三档水位。',
|
|
521
|
+
wmSubagentForceLabel: '子智能体强制阈值',
|
|
522
|
+
wmSubagentForceHelp:
|
|
523
|
+
'仅覆盖子智能体的 force 档(soft/hard 与主会话共用);0 = 跟随上方强制水位。',
|
|
524
|
+
wmSubagentFollowForce: '跟随 force 档(0)',
|
|
525
|
+
wmCommands:
|
|
526
|
+
'命令:/ctx-pause 暂停水位干预 · /ctx-resume 恢复干预 · /ctx-handover 立即备份并交接压缩。',
|
|
527
|
+
},
|
|
528
|
+
en: {
|
|
529
|
+
badge: 'Team autonomy',
|
|
530
|
+
badgeTip:
|
|
531
|
+
'dsh-switchman: the four built-in presets (standard / ptc / minimal / cordis) carry the autonomous Agent Teams doctrine',
|
|
532
|
+
settingsTitle: 'dsh-switchman',
|
|
533
|
+
settingsDescription: 'Configure dsh-switchman preferences.',
|
|
534
|
+
langSectionTitle: 'Language',
|
|
535
|
+
langSectionDescription:
|
|
536
|
+
'Controls the language of agent replies, code comments, and authored documents.',
|
|
537
|
+
langConversationLabel: 'Conversation language',
|
|
538
|
+
langConversationHelp:
|
|
539
|
+
'The language agents reply and discuss in; when unset, you are asked once on first use and the choice is remembered.',
|
|
540
|
+
langCommentsLabel: 'Code comment language',
|
|
541
|
+
langCommentsHelp: 'The language agents write code comments in.',
|
|
542
|
+
langDocsLabel: 'Document language',
|
|
543
|
+
langDocsHelp: 'The language agents author documents in.',
|
|
544
|
+
followConversation: 'Follow conversation language',
|
|
545
|
+
notSetAsk: 'Not set (ask on first use)',
|
|
546
|
+
customLanguage: 'Custom…',
|
|
547
|
+
customLanguagePlaceholder: 'Language name or BCP tag',
|
|
548
|
+
customLanguageRequired: 'Enter a custom language (1–48 characters).',
|
|
549
|
+
currentValue: 'Current: {value}',
|
|
550
|
+
currentValueUnset: 'Current: not set',
|
|
551
|
+
suggestedValue: 'Suggested: {value}',
|
|
552
|
+
save: 'Save',
|
|
553
|
+
saving: 'Saving…',
|
|
554
|
+
discard: 'Discard changes',
|
|
555
|
+
saveFailed:
|
|
556
|
+
'The deployment did not accept these values; they were left for you to correct.',
|
|
557
|
+
saveConflict: 'Settings changed elsewhere. Discard your draft and try again.',
|
|
558
|
+
formLoading: 'Loading settings…',
|
|
559
|
+
formUnavailable: 'This plugin is not loaded, so it cannot be configured right now.',
|
|
560
|
+
formReadOnly: 'This deployment stores settings read-only.',
|
|
561
|
+
dispatchSectionTitle: 'Dispatch pools & model ranking',
|
|
562
|
+
dispatchSectionDescription:
|
|
563
|
+
'Group models into six dispatch pools by task complexity and maintain a capability ranking; only affects dsh-switchman delegation.',
|
|
564
|
+
dispatchProgress: '{pools}/6 pools set · {ranked} ranked · mode {mode}',
|
|
565
|
+
poolEconomyTitle: 'Economy',
|
|
566
|
+
poolEconomyDesc: 'Lightweight batch and low-complexity tasks',
|
|
567
|
+
poolMechanicalTitle: 'Mechanical',
|
|
568
|
+
poolMechanicalDesc: 'Mechanical rewrites and templated operations',
|
|
569
|
+
poolMainTitle: 'Main',
|
|
570
|
+
poolMainDesc: 'Routine coding and everyday tasks',
|
|
571
|
+
poolHardTitle: 'Hard',
|
|
572
|
+
poolHardDesc: 'Hard reasoning and large-scale refactors',
|
|
573
|
+
poolVisionTitle: 'Vision',
|
|
574
|
+
poolVisionDesc: 'Multimodal (image) work',
|
|
575
|
+
poolReviewTitle: 'Review',
|
|
576
|
+
poolReviewDesc: 'Read-only review and independent verification',
|
|
577
|
+
poolSelectAll: 'Select all',
|
|
578
|
+
poolClear: 'Clear',
|
|
579
|
+
poolEmpty: 'No candidate models',
|
|
580
|
+
poolManualLabel: 'Manual order',
|
|
581
|
+
poolManualHelp:
|
|
582
|
+
'When on, dispatch follows the listed order (head = first choice); when off, lanes are ordered by capability automatically.',
|
|
583
|
+
poolManualSelected: 'Selected · dispatch priority',
|
|
584
|
+
poolManualAddable: 'Add models',
|
|
585
|
+
poolMoveUp: 'Move up',
|
|
586
|
+
poolMoveDown: 'Move down',
|
|
587
|
+
poolRemoveOne: 'Remove',
|
|
588
|
+
poolEffortFollow: 'follow lane',
|
|
589
|
+
poolEffortLabel: 'effort',
|
|
590
|
+
poolEffortHelp:
|
|
591
|
+
"Pin this model's reasoning effort; it overrides the lane default when dispatching",
|
|
592
|
+
catalogLoading: 'Loading model catalog…',
|
|
593
|
+
catalogLoadFailed: 'The model catalog could not be loaded.',
|
|
594
|
+
catalogRetry: 'Retry',
|
|
595
|
+
catalogPartial: 'Some model providers could not be loaded; saved choices remain removable.',
|
|
596
|
+
catalogEmpty: 'No model provider currently advertises a model.',
|
|
597
|
+
catalogUnavailableGroup: 'Saved but currently unavailable',
|
|
598
|
+
catalogUnavailable: 'currently unavailable',
|
|
599
|
+
rankTitle: 'Capability ranking',
|
|
600
|
+
rankDescription:
|
|
601
|
+
'Numbered strongest first; each entry may anchor an S/A/B/C tier. The list is pool selections ∪ the saved ranking; any edit persists the displayed order.',
|
|
602
|
+
rankEmpty: 'Select models in the pools above; the ranking list appears automatically.',
|
|
603
|
+
rankTier: 'Tier',
|
|
604
|
+
rankTierNone: '—',
|
|
605
|
+
rankMoveUp: 'Move up',
|
|
606
|
+
rankMoveDown: 'Move down',
|
|
607
|
+
rankRemove: 'Remove from ranking',
|
|
608
|
+
enforceLabel: 'Enforcement',
|
|
609
|
+
enforceOff: 'Off',
|
|
610
|
+
enforceAdvice: 'Advice',
|
|
611
|
+
enforceEnforce: 'Enforce',
|
|
612
|
+
enforceHelpOff: 'No pool guidance is injected into delegation.',
|
|
613
|
+
enforceHelpAdvice: 'Pool guidance is injected as advice; models may deviate.',
|
|
614
|
+
enforceHelpEnforce: 'Pool guidance is injected as a hard constraint; deviations are refused.',
|
|
615
|
+
wmSectionTitle: 'Context watermark',
|
|
616
|
+
wmSectionDescription:
|
|
617
|
+
'Tiered behavior by token usage: advice, capped reads and wrap-up, automatic handover.',
|
|
618
|
+
wmSoftLabel: 'Soft watermark',
|
|
619
|
+
wmSoftHelp: 'Past this, a banner appears and delegating or tidying the context is advised.',
|
|
620
|
+
wmHardLabel: 'Hard watermark',
|
|
621
|
+
wmHardHelp: 'Past this, the read budget tightens and wrapping up is advised.',
|
|
622
|
+
wmForceLabel: 'Force watermark',
|
|
623
|
+
wmForceHelp: 'Past this, back up and hand over to compaction (can be automatic).',
|
|
624
|
+
wmReadBudgetLabel: 'Read budget',
|
|
625
|
+
wmReadBudgetHelp: 'Token budget per read; each round caps at twice this.',
|
|
626
|
+
wmNumberInvalid: 'Enter a whole number of {min} or more.',
|
|
627
|
+
wmOrderInvalid: 'The three tiers must strictly ascend: soft < hard < force.',
|
|
628
|
+
wmDenyModeLabel: 'Hard-tier behavior',
|
|
629
|
+
wmDenyModeCap: 'Cap and warn',
|
|
630
|
+
wmDenyModeDeny: 'Deny outright',
|
|
631
|
+
wmDenyModeHelpCap: 'Over-budget calls pass, but a warning is recorded.',
|
|
632
|
+
wmDenyModeHelpDeny: 'Over-budget calls are denied outright.',
|
|
633
|
+
wmAutoHandoverLabel: 'Automatic handover',
|
|
634
|
+
wmAutoHandoverHelp: 'At the force watermark, back up and hand over to compaction automatically.',
|
|
635
|
+
wmSubagentTitle: 'Subagents',
|
|
636
|
+
wmSubagentCapLabel: 'Subagent context cap',
|
|
637
|
+
wmSubagentCapHelp:
|
|
638
|
+
'Lets subagents use the dedicated force threshold below; off = fully follow the three tiers above.',
|
|
639
|
+
wmSubagentForceLabel: 'Subagent force threshold',
|
|
640
|
+
wmSubagentForceHelp:
|
|
641
|
+
'Overrides only the subagent force tier (soft/hard are shared with the main session); 0 follows the force watermark above.',
|
|
642
|
+
wmSubagentFollowForce: 'Follow the force tier (0)',
|
|
643
|
+
wmCommands:
|
|
644
|
+
'Commands: /ctx-pause suspends watermark actions · /ctx-resume resumes them · /ctx-handover backs up and hands over now.',
|
|
645
|
+
},
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
// ------------------------------------------------------------------
|
|
649
|
+
// Session-header badge (Phase 0, unchanged)
|
|
650
|
+
// ------------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
function SwitchmanBadge({ locale, t }) {
|
|
653
|
+
// Subscription only: stable snapshot via getLocale() re-renders on switch.
|
|
654
|
+
React.useSyncExternalStore(
|
|
655
|
+
(fn) => locale.subscribe(fn),
|
|
656
|
+
() => locale.getLocale(),
|
|
657
|
+
);
|
|
658
|
+
const text = t('badge');
|
|
659
|
+
const tip = t('badgeTip');
|
|
660
|
+
return h(
|
|
661
|
+
'span',
|
|
662
|
+
{
|
|
663
|
+
title: tip,
|
|
664
|
+
'data-dsh-switchman': 'team-autonomy',
|
|
665
|
+
style: {
|
|
666
|
+
borderRadius: 'var(--dsw-radius-xs)',
|
|
667
|
+
// NOTE: --dsw-alias-fill-tsp-secondary (used by the shipped preset
|
|
668
|
+
// chip) is a dangling reference — no --dsw-alias-fill-* family exists
|
|
669
|
+
// in the theme alias tables, so it silently computes to transparent.
|
|
670
|
+
// Use the real hover-fill alias (defined for both light and dark).
|
|
671
|
+
background: 'var(--dsw-alias-interactive-bg-hover, transparent)',
|
|
672
|
+
height: '22px',
|
|
673
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
674
|
+
whiteSpace: 'nowrap',
|
|
675
|
+
display: 'inline-flex',
|
|
676
|
+
alignItems: 'center',
|
|
677
|
+
gap: '4px',
|
|
678
|
+
padding: '0 8px',
|
|
679
|
+
fontSize: '12px',
|
|
680
|
+
lineHeight: '22px',
|
|
681
|
+
flex: 'none',
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
h('span', { 'aria-hidden': true, style: { flex: 'none' } }, '⚡'),
|
|
685
|
+
h('span', null, text),
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ------------------------------------------------------------------
|
|
690
|
+
// Settings page (Phase 1)
|
|
691
|
+
// ------------------------------------------------------------------
|
|
692
|
+
|
|
693
|
+
/** Shared inline-style fragments (theme aliases only, no new deps). */
|
|
694
|
+
const STYLE = {
|
|
695
|
+
page: { minWidth: 0, minHeight: 0 },
|
|
696
|
+
section: { minWidth: 0, padding: '16px 0' },
|
|
697
|
+
heading: {
|
|
698
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
699
|
+
margin: 0,
|
|
700
|
+
fontSize: '13px',
|
|
701
|
+
fontWeight: 600,
|
|
702
|
+
lineHeight: 1.5,
|
|
703
|
+
},
|
|
704
|
+
description: {
|
|
705
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
706
|
+
margin: '4px 0 0',
|
|
707
|
+
fontSize: '12px',
|
|
708
|
+
lineHeight: 1.5,
|
|
709
|
+
},
|
|
710
|
+
rows: { display: 'grid', gap: '16px', margin: '16px 0 0' },
|
|
711
|
+
row: {
|
|
712
|
+
display: 'grid',
|
|
713
|
+
gridTemplateColumns: 'minmax(160px, 240px) minmax(0, 1fr)',
|
|
714
|
+
gap: '12px',
|
|
715
|
+
alignItems: 'start',
|
|
716
|
+
},
|
|
717
|
+
rowLabel: {
|
|
718
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
719
|
+
fontSize: '13px',
|
|
720
|
+
lineHeight: 1.5,
|
|
721
|
+
},
|
|
722
|
+
rowHelp: {
|
|
723
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
724
|
+
margin: '2px 0 0',
|
|
725
|
+
fontSize: '12px',
|
|
726
|
+
lineHeight: 1.5,
|
|
727
|
+
},
|
|
728
|
+
control: {
|
|
729
|
+
font: 'inherit',
|
|
730
|
+
fontSize: '13px',
|
|
731
|
+
lineHeight: 1.5,
|
|
732
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
733
|
+
background: 'transparent',
|
|
734
|
+
border: '.5px solid var(--dsw-alias-border-l2)',
|
|
735
|
+
borderRadius: 'var(--dsw-radius-xs)',
|
|
736
|
+
padding: '4px 8px',
|
|
737
|
+
minWidth: '0',
|
|
738
|
+
width: 'min(280px, 100%)',
|
|
739
|
+
},
|
|
740
|
+
currentValue: {
|
|
741
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
742
|
+
margin: '6px 0 0',
|
|
743
|
+
fontSize: '12px',
|
|
744
|
+
lineHeight: 1.5,
|
|
745
|
+
},
|
|
746
|
+
invalid: {
|
|
747
|
+
color: 'var(--dsw-alias-state-error-primary)',
|
|
748
|
+
margin: '6px 0 0',
|
|
749
|
+
fontSize: '12px',
|
|
750
|
+
lineHeight: 1.5,
|
|
751
|
+
},
|
|
752
|
+
footer: {
|
|
753
|
+
display: 'flex',
|
|
754
|
+
alignItems: 'center',
|
|
755
|
+
justifyContent: 'space-between',
|
|
756
|
+
gap: '12px',
|
|
757
|
+
flexWrap: 'wrap',
|
|
758
|
+
borderTop: '.5px solid var(--dsw-alias-border-l1)',
|
|
759
|
+
marginTop: '20px',
|
|
760
|
+
paddingTop: '12px',
|
|
761
|
+
},
|
|
762
|
+
message: {
|
|
763
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
764
|
+
margin: 0,
|
|
765
|
+
fontSize: '12px',
|
|
766
|
+
lineHeight: 1.5,
|
|
767
|
+
},
|
|
768
|
+
messageError: {
|
|
769
|
+
color: 'var(--dsw-alias-state-error-primary)',
|
|
770
|
+
margin: 0,
|
|
771
|
+
fontSize: '12px',
|
|
772
|
+
lineHeight: 1.5,
|
|
773
|
+
},
|
|
774
|
+
actions: { display: 'flex', gap: '8px', flex: 'none' },
|
|
775
|
+
buttonBase: {
|
|
776
|
+
font: 'inherit',
|
|
777
|
+
fontSize: '13px',
|
|
778
|
+
lineHeight: 1.5,
|
|
779
|
+
padding: '4px 14px',
|
|
780
|
+
borderRadius: 'var(--dsw-radius-xs)',
|
|
781
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
782
|
+
background: 'transparent',
|
|
783
|
+
cursor: 'pointer',
|
|
784
|
+
},
|
|
785
|
+
sectionDivider: {
|
|
786
|
+
borderTop: '.5px solid var(--dsw-alias-border-l1)',
|
|
787
|
+
},
|
|
788
|
+
progress: {
|
|
789
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
790
|
+
margin: '8px 0 0',
|
|
791
|
+
fontSize: '12px',
|
|
792
|
+
lineHeight: 1.5,
|
|
793
|
+
},
|
|
794
|
+
poolGrid: {
|
|
795
|
+
display: 'grid',
|
|
796
|
+
gridTemplateColumns: 'repeat(auto-fit, minmax(min(320px, 100%), 1fr))',
|
|
797
|
+
gap: '12px',
|
|
798
|
+
margin: '16px 0 0',
|
|
799
|
+
},
|
|
800
|
+
poolCard: {
|
|
801
|
+
border: '.5px solid var(--dsw-alias-border-l2)',
|
|
802
|
+
borderRadius: 'var(--dsw-radius-md)',
|
|
803
|
+
padding: '12px',
|
|
804
|
+
minWidth: 0,
|
|
805
|
+
display: 'grid',
|
|
806
|
+
gap: '8px',
|
|
807
|
+
alignContent: 'start',
|
|
808
|
+
},
|
|
809
|
+
poolCardTitle: {
|
|
810
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
811
|
+
fontSize: '13px',
|
|
812
|
+
fontWeight: 600,
|
|
813
|
+
lineHeight: 1.5,
|
|
814
|
+
},
|
|
815
|
+
poolCardDesc: {
|
|
816
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
817
|
+
margin: 0,
|
|
818
|
+
fontSize: '12px',
|
|
819
|
+
lineHeight: 1.5,
|
|
820
|
+
},
|
|
821
|
+
poolActions: {
|
|
822
|
+
display: 'flex',
|
|
823
|
+
gap: '12px',
|
|
824
|
+
flex: 'none',
|
|
825
|
+
},
|
|
826
|
+
linkButton: {
|
|
827
|
+
font: 'inherit',
|
|
828
|
+
fontSize: '12px',
|
|
829
|
+
lineHeight: 1.5,
|
|
830
|
+
padding: '0',
|
|
831
|
+
border: 'none',
|
|
832
|
+
background: 'transparent',
|
|
833
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
834
|
+
cursor: 'pointer',
|
|
835
|
+
},
|
|
836
|
+
poolList: {
|
|
837
|
+
maxHeight: '190px',
|
|
838
|
+
overflowY: 'auto',
|
|
839
|
+
display: 'grid',
|
|
840
|
+
gap: '2px',
|
|
841
|
+
borderTop: '.5px solid var(--dsw-alias-border-l1)',
|
|
842
|
+
paddingTop: '6px',
|
|
843
|
+
},
|
|
844
|
+
groupHeader: {
|
|
845
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
846
|
+
margin: '6px 0 2px',
|
|
847
|
+
fontSize: '11px',
|
|
848
|
+
fontWeight: 600,
|
|
849
|
+
lineHeight: 1.5,
|
|
850
|
+
},
|
|
851
|
+
checkRow: {
|
|
852
|
+
display: 'flex',
|
|
853
|
+
alignItems: 'center',
|
|
854
|
+
gap: '6px',
|
|
855
|
+
minWidth: 0,
|
|
856
|
+
fontSize: '12.5px',
|
|
857
|
+
lineHeight: 1.6,
|
|
858
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
859
|
+
cursor: 'pointer',
|
|
860
|
+
},
|
|
861
|
+
checkText: {
|
|
862
|
+
overflow: 'hidden',
|
|
863
|
+
textOverflow: 'ellipsis',
|
|
864
|
+
whiteSpace: 'nowrap',
|
|
865
|
+
},
|
|
866
|
+
unavailableMark: {
|
|
867
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
868
|
+
flex: 'none',
|
|
869
|
+
},
|
|
870
|
+
rankList: { display: 'grid', gap: '2px', marginTop: '8px' },
|
|
871
|
+
rankRow: {
|
|
872
|
+
display: 'grid',
|
|
873
|
+
gridTemplateColumns: '24px minmax(0, 1fr) auto auto',
|
|
874
|
+
gap: '8px',
|
|
875
|
+
alignItems: 'center',
|
|
876
|
+
padding: '2px 0',
|
|
877
|
+
},
|
|
878
|
+
rankIndex: {
|
|
879
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
880
|
+
fontSize: '12px',
|
|
881
|
+
fontVariantNumeric: 'tabular-nums',
|
|
882
|
+
textAlign: 'right',
|
|
883
|
+
},
|
|
884
|
+
rankLabel: {
|
|
885
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
886
|
+
fontSize: '12.5px',
|
|
887
|
+
lineHeight: 1.6,
|
|
888
|
+
overflow: 'hidden',
|
|
889
|
+
textOverflow: 'ellipsis',
|
|
890
|
+
whiteSpace: 'nowrap',
|
|
891
|
+
},
|
|
892
|
+
rankActions: { display: 'flex', gap: '4px', flex: 'none' },
|
|
893
|
+
effortSelect: {
|
|
894
|
+
font: 'inherit',
|
|
895
|
+
fontSize: '12px',
|
|
896
|
+
lineHeight: 1.5,
|
|
897
|
+
padding: '1px 2px',
|
|
898
|
+
borderRadius: 'var(--dsw-radius-xs)',
|
|
899
|
+
border: '.5px solid var(--dsw-alias-border-l2)',
|
|
900
|
+
background: 'transparent',
|
|
901
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
902
|
+
marginLeft: '8px',
|
|
903
|
+
flex: 'none',
|
|
904
|
+
},
|
|
905
|
+
iconButton: {
|
|
906
|
+
font: 'inherit',
|
|
907
|
+
fontSize: '12px',
|
|
908
|
+
lineHeight: 1.5,
|
|
909
|
+
padding: '1px 7px',
|
|
910
|
+
borderRadius: 'var(--dsw-radius-xs)',
|
|
911
|
+
border: '.5px solid var(--dsw-alias-border-l2)',
|
|
912
|
+
background: 'transparent',
|
|
913
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
914
|
+
cursor: 'pointer',
|
|
915
|
+
},
|
|
916
|
+
subheading: {
|
|
917
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
918
|
+
margin: '20px 0 0',
|
|
919
|
+
fontSize: '13px',
|
|
920
|
+
fontWeight: 600,
|
|
921
|
+
lineHeight: 1.5,
|
|
922
|
+
},
|
|
923
|
+
checkbox: {
|
|
924
|
+
width: '16px',
|
|
925
|
+
height: '16px',
|
|
926
|
+
margin: '5px 0',
|
|
927
|
+
accentColor: 'var(--dsw-alias-brand-primary)',
|
|
928
|
+
},
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
/** One settings row: label + help on the left, control + status lines
|
|
932
|
+
* (current value, suggestion, validation) on the right. */
|
|
933
|
+
function SettingsRow({ id, label, help, control, lines }) {
|
|
934
|
+
return h(
|
|
935
|
+
'div',
|
|
936
|
+
{ style: STYLE.row },
|
|
937
|
+
h(
|
|
938
|
+
'div',
|
|
939
|
+
null,
|
|
940
|
+
h('label', { htmlFor: id, style: STYLE.rowLabel }, label),
|
|
941
|
+
help === undefined ? null : h('p', { style: STYLE.rowHelp }, help),
|
|
942
|
+
),
|
|
943
|
+
h(
|
|
944
|
+
'div',
|
|
945
|
+
{ style: { minWidth: 0 } },
|
|
946
|
+
control,
|
|
947
|
+
...lines.map((line, index) =>
|
|
948
|
+
h(
|
|
949
|
+
'p',
|
|
950
|
+
{
|
|
951
|
+
key: index,
|
|
952
|
+
role: 'status',
|
|
953
|
+
style: line.invalid ? STYLE.invalid : STYLE.currentValue,
|
|
954
|
+
},
|
|
955
|
+
line.text,
|
|
956
|
+
),
|
|
957
|
+
),
|
|
958
|
+
),
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** One <option> per candidate, rendered "native (tag)". */
|
|
963
|
+
function languageOptions(t, { withUnset, withCustom }) {
|
|
964
|
+
const options = [];
|
|
965
|
+
if (withUnset)
|
|
966
|
+
options.push(
|
|
967
|
+
h(
|
|
968
|
+
'option',
|
|
969
|
+
{ key: '', value: '' },
|
|
970
|
+
withUnset === 'follow'
|
|
971
|
+
? t('followConversation')
|
|
972
|
+
: t('notSetAsk'),
|
|
973
|
+
),
|
|
974
|
+
);
|
|
975
|
+
for (const language of LANGUAGES)
|
|
976
|
+
options.push(
|
|
977
|
+
h(
|
|
978
|
+
'option',
|
|
979
|
+
{ key: language.value, value: language.value },
|
|
980
|
+
`${language.label} (${language.value})`,
|
|
981
|
+
),
|
|
982
|
+
);
|
|
983
|
+
if (withCustom)
|
|
984
|
+
options.push(
|
|
985
|
+
h('option', { key: CUSTOM, value: CUSTOM }, t('customLanguage')),
|
|
986
|
+
);
|
|
987
|
+
return options;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/** One dispatch-pool card: title + positioning, select-all/clear, and a
|
|
991
|
+
* provider-grouped checkbox list of catalog candidates plus this pool's
|
|
992
|
+
* saved-but-unavailable routes. With `manual` on, the body switches to
|
|
993
|
+
* a stored-order priority list (move up/down/remove) plus an
|
|
994
|
+
* add-only checkbox list. */
|
|
995
|
+
function PoolCard({
|
|
996
|
+
t,
|
|
997
|
+
title,
|
|
998
|
+
description,
|
|
999
|
+
rows,
|
|
1000
|
+
extras,
|
|
1001
|
+
routes,
|
|
1002
|
+
efforts,
|
|
1003
|
+
manual,
|
|
1004
|
+
editable,
|
|
1005
|
+
onToggle,
|
|
1006
|
+
onToggleManual,
|
|
1007
|
+
onSetEffort,
|
|
1008
|
+
onMove,
|
|
1009
|
+
onRemove,
|
|
1010
|
+
onSelectAll,
|
|
1011
|
+
onClear,
|
|
1012
|
+
}) {
|
|
1013
|
+
const hasCandidates = rows.length > 0 || extras.length > 0;
|
|
1014
|
+
/** 显示名查找:覆盖目录行与已存离架行。 */
|
|
1015
|
+
const nameByKey = new Map(
|
|
1016
|
+
[...rows, ...extras]
|
|
1017
|
+
.filter((row) => row.header === undefined)
|
|
1018
|
+
.map((row) => [
|
|
1019
|
+
routeKey(row),
|
|
1020
|
+
`${row.providerName} · ${row.modelName}`,
|
|
1021
|
+
]),
|
|
1022
|
+
);
|
|
1023
|
+
/** effort 查找:该池已配置条目的 key → effort。 */
|
|
1024
|
+
const effortByKey = new Map(
|
|
1025
|
+
efforts.map((entry) => [routeKey(entry), entry.effort]),
|
|
1026
|
+
);
|
|
1027
|
+
/** effort 元数据查找:routeKey → wire reasoning.efforts(无则缺省)。 */
|
|
1028
|
+
const effortsMetaByKey = new Map(
|
|
1029
|
+
[...rows, ...extras]
|
|
1030
|
+
.filter(
|
|
1031
|
+
(row) =>
|
|
1032
|
+
row.header === undefined && Array.isArray(row.reasoning?.efforts),
|
|
1033
|
+
)
|
|
1034
|
+
.map((row) => [routeKey(row), row.reasoning.efforts]),
|
|
1035
|
+
);
|
|
1036
|
+
/** 单个已选模型的 effort 下拉选项:优先该模型 wire 里的真实
|
|
1037
|
+
* efforts;拿不到时退回 EFFORTS 常量;已存旧值不在列表里时追加,
|
|
1038
|
+
* 避免受控 select 错位。 */
|
|
1039
|
+
const effortOptions = (route) => {
|
|
1040
|
+
const key = routeKey(route);
|
|
1041
|
+
const wireEfforts = effortsMetaByKey.get(key);
|
|
1042
|
+
const raw =
|
|
1043
|
+
wireEfforts !== undefined && wireEfforts.length > 0
|
|
1044
|
+
? wireEfforts
|
|
1045
|
+
: EFFORTS.map((effort) => ({ id: effort }));
|
|
1046
|
+
const seen = new Set(raw.map((entry) => entry.id));
|
|
1047
|
+
const current = effortByKey.get(key);
|
|
1048
|
+
const pending =
|
|
1049
|
+
typeof current === 'string' && current !== '' && !seen.has(current)
|
|
1050
|
+
? [{ id: current }]
|
|
1051
|
+
: [];
|
|
1052
|
+
return [
|
|
1053
|
+
{ id: '', name: t('poolEffortFollow') },
|
|
1054
|
+
...raw.map((entry) => ({
|
|
1055
|
+
id: entry.id,
|
|
1056
|
+
name: entry.name ?? entry.id,
|
|
1057
|
+
})),
|
|
1058
|
+
...pending,
|
|
1059
|
+
];
|
|
1060
|
+
};
|
|
1061
|
+
/** 单个已选模型的 effort 下拉('' = 跟随泳道默认);阻止冒泡避免
|
|
1062
|
+
* 触发外层 label 的 checkbox。 */
|
|
1063
|
+
const effortSelect = (route) =>
|
|
1064
|
+
h(
|
|
1065
|
+
'select',
|
|
1066
|
+
{
|
|
1067
|
+
style: STYLE.effortSelect,
|
|
1068
|
+
title: t('poolEffortHelp'),
|
|
1069
|
+
'aria-label': `${t('poolEffortLabel')} · ${route.provider}/${route.model}`,
|
|
1070
|
+
disabled: !editable,
|
|
1071
|
+
value: effortByKey.get(routeKey(route)) ?? '',
|
|
1072
|
+
onClick: (e) => e.stopPropagation(),
|
|
1073
|
+
onChange: (e) => {
|
|
1074
|
+
onSetEffort(route, e.target.value);
|
|
1075
|
+
},
|
|
1076
|
+
},
|
|
1077
|
+
...effortOptions(route).map((option) =>
|
|
1078
|
+
h('option', { value: option.id }, option.name),
|
|
1079
|
+
),
|
|
1080
|
+
);
|
|
1081
|
+
/** 手动态的「可添加」列表:仅未勾选的目录行,空分组头剔除。 */
|
|
1082
|
+
const addableRows = [];
|
|
1083
|
+
if (manual) {
|
|
1084
|
+
let pendingHeader = null;
|
|
1085
|
+
for (const row of rows) {
|
|
1086
|
+
if (row.header !== undefined) {
|
|
1087
|
+
pendingHeader = row;
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
if (row.selected) continue;
|
|
1091
|
+
if (pendingHeader !== null) {
|
|
1092
|
+
addableRows.push(pendingHeader);
|
|
1093
|
+
pendingHeader = null;
|
|
1094
|
+
}
|
|
1095
|
+
addableRows.push(row);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
/** 标题行右侧的手动序开关。 */
|
|
1099
|
+
const manualToggle = h(
|
|
1100
|
+
'label',
|
|
1101
|
+
{ style: STYLE.checkRow, title: t('poolManualHelp') },
|
|
1102
|
+
h('input', {
|
|
1103
|
+
type: 'checkbox',
|
|
1104
|
+
checked: manual,
|
|
1105
|
+
disabled: !editable,
|
|
1106
|
+
onChange: onToggleManual,
|
|
1107
|
+
}),
|
|
1108
|
+
h('span', { style: STYLE.checkText }, t('poolManualLabel')),
|
|
1109
|
+
);
|
|
1110
|
+
if (manual)
|
|
1111
|
+
return h(
|
|
1112
|
+
'div',
|
|
1113
|
+
{ style: STYLE.poolCard },
|
|
1114
|
+
h('div', { style: STYLE.poolCardTitle }, title, manualToggle),
|
|
1115
|
+
h('p', { style: STYLE.poolCardDesc }, description),
|
|
1116
|
+
routes.length > 0
|
|
1117
|
+
? h(
|
|
1118
|
+
'div',
|
|
1119
|
+
{ style: STYLE.poolList },
|
|
1120
|
+
h('p', { style: STYLE.groupHeader }, t('poolManualSelected')),
|
|
1121
|
+
h(
|
|
1122
|
+
'div',
|
|
1123
|
+
{ style: STYLE.rankList },
|
|
1124
|
+
...routes.map((route, index) =>
|
|
1125
|
+
h(
|
|
1126
|
+
'div',
|
|
1127
|
+
{ key: routeKey(route), style: STYLE.rankRow },
|
|
1128
|
+
h('span', { style: STYLE.rankIndex }, String(index + 1)),
|
|
1129
|
+
h(
|
|
1130
|
+
'span',
|
|
1131
|
+
{
|
|
1132
|
+
style: STYLE.rankLabel,
|
|
1133
|
+
title: `${route.provider}/${route.model}`,
|
|
1134
|
+
},
|
|
1135
|
+
nameByKey.get(routeKey(route)) ??
|
|
1136
|
+
`${route.provider} · ${route.model}`,
|
|
1137
|
+
),
|
|
1138
|
+
effortSelect(route),
|
|
1139
|
+
h(
|
|
1140
|
+
'div',
|
|
1141
|
+
{ style: STYLE.rankActions },
|
|
1142
|
+
h(
|
|
1143
|
+
'button',
|
|
1144
|
+
{
|
|
1145
|
+
type: 'button',
|
|
1146
|
+
onClick: () => {
|
|
1147
|
+
onMove(index, -1);
|
|
1148
|
+
},
|
|
1149
|
+
disabled: !editable || index === 0,
|
|
1150
|
+
title: t('poolMoveUp'),
|
|
1151
|
+
'aria-label': t('poolMoveUp'),
|
|
1152
|
+
style: STYLE.iconButton,
|
|
1153
|
+
},
|
|
1154
|
+
'↑',
|
|
1155
|
+
),
|
|
1156
|
+
h(
|
|
1157
|
+
'button',
|
|
1158
|
+
{
|
|
1159
|
+
type: 'button',
|
|
1160
|
+
onClick: () => {
|
|
1161
|
+
onMove(index, 1);
|
|
1162
|
+
},
|
|
1163
|
+
disabled: !editable || index === routes.length - 1,
|
|
1164
|
+
title: t('poolMoveDown'),
|
|
1165
|
+
'aria-label': t('poolMoveDown'),
|
|
1166
|
+
style: STYLE.iconButton,
|
|
1167
|
+
},
|
|
1168
|
+
'↓',
|
|
1169
|
+
),
|
|
1170
|
+
h(
|
|
1171
|
+
'button',
|
|
1172
|
+
{
|
|
1173
|
+
type: 'button',
|
|
1174
|
+
onClick: () => {
|
|
1175
|
+
onRemove(index);
|
|
1176
|
+
},
|
|
1177
|
+
disabled: !editable,
|
|
1178
|
+
title: t('poolRemoveOne'),
|
|
1179
|
+
'aria-label': t('poolRemoveOne'),
|
|
1180
|
+
style: STYLE.iconButton,
|
|
1181
|
+
},
|
|
1182
|
+
'✕',
|
|
1183
|
+
),
|
|
1184
|
+
),
|
|
1185
|
+
),
|
|
1186
|
+
),
|
|
1187
|
+
),
|
|
1188
|
+
)
|
|
1189
|
+
: h('p', { style: STYLE.poolCardDesc }, t('poolEmpty')),
|
|
1190
|
+
addableRows.length > 0
|
|
1191
|
+
? h(
|
|
1192
|
+
'div',
|
|
1193
|
+
{ style: STYLE.poolList },
|
|
1194
|
+
h('p', { style: STYLE.groupHeader }, t('poolManualAddable')),
|
|
1195
|
+
...addableRows.map((row, index) =>
|
|
1196
|
+
row.header !== undefined
|
|
1197
|
+
? h(
|
|
1198
|
+
'p',
|
|
1199
|
+
{ key: `h${index}`, style: STYLE.groupHeader },
|
|
1200
|
+
row.header,
|
|
1201
|
+
)
|
|
1202
|
+
: h(
|
|
1203
|
+
'label',
|
|
1204
|
+
{
|
|
1205
|
+
key: routeKey(row),
|
|
1206
|
+
style: STYLE.checkRow,
|
|
1207
|
+
title: `${row.provider}/${row.model}`,
|
|
1208
|
+
},
|
|
1209
|
+
h('input', {
|
|
1210
|
+
type: 'checkbox',
|
|
1211
|
+
checked: false,
|
|
1212
|
+
disabled: !editable,
|
|
1213
|
+
onChange: () => {
|
|
1214
|
+
onToggle(row);
|
|
1215
|
+
},
|
|
1216
|
+
}),
|
|
1217
|
+
h(
|
|
1218
|
+
'span',
|
|
1219
|
+
{ style: STYLE.checkText },
|
|
1220
|
+
`${row.providerName} · ${row.modelName}`,
|
|
1221
|
+
),
|
|
1222
|
+
),
|
|
1223
|
+
),
|
|
1224
|
+
)
|
|
1225
|
+
: null,
|
|
1226
|
+
h(
|
|
1227
|
+
'div',
|
|
1228
|
+
{ style: STYLE.poolActions },
|
|
1229
|
+
h(
|
|
1230
|
+
'button',
|
|
1231
|
+
{
|
|
1232
|
+
type: 'button',
|
|
1233
|
+
onClick: onClear,
|
|
1234
|
+
disabled: !editable,
|
|
1235
|
+
style: STYLE.linkButton,
|
|
1236
|
+
},
|
|
1237
|
+
t('poolClear'),
|
|
1238
|
+
),
|
|
1239
|
+
),
|
|
1240
|
+
);
|
|
1241
|
+
return h(
|
|
1242
|
+
'div',
|
|
1243
|
+
{ style: STYLE.poolCard },
|
|
1244
|
+
h('div', { style: STYLE.poolCardTitle }, title, manualToggle),
|
|
1245
|
+
h('p', { style: STYLE.poolCardDesc }, description),
|
|
1246
|
+
h(
|
|
1247
|
+
'div',
|
|
1248
|
+
{ style: STYLE.poolActions },
|
|
1249
|
+
h(
|
|
1250
|
+
'button',
|
|
1251
|
+
{
|
|
1252
|
+
type: 'button',
|
|
1253
|
+
onClick: onSelectAll,
|
|
1254
|
+
disabled: !editable,
|
|
1255
|
+
style: STYLE.linkButton,
|
|
1256
|
+
},
|
|
1257
|
+
t('poolSelectAll'),
|
|
1258
|
+
),
|
|
1259
|
+
h(
|
|
1260
|
+
'button',
|
|
1261
|
+
{
|
|
1262
|
+
type: 'button',
|
|
1263
|
+
onClick: onClear,
|
|
1264
|
+
disabled: !editable,
|
|
1265
|
+
style: STYLE.linkButton,
|
|
1266
|
+
},
|
|
1267
|
+
t('poolClear'),
|
|
1268
|
+
),
|
|
1269
|
+
),
|
|
1270
|
+
hasCandidates
|
|
1271
|
+
? h(
|
|
1272
|
+
'div',
|
|
1273
|
+
{ style: STYLE.poolList },
|
|
1274
|
+
...rows.map((row, index) =>
|
|
1275
|
+
row.header !== undefined
|
|
1276
|
+
? h(
|
|
1277
|
+
'p',
|
|
1278
|
+
{ key: `h${index}`, style: STYLE.groupHeader },
|
|
1279
|
+
row.header,
|
|
1280
|
+
)
|
|
1281
|
+
: h(
|
|
1282
|
+
'label',
|
|
1283
|
+
{
|
|
1284
|
+
key: routeKey(row),
|
|
1285
|
+
style: STYLE.checkRow,
|
|
1286
|
+
title: `${row.provider}/${row.model}`,
|
|
1287
|
+
},
|
|
1288
|
+
h('input', {
|
|
1289
|
+
type: 'checkbox',
|
|
1290
|
+
checked: row.selected,
|
|
1291
|
+
disabled: !editable,
|
|
1292
|
+
onChange: () => {
|
|
1293
|
+
onToggle(row);
|
|
1294
|
+
},
|
|
1295
|
+
}),
|
|
1296
|
+
h(
|
|
1297
|
+
'span',
|
|
1298
|
+
{ style: STYLE.checkText },
|
|
1299
|
+
`${row.providerName} · ${row.modelName}`,
|
|
1300
|
+
),
|
|
1301
|
+
...(row.selected ? [effortSelect(row)] : []),
|
|
1302
|
+
),
|
|
1303
|
+
),
|
|
1304
|
+
extras.length > 0
|
|
1305
|
+
? h(
|
|
1306
|
+
'p',
|
|
1307
|
+
{ key: 'extras', style: STYLE.groupHeader },
|
|
1308
|
+
t('catalogUnavailableGroup'),
|
|
1309
|
+
)
|
|
1310
|
+
: null,
|
|
1311
|
+
...extras.map((row) =>
|
|
1312
|
+
h(
|
|
1313
|
+
'label',
|
|
1314
|
+
{
|
|
1315
|
+
key: routeKey(row),
|
|
1316
|
+
style: STYLE.checkRow,
|
|
1317
|
+
title: `${row.provider}/${row.model}`,
|
|
1318
|
+
},
|
|
1319
|
+
h('input', {
|
|
1320
|
+
type: 'checkbox',
|
|
1321
|
+
checked: true,
|
|
1322
|
+
disabled: !editable,
|
|
1323
|
+
onChange: () => {
|
|
1324
|
+
onToggle(row);
|
|
1325
|
+
},
|
|
1326
|
+
}),
|
|
1327
|
+
h(
|
|
1328
|
+
'span',
|
|
1329
|
+
{ style: STYLE.checkText },
|
|
1330
|
+
`${row.providerName} · ${row.modelName}`,
|
|
1331
|
+
),
|
|
1332
|
+
effortSelect(row),
|
|
1333
|
+
h(
|
|
1334
|
+
'span',
|
|
1335
|
+
{ style: STYLE.unavailableMark },
|
|
1336
|
+
` · ${t('catalogUnavailable')}`,
|
|
1337
|
+
),
|
|
1338
|
+
),
|
|
1339
|
+
),
|
|
1340
|
+
)
|
|
1341
|
+
: h('p', { style: STYLE.poolCardDesc }, t('poolEmpty')),
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** The dsh-switchman settings page: language preferences, dispatch
|
|
1346
|
+
* pools, capability ranking, and enforcement mode — one staged draft
|
|
1347
|
+
* (revision-fenced) committed through one atomic mutate, following the
|
|
1348
|
+
* shipped settings pages' save/conflict model. Pool and ranking
|
|
1349
|
+
* candidates come from the live model catalog, refreshed whenever the
|
|
1350
|
+
* Host's adapter set changes. */
|
|
1351
|
+
function SwitchmanSettingsPage({ t, locale }) {
|
|
1352
|
+
// Re-render on UI-language switches (drives t() and the suggestion).
|
|
1353
|
+
React.useSyncExternalStore(
|
|
1354
|
+
(fn) => locale.subscribe(fn),
|
|
1355
|
+
() => locale.getLocale(),
|
|
1356
|
+
);
|
|
1357
|
+
// Hooks above the availability early-returns keep a stable order.
|
|
1358
|
+
const headingId = React.useId();
|
|
1359
|
+
|
|
1360
|
+
/** Live settings snapshot from the Host route: { status, writable,
|
|
1361
|
+
* values } — mirrors the configForms snapshot shape the page was
|
|
1362
|
+
* originally written against. */
|
|
1363
|
+
const [form, setForm] = React.useState({ status: 'loading', writable: false });
|
|
1364
|
+
/** draft: { values } | null — values hold all three fields. */
|
|
1365
|
+
const [draft, setDraft] = React.useState(null);
|
|
1366
|
+
/** Explicitly switched to the "Custom…" select entry. */
|
|
1367
|
+
const [customArmed, setCustomArmed] = React.useState(false);
|
|
1368
|
+
const [saving, setSaving] = React.useState(false);
|
|
1369
|
+
const [failed, setFailed] = React.useState(false);
|
|
1370
|
+
const [conflicted, setConflicted] = React.useState(false);
|
|
1371
|
+
|
|
1372
|
+
// Mirror the editable state for async save flows below (setState
|
|
1373
|
+
// alone is too late inside stale closures).
|
|
1374
|
+
const draftRef = React.useRef(null);
|
|
1375
|
+
const savingRef = React.useRef(false);
|
|
1376
|
+
const saveGeneration = React.useRef(0);
|
|
1377
|
+
/** Values this page loaded — the optimistic-concurrency baseline the
|
|
1378
|
+
* Host route fences writes against. */
|
|
1379
|
+
const loadedRef = React.useRef(null);
|
|
1380
|
+
|
|
1381
|
+
const assignDraft = (next) => {
|
|
1382
|
+
draftRef.current = next;
|
|
1383
|
+
setDraft(next);
|
|
1384
|
+
};
|
|
1385
|
+
|
|
1386
|
+
// Load the settings snapshot from the Host route on mount.
|
|
1387
|
+
React.useEffect(() => {
|
|
1388
|
+
let alive = true;
|
|
1389
|
+
void (async () => {
|
|
1390
|
+
const response = await api.get('config');
|
|
1391
|
+
if (!alive) return;
|
|
1392
|
+
if (response.ok && response.value?.values !== undefined) {
|
|
1393
|
+
loadedRef.current = response.value.values;
|
|
1394
|
+
setForm({ status: 'ready', writable: true, values: response.value.values });
|
|
1395
|
+
} else setForm({ status: 'error', writable: false });
|
|
1396
|
+
})();
|
|
1397
|
+
return () => {
|
|
1398
|
+
alive = false;
|
|
1399
|
+
};
|
|
1400
|
+
}, []);
|
|
1401
|
+
|
|
1402
|
+
// Suppress late save settlements after unmount.
|
|
1403
|
+
React.useEffect(() => () => {
|
|
1404
|
+
saveGeneration.current += 1;
|
|
1405
|
+
}, []);
|
|
1406
|
+
|
|
1407
|
+
// Model catalog (Phase 2): loaded once on mount from the Host
|
|
1408
|
+
// route (the restricted runtime has no remote service to subscribe
|
|
1409
|
+
// to adapter updates); generation-guarded so a stale answer cannot
|
|
1410
|
+
// overwrite a newer one.
|
|
1411
|
+
const [catalog, setCatalog] = React.useState({
|
|
1412
|
+
status: 'idle',
|
|
1413
|
+
groups: [],
|
|
1414
|
+
failures: 0,
|
|
1415
|
+
});
|
|
1416
|
+
const catalogGeneration = React.useRef(0);
|
|
1417
|
+
const loadCatalog = React.useCallback(async () => {
|
|
1418
|
+
const generation = ++catalogGeneration.current;
|
|
1419
|
+
setCatalog((previous) => ({ ...previous, status: 'loading' }));
|
|
1420
|
+
const response = await api.get('models');
|
|
1421
|
+
if (generation !== catalogGeneration.current) return;
|
|
1422
|
+
if (response.ok && Array.isArray(response.value?.groups))
|
|
1423
|
+
setCatalog({
|
|
1424
|
+
status: 'ready',
|
|
1425
|
+
groups: response.value.groups,
|
|
1426
|
+
failures: response.value.failures?.length ?? 0,
|
|
1427
|
+
});
|
|
1428
|
+
else setCatalog((previous) => ({ ...previous, status: 'error' }));
|
|
1429
|
+
}, []);
|
|
1430
|
+
React.useEffect(() => {
|
|
1431
|
+
loadCatalog();
|
|
1432
|
+
}, [loadCatalog]);
|
|
1433
|
+
const catalogIndex = indexCatalog(catalog.groups);
|
|
1434
|
+
|
|
1435
|
+
// Numeric watermark editing: one raw-text overlay per field so
|
|
1436
|
+
// partial input ('', '5', leading zeros) is editable without
|
|
1437
|
+
// corrupting the draft; parseable integers commit immediately,
|
|
1438
|
+
// blanks/invalid text stay local but block saving. Cleared on blur
|
|
1439
|
+
// (display falls back to the field's draft value) and whenever the
|
|
1440
|
+
// draft itself is cleared.
|
|
1441
|
+
const [numText, setNumText] = React.useState({});
|
|
1442
|
+
const clearNumText = () => {
|
|
1443
|
+
setNumText({});
|
|
1444
|
+
};
|
|
1445
|
+
|
|
1446
|
+
const current = readCurrent(form);
|
|
1447
|
+
const values = draft === null ? current : draft.values;
|
|
1448
|
+
const customActive =
|
|
1449
|
+
customArmed ||
|
|
1450
|
+
(values.langConversation !== '' &&
|
|
1451
|
+
!isCandidate(values.langConversation));
|
|
1452
|
+
/** Per-field numeric validity: raw text (when present) must be a
|
|
1453
|
+
* whole number at or above the schema minimum. */
|
|
1454
|
+
const numberInvalid = Object.fromEntries(
|
|
1455
|
+
WM_NUMBER_FIELDS.map(({ field, min }) => {
|
|
1456
|
+
const raw = numText[field];
|
|
1457
|
+
return [
|
|
1458
|
+
field,
|
|
1459
|
+
raw !== undefined &&
|
|
1460
|
+
(!/^\d+$/.test(raw.trim()) || Number(raw.trim()) < min),
|
|
1461
|
+
];
|
|
1462
|
+
}),
|
|
1463
|
+
);
|
|
1464
|
+
const anyNumberInvalid = Object.values(numberInvalid).some(Boolean);
|
|
1465
|
+
/** Ported hard rule (opencode config.ts): strict soft < hard < force. */
|
|
1466
|
+
const orderInvalid =
|
|
1467
|
+
!(values.wmSoftTokens < values.wmHardTokens) ||
|
|
1468
|
+
!(values.wmHardTokens < values.wmForceTokens);
|
|
1469
|
+
const invalid =
|
|
1470
|
+
(customActive && values.langConversation.trim() === '') ||
|
|
1471
|
+
anyNumberInvalid ||
|
|
1472
|
+
orderInvalid;
|
|
1473
|
+
const dirty = !sameValues(current, values);
|
|
1474
|
+
const available = form.status === 'ready';
|
|
1475
|
+
const editable = available && form.writable && !saving;
|
|
1476
|
+
|
|
1477
|
+
/** Begin or extend the draft with one field write. */
|
|
1478
|
+
function edit(field, value) {
|
|
1479
|
+
if (!editable) return;
|
|
1480
|
+
let next = draftRef.current;
|
|
1481
|
+
if (next === null)
|
|
1482
|
+
next = { values: current };
|
|
1483
|
+
assignDraft({
|
|
1484
|
+
...next,
|
|
1485
|
+
values: { ...next.values, [field]: value },
|
|
1486
|
+
});
|
|
1487
|
+
setFailed(false);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/** Number-input onChange: stage the raw text, commit only whole
|
|
1491
|
+
* numbers (the draft always holds parseable values). */
|
|
1492
|
+
function editNumber(field, raw) {
|
|
1493
|
+
setNumText((previous) => ({ ...previous, [field]: raw }));
|
|
1494
|
+
const trimmed = raw.trim();
|
|
1495
|
+
if (/^\d+$/.test(trimmed)) edit(field, Number(trimmed));
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/** Number-input onBlur: drop the overlay so the display falls back
|
|
1499
|
+
* to the (last committed or accepted) numeric value. */
|
|
1500
|
+
const blurNumber = (field) => () => {
|
|
1501
|
+
setNumText((previous) => {
|
|
1502
|
+
if (previous[field] === undefined) return previous;
|
|
1503
|
+
const next = { ...previous };
|
|
1504
|
+
delete next[field];
|
|
1505
|
+
return next;
|
|
1506
|
+
});
|
|
1507
|
+
};
|
|
1508
|
+
|
|
1509
|
+
/** Conversation select: a candidate/'' write disarms custom mode. */
|
|
1510
|
+
function selectConversation(value) {
|
|
1511
|
+
if (value === CUSTOM) {
|
|
1512
|
+
setCustomArmed(true);
|
|
1513
|
+
edit('langConversation', '');
|
|
1514
|
+
} else {
|
|
1515
|
+
setCustomArmed(false);
|
|
1516
|
+
edit('langConversation', value);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** Toggle one route in one dispatch pool (checkbox rows). */
|
|
1521
|
+
function togglePool(field, meta) {
|
|
1522
|
+
const pool = values[field];
|
|
1523
|
+
const key = routeKey(meta);
|
|
1524
|
+
const existed = pool.some((route) => routeKey(route) === key);
|
|
1525
|
+
edit(
|
|
1526
|
+
field,
|
|
1527
|
+
existed
|
|
1528
|
+
? pool.filter((route) => routeKey(route) !== key)
|
|
1529
|
+
: [...pool, { provider: meta.provider, model: meta.model }],
|
|
1530
|
+
);
|
|
1531
|
+
// 取消勾选时同步移除该路由的手动 effort 条目
|
|
1532
|
+
if (existed) {
|
|
1533
|
+
const { effortsField } = POOL_FIELDS.find(
|
|
1534
|
+
(spec) => spec.field === field,
|
|
1535
|
+
);
|
|
1536
|
+
edit(
|
|
1537
|
+
effortsField,
|
|
1538
|
+
values[effortsField].filter((entry) => routeKey(entry) !== key),
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/** Select every listed candidate (catalog + saved extras) in one
|
|
1544
|
+
* pool, or clear it. */
|
|
1545
|
+
function setAllPool(field, select) {
|
|
1546
|
+
if (!select) {
|
|
1547
|
+
edit(field, []);
|
|
1548
|
+
// 清空池时同步清空该池的手动 effort 条目
|
|
1549
|
+
const { effortsField } = POOL_FIELDS.find(
|
|
1550
|
+
(spec) => spec.field === field,
|
|
1551
|
+
);
|
|
1552
|
+
edit(effortsField, []);
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
const extras = values[field]
|
|
1556
|
+
.filter((route) => !catalogIndex.has(routeKey(route)))
|
|
1557
|
+
.map((route) => ({ provider: route.provider, model: route.model }));
|
|
1558
|
+
const clean = (meta) => ({ provider: meta.provider, model: meta.model });
|
|
1559
|
+
edit(field, [...[...catalogIndex.values()].map(clean), ...extras]);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/** 手动序:把 index 处的路由与相邻项交换(边界守卫)。 */
|
|
1563
|
+
function movePoolRoute(field, index, delta) {
|
|
1564
|
+
const pool = values[field];
|
|
1565
|
+
const target = index + delta;
|
|
1566
|
+
if (target < 0 || target >= pool.length) return;
|
|
1567
|
+
const next = [...pool];
|
|
1568
|
+
[next[index], next[target]] = [next[target], next[index]];
|
|
1569
|
+
edit(field, next.map((route) => ({
|
|
1570
|
+
provider: route.provider,
|
|
1571
|
+
model: route.model,
|
|
1572
|
+
})));
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/** 手动序:移除 index 处的路由。 */
|
|
1576
|
+
function removePoolRoute(field, index) {
|
|
1577
|
+
const next = [...values[field]];
|
|
1578
|
+
const [removed] = next.splice(index, 1);
|
|
1579
|
+
edit(field, next.map((route) => ({
|
|
1580
|
+
provider: route.provider,
|
|
1581
|
+
model: route.model,
|
|
1582
|
+
})));
|
|
1583
|
+
// 同步移除该路由的手动 effort 条目
|
|
1584
|
+
const { effortsField } = POOL_FIELDS.find(
|
|
1585
|
+
(spec) => spec.field === field,
|
|
1586
|
+
);
|
|
1587
|
+
edit(
|
|
1588
|
+
effortsField,
|
|
1589
|
+
values[effortsField].filter(
|
|
1590
|
+
(entry) => routeKey(entry) !== routeKey(removed),
|
|
1591
|
+
),
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/** 设置/清除某池某路由的手动 effort 条目(effort '' = 移除条目)。 */
|
|
1596
|
+
function setPoolEffort(effortsField, meta, effort) {
|
|
1597
|
+
const key = routeKey(meta);
|
|
1598
|
+
const rest = values[effortsField].filter(
|
|
1599
|
+
(entry) => routeKey(entry) !== key,
|
|
1600
|
+
);
|
|
1601
|
+
edit(
|
|
1602
|
+
effortsField,
|
|
1603
|
+
effort === ''
|
|
1604
|
+
? rest
|
|
1605
|
+
: [
|
|
1606
|
+
...rest,
|
|
1607
|
+
{
|
|
1608
|
+
provider: meta.provider,
|
|
1609
|
+
model: meta.model,
|
|
1610
|
+
effort,
|
|
1611
|
+
},
|
|
1612
|
+
],
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/** Apply one edit to the rank display list and persist the whole
|
|
1617
|
+
* displayed order as modelRank (a WYSIWYG edit materializes the
|
|
1618
|
+
* unranked tail's catalog order too — same ordering either way). */
|
|
1619
|
+
function editRank(mutate) {
|
|
1620
|
+
const display = rankDisplay(values, catalogIndex);
|
|
1621
|
+
mutate(display);
|
|
1622
|
+
edit('modelRank', rankValue(display));
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
/** Move the entry at `index` by `delta` rows (bounds-guarded). */
|
|
1626
|
+
function moveRank(index, delta) {
|
|
1627
|
+
editRank((display) => {
|
|
1628
|
+
const target = index + delta;
|
|
1629
|
+
if (target < 0 || target >= display.length) return;
|
|
1630
|
+
[display[index], display[target]] = [display[target], display[index]];
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/** Anchor (or clear, '') the tier of the entry at `index`. */
|
|
1635
|
+
function setRankTier(index, tier) {
|
|
1636
|
+
editRank((display) => {
|
|
1637
|
+
display[index] =
|
|
1638
|
+
tier === ''
|
|
1639
|
+
? { provider: display[index].provider, model: display[index].model }
|
|
1640
|
+
: { ...display[index], tier };
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/** Drop the entry at `index` from the ranking (a pool-selected
|
|
1645
|
+
* model returns to the unranked tail). */
|
|
1646
|
+
function removeRank(index) {
|
|
1647
|
+
editRank((display) => {
|
|
1648
|
+
display.splice(index, 1);
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
async function save() {
|
|
1653
|
+
if (form.status !== 'ready' || !form.writable || savingRef.current)
|
|
1654
|
+
return;
|
|
1655
|
+
const desired = {
|
|
1656
|
+
langConversation: values.langConversation.trim(),
|
|
1657
|
+
langComments: values.langComments,
|
|
1658
|
+
langDocs: values.langDocs,
|
|
1659
|
+
...Object.fromEntries(
|
|
1660
|
+
POOL_FIELDS.map(({ field }) => [
|
|
1661
|
+
field,
|
|
1662
|
+
values[field].map((route) => ({
|
|
1663
|
+
provider: route.provider,
|
|
1664
|
+
model: route.model,
|
|
1665
|
+
})),
|
|
1666
|
+
]),
|
|
1667
|
+
),
|
|
1668
|
+
...Object.fromEntries(
|
|
1669
|
+
POOL_FIELDS.map(({ manualField }) => [
|
|
1670
|
+
manualField,
|
|
1671
|
+
values[manualField],
|
|
1672
|
+
]),
|
|
1673
|
+
),
|
|
1674
|
+
...Object.fromEntries(
|
|
1675
|
+
POOL_FIELDS.map(({ effortsField }) => [
|
|
1676
|
+
effortsField,
|
|
1677
|
+
values[effortsField].map((entry) => ({
|
|
1678
|
+
provider: entry.provider,
|
|
1679
|
+
model: entry.model,
|
|
1680
|
+
effort: entry.effort,
|
|
1681
|
+
})),
|
|
1682
|
+
]),
|
|
1683
|
+
),
|
|
1684
|
+
modelRank: rankValue(values.modelRank),
|
|
1685
|
+
dispatchEnforce: values.dispatchEnforce,
|
|
1686
|
+
...Object.fromEntries(
|
|
1687
|
+
WM_NUMBER_FIELDS.map(({ field }) => [field, values[field]]),
|
|
1688
|
+
),
|
|
1689
|
+
wmDenyMode: values.wmDenyMode,
|
|
1690
|
+
wmAutoHandover: values.wmAutoHandover,
|
|
1691
|
+
wmSubagentCap: values.wmSubagentCap,
|
|
1692
|
+
};
|
|
1693
|
+
if (sameValues(current, desired)) {
|
|
1694
|
+
// Whitespace-only differences: nothing to write, close the draft.
|
|
1695
|
+
if (draftRef.current !== null) {
|
|
1696
|
+
assignDraft(null);
|
|
1697
|
+
setCustomArmed(false);
|
|
1698
|
+
clearNumText();
|
|
1699
|
+
}
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
const generation = ++saveGeneration.current;
|
|
1703
|
+
savingRef.current = true;
|
|
1704
|
+
setSaving(true);
|
|
1705
|
+
setFailed(false);
|
|
1706
|
+
setConflicted(false);
|
|
1707
|
+
let response;
|
|
1708
|
+
try {
|
|
1709
|
+
response = await api.post('config', {
|
|
1710
|
+
expected: loadedRef.current,
|
|
1711
|
+
values: desired,
|
|
1712
|
+
});
|
|
1713
|
+
} catch {
|
|
1714
|
+
response = { ok: false, status: 0 };
|
|
1715
|
+
}
|
|
1716
|
+
if (generation !== saveGeneration.current) return;
|
|
1717
|
+
savingRef.current = false;
|
|
1718
|
+
setSaving(false);
|
|
1719
|
+
if (response.ok) {
|
|
1720
|
+
loadedRef.current = response.value.values;
|
|
1721
|
+
setForm({
|
|
1722
|
+
status: 'ready',
|
|
1723
|
+
writable: true,
|
|
1724
|
+
values: response.value.values,
|
|
1725
|
+
});
|
|
1726
|
+
assignDraft(null);
|
|
1727
|
+
setCustomArmed(false);
|
|
1728
|
+
clearNumText();
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
if (response.status === 409) {
|
|
1732
|
+
// Settings changed outside this page (e.g. the language
|
|
1733
|
+
// capture): adopt the fresh values, keep the draft, flag.
|
|
1734
|
+
setConflicted(true);
|
|
1735
|
+
if (response.value?.values !== undefined) {
|
|
1736
|
+
loadedRef.current = response.value.values;
|
|
1737
|
+
setForm({
|
|
1738
|
+
status: 'ready',
|
|
1739
|
+
writable: true,
|
|
1740
|
+
values: response.value.values,
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
setFailed(true);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
function discard() {
|
|
1749
|
+
if (savingRef.current) return;
|
|
1750
|
+
assignDraft(null);
|
|
1751
|
+
setCustomArmed(false);
|
|
1752
|
+
clearNumText();
|
|
1753
|
+
setFailed(false);
|
|
1754
|
+
setConflicted(false);
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
if (form.status === 'loading')
|
|
1758
|
+
return h(
|
|
1759
|
+
'p',
|
|
1760
|
+
{ style: STYLE.message, role: 'status' },
|
|
1761
|
+
t('formLoading'),
|
|
1762
|
+
);
|
|
1763
|
+
if (!available)
|
|
1764
|
+
return h(
|
|
1765
|
+
'p',
|
|
1766
|
+
{ style: STYLE.message, role: 'status' },
|
|
1767
|
+
t('formUnavailable'),
|
|
1768
|
+
);
|
|
1769
|
+
|
|
1770
|
+
const conversationId = `${headingId}-conversation`;
|
|
1771
|
+
const commentsId = `${headingId}-comments`;
|
|
1772
|
+
const docsId = `${headingId}-docs`;
|
|
1773
|
+
const suggestion = suggestFromUiLocale(locale.getLocale().active);
|
|
1774
|
+
|
|
1775
|
+
/** Status lines under one row's control. */
|
|
1776
|
+
const linesFor = (field) => {
|
|
1777
|
+
const lines = [];
|
|
1778
|
+
if (field === 'langConversation' && invalid)
|
|
1779
|
+
lines.push({ text: t('customLanguageRequired'), invalid: true });
|
|
1780
|
+
lines.push(
|
|
1781
|
+
current[field] === ''
|
|
1782
|
+
? {
|
|
1783
|
+
text:
|
|
1784
|
+
field === 'langConversation'
|
|
1785
|
+
? t('currentValueUnset')
|
|
1786
|
+
: t('currentValue', {
|
|
1787
|
+
value: t('followConversation'),
|
|
1788
|
+
}),
|
|
1789
|
+
invalid: false,
|
|
1790
|
+
}
|
|
1791
|
+
: {
|
|
1792
|
+
text: t('currentValue', {
|
|
1793
|
+
value: formatLanguage(current[field]),
|
|
1794
|
+
}),
|
|
1795
|
+
invalid: false,
|
|
1796
|
+
},
|
|
1797
|
+
);
|
|
1798
|
+
if (field === 'langConversation' && current.langConversation === '')
|
|
1799
|
+
lines.push({
|
|
1800
|
+
text: t('suggestedValue', {
|
|
1801
|
+
value: formatLanguage(suggestion),
|
|
1802
|
+
}),
|
|
1803
|
+
invalid: false,
|
|
1804
|
+
});
|
|
1805
|
+
return lines;
|
|
1806
|
+
};
|
|
1807
|
+
|
|
1808
|
+
/** Shared select props (disabled while saving / read-only). */
|
|
1809
|
+
const selectProps = (id, value, onChange) => ({
|
|
1810
|
+
id,
|
|
1811
|
+
value,
|
|
1812
|
+
onChange: (event) => onChange(event.target.value),
|
|
1813
|
+
disabled: !editable,
|
|
1814
|
+
style: STYLE.control,
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
const conversationControl = h(
|
|
1818
|
+
'div',
|
|
1819
|
+
{ style: { display: 'grid', gap: '6px', minWidth: 0 } },
|
|
1820
|
+
h(
|
|
1821
|
+
'select',
|
|
1822
|
+
selectProps(
|
|
1823
|
+
conversationId,
|
|
1824
|
+
customActive
|
|
1825
|
+
? CUSTOM
|
|
1826
|
+
: values.langConversation,
|
|
1827
|
+
selectConversation,
|
|
1828
|
+
),
|
|
1829
|
+
...languageOptions(t, { withUnset: 'ask', withCustom: true }),
|
|
1830
|
+
),
|
|
1831
|
+
customActive
|
|
1832
|
+
? h('input', {
|
|
1833
|
+
type: 'text',
|
|
1834
|
+
value: values.langConversation,
|
|
1835
|
+
onChange: (event) =>
|
|
1836
|
+
edit('langConversation', event.target.value),
|
|
1837
|
+
placeholder: t('customLanguagePlaceholder'),
|
|
1838
|
+
maxLength: LANG_MAX,
|
|
1839
|
+
disabled: !editable,
|
|
1840
|
+
'aria-label': t('customLanguage'),
|
|
1841
|
+
style: STYLE.control,
|
|
1842
|
+
})
|
|
1843
|
+
: null,
|
|
1844
|
+
);
|
|
1845
|
+
|
|
1846
|
+
const saveDisabled =
|
|
1847
|
+
!form.writable || saving || conflicted || invalid || !dirty;
|
|
1848
|
+
|
|
1849
|
+
// ---- dispatch section derived state ----
|
|
1850
|
+
const dispatchId = `${headingId}-dispatch`;
|
|
1851
|
+
const rankId = `${headingId}-rank`;
|
|
1852
|
+
const enforceId = `${headingId}-enforce`;
|
|
1853
|
+
const poolsSet = POOL_FIELDS.filter(
|
|
1854
|
+
({ field }) => values[field].length > 0,
|
|
1855
|
+
).length;
|
|
1856
|
+
const enforceLabelKey = {
|
|
1857
|
+
off: 'enforceOff',
|
|
1858
|
+
advice: 'enforceAdvice',
|
|
1859
|
+
enforce: 'enforceEnforce',
|
|
1860
|
+
};
|
|
1861
|
+
const enforceHelpKey = {
|
|
1862
|
+
off: 'enforceHelpOff',
|
|
1863
|
+
advice: 'enforceHelpAdvice',
|
|
1864
|
+
enforce: 'enforceHelpEnforce',
|
|
1865
|
+
};
|
|
1866
|
+
const display = rankDisplay(values, catalogIndex);
|
|
1867
|
+
|
|
1868
|
+
// ---- watermark section derived state ----
|
|
1869
|
+
const wmId = `${headingId}-wm`;
|
|
1870
|
+
|
|
1871
|
+
/** One watermark number input: raw-text overlay over the draft
|
|
1872
|
+
* value (0 renders as the placeholder for the follow-force row),
|
|
1873
|
+
* red border while the raw text fails its minimum. */
|
|
1874
|
+
const wmNumberControl = (spec, placeholder) => {
|
|
1875
|
+
const { field } = spec;
|
|
1876
|
+
const shown =
|
|
1877
|
+
numText[field] ??
|
|
1878
|
+
(values[field] === 0 && placeholder !== undefined
|
|
1879
|
+
? ''
|
|
1880
|
+
: String(values[field]));
|
|
1881
|
+
return h('input', {
|
|
1882
|
+
type: 'text',
|
|
1883
|
+
inputMode: 'numeric',
|
|
1884
|
+
id: `${wmId}-${field}`,
|
|
1885
|
+
value: shown,
|
|
1886
|
+
placeholder,
|
|
1887
|
+
onChange: (event) => {
|
|
1888
|
+
editNumber(field, event.target.value);
|
|
1889
|
+
},
|
|
1890
|
+
onBlur: blurNumber(field),
|
|
1891
|
+
disabled: !editable,
|
|
1892
|
+
'aria-label': t(WM_ROW_KEYS[field].label),
|
|
1893
|
+
style: numberInvalid[field]
|
|
1894
|
+
? {
|
|
1895
|
+
...STYLE.control,
|
|
1896
|
+
borderColor: 'var(--dsw-alias-state-error-primary)',
|
|
1897
|
+
color: 'var(--dsw-alias-state-error-primary)',
|
|
1898
|
+
}
|
|
1899
|
+
: STYLE.control,
|
|
1900
|
+
});
|
|
1901
|
+
};
|
|
1902
|
+
|
|
1903
|
+
/** Status lines under one number row: minimum + order hints. */
|
|
1904
|
+
const wmNumberLines = (spec) => {
|
|
1905
|
+
const lines = [];
|
|
1906
|
+
if (numberInvalid[spec.field])
|
|
1907
|
+
lines.push({
|
|
1908
|
+
text: t('wmNumberInvalid', { min: spec.min }),
|
|
1909
|
+
invalid: true,
|
|
1910
|
+
});
|
|
1911
|
+
if (orderInvalid && WM_ORDER_FIELDS.includes(spec.field))
|
|
1912
|
+
lines.push({ text: t('wmOrderInvalid'), invalid: true });
|
|
1913
|
+
return lines;
|
|
1914
|
+
};
|
|
1915
|
+
|
|
1916
|
+
/** Catalog status line: loading / error + retry / partial / empty. */
|
|
1917
|
+
const catalogNotice =
|
|
1918
|
+
catalog.status === 'loading'
|
|
1919
|
+
? h(
|
|
1920
|
+
'p',
|
|
1921
|
+
{ key: 'loading', role: 'status', style: STYLE.progress },
|
|
1922
|
+
t('catalogLoading'),
|
|
1923
|
+
)
|
|
1924
|
+
: catalog.status === 'error'
|
|
1925
|
+
? h(
|
|
1926
|
+
'div',
|
|
1927
|
+
{
|
|
1928
|
+
key: 'error',
|
|
1929
|
+
role: 'alert',
|
|
1930
|
+
style: { display: 'flex', gap: '12px', marginTop: '8px' },
|
|
1931
|
+
},
|
|
1932
|
+
h('span', { style: STYLE.messageError }, t('catalogLoadFailed')),
|
|
1933
|
+
h(
|
|
1934
|
+
'button',
|
|
1935
|
+
{
|
|
1936
|
+
type: 'button',
|
|
1937
|
+
onClick: loadCatalog,
|
|
1938
|
+
disabled: saving,
|
|
1939
|
+
style: STYLE.linkButton,
|
|
1940
|
+
},
|
|
1941
|
+
t('catalogRetry'),
|
|
1942
|
+
),
|
|
1943
|
+
)
|
|
1944
|
+
: catalog.status === 'ready' && catalog.failures > 0
|
|
1945
|
+
? h(
|
|
1946
|
+
'p',
|
|
1947
|
+
{ key: 'partial', style: STYLE.progress },
|
|
1948
|
+
t('catalogPartial'),
|
|
1949
|
+
)
|
|
1950
|
+
: catalog.status === 'ready' && catalogIndex.size === 0
|
|
1951
|
+
? h(
|
|
1952
|
+
'p',
|
|
1953
|
+
{ key: 'empty', style: STYLE.progress },
|
|
1954
|
+
t('catalogEmpty'),
|
|
1955
|
+
)
|
|
1956
|
+
: null;
|
|
1957
|
+
|
|
1958
|
+
/** One ranking row: index, label, tier select, move/remove buttons. */
|
|
1959
|
+
const rankRow = (entry, index) => {
|
|
1960
|
+
const key = routeKey(entry);
|
|
1961
|
+
const meta = catalogIndex.get(key);
|
|
1962
|
+
const up = t('rankMoveUp');
|
|
1963
|
+
const down = t('rankMoveDown');
|
|
1964
|
+
const remove = t('rankRemove');
|
|
1965
|
+
return h(
|
|
1966
|
+
'div',
|
|
1967
|
+
{ key, style: STYLE.rankRow },
|
|
1968
|
+
h('span', { style: STYLE.rankIndex }, String(index + 1)),
|
|
1969
|
+
h(
|
|
1970
|
+
'span',
|
|
1971
|
+
{
|
|
1972
|
+
style: STYLE.rankLabel,
|
|
1973
|
+
title: `${entry.provider}/${entry.model}`,
|
|
1974
|
+
},
|
|
1975
|
+
`${meta?.providerName ?? entry.provider} · ${meta?.modelName ?? entry.model}`,
|
|
1976
|
+
meta === undefined
|
|
1977
|
+
? h(
|
|
1978
|
+
'span',
|
|
1979
|
+
{ style: STYLE.unavailableMark },
|
|
1980
|
+
` · ${t('catalogUnavailable')}`,
|
|
1981
|
+
)
|
|
1982
|
+
: null,
|
|
1983
|
+
),
|
|
1984
|
+
h(
|
|
1985
|
+
'select',
|
|
1986
|
+
{
|
|
1987
|
+
value: entry.tier ?? '',
|
|
1988
|
+
onChange: (event) => {
|
|
1989
|
+
setRankTier(index, event.target.value);
|
|
1990
|
+
},
|
|
1991
|
+
disabled: !editable,
|
|
1992
|
+
'aria-label': t('rankTier'),
|
|
1993
|
+
style: { ...STYLE.control, width: 'auto', justifySelf: 'start' },
|
|
1994
|
+
},
|
|
1995
|
+
['', ...TIERS].map((tier) =>
|
|
1996
|
+
h(
|
|
1997
|
+
'option',
|
|
1998
|
+
{ key: tier, value: tier },
|
|
1999
|
+
tier === '' ? t('rankTierNone') : tier,
|
|
2000
|
+
),
|
|
2001
|
+
),
|
|
2002
|
+
),
|
|
2003
|
+
h(
|
|
2004
|
+
'div',
|
|
2005
|
+
{ style: STYLE.rankActions },
|
|
2006
|
+
h(
|
|
2007
|
+
'button',
|
|
2008
|
+
{
|
|
2009
|
+
type: 'button',
|
|
2010
|
+
onClick: () => {
|
|
2011
|
+
moveRank(index, -1);
|
|
2012
|
+
},
|
|
2013
|
+
disabled: !editable || index === 0,
|
|
2014
|
+
title: up,
|
|
2015
|
+
'aria-label': up,
|
|
2016
|
+
style: STYLE.iconButton,
|
|
2017
|
+
},
|
|
2018
|
+
'↑',
|
|
2019
|
+
),
|
|
2020
|
+
h(
|
|
2021
|
+
'button',
|
|
2022
|
+
{
|
|
2023
|
+
type: 'button',
|
|
2024
|
+
onClick: () => {
|
|
2025
|
+
moveRank(index, 1);
|
|
2026
|
+
},
|
|
2027
|
+
disabled: !editable || index === display.length - 1,
|
|
2028
|
+
title: down,
|
|
2029
|
+
'aria-label': down,
|
|
2030
|
+
style: STYLE.iconButton,
|
|
2031
|
+
},
|
|
2032
|
+
'↓',
|
|
2033
|
+
),
|
|
2034
|
+
h(
|
|
2035
|
+
'button',
|
|
2036
|
+
{
|
|
2037
|
+
type: 'button',
|
|
2038
|
+
onClick: () => {
|
|
2039
|
+
removeRank(index);
|
|
2040
|
+
},
|
|
2041
|
+
disabled: !editable,
|
|
2042
|
+
title: remove,
|
|
2043
|
+
'aria-label': remove,
|
|
2044
|
+
style: STYLE.iconButton,
|
|
2045
|
+
},
|
|
2046
|
+
'✕',
|
|
2047
|
+
),
|
|
2048
|
+
),
|
|
2049
|
+
);
|
|
2050
|
+
};
|
|
2051
|
+
|
|
2052
|
+
return h(
|
|
2053
|
+
'div',
|
|
2054
|
+
{ 'data-dsh-switchman': 'settings', style: STYLE.page },
|
|
2055
|
+
h('p', { style: STYLE.description }, t('settingsDescription')),
|
|
2056
|
+
h(
|
|
2057
|
+
'section',
|
|
2058
|
+
{ 'aria-labelledby': headingId, style: STYLE.section },
|
|
2059
|
+
h(
|
|
2060
|
+
'h3',
|
|
2061
|
+
{ id: headingId, style: STYLE.heading },
|
|
2062
|
+
t('langSectionTitle'),
|
|
2063
|
+
),
|
|
2064
|
+
h(
|
|
2065
|
+
'p',
|
|
2066
|
+
{ style: STYLE.description },
|
|
2067
|
+
t('langSectionDescription'),
|
|
2068
|
+
),
|
|
2069
|
+
h(
|
|
2070
|
+
'div',
|
|
2071
|
+
{ style: STYLE.rows },
|
|
2072
|
+
h(
|
|
2073
|
+
SettingsRow,
|
|
2074
|
+
{
|
|
2075
|
+
key: 'conversation',
|
|
2076
|
+
id: conversationId,
|
|
2077
|
+
label: t('langConversationLabel'),
|
|
2078
|
+
help: t('langConversationHelp'),
|
|
2079
|
+
control: conversationControl,
|
|
2080
|
+
lines: linesFor('langConversation'),
|
|
2081
|
+
},
|
|
2082
|
+
),
|
|
2083
|
+
h(
|
|
2084
|
+
SettingsRow,
|
|
2085
|
+
{
|
|
2086
|
+
key: 'comments',
|
|
2087
|
+
id: commentsId,
|
|
2088
|
+
label: t('langCommentsLabel'),
|
|
2089
|
+
help: t('langCommentsHelp'),
|
|
2090
|
+
control: h(
|
|
2091
|
+
'select',
|
|
2092
|
+
selectProps(
|
|
2093
|
+
commentsId,
|
|
2094
|
+
values.langComments,
|
|
2095
|
+
(value) => edit('langComments', value),
|
|
2096
|
+
),
|
|
2097
|
+
...languageOptions(t, { withUnset: 'follow' }),
|
|
2098
|
+
),
|
|
2099
|
+
lines: linesFor('langComments'),
|
|
2100
|
+
},
|
|
2101
|
+
),
|
|
2102
|
+
h(
|
|
2103
|
+
SettingsRow,
|
|
2104
|
+
{
|
|
2105
|
+
key: 'docs',
|
|
2106
|
+
id: docsId,
|
|
2107
|
+
label: t('langDocsLabel'),
|
|
2108
|
+
help: t('langDocsHelp'),
|
|
2109
|
+
control: h(
|
|
2110
|
+
'select',
|
|
2111
|
+
selectProps(
|
|
2112
|
+
docsId,
|
|
2113
|
+
values.langDocs,
|
|
2114
|
+
(value) => edit('langDocs', value),
|
|
2115
|
+
),
|
|
2116
|
+
...languageOptions(t, { withUnset: 'follow' }),
|
|
2117
|
+
),
|
|
2118
|
+
lines: linesFor('langDocs'),
|
|
2119
|
+
},
|
|
2120
|
+
),
|
|
2121
|
+
),
|
|
2122
|
+
),
|
|
2123
|
+
h(
|
|
2124
|
+
'section',
|
|
2125
|
+
{
|
|
2126
|
+
'aria-labelledby': dispatchId,
|
|
2127
|
+
style: { ...STYLE.section, ...STYLE.sectionDivider },
|
|
2128
|
+
},
|
|
2129
|
+
h(
|
|
2130
|
+
'h3',
|
|
2131
|
+
{ id: dispatchId, style: STYLE.heading },
|
|
2132
|
+
t('dispatchSectionTitle'),
|
|
2133
|
+
),
|
|
2134
|
+
h(
|
|
2135
|
+
'p',
|
|
2136
|
+
{ style: STYLE.description },
|
|
2137
|
+
t('dispatchSectionDescription'),
|
|
2138
|
+
),
|
|
2139
|
+
h(
|
|
2140
|
+
'p',
|
|
2141
|
+
{ style: STYLE.progress },
|
|
2142
|
+
t('dispatchProgress', {
|
|
2143
|
+
pools: poolsSet,
|
|
2144
|
+
ranked: display.length,
|
|
2145
|
+
mode: t(enforceLabelKey[values.dispatchEnforce]),
|
|
2146
|
+
}),
|
|
2147
|
+
),
|
|
2148
|
+
catalogNotice,
|
|
2149
|
+
h(
|
|
2150
|
+
'div',
|
|
2151
|
+
{ style: STYLE.poolGrid },
|
|
2152
|
+
...POOL_FIELDS.map(({ field, manualField, effortsField, lane }) => {
|
|
2153
|
+
const { rows, extras } = poolRows(values[field], catalogIndex);
|
|
2154
|
+
return h(PoolCard, {
|
|
2155
|
+
key: field,
|
|
2156
|
+
t,
|
|
2157
|
+
title: t(`pool${lane[0].toUpperCase()}${lane.slice(1)}Title`),
|
|
2158
|
+
description: t(
|
|
2159
|
+
`pool${lane[0].toUpperCase()}${lane.slice(1)}Desc`,
|
|
2160
|
+
),
|
|
2161
|
+
rows,
|
|
2162
|
+
extras,
|
|
2163
|
+
routes: values[field],
|
|
2164
|
+
efforts: values[effortsField],
|
|
2165
|
+
manual: values[manualField],
|
|
2166
|
+
editable,
|
|
2167
|
+
onToggle: (meta) => {
|
|
2168
|
+
togglePool(field, meta);
|
|
2169
|
+
},
|
|
2170
|
+
onToggleManual: () => {
|
|
2171
|
+
edit(manualField, !values[manualField]);
|
|
2172
|
+
},
|
|
2173
|
+
onSetEffort: (meta, effort) => {
|
|
2174
|
+
setPoolEffort(effortsField, meta, effort);
|
|
2175
|
+
},
|
|
2176
|
+
onMove: (index, delta) => {
|
|
2177
|
+
movePoolRoute(field, index, delta);
|
|
2178
|
+
},
|
|
2179
|
+
onRemove: (index) => {
|
|
2180
|
+
removePoolRoute(field, index);
|
|
2181
|
+
},
|
|
2182
|
+
onSelectAll: () => {
|
|
2183
|
+
setAllPool(field, true);
|
|
2184
|
+
},
|
|
2185
|
+
onClear: () => {
|
|
2186
|
+
setAllPool(field, false);
|
|
2187
|
+
},
|
|
2188
|
+
});
|
|
2189
|
+
}),
|
|
2190
|
+
),
|
|
2191
|
+
h(
|
|
2192
|
+
'h4',
|
|
2193
|
+
{ id: rankId, style: STYLE.subheading },
|
|
2194
|
+
t('rankTitle'),
|
|
2195
|
+
),
|
|
2196
|
+
h('p', { style: STYLE.description }, t('rankDescription')),
|
|
2197
|
+
display.length > 0
|
|
2198
|
+
? h('div', { style: STYLE.rankList }, ...display.map(rankRow))
|
|
2199
|
+
: h('p', { style: STYLE.progress }, t('rankEmpty')),
|
|
2200
|
+
h(
|
|
2201
|
+
'h4',
|
|
2202
|
+
{ style: STYLE.subheading },
|
|
2203
|
+
t('enforceLabel'),
|
|
2204
|
+
),
|
|
2205
|
+
h(SettingsRow, {
|
|
2206
|
+
key: 'enforce',
|
|
2207
|
+
id: enforceId,
|
|
2208
|
+
label: t('enforceLabel'),
|
|
2209
|
+
control: h(
|
|
2210
|
+
'select',
|
|
2211
|
+
selectProps(enforceId, values.dispatchEnforce, (mode) => {
|
|
2212
|
+
if (ENFORCE_MODES.includes(mode)) edit('dispatchEnforce', mode);
|
|
2213
|
+
}),
|
|
2214
|
+
...ENFORCE_MODES.map((mode) =>
|
|
2215
|
+
h(
|
|
2216
|
+
'option',
|
|
2217
|
+
{ key: mode, value: mode },
|
|
2218
|
+
t(enforceLabelKey[mode]),
|
|
2219
|
+
),
|
|
2220
|
+
),
|
|
2221
|
+
),
|
|
2222
|
+
lines: [
|
|
2223
|
+
{
|
|
2224
|
+
text: t(enforceHelpKey[values.dispatchEnforce]),
|
|
2225
|
+
invalid: false,
|
|
2226
|
+
},
|
|
2227
|
+
],
|
|
2228
|
+
}),
|
|
2229
|
+
),
|
|
2230
|
+
h(
|
|
2231
|
+
'section',
|
|
2232
|
+
{
|
|
2233
|
+
'aria-labelledby': wmId,
|
|
2234
|
+
style: { ...STYLE.section, ...STYLE.sectionDivider },
|
|
2235
|
+
},
|
|
2236
|
+
h('h3', { id: wmId, style: STYLE.heading }, t('wmSectionTitle')),
|
|
2237
|
+
h('p', { style: STYLE.description }, t('wmSectionDescription')),
|
|
2238
|
+
h(
|
|
2239
|
+
'div',
|
|
2240
|
+
{ style: STYLE.rows },
|
|
2241
|
+
...WM_NUMBER_FIELDS.filter(
|
|
2242
|
+
({ field }) => field !== 'wmSubagentForceTokens',
|
|
2243
|
+
).map((spec) =>
|
|
2244
|
+
h(SettingsRow, {
|
|
2245
|
+
key: spec.field,
|
|
2246
|
+
id: `${wmId}-${spec.field}`,
|
|
2247
|
+
label: t(WM_ROW_KEYS[spec.field].label),
|
|
2248
|
+
help: t(WM_ROW_KEYS[spec.field].help),
|
|
2249
|
+
control: wmNumberControl(spec),
|
|
2250
|
+
lines: wmNumberLines(spec),
|
|
2251
|
+
}),
|
|
2252
|
+
),
|
|
2253
|
+
h(SettingsRow, {
|
|
2254
|
+
key: 'wmDenyMode',
|
|
2255
|
+
id: `${wmId}-deny`,
|
|
2256
|
+
label: t('wmDenyModeLabel'),
|
|
2257
|
+
control: h(
|
|
2258
|
+
'select',
|
|
2259
|
+
selectProps(`${wmId}-deny`, values.wmDenyMode, (mode) => {
|
|
2260
|
+
if (mode === 'cap' || mode === 'deny')
|
|
2261
|
+
edit('wmDenyMode', mode);
|
|
2262
|
+
}),
|
|
2263
|
+
h('option', { key: 'cap', value: 'cap' }, t('wmDenyModeCap')),
|
|
2264
|
+
h('option', { key: 'deny', value: 'deny' }, t('wmDenyModeDeny')),
|
|
2265
|
+
),
|
|
2266
|
+
lines: [
|
|
2267
|
+
{
|
|
2268
|
+
text: t(
|
|
2269
|
+
values.wmDenyMode === 'deny'
|
|
2270
|
+
? 'wmDenyModeHelpDeny'
|
|
2271
|
+
: 'wmDenyModeHelpCap',
|
|
2272
|
+
),
|
|
2273
|
+
invalid: false,
|
|
2274
|
+
},
|
|
2275
|
+
],
|
|
2276
|
+
}),
|
|
2277
|
+
h(SettingsRow, {
|
|
2278
|
+
key: 'wmAutoHandover',
|
|
2279
|
+
id: `${wmId}-auto`,
|
|
2280
|
+
label: t('wmAutoHandoverLabel'),
|
|
2281
|
+
help: t('wmAutoHandoverHelp'),
|
|
2282
|
+
control: h('input', {
|
|
2283
|
+
type: 'checkbox',
|
|
2284
|
+
id: `${wmId}-auto`,
|
|
2285
|
+
checked: values.wmAutoHandover,
|
|
2286
|
+
onChange: () => {
|
|
2287
|
+
edit('wmAutoHandover', !values.wmAutoHandover);
|
|
2288
|
+
},
|
|
2289
|
+
disabled: !editable,
|
|
2290
|
+
style: STYLE.checkbox,
|
|
2291
|
+
}),
|
|
2292
|
+
lines: [],
|
|
2293
|
+
}),
|
|
2294
|
+
),
|
|
2295
|
+
h('h4', { style: STYLE.subheading }, t('wmSubagentTitle')),
|
|
2296
|
+
h(
|
|
2297
|
+
'div',
|
|
2298
|
+
{ style: STYLE.rows },
|
|
2299
|
+
h(SettingsRow, {
|
|
2300
|
+
key: 'wmSubagentCap',
|
|
2301
|
+
id: `${wmId}-subcap`,
|
|
2302
|
+
label: t('wmSubagentCapLabel'),
|
|
2303
|
+
help: t('wmSubagentCapHelp'),
|
|
2304
|
+
control: h('input', {
|
|
2305
|
+
type: 'checkbox',
|
|
2306
|
+
id: `${wmId}-subcap`,
|
|
2307
|
+
checked: values.wmSubagentCap,
|
|
2308
|
+
onChange: () => {
|
|
2309
|
+
edit('wmSubagentCap', !values.wmSubagentCap);
|
|
2310
|
+
},
|
|
2311
|
+
disabled: !editable,
|
|
2312
|
+
style: STYLE.checkbox,
|
|
2313
|
+
}),
|
|
2314
|
+
lines: [],
|
|
2315
|
+
}),
|
|
2316
|
+
h(SettingsRow, {
|
|
2317
|
+
key: 'wmSubagentForceTokens',
|
|
2318
|
+
id: `${wmId}-subforce`,
|
|
2319
|
+
label: t('wmSubagentForceLabel'),
|
|
2320
|
+
help: t('wmSubagentForceHelp'),
|
|
2321
|
+
control: wmNumberControl(
|
|
2322
|
+
WM_NUMBER_FIELDS.find(
|
|
2323
|
+
({ field }) => field === 'wmSubagentForceTokens',
|
|
2324
|
+
),
|
|
2325
|
+
t('wmSubagentFollowForce'),
|
|
2326
|
+
),
|
|
2327
|
+
lines: wmNumberLines(
|
|
2328
|
+
WM_NUMBER_FIELDS.find(
|
|
2329
|
+
({ field }) => field === 'wmSubagentForceTokens',
|
|
2330
|
+
),
|
|
2331
|
+
),
|
|
2332
|
+
}),
|
|
2333
|
+
),
|
|
2334
|
+
h('p', { style: STYLE.progress }, t('wmCommands')),
|
|
2335
|
+
),
|
|
2336
|
+
h(
|
|
2337
|
+
'div',
|
|
2338
|
+
{ style: STYLE.footer },
|
|
2339
|
+
h(
|
|
2340
|
+
'p',
|
|
2341
|
+
{
|
|
2342
|
+
role: 'status',
|
|
2343
|
+
style: failed ? STYLE.messageError : STYLE.message,
|
|
2344
|
+
},
|
|
2345
|
+
failed
|
|
2346
|
+
? t('saveFailed')
|
|
2347
|
+
: conflicted
|
|
2348
|
+
? t('saveConflict')
|
|
2349
|
+
: saving
|
|
2350
|
+
? t('saving')
|
|
2351
|
+
: !form.writable
|
|
2352
|
+
? t('formReadOnly')
|
|
2353
|
+
: '',
|
|
2354
|
+
),
|
|
2355
|
+
form.writable
|
|
2356
|
+
? h(
|
|
2357
|
+
'div',
|
|
2358
|
+
{ style: STYLE.actions },
|
|
2359
|
+
draft === null && !failed && !conflicted
|
|
2360
|
+
? null
|
|
2361
|
+
: h(
|
|
2362
|
+
'button',
|
|
2363
|
+
{
|
|
2364
|
+
type: 'button',
|
|
2365
|
+
onClick: discard,
|
|
2366
|
+
disabled: saving,
|
|
2367
|
+
style: {
|
|
2368
|
+
...STYLE.buttonBase,
|
|
2369
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
2370
|
+
},
|
|
2371
|
+
},
|
|
2372
|
+
t('discard'),
|
|
2373
|
+
),
|
|
2374
|
+
h(
|
|
2375
|
+
'button',
|
|
2376
|
+
{
|
|
2377
|
+
type: 'button',
|
|
2378
|
+
onClick: save,
|
|
2379
|
+
disabled: saveDisabled,
|
|
2380
|
+
style: {
|
|
2381
|
+
...STYLE.buttonBase,
|
|
2382
|
+
color: 'var(--dsw-alias-brand-primary)',
|
|
2383
|
+
borderColor: 'var(--dsw-alias-brand-primary)',
|
|
2384
|
+
opacity: saveDisabled ? 0.5 : 1,
|
|
2385
|
+
cursor: saveDisabled ? 'default' : 'pointer',
|
|
2386
|
+
},
|
|
2387
|
+
},
|
|
2388
|
+
saving ? t('saving') : t('save'),
|
|
2389
|
+
),
|
|
2390
|
+
)
|
|
2391
|
+
: null,
|
|
2392
|
+
),
|
|
2393
|
+
);
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
return {
|
|
2397
|
+
inject: ['slots', 'locale'],
|
|
2398
|
+
apply(ctx) {
|
|
2399
|
+
ctx.effect(() => ctx.locale.register(NS, DICTS));
|
|
2400
|
+
const t = ctx.locale.bind(NS);
|
|
2401
|
+
ctx.slots.inject('conversation.session.header.actions', () =>
|
|
2402
|
+
ctx.slots.register(
|
|
2403
|
+
{
|
|
2404
|
+
name: 'conversation.session.header.actions',
|
|
2405
|
+
id: 'dsh-switchman',
|
|
2406
|
+
order: -9,
|
|
2407
|
+
},
|
|
2408
|
+
() => h(SwitchmanBadge, { locale: ctx.locale, t }),
|
|
2409
|
+
),
|
|
2410
|
+
);
|
|
2411
|
+
// Settings page: one settings.section entry. The restricted
|
|
2412
|
+
// dynamic-module runtime has no configForms service, so there
|
|
2413
|
+
// is no whileServed gating — the page loads its snapshot from
|
|
2414
|
+
// the Host route family on mount.
|
|
2415
|
+
ctx.slots.inject('settings.section', () =>
|
|
2416
|
+
ctx.slots.register(
|
|
2417
|
+
{
|
|
2418
|
+
name: 'settings.section',
|
|
2419
|
+
id: 'dsh-switchman',
|
|
2420
|
+
order: 50,
|
|
2421
|
+
label: () => t('settingsTitle'),
|
|
2422
|
+
},
|
|
2423
|
+
() => h(SwitchmanSettingsPage, { t, locale: ctx.locale }),
|
|
2424
|
+
),
|
|
2425
|
+
);
|
|
2426
|
+
// The same configuration surface on the bundle's own Plugins
|
|
2427
|
+
// page card (keyed by package name, rendered between the
|
|
2428
|
+
// bundle's description and its rows).
|
|
2429
|
+
ctx.slots.inject('plugins.bundle.config', () =>
|
|
2430
|
+
ctx.slots.register(
|
|
2431
|
+
{
|
|
2432
|
+
name: 'plugins.bundle.config',
|
|
2433
|
+
key: 'dsh-switchman',
|
|
2434
|
+
},
|
|
2435
|
+
() => h(SwitchmanSettingsPage, { t, locale: ctx.locale }),
|
|
2436
|
+
),
|
|
2437
|
+
);
|
|
2438
|
+
},
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
});
|