cabletidy 0.1.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 +230 -0
- package/bin/cabletidy.mjs +2 -0
- package/package.json +40 -0
- package/src/cli.mjs +141 -0
- package/src/codex-catalog.mjs +227 -0
- package/src/codex-config-file.mjs +67 -0
- package/src/codex-native-provider.mjs +311 -0
- package/src/config.mjs +361 -0
- package/src/model-resolver.mjs +187 -0
- package/src/server.mjs +1269 -0
- package/src/target-artifacts.mjs +177 -0
- package/src/validation.mjs +387 -0
- package/web/app.js +1642 -0
- package/web/config-identity.js +73 -0
- package/web/index.html +48 -0
- package/web/styles.css +1188 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,1642 @@
|
|
|
1
|
+
import { configurationId, providerIdForConfiguration, normalizeConfigurationIdentities, configurationBaseUrl } from "./config-identity.js";
|
|
2
|
+
|
|
3
|
+
const pageContent = document.querySelector("#page-content");
|
|
4
|
+
const pageTitle = document.querySelector("#page-title");
|
|
5
|
+
const railStatus = document.querySelector("#rail-status");
|
|
6
|
+
const revisionLabel = document.querySelector("#revision-label");
|
|
7
|
+
const toastRegion = document.querySelector("#toast-region");
|
|
8
|
+
const formBaselines = new WeakMap();
|
|
9
|
+
const formConflicts = new WeakSet();
|
|
10
|
+
const lockedControls = new WeakMap();
|
|
11
|
+
let modelHintSequence = 0;
|
|
12
|
+
|
|
13
|
+
// Explicit product guidance; catalog visibility does not indicate authorization.
|
|
14
|
+
const SECURITY_MODELS = new Map([
|
|
15
|
+
["gpt-daybreak-blue-latest", {
|
|
16
|
+
label: "Daybreak Blue · 需授权",
|
|
17
|
+
hint: "用于防御性安全工作,需确认上游支持并已获授权。",
|
|
18
|
+
}],
|
|
19
|
+
["gpt-daybreak-red-latest", {
|
|
20
|
+
label: "Daybreak Red · 需专项授权",
|
|
21
|
+
hint: "用于专项授权安全研究,需单独获得 Red 授权并确认上游支持。",
|
|
22
|
+
}],
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const PAGE_META = {
|
|
26
|
+
overview: "配置套装",
|
|
27
|
+
"suite-create": "新建配置",
|
|
28
|
+
"suite-detail": "配置详情",
|
|
29
|
+
upstreams: "上游管理",
|
|
30
|
+
models: "模型管理",
|
|
31
|
+
diagnostics: "诊断",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const state = {
|
|
35
|
+
page: "overview",
|
|
36
|
+
config: null,
|
|
37
|
+
candidate: null,
|
|
38
|
+
runtime: null,
|
|
39
|
+
events: [],
|
|
40
|
+
codexCatalog: null,
|
|
41
|
+
busy: false,
|
|
42
|
+
pendingSecrets: {
|
|
43
|
+
upstreamSecrets: {},
|
|
44
|
+
},
|
|
45
|
+
selected: {
|
|
46
|
+
upstream: null,
|
|
47
|
+
model: null,
|
|
48
|
+
virtualProvider: null,
|
|
49
|
+
binding: null,
|
|
50
|
+
suite: null,
|
|
51
|
+
},
|
|
52
|
+
artifactPreview: null,
|
|
53
|
+
resolveResult: null,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function clone(value) {
|
|
57
|
+
return structuredClone(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function esc(value) {
|
|
61
|
+
return String(value ?? "")
|
|
62
|
+
.replaceAll("&", "&")
|
|
63
|
+
.replaceAll("<", "<")
|
|
64
|
+
.replaceAll(">", ">")
|
|
65
|
+
.replaceAll('"', """)
|
|
66
|
+
.replaceAll("'", "'");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function values(record) {
|
|
70
|
+
return Object.values(record || {});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function entries(record) {
|
|
74
|
+
return Object.entries(record || {});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function commaList(value) {
|
|
78
|
+
return String(value || "")
|
|
79
|
+
.split(",")
|
|
80
|
+
.map((item) => item.trim())
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function optionList(items, selected) {
|
|
85
|
+
return items
|
|
86
|
+
.map((item) => `<option value="${esc(item)}" ${item === selected ? "selected" : ""}>${esc(item)}</option>`)
|
|
87
|
+
.join("");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function api(path, options = {}) {
|
|
91
|
+
const response = await fetch(`/api/v1${path}`, {
|
|
92
|
+
...options,
|
|
93
|
+
headers: {
|
|
94
|
+
"content-type": "application/json",
|
|
95
|
+
...(options.headers || {}),
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
const body = await response.json().catch(() => ({}));
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
const message = body.error?.message || body.message || `请求失败 (${response.status})`;
|
|
101
|
+
const error = new Error(message);
|
|
102
|
+
error.status = response.status;
|
|
103
|
+
error.body = body;
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
return body;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function bootstrap() {
|
|
110
|
+
try {
|
|
111
|
+
const [configResult, runtime, events, codexCatalog] = await Promise.all([
|
|
112
|
+
api("/config"),
|
|
113
|
+
api("/runtime"),
|
|
114
|
+
api("/events"),
|
|
115
|
+
api("/codex/models"),
|
|
116
|
+
]);
|
|
117
|
+
state.config = configResult.config;
|
|
118
|
+
state.candidate = clone(state.config);
|
|
119
|
+
state.runtime = runtime;
|
|
120
|
+
state.codexCatalog = codexCatalog;
|
|
121
|
+
state.events = events.events || [];
|
|
122
|
+
state.selected.upstream = firstKey(state.candidate.upstreams);
|
|
123
|
+
state.selected.model = firstKey(state.candidate.models);
|
|
124
|
+
state.selected.virtualProvider = firstKey(state.candidate.virtualProviders);
|
|
125
|
+
state.selected.binding = firstKey(state.candidate.bindings);
|
|
126
|
+
state.selected.suite = firstKey(state.candidate.bindings);
|
|
127
|
+
railStatus.textContent = "服务运行中";
|
|
128
|
+
railStatus.parentElement.classList.remove("is-offline");
|
|
129
|
+
render();
|
|
130
|
+
} catch (error) {
|
|
131
|
+
renderUnavailable(error.message);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderUnavailable(message) {
|
|
136
|
+
railStatus.textContent = "无法连接";
|
|
137
|
+
railStatus.parentElement.classList.add("is-offline");
|
|
138
|
+
pageTitle.textContent = "无法连接到 CableTidy";
|
|
139
|
+
pageContent.innerHTML = `
|
|
140
|
+
<div class="notice warning" role="alert">
|
|
141
|
+
<p>${esc(message || "服务不可用")}</p>
|
|
142
|
+
<span>运行 <code>npm start</code> 后重试。</span>
|
|
143
|
+
<button class="button" data-action="reconnect">重试连接</button>
|
|
144
|
+
</div>
|
|
145
|
+
`;
|
|
146
|
+
pageContent.querySelector('[data-action="reconnect"]').addEventListener("click", bootstrap);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function render(preservedForms = []) {
|
|
150
|
+
pageTitle.textContent = state.page === "suite-detail"
|
|
151
|
+
? selectedSuite()?.name || PAGE_META["suite-detail"]
|
|
152
|
+
: pageMeta();
|
|
153
|
+
revisionLabel.textContent = `版本 ${state.config?.revision ?? 0}`;
|
|
154
|
+
document.querySelectorAll(".nav-item").forEach((item) => {
|
|
155
|
+
const active = item.dataset.page === (state.page === "diagnostics" ? "diagnostics" : "overview");
|
|
156
|
+
item.classList.toggle("is-active", active);
|
|
157
|
+
if (active) item.setAttribute("aria-current", "page");
|
|
158
|
+
else item.removeAttribute("aria-current");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const renderers = {
|
|
162
|
+
overview: renderOverview,
|
|
163
|
+
"suite-create": renderSuiteCreate,
|
|
164
|
+
"suite-detail": renderSuiteDetail,
|
|
165
|
+
upstreams: renderUpstreams,
|
|
166
|
+
models: renderModels,
|
|
167
|
+
diagnostics: renderDiagnostics,
|
|
168
|
+
};
|
|
169
|
+
pageContent.innerHTML = renderers[state.page]();
|
|
170
|
+
for (const form of pageContent.querySelectorAll("form")) {
|
|
171
|
+
formBaselines.set(form, formSnapshot(form));
|
|
172
|
+
}
|
|
173
|
+
for (const form of preservedForms) {
|
|
174
|
+
const replacement = pageContent.querySelector(`#${CSS.escape(form.getAttribute("id"))}`);
|
|
175
|
+
if (replacement) restoreFormChanges(form, replacement);
|
|
176
|
+
else {
|
|
177
|
+
formConflicts.add(form);
|
|
178
|
+
pageContent.append(form);
|
|
179
|
+
showFormError(form, new Error("配置项已在其他窗口删除。"));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function fieldSnapshot(control) {
|
|
185
|
+
return [
|
|
186
|
+
control.name || "", control.type, { ...control.dataset },
|
|
187
|
+
control.closest("[data-suite-model]")?.dataset.modelId || "",
|
|
188
|
+
["checkbox", "radio"].includes(control.type) ? control.checked
|
|
189
|
+
: control.multiple ? [...control.selectedOptions].map((option) => option.value)
|
|
190
|
+
: control.value,
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function formSnapshot(form) {
|
|
195
|
+
const fields = [...form.querySelectorAll("input, select, textarea")].filter((control) => {
|
|
196
|
+
// Official metadata is derived, not an editable configuration value.
|
|
197
|
+
if ("suiteModelContext" in control.dataset || "codexVision" in control.dataset) {
|
|
198
|
+
const mode = control.closest("[data-suite-model]")?.querySelector("[data-codex-metadata-mode]");
|
|
199
|
+
if (mode?.value === "official") return false;
|
|
200
|
+
}
|
|
201
|
+
return !["submit", "button", "reset"].includes(control.type);
|
|
202
|
+
});
|
|
203
|
+
return JSON.stringify(fields.map(fieldSnapshot));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function fieldKey(field) {
|
|
207
|
+
return JSON.stringify(field.slice(0, 4));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function mergeFormFields(base, local, remote) {
|
|
211
|
+
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
212
|
+
const index = (fields) => new Map(fields.map((field) => [fieldKey(field), field]));
|
|
213
|
+
const [before, ours, theirs] = [base, local, remote].map(index);
|
|
214
|
+
// Repeated controls without stable row IDs cannot be merged safely.
|
|
215
|
+
if ([base, local, remote].some((fields, i) => fields.length !== [before, ours, theirs][i].size)) return null;
|
|
216
|
+
for (const field of base) {
|
|
217
|
+
const key = fieldKey(field);
|
|
218
|
+
if (field[0] === "id" && !same(field, theirs.get(key))) return null;
|
|
219
|
+
if (!("suiteModelClient" in field[2] || "codexMetadataMode" in field[2])) continue;
|
|
220
|
+
if (same(field, ours.get(key)) && same(field, theirs.get(key))) continue;
|
|
221
|
+
const row = (fields) => fields.filter((item) => item[3] === field[3]);
|
|
222
|
+
// A changed model or metadata mode changes the meaning of its other fields.
|
|
223
|
+
if (!same(row(base), row(local)) && !same(row(base), row(remote)) && !same(row(local), row(remote))) return null;
|
|
224
|
+
}
|
|
225
|
+
const merged = [];
|
|
226
|
+
for (const key of new Set([...before.keys(), ...ours.keys(), ...theirs.keys()])) {
|
|
227
|
+
const original = before.get(key);
|
|
228
|
+
const current = ours.get(key);
|
|
229
|
+
const latest = theirs.get(key);
|
|
230
|
+
if (same(current, original)) {
|
|
231
|
+
if (latest) merged.push(latest);
|
|
232
|
+
} else if (same(latest, original) || same(current, latest)) {
|
|
233
|
+
if (current) merged.push(current);
|
|
234
|
+
} else return null;
|
|
235
|
+
}
|
|
236
|
+
return merged;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function restoreFormChanges(form, replacement) {
|
|
240
|
+
const baseline = formBaselines.get(form);
|
|
241
|
+
const latest = formBaselines.get(replacement);
|
|
242
|
+
const current = formSnapshot(form);
|
|
243
|
+
const sameContext = form.getAttribute("data-suite-context") === replacement.getAttribute("data-suite-context");
|
|
244
|
+
if (sameContext && current === latest) return;
|
|
245
|
+
if (sameContext && baseline === latest) {
|
|
246
|
+
if (formConflicts.has(form)) form.querySelector("[data-form-feedback]")?.remove();
|
|
247
|
+
formConflicts.delete(form);
|
|
248
|
+
replacement.replaceWith(form);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const suiteModels = form.getAttribute("id") === "suite-models-form";
|
|
252
|
+
const savedFields = (snapshot) => JSON.parse(snapshot).filter((field) => !suiteModels || field[3]);
|
|
253
|
+
const structure = (snapshot) => JSON.stringify(JSON.parse(snapshot).map(fieldKey));
|
|
254
|
+
const sameStructure = baseline !== undefined && (suiteModels
|
|
255
|
+
|| structure(baseline) === structure(current) && structure(baseline) === structure(latest));
|
|
256
|
+
const merged = sameContext && sameStructure
|
|
257
|
+
? mergeFormFields(savedFields(baseline), savedFields(current), savedFields(latest)) : null;
|
|
258
|
+
const controls = new Map([...replacement.querySelectorAll("input, select, textarea")]
|
|
259
|
+
.map((control) => [fieldKey(fieldSnapshot(control)), control]));
|
|
260
|
+
if (!merged || merged.some((field) => !controls.has(fieldKey(field)))) {
|
|
261
|
+
// Keep the old baseline and input; never bless a stale form with a new revision.
|
|
262
|
+
formConflicts.add(form);
|
|
263
|
+
replacement.replaceWith(form);
|
|
264
|
+
showFormError(form, new Error("配置存在并发修改冲突。"));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
for (const field of merged) {
|
|
268
|
+
const control = controls.get(fieldKey(field));
|
|
269
|
+
if (["checkbox", "radio"].includes(control.type)) control.checked = field[4];
|
|
270
|
+
else if (control.multiple) {
|
|
271
|
+
for (const option of control.options) option.selected = field[4].includes(option.value);
|
|
272
|
+
} else control.value = field[4];
|
|
273
|
+
}
|
|
274
|
+
if (suiteModels) {
|
|
275
|
+
const retained = new Set(merged.map((field) => field[3]));
|
|
276
|
+
replacement.querySelectorAll("[data-suite-model]").forEach((row) => {
|
|
277
|
+
if (!retained.has(row.dataset.modelId)) row.remove();
|
|
278
|
+
else syncCodexMetadata(row);
|
|
279
|
+
});
|
|
280
|
+
const list = replacement.querySelector("#suite-model-list");
|
|
281
|
+
form.querySelectorAll("[data-suite-model]").forEach((row) => {
|
|
282
|
+
if (!row.dataset.modelId) {
|
|
283
|
+
list.querySelector(".empty")?.remove();
|
|
284
|
+
list.append(row);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
replacement.querySelectorAll("[data-official-model]").forEach(updateOfficialModelHint);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function isFormEdited(form) {
|
|
292
|
+
const baseline = formBaselines.get(form);
|
|
293
|
+
return formConflicts.has(form) || baseline !== undefined && baseline !== formSnapshot(form);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function captureEditedForms(excludedForm) {
|
|
297
|
+
return [...pageContent.querySelectorAll("form")].filter(
|
|
298
|
+
(form) => form !== excludedForm && isFormEdited(form),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function hasUnsavedChanges() {
|
|
303
|
+
return captureEditedForms().some((form) => form.getAttribute("id") !== "resolve-form");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function confirmPageLeave() {
|
|
307
|
+
return !hasUnsavedChanges() || window.confirm("有未保存的修改,确定放弃并离开?");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function navigatePage(page) {
|
|
311
|
+
if (state.busy || !state.config || page === state.page || !confirmPageLeave()) return;
|
|
312
|
+
state.page = page;
|
|
313
|
+
render();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
pageContent.addEventListener("click", (event) => {
|
|
317
|
+
const element = event.target.closest("[data-action], [data-page]");
|
|
318
|
+
if (!element || !pageContent.contains(element) || state.busy) return;
|
|
319
|
+
if (element.dataset.action) handleAction(element.dataset.action, element);
|
|
320
|
+
else navigatePage(element.dataset.page);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
pageContent.addEventListener("submit", (event) => handleFormSubmit(event, event.target));
|
|
324
|
+
window.addEventListener("beforeunload", (event) => {
|
|
325
|
+
if (!hasUnsavedChanges()) return;
|
|
326
|
+
event.preventDefault();
|
|
327
|
+
event.returnValue = "";
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
function pageMeta() {
|
|
331
|
+
return PAGE_META[state.page] || PAGE_META.overview;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function targetLabel(target) {
|
|
335
|
+
return {
|
|
336
|
+
codex: "Codex",
|
|
337
|
+
"claude-code": "Claude Code",
|
|
338
|
+
"generic-env": "Generic CLI",
|
|
339
|
+
}[target] || target || "CLI";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function suiteRouteBackend(route) {
|
|
343
|
+
return route?.backends?.length === 1 ? route.backends[0] : null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function configurationSuites(config) {
|
|
347
|
+
return entries(config.bindings).map(([bindingId, binding]) => {
|
|
348
|
+
const virtualProvider = config.virtualProviders?.[binding.virtualProvider] || {};
|
|
349
|
+
const route = config.routes?.[virtualProvider.route] || {};
|
|
350
|
+
const routeBackend = suiteRouteBackend(route);
|
|
351
|
+
const upstreamId = routeBackend?.upstream || null;
|
|
352
|
+
const upstream = upstreamId ? config.upstreams?.[upstreamId] || null : null;
|
|
353
|
+
const modelIdSet = new Set(virtualProvider.allowedModels || []);
|
|
354
|
+
for (const modelId of routeBackend?.models || []) {
|
|
355
|
+
modelIdSet.add(modelId);
|
|
356
|
+
}
|
|
357
|
+
const modelIds = [...modelIdSet].filter((id) => config.models?.[id]);
|
|
358
|
+
const name =
|
|
359
|
+
binding.name ||
|
|
360
|
+
virtualProvider.name ||
|
|
361
|
+
`${targetLabel(binding.target)} / ${upstream?.name || upstreamId || "未配置上游"}`;
|
|
362
|
+
return {
|
|
363
|
+
id: bindingId,
|
|
364
|
+
bindingId,
|
|
365
|
+
name,
|
|
366
|
+
target: binding.target,
|
|
367
|
+
binding,
|
|
368
|
+
virtualProvider,
|
|
369
|
+
route,
|
|
370
|
+
routeBackend,
|
|
371
|
+
upstreamId,
|
|
372
|
+
upstream,
|
|
373
|
+
modelIds,
|
|
374
|
+
models: modelIds.map((id) => ({ id, profile: config.models[id] })),
|
|
375
|
+
};
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function selectedSuite() {
|
|
380
|
+
return configurationSuites(state.candidate || {}).find(
|
|
381
|
+
(suite) => suite.id === state.selected.suite,
|
|
382
|
+
) || null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function selectSuite(id) {
|
|
386
|
+
const suite = configurationSuites(state.candidate || {}).find((item) => item.id === id);
|
|
387
|
+
state.selected.suite = id || null;
|
|
388
|
+
if (!suite) return;
|
|
389
|
+
state.selected.binding = suite.bindingId;
|
|
390
|
+
state.selected.virtualProvider = suite.binding.virtualProvider;
|
|
391
|
+
state.selected.upstream = suite.upstreamId;
|
|
392
|
+
state.selected.model = suite.modelIds[0] || null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function suiteProviderStatus(suite) {
|
|
396
|
+
const runtimeProvider = state.runtime?.virtualProviders?.find(
|
|
397
|
+
(item) => item.id === suite.binding.virtualProvider,
|
|
398
|
+
);
|
|
399
|
+
if (runtimeProvider?.status === "paused") {
|
|
400
|
+
return { label: "已暂停", className: "warning" };
|
|
401
|
+
}
|
|
402
|
+
if (runtimeProvider?.status === "listening") {
|
|
403
|
+
return { label: "已启动", className: "" };
|
|
404
|
+
}
|
|
405
|
+
return { label: "未启动", className: "neutral" };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function suiteEndpoint(suite) {
|
|
409
|
+
return `${configurationBaseUrl(state.candidate, suite.bindingId)}/v1`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function suiteRow(suite) {
|
|
413
|
+
const providerStatus = suiteProviderStatus(suite);
|
|
414
|
+
return `
|
|
415
|
+
<tr>
|
|
416
|
+
<th scope="row"><button class="suite-name" data-action="open-suite" data-id="${esc(suite.id)}">${esc(suite.name)}</button><span class="suite-upstream">${esc(suite.upstream?.name || suite.upstreamId || "未配置上游")}</span></th>
|
|
417
|
+
<td data-label="CLI">${esc(targetLabel(suite.target))}</td>
|
|
418
|
+
<td data-label="模型设置">${suite.modelIds.length ? `${suite.modelIds.length} 项设置` : "直接透传"}</td>
|
|
419
|
+
<td data-label="本地地址" class="mono">${esc(suiteEndpoint(suite))}</td>
|
|
420
|
+
<td data-label="本地服务"><span class="status-badge ${providerStatus.className}">${esc(providerStatus.label)}</span></td>
|
|
421
|
+
</tr>
|
|
422
|
+
`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function renderOverview() {
|
|
426
|
+
const suites = configurationSuites(state.candidate);
|
|
427
|
+
return `
|
|
428
|
+
<div class="suite-toolbar">
|
|
429
|
+
<span class="muted">${suites.length} 套配置</span>
|
|
430
|
+
<button class="button button-primary" data-action="create-suite">新建配置</button>
|
|
431
|
+
</div>
|
|
432
|
+
${
|
|
433
|
+
suites.length
|
|
434
|
+
? `<div class="suite-list"><table class="suite-table" aria-label="配置套装"><thead><tr><th scope="col">配置名称</th><th scope="col">CLI</th><th scope="col">模型设置</th><th scope="col">本地地址</th><th scope="col">本地服务</th></tr></thead><tbody>${suites.map(suiteRow).join("")}</tbody></table></div>`
|
|
435
|
+
: `<div class="panel"><div class="empty">暂无配置</div></div>`
|
|
436
|
+
}
|
|
437
|
+
`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function suiteModelEditor(suite, modelId, profile, upstreamId) {
|
|
441
|
+
const model = profile || {};
|
|
442
|
+
const isCodex = suite.target === "codex";
|
|
443
|
+
const compact = model.compact || {};
|
|
444
|
+
const binding = model.upstreams?.[upstreamId] || {};
|
|
445
|
+
const expanded = model.codex?.metadataMode === "override" || Boolean(model.compact)
|
|
446
|
+
|| (isCodex && !model.codex && Boolean(model.contextWindow));
|
|
447
|
+
return `
|
|
448
|
+
<article class="suite-model-card" data-suite-model data-model-id="${esc(modelId)}">
|
|
449
|
+
<div class="suite-model-mapping">
|
|
450
|
+
${isCodex
|
|
451
|
+
? officialModelSelect("data-suite-model-client", model.clientModelId || model.aliases?.[0] || "")
|
|
452
|
+
: `<label class="field"><span>客户端模型 ID</span><input data-suite-model-client value="${esc(model.clientModelId || model.aliases?.[0] || modelId)}" placeholder="客户端模型 ID" required /></label>`}
|
|
453
|
+
<span class="suite-mapping-arrow" aria-hidden="true">→</span>
|
|
454
|
+
${upstreamId
|
|
455
|
+
? `<label class="field"><span>上游模型 ID(可选)</span><input data-suite-model-upstream="${esc(upstreamId)}" value="${esc(binding.upstreamModelId || "")}" placeholder="留空使用请求中的模型名" /></label>`
|
|
456
|
+
: `<div class="notice warning">请先配置上游连接</div>`}
|
|
457
|
+
<button class="mini-button" type="button" data-action="remove-suite-model" aria-label="移除模型 ${esc(model.clientModelId || modelId || "映射")}">移除</button>
|
|
458
|
+
</div>
|
|
459
|
+
<details class="suite-model-settings" ${expanded ? "open" : ""}>
|
|
460
|
+
<summary><span>模型能力与上下文</span><span class="field-hint" data-model-policy-summary>${isCodex ? model.codex?.metadataMode === "override" ? "覆盖上游限制" : "沿用官方定义" : "自定义设置"}</span></summary>
|
|
461
|
+
<div class="form-grid suite-model-policy">
|
|
462
|
+
${isCodex ? codexMetadataFields(model) : `
|
|
463
|
+
<label class="field full"><span>Capabilities</span><input data-suite-model-capabilities-common value="${esc((model.capabilities || ["streaming", "tools", "reasoning"]).join(", "))}" placeholder="streaming, tools, reasoning" /></label>
|
|
464
|
+
<label class="field"><span>Context window</span><input type="number" data-suite-model-context value="${esc(model.contextWindow ?? 1000000)}" placeholder="tokens" /></label>
|
|
465
|
+
<label class="field"><span>Compact strategy</span><select data-suite-model-compact>${optionList(["auto", "manual", "disabled"], compact.strategy || "auto")}</select></label>
|
|
466
|
+
<label class="field"><span>Compact token limit</span><input type="number" data-suite-model-compact-limit value="${esc(compact.tokenLimit ?? 850000)}" placeholder="tokens" /></label>
|
|
467
|
+
${upstreamId ? `<label class="field"><span>上游能力覆盖</span><input data-suite-model-capabilities="${esc(upstreamId)}" value="${esc((binding.capabilityOverrides || []).join(", "))}" placeholder="可留空" /></label>` : ""}
|
|
468
|
+
`}
|
|
469
|
+
</div>
|
|
470
|
+
</details>
|
|
471
|
+
</article>
|
|
472
|
+
`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function officialModel(id) {
|
|
476
|
+
return state.codexCatalog?.models?.find((model) => model.id === id);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function officialModelOptions(selected) {
|
|
480
|
+
const models = state.codexCatalog?.models || [];
|
|
481
|
+
const unknown = selected && !officialModel(selected);
|
|
482
|
+
const option = (model) => `<option value="${esc(model.id)}" ${selected === model.id ? "selected" : ""}>${esc(
|
|
483
|
+
SECURITY_MODELS.get(model.id)?.label || `${model.name} (${model.id})`,
|
|
484
|
+
)}</option>`;
|
|
485
|
+
const securityModels = models.filter((model) => SECURITY_MODELS.has(model.id));
|
|
486
|
+
return `
|
|
487
|
+
<option value="">选择官方模型</option>
|
|
488
|
+
${unknown ? `<option value="${esc(selected)}" selected>${esc(SECURITY_MODELS.get(selected)?.label || selected)}(旧配置,待确认)</option>` : ""}
|
|
489
|
+
${models.filter((model) => !SECURITY_MODELS.has(model.id)).map(option).join("")}
|
|
490
|
+
${securityModels.length ? `<optgroup label="安全专项模型">${securityModels.map(option).join("")}</optgroup>` : ""}
|
|
491
|
+
`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function officialModelHint(selected) {
|
|
495
|
+
return [
|
|
496
|
+
SECURITY_MODELS.get(selected)?.hint,
|
|
497
|
+
selected && !officialModel(selected) ? "未匹配本机官方目录" : "",
|
|
498
|
+
].filter(Boolean).join(" ");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function updateOfficialModelHint(select) {
|
|
502
|
+
const hint = select.parentElement.querySelector("[data-official-model-hint]");
|
|
503
|
+
hint.textContent = officialModelHint(select.value);
|
|
504
|
+
hint.hidden = !hint.textContent;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function officialModelSelect(attributes, selected) {
|
|
508
|
+
const hint = officialModelHint(selected);
|
|
509
|
+
const hintId = `official-model-hint-${++modelHintSequence}`;
|
|
510
|
+
return `<label class="field"><span>Codex 官方模型</span><select ${attributes} data-official-model aria-label="Codex 官方模型" aria-describedby="${hintId}" required>
|
|
511
|
+
${officialModelOptions(selected)}
|
|
512
|
+
</select><span class="field-hint" id="${hintId}" data-official-model-hint aria-live="polite" ${hint ? "" : "hidden"}>${esc(hint)}</span></label>`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function codexCatalogStatus() {
|
|
516
|
+
return `<div class="catalog-status"><span>${esc(state.codexCatalog?.available
|
|
517
|
+
? `${state.codexCatalog.version} / ${state.codexCatalog.models.length} 个官方模型`
|
|
518
|
+
: state.codexCatalog?.error?.message || "官方模型目录不可用")}</span>
|
|
519
|
+
<button class="mini-button" type="button" data-action="refresh-codex-models">刷新模型列表</button></div>`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function codexMetadataFields(model) {
|
|
523
|
+
const definition = officialModel(model.clientModelId);
|
|
524
|
+
const override = model.codex?.metadataMode === "override";
|
|
525
|
+
const vision = override ? model.codex.inputModalities?.includes("image") ?? definition?.inputModalities.includes("image") : definition?.inputModalities.includes("image");
|
|
526
|
+
return `
|
|
527
|
+
<label class="field"><span>模型元数据</span><select data-codex-metadata-mode>
|
|
528
|
+
<option value="official" ${!override ? "selected" : ""}>沿用官方定义</option>
|
|
529
|
+
<option value="override" ${override ? "selected" : ""}>覆盖上游限制</option>
|
|
530
|
+
</select></label>
|
|
531
|
+
<label class="field"><span>上下文窗口</span><input data-suite-model-context type="number" min="1" max="${esc(definition?.maxContextWindow || "")}" value="${esc(override ? model.contextWindow ?? "" : definition?.contextWindow ?? "")}" placeholder="沿用官方" ${override ? "" : "disabled"} /></label>
|
|
532
|
+
<label class="field checkbox-field"><span><input data-codex-vision type="checkbox" ${vision ? "checked" : ""} ${override && definition?.inputModalities.includes("image") ? "" : "disabled"} /> 图片输入</span></label>
|
|
533
|
+
<div class="field"><label>基础提示词</label><span class="field-hint" data-codex-instructions>${esc(definition?.name || "待选择官方模型")} / 官方定义</span></div>
|
|
534
|
+
${!model.codex && (model.contextWindow || model.compact) ? `<div class="notice warning full">旧策略尚未同步:context window ${esc(model.contextWindow || "未设置")};compact ${esc(model.compact?.strategy || "未设置")} ${esc(model.compact?.tokenLimit || "")}。Compact 仍由 Codex 管理。</div>` : model.compact ? `<div class="field full"><span class="field-hint">旧 Compact 策略已保留,未同步到 Codex</span></div>` : ""}
|
|
535
|
+
`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function syncCodexMetadata(row, resetOverrides = false) {
|
|
539
|
+
if (!row || !row.querySelector("[data-codex-metadata-mode]")) return;
|
|
540
|
+
const definition = officialModel(row.querySelector("[data-suite-model-client]").value);
|
|
541
|
+
const override = row.querySelector("[data-codex-metadata-mode]").value === "override";
|
|
542
|
+
const context = row.querySelector("[data-suite-model-context]");
|
|
543
|
+
const vision = row.querySelector("[data-codex-vision]");
|
|
544
|
+
if (resetOverrides || !override) {
|
|
545
|
+
context.value = definition?.contextWindow || "";
|
|
546
|
+
vision.checked = Boolean(definition?.inputModalities.includes("image"));
|
|
547
|
+
}
|
|
548
|
+
context.max = definition?.maxContextWindow || "";
|
|
549
|
+
setControlDisabled(context, !override);
|
|
550
|
+
setControlDisabled(vision, !override || !definition?.inputModalities.includes("image"));
|
|
551
|
+
row.querySelector("[data-codex-instructions]").textContent = `${definition?.name || "待选择官方模型"} / 官方定义`;
|
|
552
|
+
const summary = row.querySelector("[data-model-policy-summary]");
|
|
553
|
+
if (summary) summary.textContent = override ? "覆盖上游限制" : "沿用官方定义";
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
pageContent.addEventListener("change", (event) => {
|
|
557
|
+
const modelChanged = event.target.matches("[data-official-model]");
|
|
558
|
+
if (modelChanged) updateOfficialModelHint(event.target);
|
|
559
|
+
if (modelChanged || event.target.matches("[data-codex-metadata-mode]")) {
|
|
560
|
+
syncCodexMetadata(event.target.closest("[data-suite-model]"), modelChanged);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
pageContent.addEventListener("invalid", (event) => {
|
|
565
|
+
// Reveal invalid controls before the browser tries to focus them.
|
|
566
|
+
const settings = event.target.closest(".suite-model-settings");
|
|
567
|
+
if (settings) settings.open = true;
|
|
568
|
+
}, true);
|
|
569
|
+
|
|
570
|
+
function codexModelFields(clientModelId, existing = {}, row) {
|
|
571
|
+
const definition = officialModel(clientModelId);
|
|
572
|
+
if (!definition) throw new Error("请选择本机 Codex 目录中的官方 GPT 模型;旧模型需要先确认对应关系。");
|
|
573
|
+
const mode = row?.querySelector("[data-codex-metadata-mode]")?.value || "official";
|
|
574
|
+
const codex = { metadataMode: mode };
|
|
575
|
+
const capabilities = [...definition.capabilities];
|
|
576
|
+
let contextWindow = existing.contextWindow;
|
|
577
|
+
if (mode === "override") {
|
|
578
|
+
const value = row.querySelector("[data-suite-model-context]").value.trim();
|
|
579
|
+
contextWindow = value ? Number(value) : undefined;
|
|
580
|
+
if (value && (!Number.isSafeInteger(contextWindow) || contextWindow < 1 || contextWindow > definition.maxContextWindow)) {
|
|
581
|
+
throw new Error(`${clientModelId} 的上下文窗口必须在 1 到 ${definition.maxContextWindow} 之间`);
|
|
582
|
+
}
|
|
583
|
+
const vision = row.querySelector("[data-codex-vision]").checked && definition.inputModalities.includes("image");
|
|
584
|
+
codex.inputModalities = vision ? ["text", "image"] : ["text"];
|
|
585
|
+
if (!vision && capabilities.includes("vision")) capabilities.splice(capabilities.indexOf("vision"), 1);
|
|
586
|
+
}
|
|
587
|
+
return { codex, capabilities, contextWindow };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function renderSuiteDetail() {
|
|
591
|
+
const suite = selectedSuite();
|
|
592
|
+
if (!suite) {
|
|
593
|
+
return `
|
|
594
|
+
<div class="panel empty-state-panel">
|
|
595
|
+
<div class="empty"><strong>配置不存在</strong><div class="form-actions"><button class="button" data-action="back-overview">返回列表</button></div></div>
|
|
596
|
+
</div>
|
|
597
|
+
`;
|
|
598
|
+
}
|
|
599
|
+
const providerStatus = suiteProviderStatus(suite);
|
|
600
|
+
const upstreamId = suite.upstreamId || "";
|
|
601
|
+
const upstream = upstreamId ? state.candidate.upstreams[upstreamId] : null;
|
|
602
|
+
const binding = suite.binding;
|
|
603
|
+
const provider = suite.virtualProvider;
|
|
604
|
+
return `
|
|
605
|
+
<div class="suite-toolbar">
|
|
606
|
+
<button class="text-button" data-action="back-overview">返回列表</button>
|
|
607
|
+
<div class="suite-status"><span>${esc(targetLabel(binding.target))} · ${suite.models.length ? `${suite.models.length} 项模型设置` : "模型名直接透传"}</span><span class="status-badge ${providerStatus.className}">${esc(providerStatus.label)}</span></div>
|
|
608
|
+
</div>
|
|
609
|
+
<div class="panel suite-editor">
|
|
610
|
+
<section class="suite-section" aria-labelledby="suite-upstream-title">
|
|
611
|
+
<div class="suite-section-heading">
|
|
612
|
+
<h2 id="suite-upstream-title">上游连接</h2>
|
|
613
|
+
<p>此配置的所有模型共用这一上游。</p>
|
|
614
|
+
</div>
|
|
615
|
+
${upstream ? `
|
|
616
|
+
<form id="suite-upstream-form">
|
|
617
|
+
<input type="hidden" name="id" value="${esc(upstreamId)}" />
|
|
618
|
+
<input type="hidden" name="protocol" value="${esc(upstream.protocol || "openai.responses")}" />
|
|
619
|
+
<div class="form-grid suite-upstream-fields">
|
|
620
|
+
${field("上游名称", "name", upstream.name || upstreamId, "例如 Relay A")}
|
|
621
|
+
${field("上游地址", "baseUrl", upstream.baseUrl || "", "https://relay.example.com/v1", false, "url")}
|
|
622
|
+
${field("API Key", "secret", "", upstreamSecretConfigured(upstreamId, upstream) ? "已配置,留空保留现有密钥" : "粘贴上游 API Key", false, "password")}
|
|
623
|
+
</div>
|
|
624
|
+
<div class="suite-section-footer">
|
|
625
|
+
<span class="field-hint">保存后生效,测试使用已保存的连接。</span>
|
|
626
|
+
<div class="form-actions"><button class="button" type="submit">保存上游</button><button class="button" type="button" data-action="test-upstream" data-id="${esc(upstreamId)}">测试连通性</button></div>
|
|
627
|
+
</div>
|
|
628
|
+
</form>
|
|
629
|
+
` : `<div class="empty"><strong>未配置上游</strong><div class="form-actions"><button class="button" data-action="new-upstream">添加上游</button></div></div>`}
|
|
630
|
+
</section>
|
|
631
|
+
<section class="suite-section" aria-labelledby="suite-models-title">
|
|
632
|
+
<div class="suite-section-heading suite-models-heading">
|
|
633
|
+
<div><h2 id="suite-models-title">模型设置(可选)</h2><p>默认透传请求中的模型名;需要改名或覆盖上下文等参数时再添加设置。</p></div>
|
|
634
|
+
<button class="button" type="button" data-action="add-suite-model">添加模型设置</button>
|
|
635
|
+
</div>
|
|
636
|
+
${suite.target === "codex" ? codexCatalogStatus() : ""}
|
|
637
|
+
<form id="suite-models-form" data-suite-context="${esc(JSON.stringify([suite.bindingId, suite.target, binding.virtualProvider, suite.route?.id, upstreamId]))}">
|
|
638
|
+
<div id="suite-model-list" class="suite-model-list">
|
|
639
|
+
${suite.models.length
|
|
640
|
+
? suite.models.map(({ id, profile }) => suiteModelEditor(suite, id, profile, upstreamId)).join("")
|
|
641
|
+
: `<div class="empty">已启用模型名直接透传,无需添加模型设置。</div>`}
|
|
642
|
+
</div>
|
|
643
|
+
<div class="suite-section-footer">
|
|
644
|
+
<span class="field-hint">按需展开模型能力与上下文设置。</span>
|
|
645
|
+
<div class="form-actions">
|
|
646
|
+
<button class="button" type="submit">保存模型设置</button>
|
|
647
|
+
${suite.target === "codex" ? "" : `<button class="button" type="button" data-action="open-advanced-models">高级模型设置</button>`}
|
|
648
|
+
</div>
|
|
649
|
+
</div>
|
|
650
|
+
</form>
|
|
651
|
+
</section>
|
|
652
|
+
</div>
|
|
653
|
+
<section class="panel suite-connection" aria-labelledby="suite-connection-title">
|
|
654
|
+
<div class="panel-header">
|
|
655
|
+
<h2 id="suite-connection-title">${esc(targetLabel(binding.target))} 接入</h2>
|
|
656
|
+
<div class="suite-connection-actions">
|
|
657
|
+
<button class="button button-quiet" type="button" data-action="toggle-vp" data-id="${esc(provider.id)}">${provider.enabled === false ? "启动服务" : "暂停服务"}</button>
|
|
658
|
+
<button class="button" type="button" data-action="preview-artifacts">预览配置</button>
|
|
659
|
+
${binding.target === "codex" ? `<button class="button button-primary" type="button" data-action="apply-target">应用到 Codex</button>` : ""}
|
|
660
|
+
</div>
|
|
661
|
+
</div>
|
|
662
|
+
<div class="panel-body">
|
|
663
|
+
<dl class="suite-connection-facts">
|
|
664
|
+
<div><dt>本地地址</dt><dd class="mono">${esc(suiteEndpoint(suite))}</dd></div>
|
|
665
|
+
<div><dt>Virtual Provider ID</dt><dd class="mono">${esc(binding.virtualProvider)}</dd></div>
|
|
666
|
+
</dl>
|
|
667
|
+
${state.artifactPreview ? artifactPanel(state.artifactPreview) : ""}
|
|
668
|
+
</div>
|
|
669
|
+
</section>
|
|
670
|
+
`;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function renderSuiteCreate() {
|
|
674
|
+
return `
|
|
675
|
+
<button class="text-button" data-action="back-overview">返回列表</button>
|
|
676
|
+
<form id="suite-create-form" class="config-form panel">
|
|
677
|
+
<section class="config-section" aria-labelledby="config-basics">
|
|
678
|
+
<h2 id="config-basics">基本信息</h2>
|
|
679
|
+
<div class="form-grid">
|
|
680
|
+
${field("配置名称(可选)", "suiteName", "", "例如 my-relay;留空自动生成")}
|
|
681
|
+
${selectField("CLI", "target", "codex", ["codex"])}
|
|
682
|
+
</div>
|
|
683
|
+
</section>
|
|
684
|
+
<section class="config-section" aria-labelledby="config-upstream">
|
|
685
|
+
<h2 id="config-upstream">上游连接</h2>
|
|
686
|
+
<div class="form-grid">
|
|
687
|
+
${field("上游地址", "upstreamBaseUrl", "", "https://relay.example.com/v1", false, "url", false, true)}
|
|
688
|
+
${field("API Key", "upstreamSecret", "", "", false, "password", false, true)}
|
|
689
|
+
</div>
|
|
690
|
+
</section>
|
|
691
|
+
<section class="config-section" aria-labelledby="config-models">
|
|
692
|
+
<div class="subsection-header">
|
|
693
|
+
<h2 id="config-models">模型设置(可选)</h2>
|
|
694
|
+
<button class="mini-button" type="button" data-action="add-create-model">添加模型设置</button>
|
|
695
|
+
</div>
|
|
696
|
+
<p class="field-hint">默认透传请求中的模型名。需要改名时添加设置,上下文等参数可在创建后调整。</p>
|
|
697
|
+
${codexCatalogStatus()}
|
|
698
|
+
<div id="create-model-list" class="create-model-list"></div>
|
|
699
|
+
</section>
|
|
700
|
+
<div class="config-form-footer">
|
|
701
|
+
<button class="button" type="button" data-action="back-overview">取消</button>
|
|
702
|
+
<button class="button button-primary" type="submit">创建配置</button>
|
|
703
|
+
</div>
|
|
704
|
+
</form>
|
|
705
|
+
`;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function createModelRow(values = {}) {
|
|
709
|
+
const model = {
|
|
710
|
+
clientModelId: values.clientModelId || "",
|
|
711
|
+
upstreamModelId: values.upstreamModelId || "",
|
|
712
|
+
};
|
|
713
|
+
return `
|
|
714
|
+
<div class="create-model-row" data-create-model>
|
|
715
|
+
${officialModelSelect('data-create-field="clientModelId"', model.clientModelId)}
|
|
716
|
+
<label class="field"><span>上游模型 ID(可选)</span><input data-create-field="upstreamModelId" value="${esc(model.upstreamModelId)}" placeholder="留空使用请求中的模型名" /></label>
|
|
717
|
+
<button class="mini-button" type="button" data-action="remove-create-model" aria-label="移除模型映射">移除</button>
|
|
718
|
+
</div>
|
|
719
|
+
`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function suiteDefaults() {
|
|
723
|
+
const upstreamId = nextConfigId(state.candidate?.upstreams, "relay-main");
|
|
724
|
+
const bindingId = nextConfigId(state.candidate?.bindings, "codex-main");
|
|
725
|
+
const routeId = nextConfigId(state.candidate?.routes, `${upstreamId}-route`);
|
|
726
|
+
return { upstreamId, bindingId, routeId };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function nextConfigId(record, base) {
|
|
730
|
+
const source = record || {};
|
|
731
|
+
if (!source[base]) return base;
|
|
732
|
+
let suffix = 2;
|
|
733
|
+
while (source[`${base}-${suffix}`]) suffix += 1;
|
|
734
|
+
return `${base}-${suffix}`;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function modelProfileId(clientModelId, existingModels, reservedIds) {
|
|
738
|
+
const normalized = String(clientModelId || "")
|
|
739
|
+
.trim()
|
|
740
|
+
.toLowerCase()
|
|
741
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
742
|
+
.replace(/^-+|-+$/g, "")
|
|
743
|
+
.replace(/^[^a-z0-9]+/, "")
|
|
744
|
+
.slice(0, 56) || "model";
|
|
745
|
+
const occupiedIds = new Set([...Object.keys(existingModels || {}), ...reservedIds]);
|
|
746
|
+
let id = normalized;
|
|
747
|
+
let suffix = 2;
|
|
748
|
+
while (occupiedIds.has(id)) id = `${normalized}-${suffix++}`;
|
|
749
|
+
reservedIds.add(id);
|
|
750
|
+
return id;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function upstreamDisplayName(baseUrl, fallback) {
|
|
754
|
+
try {
|
|
755
|
+
return new URL(baseUrl).hostname || fallback;
|
|
756
|
+
} catch {
|
|
757
|
+
return fallback;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function renderUpstreams() {
|
|
762
|
+
const config = state.candidate;
|
|
763
|
+
const selectedId = state.selected.upstream;
|
|
764
|
+
const selected = selectedId ? config.upstreams[selectedId] : null;
|
|
765
|
+
const integrationLabel = selected?.integration === "codex-native-provider"
|
|
766
|
+
? "Codex Native Provider Integration"
|
|
767
|
+
: "Native upstream connection";
|
|
768
|
+
return `
|
|
769
|
+
<button class="text-button" data-action="back-overview">返回列表</button>
|
|
770
|
+
<div class="form-layout">
|
|
771
|
+
<div class="panel">
|
|
772
|
+
<div class="panel-header">
|
|
773
|
+
<h2>上游列表</h2>
|
|
774
|
+
<button class="button" data-action="new-upstream">新增上游</button>
|
|
775
|
+
</div>
|
|
776
|
+
<div class="panel-body">
|
|
777
|
+
${
|
|
778
|
+
Object.keys(config.upstreams).length
|
|
779
|
+
? `<div class="list">${entries(config.upstreams).map(([id, item]) => upstreamRow(id, item, id === selectedId)).join("")}</div>`
|
|
780
|
+
: `<div class="empty">暂无上游</div>`
|
|
781
|
+
}
|
|
782
|
+
</div>
|
|
783
|
+
</div>
|
|
784
|
+
<div class="panel">
|
|
785
|
+
<div class="panel-header">
|
|
786
|
+
<h2>${selected ? esc(selected.name || selectedId) : "添加上游"}</h2>
|
|
787
|
+
</div>
|
|
788
|
+
<div class="panel-body">
|
|
789
|
+
<form id="upstream-form">
|
|
790
|
+
<div class="form-grid">
|
|
791
|
+
${field("Upstream ID", "id", selectedId || "", "例如 relay-main", false, "text", Boolean(selectedId))}
|
|
792
|
+
${field("显示名称", "name", selected?.name || "", "例如 xxx")}
|
|
793
|
+
<div class="field"><span>上游接入方式</span><div class="static-field">${integrationLabel}</div></div>
|
|
794
|
+
${selectField("上游协议", "protocol", selected?.protocol || "openai.responses", ["openai.responses", "openai.chat_completions", "anthropic.messages"])}
|
|
795
|
+
${field("真实 base URL", "baseUrl", selected?.baseUrl || "", "https://relay.example.com/v1", true)}
|
|
796
|
+
${field("API key", "secret", "", upstreamSecretConfigured(selectedId, selected) ? "已配置,留空表示不修改" : "粘贴上游 API key", false, "password")}
|
|
797
|
+
${field("上游认证 header", "authHeader", selected?.auth?.header || selected?.authHeader || (selected?.protocol === "anthropic.messages" ? "x-api-key" : "authorization"), "authorization 或 x-api-key")}
|
|
798
|
+
</div>
|
|
799
|
+
<div class="form-actions"><button class="button button-primary" type="submit">保存</button>${selected ? `<button class="button" type="button" data-action="test-upstream" data-id="${esc(selectedId)}">测试连通性</button><button class="button button-danger" type="button" data-action="delete-upstream" data-id="${esc(selectedId)}">删除</button>` : ""}</div>
|
|
800
|
+
</form>
|
|
801
|
+
</div>
|
|
802
|
+
</div>
|
|
803
|
+
</div>
|
|
804
|
+
`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function renderModels() {
|
|
808
|
+
const config = state.candidate;
|
|
809
|
+
const selectedId = state.selected.model;
|
|
810
|
+
const selected = selectedId ? config.models[selectedId] : null;
|
|
811
|
+
const compact = selected?.compact || {};
|
|
812
|
+
const upstreamId = firstKey(selected?.upstreams) || firstKey(config.upstreams) || "";
|
|
813
|
+
const mapping = selected?.upstreams?.[upstreamId] || {};
|
|
814
|
+
return `
|
|
815
|
+
<button class="text-button" data-action="back-overview">返回列表</button>
|
|
816
|
+
<div class="form-layout">
|
|
817
|
+
<div class="panel">
|
|
818
|
+
<div class="panel-header">
|
|
819
|
+
<h2>模型列表</h2>
|
|
820
|
+
<button class="button button-primary" data-action="new-model">新增模型</button>
|
|
821
|
+
</div>
|
|
822
|
+
<div class="panel-body">
|
|
823
|
+
${
|
|
824
|
+
Object.keys(config.models).length
|
|
825
|
+
? `<div class="list">${entries(config.models).map(([id, item]) => modelRow(id, item, id === selectedId)).join("")}</div>`
|
|
826
|
+
: `<div class="empty">暂无模型</div>`
|
|
827
|
+
}
|
|
828
|
+
</div>
|
|
829
|
+
</div>
|
|
830
|
+
<div class="panel">
|
|
831
|
+
<div class="panel-header"><h2>${selected ? esc(selected.name || selectedId) : "新增模型"}</h2></div>
|
|
832
|
+
<div class="panel-body">
|
|
833
|
+
<form id="model-form">
|
|
834
|
+
<div class="form-grid">
|
|
835
|
+
${field("CableTidy Model ID", "id", selectedId || "", "例如 gpt56-sol", false, "text", Boolean(selectedId))}
|
|
836
|
+
${field("Client model ID", "clientModelId", selected?.clientModelId || selected?.aliases?.[0] || "", "Codex 请求里的 model")}
|
|
837
|
+
${field("Aliases", "aliases", (selected?.aliases || []).join(", "), "son, codex-default")}
|
|
838
|
+
${field("Family", "family", selected?.family || "codex", "codex")}
|
|
839
|
+
${field("Capabilities", "capabilities", (selected?.capabilities || ["streaming", "tools", "reasoning"]).join(", "), "streaming, tools, reasoning", true)}
|
|
840
|
+
${field("Context window", "contextWindow", selected?.contextWindow ?? 1000000, "tokens")}
|
|
841
|
+
${selectField("Compact strategy", "compactStrategy", compact.strategy || "auto", ["auto", "manual", "disabled"])}
|
|
842
|
+
${field("Compact token limit", "compactTokenLimit", compact.tokenLimit ?? 850000, "tokens")}
|
|
843
|
+
</div>
|
|
844
|
+
<div class="subsection">
|
|
845
|
+
<div class="subsection-header"><h3>上游模型映射</h3></div>
|
|
846
|
+
<div class="form-grid">
|
|
847
|
+
${selectField("Upstream", "upstreamId", upstreamId, Object.keys(config.upstreams))}
|
|
848
|
+
${field("上游模型 ID(可选)", "upstreamModelId", mapping.upstreamModelId || "", "留空使用请求中的模型名")}
|
|
849
|
+
${field("能力覆盖", "capabilityOverrides", (mapping.capabilityOverrides || []).join(", "), "可留空")}
|
|
850
|
+
</div>
|
|
851
|
+
</div>
|
|
852
|
+
<div class="form-actions"><button class="button button-primary" type="submit">保存</button>${selected ? `<button class="button button-danger" type="button" data-action="delete-model" data-id="${esc(selectedId)}">删除</button>` : ""}</div>
|
|
853
|
+
</form>
|
|
854
|
+
</div>
|
|
855
|
+
</div>
|
|
856
|
+
</div>
|
|
857
|
+
`;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function renderDiagnostics() {
|
|
861
|
+
const config = state.config;
|
|
862
|
+
const resolveResult = state.resolveResult;
|
|
863
|
+
return `
|
|
864
|
+
<div class="panel">
|
|
865
|
+
<div class="panel-header"><h2>模型解析测试</h2></div>
|
|
866
|
+
<div class="panel-body">
|
|
867
|
+
<form id="resolve-form">
|
|
868
|
+
<div class="form-grid">
|
|
869
|
+
${selectField("Virtual Provider", "virtualProviderId", state.selected.virtualProvider || "", Object.keys(config.virtualProviders))}
|
|
870
|
+
${field("Client model ID", "model", "", "sonnet 或 codex-gpt56-sol")}
|
|
871
|
+
${checkboxField("stream", "stream", true)}
|
|
872
|
+
${checkboxField("reasoning", "reasoning", false)}
|
|
873
|
+
</div>
|
|
874
|
+
<div class="form-actions"><button class="button button-primary" type="submit">解析路径</button></div>
|
|
875
|
+
</form>
|
|
876
|
+
${resolveResult ? resolveResultPanel(resolveResult) : ""}
|
|
877
|
+
</div>
|
|
878
|
+
</div>
|
|
879
|
+
<div class="panel">
|
|
880
|
+
<div class="panel-header"><h2>事件记录</h2><button class="mini-button" data-action="refresh">刷新</button></div>
|
|
881
|
+
<div class="panel-body">${state.events.length ? `<div class="list">${state.events.map(eventRow).join("")}</div>` : `<div class="empty">暂无事件</div>`}</div>
|
|
882
|
+
</div>
|
|
883
|
+
`;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function upstreamRow(id, item, active = false) {
|
|
887
|
+
const health = state.runtime?.health?.[id];
|
|
888
|
+
const healthClass = health?.outcome === "failure" ? "danger" : health ? "" : "warning";
|
|
889
|
+
const status = health?.outcome || (
|
|
890
|
+
upstreamSecretConfigured(id, item)
|
|
891
|
+
? "ready"
|
|
892
|
+
: "needs key"
|
|
893
|
+
);
|
|
894
|
+
return `<button class="list-row ${active ? "is-selected" : ""}" data-action="select-upstream" data-id="${esc(id)}"><div><h3>${esc(item.name || id)}</h3><p>${esc(item.protocol || "protocol")} · ${esc(item.baseUrl || "未设置")}</p></div><span class="status-badge ${healthClass}">${status}</span></button>`;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function upstreamSecretConfigured(id, item) {
|
|
898
|
+
return Boolean(item?.secretConfigured);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function modelRow(id, item, active = false) {
|
|
902
|
+
const context = item.contextWindow ? `${item.contextWindow.toLocaleString()} ctx` : "context unset";
|
|
903
|
+
const compact = item.compact?.tokenLimit ? `${item.compact.tokenLimit.toLocaleString()} compact` : "compact unset";
|
|
904
|
+
return `<button class="list-row ${active ? "is-selected" : ""}" data-action="select-model" data-id="${esc(id)}"><div><h3>${esc(item.clientModelId || item.aliases?.[0] || id)}</h3><p>${esc(id)} · ${(item.capabilities || []).join(" · ")} · ${esc(context)} · ${esc(compact)}</p></div><span class="status-badge">${Object.keys(item.upstreams || {}).length} bindings</span></button>`;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function eventRow(event) {
|
|
908
|
+
return `<div class="list-row"><div><h3>${esc(event.type)}</h3><p>${esc(new Date(event.at).toLocaleString())} · ${esc(event.data?.upstreamId || event.data?.revision || "control")}</p></div><span class="table-meta">${esc(event.id)}</span></div>`;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function field(label, name, value, placeholder = "", full = false, type = "text", readonly = false, required = false) {
|
|
912
|
+
return `<label class="field ${full ? "full" : ""}"><span>${esc(label)}</span><input name="${esc(name)}" type="${esc(type)}" value="${esc(value)}" placeholder="${esc(placeholder)}" ${readonly ? "readonly" : ""} ${required ? "required" : ""} /></label>`;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function selectField(label, name, value, items) {
|
|
916
|
+
return `<label class="field"><span>${esc(label)}</span><select name="${esc(name)}" aria-label="${esc(label)}" >${optionList(items, value)}</select></label>`;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function checkboxField(label, name, checked) {
|
|
920
|
+
return `<label class="field checkbox-field"><span><input name="${esc(name)}" type="checkbox" ${checked ? "checked" : ""} /> ${esc(label)}</span></label>`;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function resolveResultPanel(result) {
|
|
924
|
+
if (!result.ok) {
|
|
925
|
+
return `<div class="subsection"><div class="notice warning">${esc(result.error?.message || "模型解析失败")}</div></div>`;
|
|
926
|
+
}
|
|
927
|
+
return `<div class="subsection"><h3>解析结果</h3><pre class="code-preview">${esc(
|
|
928
|
+
`client model : ${result.clientModelId}\nprofile : ${result.profileId}\nroute : ${result.routeId}\nupstream : ${result.upstreamId}\nupstream model : ${result.upstreamModelId}\ncapabilities : ${(result.capabilities || []).join(", ")}`,
|
|
929
|
+
)}</pre></div>`;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function artifactPanel(artifacts) {
|
|
933
|
+
const title = artifacts.target === "claude-code"
|
|
934
|
+
? "Claude Code environment preview"
|
|
935
|
+
: artifacts.target === "generic-env"
|
|
936
|
+
? "Generic environment preview"
|
|
937
|
+
: "Codex local config.toml preview";
|
|
938
|
+
const variables = Object.keys(artifacts.environment?.vars || {}).length
|
|
939
|
+
? `<div class="subsection"><div class="table-meta">ENVIRONMENT</div><pre class="code-preview">${esc(Object.entries(artifacts.environment.vars).map(([key, value]) => `${key}=${value}`).join("\n"))}</pre></div>`
|
|
940
|
+
: "";
|
|
941
|
+
return `
|
|
942
|
+
<div class="subsection">
|
|
943
|
+
<div class="subsection-header"><h3>${title}</h3><span class="field-hint">${esc(artifacts.mode || "managed_proxy")}</span></div>
|
|
944
|
+
${artifacts.catalogSummary ? `<div class="field-hint">${esc(artifacts.catalogSummary.sourceVersion)} / ${artifacts.catalogSummary.mode === "managed" ? "生成模型目录" : "沿用当前 Codex 目录"}</div>` : ""}
|
|
945
|
+
${(artifacts.warnings || []).map((message) => `<div class="notice warning">${esc(message)}</div>`).join("")}
|
|
946
|
+
${(artifacts.files || []).map((file) => file.kind === "json"
|
|
947
|
+
? `<details class="subsection"><summary>${esc(file.path)}</summary><pre class="code-preview">${esc(file.contents)}</pre></details>`
|
|
948
|
+
: `<div class="subsection"><div class="table-meta">${esc(file.path)}</div><pre class="code-preview">${esc(file.contents)}</pre></div>`).join("")}
|
|
949
|
+
${variables}
|
|
950
|
+
${artifacts.environment?.shell ? `<div class="subsection"><div class="table-meta">SETUP COMMAND</div><pre class="code-preview">${esc(artifacts.environment.shell)}</pre></div>` : ""}
|
|
951
|
+
</div>
|
|
952
|
+
`;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
async function handleAction(action, element) {
|
|
956
|
+
if (state.busy) return;
|
|
957
|
+
if ([
|
|
958
|
+
"create-suite", "open-suite", "back-overview", "select-upstream", "select-model",
|
|
959
|
+
"new-upstream", "new-model", "focus-upstream", "open-advanced-models",
|
|
960
|
+
].includes(action)) {
|
|
961
|
+
if (!confirmPageLeave()) return;
|
|
962
|
+
}
|
|
963
|
+
const unlock = lockControls();
|
|
964
|
+
try {
|
|
965
|
+
if (action === "refresh") {
|
|
966
|
+
await refresh();
|
|
967
|
+
} else if (action === "refresh-codex-models") {
|
|
968
|
+
state.codexCatalog = await api("/codex/models?refresh=1");
|
|
969
|
+
// Refresh choices without discarding unsaved form inputs or secrets.
|
|
970
|
+
pageContent.querySelectorAll("[data-official-model]").forEach((select) => {
|
|
971
|
+
select.innerHTML = officialModelOptions(select.value);
|
|
972
|
+
updateOfficialModelHint(select);
|
|
973
|
+
syncCodexMetadata(select.closest("[data-suite-model]"));
|
|
974
|
+
});
|
|
975
|
+
pageContent.querySelectorAll(".catalog-status").forEach((status) => {
|
|
976
|
+
status.querySelector("span").textContent = state.codexCatalog.available ? `${state.codexCatalog.version} / ${state.codexCatalog.models.length} 个官方模型` : state.codexCatalog.error.message;
|
|
977
|
+
});
|
|
978
|
+
toast(state.codexCatalog.available ? "模型列表已刷新。" : state.codexCatalog.error.message, !state.codexCatalog.available);
|
|
979
|
+
} else if (action === "reload-form") {
|
|
980
|
+
if (!window.confirm("放弃此表单的本地修改并加载最新配置?")) return;
|
|
981
|
+
render(captureEditedForms(element.closest("form")));
|
|
982
|
+
} else if (action === "create-suite") {
|
|
983
|
+
state.selected.suite = null;
|
|
984
|
+
state.page = "suite-create";
|
|
985
|
+
render();
|
|
986
|
+
} else if (action === "open-suite") {
|
|
987
|
+
selectSuite(element.dataset.id);
|
|
988
|
+
state.page = "suite-detail";
|
|
989
|
+
render();
|
|
990
|
+
} else if (action === "back-overview") {
|
|
991
|
+
state.page = "overview";
|
|
992
|
+
render();
|
|
993
|
+
} else if (action === "select-upstream") {
|
|
994
|
+
state.selected.upstream = element.dataset.id;
|
|
995
|
+
state.page = "upstreams";
|
|
996
|
+
render();
|
|
997
|
+
} else if (action === "select-model") {
|
|
998
|
+
state.selected.model = element.dataset.id;
|
|
999
|
+
state.page = "models";
|
|
1000
|
+
render();
|
|
1001
|
+
} else if (action === "new-upstream") {
|
|
1002
|
+
state.selected.upstream = null;
|
|
1003
|
+
state.page = "upstreams";
|
|
1004
|
+
render();
|
|
1005
|
+
} else if (action === "new-model") {
|
|
1006
|
+
state.selected.model = null;
|
|
1007
|
+
state.page = "models";
|
|
1008
|
+
render();
|
|
1009
|
+
} else if (action === "add-suite-model") {
|
|
1010
|
+
const list = pageContent.querySelector("#suite-model-list");
|
|
1011
|
+
if (!list) return;
|
|
1012
|
+
const wrapper = document.createElement("div");
|
|
1013
|
+
const suite = selectedSuite();
|
|
1014
|
+
const upstreamId = suite?.upstreamId || null;
|
|
1015
|
+
wrapper.innerHTML = suiteModelEditor(suite || {}, "", {
|
|
1016
|
+
clientModelId: "",
|
|
1017
|
+
...(suite?.target === "codex" ? { codex: { metadataMode: "official" } } : { capabilities: ["streaming", "tools", "reasoning"] }),
|
|
1018
|
+
upstreams: {},
|
|
1019
|
+
}, upstreamId);
|
|
1020
|
+
const row = wrapper.firstElementChild;
|
|
1021
|
+
list.querySelector(".empty")?.remove();
|
|
1022
|
+
list.appendChild(row);
|
|
1023
|
+
} else if (action === "remove-suite-model") {
|
|
1024
|
+
element.closest("[data-suite-model]")?.remove();
|
|
1025
|
+
} else if (action === "add-create-model") {
|
|
1026
|
+
const list = pageContent.querySelector("#create-model-list");
|
|
1027
|
+
if (!list) return;
|
|
1028
|
+
const wrapper = document.createElement("div");
|
|
1029
|
+
wrapper.innerHTML = createModelRow();
|
|
1030
|
+
const row = wrapper.firstElementChild;
|
|
1031
|
+
list.appendChild(row);
|
|
1032
|
+
} else if (action === "remove-create-model") {
|
|
1033
|
+
element.closest("[data-create-model]")?.remove();
|
|
1034
|
+
} else if (["delete-upstream", "delete-model"].includes(action)) {
|
|
1035
|
+
if (!window.confirm("删除此配置项?删除后立即生效。")) return;
|
|
1036
|
+
await saveChanges(() => removeConfigItem(action, element.dataset.id), element.closest("form"));
|
|
1037
|
+
} else if (action === "test-upstream") {
|
|
1038
|
+
await testUpstream(element.dataset.id);
|
|
1039
|
+
} else if (action === "toggle-vp") {
|
|
1040
|
+
await toggleVirtualProvider(element.dataset.id);
|
|
1041
|
+
} else if (action === "preview-artifacts") {
|
|
1042
|
+
await previewArtifacts();
|
|
1043
|
+
} else if (action === "apply-target") {
|
|
1044
|
+
await applyTarget();
|
|
1045
|
+
} else if (action === "focus-upstream") {
|
|
1046
|
+
state.page = "upstreams";
|
|
1047
|
+
state.selected.upstream = element.dataset.id;
|
|
1048
|
+
render();
|
|
1049
|
+
} else if (action === "open-advanced-models") {
|
|
1050
|
+
state.page = "models";
|
|
1051
|
+
state.selected.model = selectedSuite()?.modelIds?.[0] || firstKey(state.candidate.models);
|
|
1052
|
+
render();
|
|
1053
|
+
}
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
const form = element?.closest("form");
|
|
1056
|
+
if (form) showFormError(form, error);
|
|
1057
|
+
else toast(error.message, true);
|
|
1058
|
+
} finally {
|
|
1059
|
+
unlock();
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function lockControls() {
|
|
1064
|
+
state.busy = true;
|
|
1065
|
+
const controls = [...document.querySelectorAll("button, input, select, textarea")];
|
|
1066
|
+
for (const control of controls) {
|
|
1067
|
+
lockedControls.set(control, control.disabled);
|
|
1068
|
+
control.disabled = true;
|
|
1069
|
+
}
|
|
1070
|
+
return () => {
|
|
1071
|
+
for (const control of controls) {
|
|
1072
|
+
control.disabled = lockedControls.get(control);
|
|
1073
|
+
lockedControls.delete(control);
|
|
1074
|
+
}
|
|
1075
|
+
state.busy = false;
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function setControlDisabled(control, disabled) {
|
|
1080
|
+
// Metadata may change while an async action temporarily locks all controls.
|
|
1081
|
+
if (lockedControls.has(control)) lockedControls.set(control, disabled);
|
|
1082
|
+
else control.disabled = disabled;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function showFormError(form, error) {
|
|
1086
|
+
form.querySelector("[data-form-feedback]")?.remove();
|
|
1087
|
+
const feedback = document.createElement("div");
|
|
1088
|
+
feedback.className = "notice warning form-feedback";
|
|
1089
|
+
feedback.dataset.formFeedback = "";
|
|
1090
|
+
feedback.setAttribute("role", "alert");
|
|
1091
|
+
feedback.tabIndex = -1;
|
|
1092
|
+
const conflict = formConflicts.has(form);
|
|
1093
|
+
const message = conflict
|
|
1094
|
+
? "此表单与其他窗口的修改冲突,已暂停保存并保留当前输入。请先复制需要保留的内容,再加载最新配置后重新编辑。"
|
|
1095
|
+
: error.status === 409
|
|
1096
|
+
? "配置已在其他窗口变更。点击页面顶部的“刷新”合并最新配置后重试;无法自动合并的修改会提示处理。"
|
|
1097
|
+
: error.message || "保存失败,请重试。";
|
|
1098
|
+
const errors = error.body?.errors || [];
|
|
1099
|
+
feedback.innerHTML = esc(message) + (errors.length
|
|
1100
|
+
? "<ul>" + errors.map((item) => "<li>" + esc(item.path || "") + ": " + esc(item.message) + "</li>").join("") + "</ul>"
|
|
1101
|
+
: "") + (conflict ? '<div class="form-actions"><button class="button" type="button" data-action="reload-form">加载最新配置</button></div>' : "");
|
|
1102
|
+
form.prepend(feedback);
|
|
1103
|
+
feedback.focus({ preventScroll: true });
|
|
1104
|
+
feedback.scrollIntoView({ block: "nearest" });
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async function handleFormSubmit(event, form) {
|
|
1108
|
+
event.preventDefault();
|
|
1109
|
+
if (state.busy) return;
|
|
1110
|
+
if (formConflicts.has(form)) {
|
|
1111
|
+
showFormError(form, new Error("请先处理并发修改冲突。"));
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
// Read FormData before disabling controls; disabled inputs are omitted.
|
|
1115
|
+
const data = new FormData(form);
|
|
1116
|
+
const unlock = lockControls();
|
|
1117
|
+
const submitButton = form.querySelector('[type="submit"]');
|
|
1118
|
+
const buttonLabel = submitButton?.textContent;
|
|
1119
|
+
form.setAttribute("aria-busy", "true");
|
|
1120
|
+
form.querySelector("[data-form-feedback]")?.remove();
|
|
1121
|
+
try {
|
|
1122
|
+
// Inputs named "id" shadow the form.id property in the browser.
|
|
1123
|
+
const formId = form.getAttribute("id");
|
|
1124
|
+
if (formId === "resolve-form") {
|
|
1125
|
+
if (submitButton) submitButton.textContent = "解析中...";
|
|
1126
|
+
await resolveModel(data);
|
|
1127
|
+
render([form]);
|
|
1128
|
+
toast("模型解析已完成。");
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
if (submitButton) submitButton.textContent = "保存中...";
|
|
1132
|
+
const handlers = {
|
|
1133
|
+
"suite-create-form": () => saveSuiteCreate(data, form),
|
|
1134
|
+
"upstream-form": () => saveUpstream(data),
|
|
1135
|
+
"suite-upstream-form": () => saveSuiteUpstream(data),
|
|
1136
|
+
"suite-models-form": () => saveSuiteModels(form),
|
|
1137
|
+
"model-form": () => saveModel(data, form),
|
|
1138
|
+
};
|
|
1139
|
+
if (!handlers[formId]) throw new Error("无法保存此表单,请刷新页面后重试。");
|
|
1140
|
+
await saveChanges(handlers[formId], form);
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
showFormError(form, error);
|
|
1143
|
+
} finally {
|
|
1144
|
+
if (submitButton) submitButton.textContent = buttonLabel;
|
|
1145
|
+
form.removeAttribute("aria-busy");
|
|
1146
|
+
unlock();
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function removeConfigItem(action, id) {
|
|
1151
|
+
if (action === "delete-upstream") {
|
|
1152
|
+
delete state.candidate.upstreams[id];
|
|
1153
|
+
for (const model of values(state.candidate.models)) delete model.upstreams?.[id];
|
|
1154
|
+
state.selected.upstream = firstKey(state.candidate.upstreams);
|
|
1155
|
+
} else if (action === "delete-model") {
|
|
1156
|
+
delete state.candidate.models[id];
|
|
1157
|
+
for (const provider of values(state.candidate.virtualProviders)) {
|
|
1158
|
+
provider.allowedModels = (provider.allowedModels || []).filter((modelId) => modelId !== id);
|
|
1159
|
+
if (provider.defaultModel === id) delete provider.defaultModel;
|
|
1160
|
+
}
|
|
1161
|
+
for (const route of values(state.candidate.routes)) {
|
|
1162
|
+
for (const backend of route.backends || []) {
|
|
1163
|
+
backend.models = (backend.models || []).filter((modelId) => modelId !== id);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
for (const binding of values(state.candidate.bindings)) {
|
|
1167
|
+
if (binding.defaultModel === id) delete binding.defaultModel;
|
|
1168
|
+
}
|
|
1169
|
+
state.selected.model = firstKey(state.candidate.models);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function saveSuiteUpstream(data) {
|
|
1174
|
+
saveUpstream(data, true);
|
|
1175
|
+
state.page = "suite-detail";
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function saveSuiteModels(form) {
|
|
1179
|
+
const suite = selectedSuite();
|
|
1180
|
+
if (!suite) throw new Error("找不到当前配置套装");
|
|
1181
|
+
const modelRows = [...form.querySelectorAll("[data-suite-model]")];
|
|
1182
|
+
|
|
1183
|
+
const upstreamId = suite.upstreamId;
|
|
1184
|
+
if (!upstreamId || !state.candidate.upstreams[upstreamId]) {
|
|
1185
|
+
throw new Error("当前配置套装没有可用的上游");
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const clientModelIds = modelRows.map(
|
|
1189
|
+
(row) => row.querySelector("[data-suite-model-client]")?.value.trim() || "",
|
|
1190
|
+
);
|
|
1191
|
+
if (clientModelIds.some((id) => !id)) throw new Error("每个模型都必须填写客户端模型 ID");
|
|
1192
|
+
if (new Set(clientModelIds).size !== clientModelIds.length) {
|
|
1193
|
+
throw new Error("客户端模型 ID 不能重复");
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const reservedIds = new Set();
|
|
1197
|
+
const nextModels = {};
|
|
1198
|
+
for (const row of modelRows) {
|
|
1199
|
+
const oldId = row.dataset.modelId || "";
|
|
1200
|
+
const existing = state.candidate.models[oldId] || {};
|
|
1201
|
+
const clientModelId = row.querySelector("[data-suite-model-client]").value.trim();
|
|
1202
|
+
const profileId = oldId || modelProfileId(clientModelId, state.candidate.models, reservedIds);
|
|
1203
|
+
if (nextModels[profileId]) throw new Error(`模型 ID 冲突: ${profileId}`);
|
|
1204
|
+
|
|
1205
|
+
const upstreams = {};
|
|
1206
|
+
const upstreamInput = row.querySelector(
|
|
1207
|
+
`[data-suite-model-upstream="${CSS.escape(upstreamId)}"]`,
|
|
1208
|
+
);
|
|
1209
|
+
const capabilityInput = row.querySelector(
|
|
1210
|
+
`[data-suite-model-capabilities="${CSS.escape(upstreamId)}"]`,
|
|
1211
|
+
);
|
|
1212
|
+
const upstreamModelId = upstreamInput?.value.trim() || "";
|
|
1213
|
+
const capabilityOverrides = capabilityInput ? commaList(capabilityInput.value) : existing.upstreams?.[upstreamId]?.capabilityOverrides || [];
|
|
1214
|
+
upstreams[upstreamId] = { ...(existing.upstreams?.[upstreamId] || {}) };
|
|
1215
|
+
if (upstreamModelId) upstreams[upstreamId].upstreamModelId = upstreamModelId;
|
|
1216
|
+
else delete upstreams[upstreamId].upstreamModelId;
|
|
1217
|
+
if (capabilityOverrides.length) upstreams[upstreamId].capabilityOverrides = capabilityOverrides;
|
|
1218
|
+
else delete upstreams[upstreamId].capabilityOverrides;
|
|
1219
|
+
|
|
1220
|
+
const aliases = [...new Set([
|
|
1221
|
+
...(existing.aliases || []).filter((alias) => alias !== existing.clientModelId),
|
|
1222
|
+
clientModelId,
|
|
1223
|
+
])];
|
|
1224
|
+
const capabilities = commaList(
|
|
1225
|
+
row.querySelector("[data-suite-model-capabilities-common]")?.value,
|
|
1226
|
+
);
|
|
1227
|
+
nextModels[profileId] = {
|
|
1228
|
+
...existing,
|
|
1229
|
+
id: profileId,
|
|
1230
|
+
name: clientModelId,
|
|
1231
|
+
clientModelId,
|
|
1232
|
+
aliases,
|
|
1233
|
+
family: existing.family || "codex",
|
|
1234
|
+
capabilities: capabilities.length ? capabilities : ["streaming", "tools", "reasoning"],
|
|
1235
|
+
...(suite.target === "codex" ? codexModelFields(clientModelId, existing, row) : {
|
|
1236
|
+
contextWindow: Number(row.querySelector("[data-suite-model-context]")?.value || 1000000),
|
|
1237
|
+
compact: {
|
|
1238
|
+
strategy: row.querySelector("[data-suite-model-compact]")?.value || "auto",
|
|
1239
|
+
tokenLimit: Number(
|
|
1240
|
+
row.querySelector("[data-suite-model-compact-limit]")?.value || 850000,
|
|
1241
|
+
),
|
|
1242
|
+
},
|
|
1243
|
+
}),
|
|
1244
|
+
upstreams,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const routeId = suite.route?.id;
|
|
1249
|
+
if (!routeId) throw new Error("当前配置套装没有可编辑的 Route");
|
|
1250
|
+
const existingRouteBackend = suite.routeBackend || {};
|
|
1251
|
+
const nextRouteBackend = {
|
|
1252
|
+
...existingRouteBackend,
|
|
1253
|
+
upstream: upstreamId,
|
|
1254
|
+
models: Object.keys(nextModels),
|
|
1255
|
+
enabled: true,
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
const oldModelIds = new Set(suite.modelIds);
|
|
1259
|
+
const nextModelIds = new Set(Object.keys(nextModels));
|
|
1260
|
+
for (const oldModelId of oldModelIds) {
|
|
1261
|
+
if (nextModelIds.has(oldModelId)) continue;
|
|
1262
|
+
const usedByOtherProvider = values(state.candidate.virtualProviders).some(
|
|
1263
|
+
(provider) =>
|
|
1264
|
+
provider !== suite.virtualProvider &&
|
|
1265
|
+
(provider.allowedModels || []).includes(oldModelId),
|
|
1266
|
+
);
|
|
1267
|
+
const usedByOtherRoute = entries(state.candidate.routes).some(
|
|
1268
|
+
([id, route]) =>
|
|
1269
|
+
id !== routeId &&
|
|
1270
|
+
(route.backends || []).some((backend) => (backend.models || []).includes(oldModelId)),
|
|
1271
|
+
);
|
|
1272
|
+
if (!usedByOtherProvider && !usedByOtherRoute) delete state.candidate.models[oldModelId];
|
|
1273
|
+
}
|
|
1274
|
+
Object.assign(state.candidate.models, nextModels);
|
|
1275
|
+
state.candidate.routes[routeId] = {
|
|
1276
|
+
...state.candidate.routes[routeId],
|
|
1277
|
+
backends: [nextRouteBackend],
|
|
1278
|
+
};
|
|
1279
|
+
state.candidate.virtualProviders[suite.binding.virtualProvider] = {
|
|
1280
|
+
...state.candidate.virtualProviders[suite.binding.virtualProvider],
|
|
1281
|
+
allowedModels: Object.keys(nextModels),
|
|
1282
|
+
defaultModel: oldModelIds.has(suite.virtualProvider.defaultModel) && !nextModelIds.has(suite.virtualProvider.defaultModel)
|
|
1283
|
+
? undefined : suite.virtualProvider.defaultModel,
|
|
1284
|
+
};
|
|
1285
|
+
state.candidate.bindings[suite.bindingId] = {
|
|
1286
|
+
...state.candidate.bindings[suite.bindingId],
|
|
1287
|
+
defaultModel: oldModelIds.has(suite.binding.defaultModel) && !nextModelIds.has(suite.binding.defaultModel)
|
|
1288
|
+
? undefined : suite.binding.defaultModel,
|
|
1289
|
+
};
|
|
1290
|
+
selectSuite(suite.bindingId);
|
|
1291
|
+
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function saveSuiteCreate(data, form) {
|
|
1295
|
+
const defaults = suiteDefaults();
|
|
1296
|
+
const upstreamBaseUrl = String(data.get("upstreamBaseUrl") || "").trim();
|
|
1297
|
+
const upstreamSecret = String(data.get("upstreamSecret") || "").trim();
|
|
1298
|
+
const target = String(data.get("target") || "codex").trim();
|
|
1299
|
+
const suiteName = String(data.get("suiteName") || "").trim();
|
|
1300
|
+
const modelEntries = [...form.querySelectorAll("[data-create-model]")].map((row) => {
|
|
1301
|
+
const read = (key) =>
|
|
1302
|
+
row.querySelector(`[data-create-field="${CSS.escape(key)}"]`)?.value.trim() || "";
|
|
1303
|
+
return {
|
|
1304
|
+
clientModelId: read("clientModelId"),
|
|
1305
|
+
upstreamModelId: read("upstreamModelId"),
|
|
1306
|
+
};
|
|
1307
|
+
});
|
|
1308
|
+
if (!upstreamBaseUrl || !upstreamSecret) {
|
|
1309
|
+
throw new Error("请填写上游地址和上游 API Key");
|
|
1310
|
+
}
|
|
1311
|
+
const missingModelField = modelEntries.find(
|
|
1312
|
+
(model) => !model.clientModelId,
|
|
1313
|
+
);
|
|
1314
|
+
if (missingModelField) {
|
|
1315
|
+
throw new Error("添加模型设置时请选择客户端模型");
|
|
1316
|
+
}
|
|
1317
|
+
const clientModelIds = modelEntries.map((model) => model.clientModelId);
|
|
1318
|
+
if (new Set(clientModelIds).size !== clientModelIds.length) {
|
|
1319
|
+
throw new Error("Client model ID 不能重复");
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
const upstreamId = defaults.upstreamId;
|
|
1323
|
+
const upstreamName = upstreamDisplayName(upstreamBaseUrl, upstreamId);
|
|
1324
|
+
const routeId = defaults.routeId;
|
|
1325
|
+
const name = suiteName || `Codex / ${upstreamName}`;
|
|
1326
|
+
const bindingId = configurationId(name, defaults.bindingId);
|
|
1327
|
+
const virtualProviderId = providerIdForConfiguration(bindingId);
|
|
1328
|
+
if (Object.hasOwn(state.candidate.bindings, bindingId) || Object.hasOwn(state.candidate.virtualProviders, virtualProviderId)) {
|
|
1329
|
+
throw new Error(`配置名称对应的 ID 已存在: ${bindingId},请使用不同的配置名称`);
|
|
1330
|
+
}
|
|
1331
|
+
const reservedModelIds = new Set();
|
|
1332
|
+
const models = Object.fromEntries(
|
|
1333
|
+
modelEntries.map((model) => {
|
|
1334
|
+
const profileId = modelProfileId(
|
|
1335
|
+
model.clientModelId,
|
|
1336
|
+
state.candidate.models,
|
|
1337
|
+
reservedModelIds,
|
|
1338
|
+
);
|
|
1339
|
+
return [
|
|
1340
|
+
profileId,
|
|
1341
|
+
{
|
|
1342
|
+
id: profileId,
|
|
1343
|
+
name: model.clientModelId,
|
|
1344
|
+
clientModelId: model.clientModelId,
|
|
1345
|
+
aliases: [model.clientModelId],
|
|
1346
|
+
family: "codex",
|
|
1347
|
+
...codexModelFields(model.clientModelId),
|
|
1348
|
+
upstreams: {
|
|
1349
|
+
[upstreamId]: {
|
|
1350
|
+
...(model.upstreamModelId ? { upstreamModelId: model.upstreamModelId } : {}),
|
|
1351
|
+
},
|
|
1352
|
+
},
|
|
1353
|
+
},
|
|
1354
|
+
];
|
|
1355
|
+
}),
|
|
1356
|
+
);
|
|
1357
|
+
const modelIds = Object.keys(models);
|
|
1358
|
+
|
|
1359
|
+
state.candidate.upstreams[upstreamId] = {
|
|
1360
|
+
id: upstreamId,
|
|
1361
|
+
name: upstreamName,
|
|
1362
|
+
integration: "codex-native-provider",
|
|
1363
|
+
protocol: "openai.responses",
|
|
1364
|
+
baseUrl: upstreamBaseUrl,
|
|
1365
|
+
secretRef: `secret://upstreams/${upstreamId}`,
|
|
1366
|
+
enabled: true,
|
|
1367
|
+
};
|
|
1368
|
+
for (const modelId of modelIds) delete state.candidate.models[modelId];
|
|
1369
|
+
Object.assign(state.candidate.models, models);
|
|
1370
|
+
state.candidate.routes[routeId] = {
|
|
1371
|
+
id: routeId,
|
|
1372
|
+
name: `${upstreamName} route`,
|
|
1373
|
+
backends: [
|
|
1374
|
+
{
|
|
1375
|
+
upstream: upstreamId,
|
|
1376
|
+
models: modelIds,
|
|
1377
|
+
enabled: true,
|
|
1378
|
+
},
|
|
1379
|
+
],
|
|
1380
|
+
};
|
|
1381
|
+
state.candidate.virtualProviders[virtualProviderId] = {
|
|
1382
|
+
id: virtualProviderId,
|
|
1383
|
+
name: `Codex via ${upstreamName}`,
|
|
1384
|
+
ingressProtocol: "openai.responses",
|
|
1385
|
+
route: routeId,
|
|
1386
|
+
allowedModels: modelIds,
|
|
1387
|
+
enabled: true,
|
|
1388
|
+
};
|
|
1389
|
+
state.candidate.bindings[bindingId] = {
|
|
1390
|
+
id: bindingId,
|
|
1391
|
+
name,
|
|
1392
|
+
target,
|
|
1393
|
+
integration: "codex-native-provider",
|
|
1394
|
+
targetFormat: "codex.config.toml.v1",
|
|
1395
|
+
mode: "config",
|
|
1396
|
+
virtualProvider: virtualProviderId,
|
|
1397
|
+
codex: {},
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
state.pendingSecrets.upstreamSecrets[upstreamId] = upstreamSecret;
|
|
1401
|
+
|
|
1402
|
+
state.selected.upstream = upstreamId;
|
|
1403
|
+
state.selected.model = modelIds[0] || null;
|
|
1404
|
+
state.selected.virtualProvider = virtualProviderId;
|
|
1405
|
+
state.selected.binding = bindingId;
|
|
1406
|
+
state.selected.suite = bindingId;
|
|
1407
|
+
state.page = "suite-detail";
|
|
1408
|
+
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function saveUpstream(data, connectionOnly = false) {
|
|
1412
|
+
const id = String(data.get("id") || "").trim();
|
|
1413
|
+
if (!id) throw new Error("upstream ID 不能为空");
|
|
1414
|
+
const existing = state.candidate.upstreams[id] || {};
|
|
1415
|
+
state.candidate.upstreams[id] = {
|
|
1416
|
+
...existing,
|
|
1417
|
+
id,
|
|
1418
|
+
name: String(data.get("name") || id).trim(),
|
|
1419
|
+
...(data.get("protocol") === "openai.responses"
|
|
1420
|
+
? { integration: "codex-native-provider" }
|
|
1421
|
+
: {}),
|
|
1422
|
+
protocol: data.get("protocol"),
|
|
1423
|
+
baseUrl: String(data.get("baseUrl") || "").trim(),
|
|
1424
|
+
secretRef: existing.secretRef || `secret://upstreams/${id}`,
|
|
1425
|
+
...(!connectionOnly ? {
|
|
1426
|
+
auth: {
|
|
1427
|
+
...(existing.auth || {}),
|
|
1428
|
+
header: String(data.get("authHeader") || "").trim().toLowerCase() || undefined,
|
|
1429
|
+
},
|
|
1430
|
+
} : {}),
|
|
1431
|
+
enabled: existing.enabled !== false,
|
|
1432
|
+
};
|
|
1433
|
+
if (data.get("protocol") !== "openai.responses") {
|
|
1434
|
+
delete state.candidate.upstreams[id].integration;
|
|
1435
|
+
}
|
|
1436
|
+
const secret = String(data.get("secret") || "").trim();
|
|
1437
|
+
if (secret) {
|
|
1438
|
+
state.pendingSecrets.upstreamSecrets[id] = secret;
|
|
1439
|
+
}
|
|
1440
|
+
state.selected.upstream = id;
|
|
1441
|
+
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function saveModel(data, form) {
|
|
1445
|
+
const id = String(data.get("id") || "").trim();
|
|
1446
|
+
if (!id) throw new Error("CableTidy Model ID 不能为空");
|
|
1447
|
+
const existing = state.candidate.models[id] || {};
|
|
1448
|
+
const upstreamId = String(data.get("upstreamId") || "").trim();
|
|
1449
|
+
const upstreamModelId = String(data.get("upstreamModelId") || "").trim();
|
|
1450
|
+
if (!upstreamId) throw new Error("请选择一个 upstream");
|
|
1451
|
+
const upstreams = {
|
|
1452
|
+
[upstreamId]: {
|
|
1453
|
+
...(existing.upstreams?.[upstreamId] || {}),
|
|
1454
|
+
...(upstreamModelId ? { upstreamModelId } : {}),
|
|
1455
|
+
capabilityOverrides: commaList(data.get("capabilityOverrides")),
|
|
1456
|
+
},
|
|
1457
|
+
};
|
|
1458
|
+
if (!upstreamModelId) delete upstreams[upstreamId].upstreamModelId;
|
|
1459
|
+
const clientModelId = String(data.get("clientModelId") || "").trim();
|
|
1460
|
+
state.candidate.models[id] = {
|
|
1461
|
+
...existing,
|
|
1462
|
+
id,
|
|
1463
|
+
clientModelId: clientModelId || id,
|
|
1464
|
+
aliases: commaList(data.get("aliases")),
|
|
1465
|
+
family: String(data.get("family") || "codex").trim(),
|
|
1466
|
+
capabilities: commaList(data.get("capabilities")),
|
|
1467
|
+
contextWindow: Number(data.get("contextWindow") || 1000000),
|
|
1468
|
+
compact: {
|
|
1469
|
+
strategy: String(data.get("compactStrategy") || "auto").trim(),
|
|
1470
|
+
tokenLimit: Number(data.get("compactTokenLimit") || 850000),
|
|
1471
|
+
},
|
|
1472
|
+
upstreams,
|
|
1473
|
+
};
|
|
1474
|
+
state.selected.model = id;
|
|
1475
|
+
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
async function testUpstream(id) {
|
|
1479
|
+
const result = await api("/tests/upstream", {
|
|
1480
|
+
method: "POST",
|
|
1481
|
+
body: JSON.stringify({ id }),
|
|
1482
|
+
});
|
|
1483
|
+
toast(`${id}: ${result.message},${result.latencyMs}ms${result.secretConfigured ? "" : ",未找到 API key"}`, !result.ok);
|
|
1484
|
+
await refresh(false);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
async function toggleVirtualProvider(id) {
|
|
1488
|
+
const provider = state.config.virtualProviders?.[id];
|
|
1489
|
+
if (!provider) throw new Error("Virtual Provider 不存在");
|
|
1490
|
+
const enabled = provider.enabled === false;
|
|
1491
|
+
await api(`/virtual-providers/${encodeURIComponent(id)}/${enabled ? "start" : "pause"}`, {
|
|
1492
|
+
method: "POST",
|
|
1493
|
+
body: JSON.stringify({}),
|
|
1494
|
+
});
|
|
1495
|
+
await refresh(false);
|
|
1496
|
+
toast(enabled ? "Virtual Provider 已启动。" : "Virtual Provider 已暂停。");
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
async function resolveModel(data) {
|
|
1501
|
+
try {
|
|
1502
|
+
state.resolveResult = await api("/tests/model-resolve", {
|
|
1503
|
+
method: "POST",
|
|
1504
|
+
body: JSON.stringify({
|
|
1505
|
+
config: state.config,
|
|
1506
|
+
virtualProviderId: data.get("virtualProviderId"),
|
|
1507
|
+
model: String(data.get("model") || "").trim(),
|
|
1508
|
+
stream: data.get("stream") === "on",
|
|
1509
|
+
reasoning: data.get("reasoning") === "on",
|
|
1510
|
+
}),
|
|
1511
|
+
});
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
state.resolveResult = error.body || {
|
|
1514
|
+
ok: false,
|
|
1515
|
+
error: { message: error.message },
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
async function previewArtifacts() {
|
|
1521
|
+
const preservedForms = captureEditedForms();
|
|
1522
|
+
const result = await api("/config/preview-target-artifacts", {
|
|
1523
|
+
method: "POST",
|
|
1524
|
+
body: JSON.stringify({
|
|
1525
|
+
config: state.config,
|
|
1526
|
+
bindingId: state.selected.binding,
|
|
1527
|
+
}),
|
|
1528
|
+
});
|
|
1529
|
+
state.artifactPreview = result.artifacts;
|
|
1530
|
+
const target = result.artifacts?.target;
|
|
1531
|
+
toast(target === "codex" ? "已生成 Codex 本地 config.toml 预览。" : "已生成环境配置预览;请将这些变量注入对应客户端。");
|
|
1532
|
+
render(preservedForms);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
async function applyTarget() {
|
|
1536
|
+
if (hasUnsavedChanges()) {
|
|
1537
|
+
throw new Error("请先保存当前编辑,再应用到 CLI。");
|
|
1538
|
+
}
|
|
1539
|
+
if (!state.selected.binding) throw new Error("请先选择一个 Target binding。");
|
|
1540
|
+
const binding = state.candidate.bindings[state.selected.binding];
|
|
1541
|
+
if (binding?.target !== "codex") {
|
|
1542
|
+
await previewArtifacts();
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
const result = await api("/targets/apply", {
|
|
1546
|
+
method: "POST",
|
|
1547
|
+
body: JSON.stringify({ bindingId: state.selected.binding }),
|
|
1548
|
+
});
|
|
1549
|
+
state.artifactPreview = result.report ? {
|
|
1550
|
+
...result.report,
|
|
1551
|
+
target: result.target,
|
|
1552
|
+
bindingId: state.selected.binding,
|
|
1553
|
+
mode: result.report.mode || "managed_proxy",
|
|
1554
|
+
} : null;
|
|
1555
|
+
toast("Codex 配置已应用。");
|
|
1556
|
+
render();
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
async function saveChanges(update, form) {
|
|
1560
|
+
const previous = {
|
|
1561
|
+
page: state.page,
|
|
1562
|
+
selected: clone(state.selected),
|
|
1563
|
+
};
|
|
1564
|
+
const preservedForms = captureEditedForms(form);
|
|
1565
|
+
state.candidate = clone(state.config);
|
|
1566
|
+
state.pendingSecrets = { upstreamSecrets: {} };
|
|
1567
|
+
try {
|
|
1568
|
+
update();
|
|
1569
|
+
const identities = normalizeConfigurationIdentities(state.candidate);
|
|
1570
|
+
for (const field of ["binding", "suite"]) {
|
|
1571
|
+
state.selected[field] = identities.bindingIds.get(state.selected[field]) ?? state.selected[field];
|
|
1572
|
+
}
|
|
1573
|
+
state.selected.virtualProvider = identities.providerIds.get(state.selected.virtualProvider) ?? state.selected.virtualProvider;
|
|
1574
|
+
// The commit endpoint validates and applies the change atomically.
|
|
1575
|
+
const result = await api("/config/commit", {
|
|
1576
|
+
method: "POST",
|
|
1577
|
+
body: JSON.stringify({
|
|
1578
|
+
baseRevision: state.config.revision,
|
|
1579
|
+
config: state.candidate,
|
|
1580
|
+
upstreamSecrets: state.pendingSecrets.upstreamSecrets,
|
|
1581
|
+
}),
|
|
1582
|
+
});
|
|
1583
|
+
state.config = result.config;
|
|
1584
|
+
state.runtime = result.runtime;
|
|
1585
|
+
state.artifactPreview = null;
|
|
1586
|
+
state.resolveResult = null;
|
|
1587
|
+
state.events.unshift({
|
|
1588
|
+
id: "local-" + Date.now(),
|
|
1589
|
+
type: "config.commit",
|
|
1590
|
+
at: new Date().toISOString(),
|
|
1591
|
+
data: { revision: result.revision },
|
|
1592
|
+
});
|
|
1593
|
+
} catch (error) {
|
|
1594
|
+
Object.assign(state, previous);
|
|
1595
|
+
throw error;
|
|
1596
|
+
} finally {
|
|
1597
|
+
state.candidate = clone(state.config);
|
|
1598
|
+
state.pendingSecrets = { upstreamSecrets: {} };
|
|
1599
|
+
}
|
|
1600
|
+
render(state.page === previous.page ? preservedForms : []);
|
|
1601
|
+
toast("配置已保存并生效。");
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
async function refresh(showToast = true) {
|
|
1605
|
+
const preservedForms = captureEditedForms();
|
|
1606
|
+
const [configResult, runtime, events] = await Promise.all([
|
|
1607
|
+
api("/config"),
|
|
1608
|
+
api("/runtime"),
|
|
1609
|
+
api("/events"),
|
|
1610
|
+
]);
|
|
1611
|
+
state.config = configResult.config;
|
|
1612
|
+
state.candidate = clone(state.config);
|
|
1613
|
+
state.pendingSecrets = { upstreamSecrets: {} };
|
|
1614
|
+
state.runtime = runtime;
|
|
1615
|
+
state.events = events.events || [];
|
|
1616
|
+
if (showToast) toast("状态已刷新。");
|
|
1617
|
+
render(preservedForms);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
function firstKey(record, excluded = new Set()) {
|
|
1621
|
+
return Object.keys(record || {}).find((key) => !excluded.has(key)) || null;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
function toast(message, error = false) {
|
|
1625
|
+
const node = document.createElement("div");
|
|
1626
|
+
node.className = `toast ${error ? "error" : ""}`;
|
|
1627
|
+
node.textContent = message;
|
|
1628
|
+
toastRegion.append(node);
|
|
1629
|
+
setTimeout(() => node.remove(), 4200);
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
document.querySelectorAll(".nav-item").forEach((item) => {
|
|
1633
|
+
item.addEventListener("click", () => navigatePage(item.dataset.page));
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
document.querySelector("#refresh-button").addEventListener("click", () => {
|
|
1637
|
+
if (state.busy) return;
|
|
1638
|
+
if (!state.config) bootstrap();
|
|
1639
|
+
else handleAction("refresh");
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
bootstrap();
|