cabletidy 0.2.0 → 0.3.0-rc.2

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