@zq-silk/yui 2.7.1 → 2.8.1

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.
Files changed (92) hide show
  1. package/dist/cli/commandCatalog.js +8 -7
  2. package/dist/cli/updateCommand.js +3 -0
  3. package/dist/cli/updatePorts.js +20 -0
  4. package/dist/commands/configCommands.js +1 -0
  5. package/dist/commands/globalRoleCommands.js +42 -35
  6. package/dist/commands/inboxCommands.js +60 -40
  7. package/dist/commands/taskCommands.js +7 -3
  8. package/dist/commands/taskRoleRuntimeStatus.js +2 -0
  9. package/dist/context/runContextPack.js +48 -3
  10. package/dist/context/taskContext.js +36 -12
  11. package/dist/controlPlaneCli.js +64 -17
  12. package/dist/controller/controller.js +5 -0
  13. package/dist/controller/fileSchedulerStoreAdapter.js +9 -4
  14. package/dist/controller/globalInputDelivery.js +17 -5
  15. package/dist/controller/globalRuntimeAttention.js +135 -5
  16. package/dist/controller/runtime.js +4 -0
  17. package/dist/controller/structuredProviderObservation.js +51 -15
  18. package/dist/controller/taskHostTerminalRecovery.js +33 -0
  19. package/dist/executor/fileRoleLaunchPlanner.js +4 -9
  20. package/dist/message/inboxReceipt.js +30 -0
  21. package/dist/message/taskInboxOwnership.js +25 -3
  22. package/dist/models/modelManagement.js +469 -0
  23. package/dist/models/modelManagementConfig.js +46 -0
  24. package/dist/models/modelManagementFiles.js +90 -0
  25. package/dist/models/modelManagementHttp.js +90 -0
  26. package/dist/models/modelManagementTypes.js +1 -0
  27. package/dist/output/boundedRead.js +43 -14
  28. package/dist/runtime/agentEndpointIdentity.js +8 -4
  29. package/dist/runtime/agentEndpointOwnership.js +5 -6
  30. package/dist/runtime/agentHost.js +62 -10
  31. package/dist/runtime/agentHostImplementation.js +18 -0
  32. package/dist/runtime/managedCaller.js +25 -1
  33. package/dist/runtime/managedIdentity.js +1 -1
  34. package/dist/runtime/providerRetry.js +19 -1
  35. package/dist/runtime/sessionCaller.js +53 -0
  36. package/dist/storage/contextRecords.js +28 -0
  37. package/dist/storage/maintenanceCaller.js +69 -0
  38. package/dist/storage/sqliteSchema.js +12 -2
  39. package/dist/storage/sqliteStore.js +9 -5
  40. package/dist/storage/storageVersions.js +1 -1
  41. package/dist/storage/taskStore.js +4 -0
  42. package/dist/task/completionReadiness.js +3 -3
  43. package/dist/web/assets/assetManifest.js +28 -2
  44. package/dist/web/assets/client/app/manage.js +6 -0
  45. package/dist/web/assets/client/app/models.js +20 -0
  46. package/dist/web/assets/client/app/settings.js +41 -9
  47. package/dist/web/assets/client/i18n/en.js +180 -2
  48. package/dist/web/assets/client/i18n/zh-CN.js +178 -2
  49. package/dist/web/assets/client/index.js +8 -0
  50. package/dist/web/assets/client/layout/manageShell.js +94 -0
  51. package/dist/web/assets/client/lib/api.js +13 -1
  52. package/dist/web/assets/client/lib/i18n.js +5 -3
  53. package/dist/web/assets/client/views/dock/conversation.js +231 -80
  54. package/dist/web/assets/client/views/models.js +777 -0
  55. package/dist/web/assets/client/views/skills.js +22 -15
  56. package/dist/web/assets/shared/icons.js +2 -0
  57. package/dist/web/assets/shared/manageNav.js +13 -0
  58. package/dist/web/assets/shell/manage.js +54 -0
  59. package/dist/web/assets/shell/models.js +7 -0
  60. package/dist/web/assets/shell/settings.js +1 -35
  61. package/dist/web/assets/shell/settingsContent.js +30 -0
  62. package/dist/web/assets/shell/sidebar.js +1 -0
  63. package/dist/web/assets/styles/index.js +6 -0
  64. package/dist/web/assets/styles/views/conversation.js +133 -0
  65. package/dist/web/assets/styles/views/manage.js +71 -0
  66. package/dist/web/assets/styles/views/models.js +129 -0
  67. package/dist/web/assets/styles/views/settings.js +28 -14
  68. package/dist/web/assets/styles/views/sidebar.js +1 -0
  69. package/dist/web/webServer.js +59 -9
  70. package/docs/cli-information-contract.md +42 -11
  71. package/docs/cli-information-contract.zh-CN.md +28 -7
  72. package/docs/managed-turn-and-session-runtime.md +47 -1
  73. package/docs/managed-turn-and-session-runtime.zh-CN.md +32 -0
  74. package/docs/release-workflow.md +80 -7
  75. package/docs/release-workflow.zh-CN.md +55 -4
  76. package/docs/storage-baseline.md +6 -1
  77. package/docs/storage-baseline.zh-CN.md +4 -1
  78. package/docs/testing/review-evaluation.md +148 -0
  79. package/docs/testing/review-evaluation.zh-CN.md +117 -0
  80. package/docs/testing/verification-levels.md +6 -2
  81. package/docs/testing/verification-levels.zh-CN.md +3 -0
  82. package/package.json +1 -1
  83. package/skills/yui-leader/VERSION +1 -1
  84. package/skills/yui-leader/references/execution.md +29 -0
  85. package/skills/yui-leader/references/replicated-execution.md +7 -0
  86. package/skills/yui-operator/SKILL.md +5 -1
  87. package/skills/yui-operator/VERSION +1 -1
  88. package/skills/yui-reviewer/SKILL.md +35 -0
  89. package/skills/yui-reviewer/VERSION +1 -1
  90. package/skills/yui-runtime/SKILL.md +53 -7
  91. package/skills/yui-runtime/VERSION +1 -1
  92. package/skills/yui-runtime/references/recovery.md +24 -5
@@ -0,0 +1,777 @@
1
+ export const MODELS_VIEW_SCRIPT = String.raw `
2
+ // Model management page body, split into four routed sections: access
3
+ // (metabridge providers), the cached model catalog, per-source quota and the
4
+ // management connection settings. Every fact comes from /api/models*; the
5
+ // page keeps only drafts and view state. Saving configuration is not applying
6
+ // it (metabridge needs its own reload), a quota or catalog observation is
7
+ // never upgraded to success, and an API key typed here lives only in its
8
+ // password input until submission.
9
+ //
10
+ // All four sections stay mounted and are only hidden, so moving between them
11
+ // (including browser back/forward) never discards a draft. The entry module
12
+ // owns the URL; this view renders whichever route it is given.
13
+ //
14
+ // deps: t, locale, api (models* functions from lib/api), confirm(text),
15
+ // route {section, source}, navigate(route) and onChange(flags), all optional
16
+ // except t and api.
17
+ import { h, icon } from "/assets/js/lib/dom.js";
18
+ import { formatDateTime } from "/assets/js/lib/format.js";
19
+
20
+ export const QUOTA_SOURCES = ["factory", "command-code"];
21
+ export const SECTIONS = ["providers", "catalog", "quota", "connection"];
22
+ const CONNECTION_GROUPS = [
23
+ { key: "bridge", fields: ["bridgeUrl", "bridgeConfigFile", "catalogKeyFile"] },
24
+ { key: "cpa", fields: ["cpaUrl", "managementKeyFile"] }
25
+ ];
26
+ const CONNECTION_FIELDS = ["bridgeUrl", "bridgeConfigFile", "cpaUrl", "managementKeyFile", "catalogKeyFile"];
27
+ const OBSERVATION_TONES = { success: "ok", partial: "warn", failed: "bad", unsupported: "idle", "not-queried": "idle" };
28
+ const ROUTE_FILTERS = ["all", "direct", "cpa", "unknown"];
29
+ const STATE_FILTERS = ["all", "enabled", "disabled", "readonly"];
30
+ // Server contract: the prefix is sent and returned with its trailing slash.
31
+ export const PREFIX_PATTERN = /^[a-zA-Z0-9_-]{1,64}\/$/;
32
+
33
+ // Normalizes any requested route; unknown values fall back to the defaults.
34
+ export function normalizeRoute(route) {
35
+ const value = route || {};
36
+ return {
37
+ section: SECTIONS.includes(value.section) ? value.section : SECTIONS[0],
38
+ source: QUOTA_SOURCES.includes(value.source) ? value.source : QUOTA_SOURCES[0]
39
+ };
40
+ }
41
+
42
+ // A window label is the server's own text. Factory labels read
43
+ // "<group> / <window>"; anything without that separator stays ungrouped.
44
+ export function splitWindowLabel(label) {
45
+ const text = String(label || "");
46
+ const at = text.indexOf(" / ");
47
+ return at > 0 ? { group: text.slice(0, at), window: text.slice(at + 3) } : { group: "", window: text };
48
+ }
49
+
50
+ export function mountModels(root, deps) {
51
+ const t = deps.t;
52
+ const api = deps.api;
53
+ const now = deps.now || Date.now;
54
+ const locale = deps.locale || function () { return "en"; };
55
+ const confirm = deps.confirm || function (text) { return window.confirm(text); };
56
+ const onChange = deps.onChange || function () {};
57
+ const initial = normalizeRoute(deps.route);
58
+ const state = {
59
+ route: initial,
60
+ view: null, loading: false, loadError: null,
61
+ // One write at a time; an unknown outcome blocks every write until the
62
+ // reader explicitly reads current facts again.
63
+ // A rejected write (for example a stale revision) keeps its draft and
64
+ // offers an explicit re-read; nothing is replayed automatically.
65
+ pending: false, unknown: false, rejected: false, receipt: null,
66
+ connectionDraft: null, providerDraft: null,
67
+ providerFilter: { query: "", route: "all", state: "all" },
68
+ catalog: { page: null, items: [], loading: false, error: null, note: null, generation: 0, query: "", source: "all" },
69
+ quota: { source: initial.source, observation: null, loading: false, error: null, unavailable: false, generation: 0 }
70
+ };
71
+ function panel(name) {
72
+ return h("section.manage-section.models-section", { dataset: { section: name }, "aria-labelledby": "models-" + name + "-title" });
73
+ }
74
+ const status = h("div.models-status", { "aria-live": "polite" });
75
+ const sections = { providers: panel("providers"), catalog: panel("catalog"), quota: panel("quota"), connection: panel("connection") };
76
+ root.replaceChildren(status, sections.providers, sections.catalog, sections.quota, sections.connection);
77
+
78
+ function tone(name, text) { return h("span.badge.tone-" + name, null, h("span.badge-dot"), text); }
79
+ function when(iso) { return iso ? formatDateTime(iso, locale()) : t("models.unknown"); }
80
+ function known(value, render) { return value === null || value === undefined ? t("models.unknown") : render ? render(value) : String(value); }
81
+ function percent(fraction) { return Math.round(fraction * 1000) / 10 + "%"; }
82
+ function number(value) { return new Intl.NumberFormat(locale(), { maximumFractionDigits: 2 }).format(value); }
83
+ // Re-reading facts also blocks writes so no save races the new revision.
84
+ function writesBlocked() { return !state.view || state.view.readOnly || state.pending || state.unknown || state.loading; }
85
+ function go(route) {
86
+ const next = normalizeRoute(Object.assign({}, state.route, route));
87
+ if (deps.navigate) deps.navigate(next);
88
+ else show(next);
89
+ }
90
+ function sectionLink(section, text) {
91
+ return h("a.btn-link", { href: (section === "connection" ? "/settings/" : "/models/") + section, onclick: function (event) {
92
+ if (event.defaultPrevented || event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
93
+ event.preventDefault(); go({ section: section });
94
+ } }, text);
95
+ }
96
+ function head(name, hint, actions) {
97
+ return h("header.manage-head", null,
98
+ h("div.manage-heading", null,
99
+ h("h1", { id: "models-" + name + "-title" }, t("models.section." + name)), hint ? h("p", null, hint) : null),
100
+ actions && actions.length ? h("div.card-actions", null, actions) : null);
101
+ }
102
+ function receiptNode(scope) {
103
+ if (!state.receipt || state.receipt.scope !== scope) return null;
104
+ const line = h("p.receipt.models-receipt", { role: "status", "aria-live": "polite" }, state.receipt.text);
105
+ line.dataset.state = state.receipt.state;
106
+ if (!(state.rejected && state.receipt.rejected && !state.unknown)) return line;
107
+ return h("div.models-receipt-group", null, line, h("div.settings-actions", null,
108
+ h("button.btn", { type: "button", disabled: state.loading || state.pending, onclick: function () { void reconcile(); } }, t("models.readFacts"))));
109
+ }
110
+ function field(labelText, control, hint) {
111
+ return h("label.field", null, h("span", null, labelText), control, hint ? h("small.faint", null, hint) : null);
112
+ }
113
+ function searchBox(labelKey, value, onInput) {
114
+ const input = h("input", { type: "search", autocomplete: "off", spellcheck: "false", placeholder: t(labelKey) });
115
+ input.value = value;
116
+ input.addEventListener("input", function () { onInput(input.value); });
117
+ return h("label.models-search", null, icon("search"), h("span.sr-only", null, t(labelKey)), input);
118
+ }
119
+ function filterGroup(labelKey, values, current, labelOf, onPick) {
120
+ return h("div.seg.seg-sm", { role: "group", "aria-label": t(labelKey) }, values.map(function (value) {
121
+ return h("button.seg-btn", { type: "button", "aria-pressed": String(value === current), onclick: function () { onPick(value); } }, labelOf(value));
122
+ }));
123
+ }
124
+ function notify() {
125
+ onChange({ connection: !!state.connectionDraft, provider: !!state.providerDraft, unknown: state.unknown, pending: state.pending });
126
+ }
127
+
128
+ // ---- Routing ----------------------------------------------------------
129
+ function show(route) {
130
+ const next = normalizeRoute(route);
131
+ // Only the quota section reads its source from the route; elsewhere the
132
+ // last shown source is kept for the way back.
133
+ if (next.section !== "quota") next.source = state.quota.source;
134
+ const sourceChanged = next.source !== state.quota.source;
135
+ state.route = next;
136
+ state.quota.source = next.source;
137
+ SECTIONS.forEach(function (name) { sections[name].hidden = name !== next.section; });
138
+ renderStatus();
139
+ if (sourceChanged) void loadQuota();
140
+ else renderQuota();
141
+ notify();
142
+ return next;
143
+ }
144
+
145
+ function renderAll() {
146
+ renderStatus(); renderConnection(); renderProviders(); renderCatalog(); renderQuota();
147
+ SECTIONS.forEach(function (name) { sections[name].hidden = name !== state.route.section; });
148
+ notify();
149
+ }
150
+
151
+ // Page-wide facts and blocking conditions stay visible in every section.
152
+ function renderStatus() {
153
+ const view = state.view;
154
+ const children = [];
155
+ if (state.loading && !view) children.push(h("p", { role: "status" }, t("models.loading")));
156
+ if (view && (state.route.section === "connection" || state.route.section === "providers")) {
157
+ children.push(h("div.models-facts", null,
158
+ tone(view.connection ? "info" : "idle", view.connection ? t("models.connected") : t("models.disconnected")),
159
+ tone("warn", t("models.application." + view.application, view.application)),
160
+ h("small.faint.models-notice", null, t(view.readOnly ? "models.readOnlyService" : "models.scope"))));
161
+ }
162
+ if (state.loadError) {
163
+ children.push(h("div.models-alert", { role: "alert" }, icon("alert"), h("p.settings-error", null, t("models.loadFailed") + " " + state.loadError),
164
+ h("button.btn", { type: "button", onclick: function () { void load(); } }, t("models.retry"))));
165
+ }
166
+ if (view && view.configurationError) {
167
+ children.push(h("div.models-alert", { role: "alert" }, icon("alert"), h("p.settings-error", null, t("models.configurationError") + " " + view.configurationError)));
168
+ }
169
+ if (state.unknown) {
170
+ children.push(h("div.models-alert", { role: "alert" }, icon("alert"), h("p.settings-error", null, t("models.unknownBlocked")),
171
+ h("button.btn", { type: "button", disabled: state.loading, onclick: function () { void reconcile(); } }, t("models.readFacts"))));
172
+ }
173
+ children.push(receiptNode("summary"));
174
+ status.replaceChildren.apply(status, children.filter(Boolean));
175
+ }
176
+
177
+ // ---- Connection settings ----------------------------------------------
178
+ function connectionValues() {
179
+ if (state.connectionDraft) return state.connectionDraft;
180
+ const current = state.view && state.view.connection;
181
+ const values = {};
182
+ CONNECTION_FIELDS.forEach(function (key) { values[key] = current ? current[key] || "" : ""; });
183
+ return values;
184
+ }
185
+ function renderConnection() {
186
+ const view = state.view;
187
+ const values = connectionValues();
188
+ const blocked = writesBlocked();
189
+ const form = h("form.models-connection-form", { novalidate: true });
190
+ const inputs = {};
191
+ const discard = h("button.btn.btn-ghost", { type: "button", disabled: !state.connectionDraft || state.pending }, t("models.discard"));
192
+ CONNECTION_GROUPS.forEach(function (group) {
193
+ const set = h("fieldset.models-fieldset", null, h("legend", null, t("models.connectionGroup." + group.key)));
194
+ group.fields.forEach(function (key) {
195
+ const input = h("input", { type: key.endsWith("Url") ? "url" : "text", autocomplete: "off", spellcheck: "false",
196
+ value: values[key], disabled: blocked, required: key === "bridgeUrl" });
197
+ input.value = values[key];
198
+ input.addEventListener("input", function () {
199
+ const draft = Object.assign({}, connectionValues());
200
+ draft[key] = input.value;
201
+ const first = !state.connectionDraft;
202
+ state.connectionDraft = draft;
203
+ discard.disabled = state.pending;
204
+ if (first) notify();
205
+ });
206
+ inputs[key] = input;
207
+ set.append(field(t("models.connection." + key), input, t("models.connection." + key + "Help")));
208
+ });
209
+ form.append(set);
210
+ });
211
+ const save = h("button.btn.btn-primary", { type: "submit", disabled: blocked }, t("models.saveConnection"));
212
+ const disconnect = h("button.btn", { type: "button", disabled: blocked || !(view && view.connection) }, t("models.disconnect"));
213
+ form.addEventListener("submit", function (event) {
214
+ event.preventDefault();
215
+ if (writesBlocked()) return;
216
+ const connection = {};
217
+ CONNECTION_FIELDS.forEach(function (key) { connection[key] = inputs[key].value.trim(); });
218
+ if (!connection.bridgeUrl) { setReceipt(t("models.bridgeUrlRequired"), "bad", "connection"); renderConnection(); return; }
219
+ void write(function (revision) { return api.saveModelConnection({ revision: revision, connection: connection }); },
220
+ { scope: "connection", clearConnection: true });
221
+ });
222
+ disconnect.addEventListener("click", function () {
223
+ if (writesBlocked() || !confirm(t("models.disconnectConfirm"))) return;
224
+ void write(function (revision) { return api.saveModelConnection({ revision: revision, connection: null }); },
225
+ { scope: "connection", clearConnection: true });
226
+ });
227
+ discard.addEventListener("click", function () { state.connectionDraft = null; renderConnection(); notify(); });
228
+ form.append(h("div.settings-actions", null, save, disconnect, discard,
229
+ state.connectionDraft ? h("small.faint", null, t("models.unsavedDraft")) : null));
230
+ const receipt = receiptNode("connection");
231
+ if (receipt) form.append(receipt);
232
+ sections.connection.replaceChildren.apply(sections.connection, [
233
+ head("connection", t("models.connectionHelp")),
234
+ view && !view.connection ? h("p.models-note", null, icon("info"), t("models.notConnected")) : null,
235
+ h("div.card.models-card", null, h("div.card-body", null, form)),
236
+ h("div.card.models-card", null, h("div.card-body.models-explain", null,
237
+ h("h2", null, t("models.applicationTitle")),
238
+ h("p", null, t("models.applicationHelp")),
239
+ h("p", null, t("models.agentSettings") + " ", h("a.btn-link", { href: "/settings?section=server" }, t("models.openSettings")))))].filter(Boolean));
240
+ }
241
+
242
+ // ---- Access (providers) -----------------------------------------------
243
+ function configurable() {
244
+ const view = state.view;
245
+ return !!(view && !view.readOnly && view.connection && view.connection.bridgeConfigFile);
246
+ }
247
+ function startProvider(provider) {
248
+ if (state.providerDraft && !confirm(t("models.discardProvider"))) return;
249
+ state.providerDraft = provider
250
+ ? { id: provider.id, name: provider.name, via: provider.via, baseUrl: provider.baseUrl, prefix: provider.prefix,
251
+ enabled: provider.enabled, credentialPresent: provider.credentialPresent }
252
+ : { id: null, name: "", via: "direct", baseUrl: "", prefix: "", enabled: true, credentialPresent: false };
253
+ renderProviders();
254
+ notify();
255
+ }
256
+ function providerMatches(provider) {
257
+ const filter = state.providerFilter;
258
+ if (filter.route !== "all" && provider.via !== filter.route) return false;
259
+ if (filter.state === "enabled" && !provider.enabled) return false;
260
+ if (filter.state === "disabled" && provider.enabled) return false;
261
+ if (filter.state === "readonly" && provider.editable) return false;
262
+ const query = filter.query.trim().toLowerCase();
263
+ if (!query) return true;
264
+ return [provider.name, provider.id, provider.baseUrl, provider.prefix].some(function (value) {
265
+ return String(value || "").toLowerCase().includes(query);
266
+ });
267
+ }
268
+ function providerRow(provider) {
269
+ const blocked = writesBlocked();
270
+ const editing = !!(state.providerDraft && state.providerDraft.id === provider.id);
271
+ const actions = h("div.models-row-actions");
272
+ if (provider.editable) {
273
+ actions.append(
274
+ h("button.btn", { type: "button", disabled: blocked, "aria-label": t("models.editProvider") + " " + provider.name,
275
+ onclick: function () { startProvider(provider); } }, t("models.editProvider")),
276
+ h("button.btn.btn-ghost.models-danger", { type: "button", disabled: blocked, "aria-label": t("models.removeProvider") + " " + provider.name, onclick: function () {
277
+ if (writesBlocked() || !confirm(t("models.removeConfirm") + " " + provider.name)) return;
278
+ void write(function (revision) { return api.saveModelProvider({ revision: revision, operation: "remove", id: provider.id }); },
279
+ { scope: "provider", clearProvider: !!(state.providerDraft && state.providerDraft.id === provider.id) });
280
+ } }, t("models.removeProvider")));
281
+ } else {
282
+ actions.append(h("span.chip", null, t("models.readOnly")));
283
+ if (provider.reason) actions.append(h("small.faint.models-reason", null,
284
+ t(provider.reason === "read-only-service" ? "models.readOnlyService" : "models.providerReadOnlyHelp")));
285
+ }
286
+ return h("tr", { dataset: { provider: provider.id }, "aria-current": editing ? "true" : null },
287
+ h("td", { dataset: { label: t("models.column.provider") } },
288
+ h("div.models-cell-main", null, h("strong", null, provider.name), h("code.id", { title: provider.id }, provider.id.slice(0, 12)))),
289
+ h("td", { dataset: { label: t("models.column.baseUrl") } },
290
+ h("span.mono-line.models-url", { title: provider.baseUrl || "" }, provider.baseUrl || t("models.unknown"))),
291
+ h("td", { dataset: { label: t("models.column.prefix") } },
292
+ provider.prefix ? h("code.id", null, provider.prefix) : h("span.chip", null, t("models.noPrefix"))),
293
+ h("td", { dataset: { label: t("models.column.route") } }, h("span.chip", null, t("models.via." + provider.via, provider.via))),
294
+ h("td", { dataset: { label: t("models.column.state") } }, h("div.models-facts", null,
295
+ tone(provider.enabled ? "ok" : "idle", provider.enabled ? t("models.enabled") : t("models.disabled")),
296
+ tone(provider.credentialPresent ? "info" : "idle", provider.credentialKind === "subscription" ? t("models.subscriptionAuth")
297
+ : provider.credentialPresent ? t("models.credentialStored") : t("models.credentialMissing")))),
298
+ h("td", { dataset: { label: t("models.column.actions") } }, actions));
299
+ }
300
+ function providerForm() {
301
+ const draft = state.providerDraft;
302
+ const isNew = !draft.id;
303
+ const blocked = writesBlocked();
304
+ const form = h("form.settings-stack.models-provider-form", { novalidate: true });
305
+ function bind(control, key, read) {
306
+ control.addEventListener(control.type === "checkbox" ? "change" : "input", function () { draft[key] = read(control); });
307
+ return control;
308
+ }
309
+ const name = bind(h("input", { autocomplete: "off", disabled: blocked, value: draft.name }), "name", function (c) { return c.value; });
310
+ name.value = draft.name;
311
+ const via = h("select", { disabled: blocked },
312
+ h("option", { value: "direct" }, t("models.via.direct")), h("option", { value: "cpa" }, t("models.via.cpa")),
313
+ draft.via === "unknown" ? h("option", { value: "unknown", disabled: true }, t("models.via.unknown")) : null);
314
+ via.value = draft.via;
315
+ // Changing the route only retitles its hints; re-rendering would discard a typed key.
316
+ const viaHint = h("small.faint");
317
+ const keyLabel = h("span");
318
+ function describeVia() {
319
+ viaHint.textContent = via.value === "cpa" ? t("models.provider.viaCpaHelp") : t("models.provider.viaDirectHelp");
320
+ keyLabel.textContent = via.value === "cpa" ? t("models.provider.apiKeyCpa") : t("models.provider.apiKey");
321
+ }
322
+ via.addEventListener("change", function () { draft.via = via.value; describeVia(); });
323
+ const baseUrl = bind(h("input", { type: "url", autocomplete: "off", spellcheck: "false", disabled: blocked, value: draft.baseUrl }), "baseUrl", function (c) { return c.value; });
324
+ baseUrl.value = draft.baseUrl;
325
+ const prefix = bind(h("input", { autocomplete: "off", spellcheck: "false", disabled: blocked, value: draft.prefix,
326
+ placeholder: "mine/", maxlength: "65" }), "prefix", function (c) { return c.value; });
327
+ prefix.value = draft.prefix;
328
+ const enabled = bind(h("input", { type: "checkbox", disabled: blocked }), "enabled", function (c) { return c.checked; });
329
+ enabled.checked = !!draft.enabled;
330
+ // The key is never part of the draft or any re-render; it is read once at
331
+ // submission and the input is discarded afterwards.
332
+ const apiKey = h("input", { type: "password", autocomplete: "new-password", spellcheck: "false", disabled: blocked,
333
+ placeholder: isNew ? "" : t("models.apiKeyKeep") });
334
+ const save = h("button.btn.btn-primary", { type: "submit", disabled: blocked }, isNew ? t("models.createProvider") : t("models.saveProvider"));
335
+ const cancel = h("button.btn.btn-ghost", { type: "button", disabled: state.pending }, t("models.cancel"));
336
+ cancel.addEventListener("click", function () { state.providerDraft = null; renderProviders(); notify(); });
337
+ form.addEventListener("submit", function (event) {
338
+ event.preventDefault();
339
+ if (writesBlocked()) return;
340
+ const key = apiKey.value;
341
+ apiKey.value = "";
342
+ const provider = { name: name.value.trim(), via: via.value, baseUrl: baseUrl.value.trim(), prefix: prefix.value.trim(), enabled: enabled.checked };
343
+ if (!isNew) provider.id = draft.id;
344
+ const missing = !provider.name || !provider.baseUrl || (isNew && !provider.prefix) || (isNew && !key)
345
+ || (provider.via !== "direct" && provider.via !== "cpa");
346
+ if (missing) { setReceipt(t(isNew ? "models.providerNewRequired" : "models.providerRequired"), "bad", "provider"); renderProviders(); return; }
347
+ if (!PREFIX_PATTERN.test(provider.prefix)) { setReceipt(t("models.prefixInvalid"), "bad", "provider"); renderProviders(); return; }
348
+ if (key) provider.apiKey = key;
349
+ void write(function (revision) { return api.saveModelProvider({ revision: revision, operation: "save", provider: provider }); },
350
+ { scope: "provider", clearProvider: true });
351
+ });
352
+ describeVia();
353
+ form.append(
354
+ h("div.models-form-head", null, h("h2", null, isNew ? t("models.newProvider") : t("models.editProvider") + " · " + draft.name),
355
+ isNew ? null : h("code.id", null, draft.id)),
356
+ h("div.settings-grid", null,
357
+ field(t("models.provider.name"), name),
358
+ h("label.field", null, h("span", null, t("models.provider.via")), via, viaHint),
359
+ field(t("models.provider.baseUrl"), baseUrl),
360
+ field(t("models.provider.prefix"), prefix, t("models.provider.prefixHelp"))),
361
+ h("label.settings-reset", null, enabled, t("models.provider.enabled")),
362
+ h("label.field", null, keyLabel, apiKey, h("small.faint", null,
363
+ isNew ? t("models.provider.apiKeyNewHelp") : (draft.credentialPresent ? t("models.provider.apiKeyKeepHelp") : t("models.provider.apiKeyMissingHelp")))),
364
+ h("div.settings-actions", null, save, cancel));
365
+ return form;
366
+ }
367
+ // The list re-renders on every keystroke in the search box; the toolbar
368
+ // (and the editor above it) are left alone so neither loses focus or input.
369
+ const providerResults = h("div.models-results");
370
+ function renderProviderResults() {
371
+ const view = state.view;
372
+ if (!view) { providerResults.replaceChildren(); return; }
373
+ const rows = view.providers.filter(providerMatches);
374
+ const count = h("p.faint.models-count", { role: "status" }, t("models.providerCount")
375
+ .replace("{shown}", String(rows.length)).replace("{total}", String(view.providers.length)));
376
+ if (!view.providers.length) { providerResults.replaceChildren(h("p.models-empty", null, t("models.noProviders"))); return; }
377
+ if (!rows.length) { providerResults.replaceChildren(count, h("p.models-empty", null, t("models.noProviderMatches"))); return; }
378
+ providerResults.replaceChildren(count, h("div.models-table-wrap", null, h("table.models-table", null,
379
+ h("thead", null, h("tr", null, ["provider", "baseUrl", "prefix", "route", "state", "actions"].map(function (key) {
380
+ return h("th", { scope: "col" }, t("models.column." + key));
381
+ }))),
382
+ h("tbody", null, rows.map(providerRow)))));
383
+ }
384
+ // Filters re-render only the toolbar and list, never an open editor.
385
+ const providerTools = h("div.models-toolbar");
386
+ function renderProviderTools() {
387
+ const filter = state.providerFilter;
388
+ providerTools.replaceChildren(
389
+ searchBox("models.searchProviders", filter.query, function (value) { filter.query = value; renderProviderResults(); }),
390
+ filterGroup("models.filter.route", ROUTE_FILTERS, filter.route, function (value) {
391
+ return value === "all" ? t("models.filter.all") : t("models.via." + value, value);
392
+ }, function (value) { filter.route = value; renderProviderTools(); }),
393
+ filterGroup("models.filter.state", STATE_FILTERS, filter.state, function (value) {
394
+ return t("models.filter." + value);
395
+ }, function (value) { filter.state = value; renderProviderTools(); }));
396
+ renderProviderResults();
397
+ }
398
+ function renderProviders() {
399
+ const view = state.view;
400
+ const add = h("button.btn.btn-primary", { type: "button", disabled: writesBlocked() || !configurable() || !!state.providerDraft,
401
+ onclick: function () { startProvider(null); } }, icon("plus"), t("models.addProvider"));
402
+ const children = [head("providers", t("models.providersHelp"), view && !view.readOnly ? [add] : null)];
403
+ if (!view) children.push(h("p.models-empty", null, state.loading ? t("models.loading") : t("models.unavailable")));
404
+ else {
405
+ if (!configurable() && !view.readOnly) children.push(h("p.models-note", null, icon("info"), t("models.configFileRequired") + " ",
406
+ sectionLink("connection", t("models.openConnection"))));
407
+ children.push(receiptNode("provider"));
408
+ if (state.providerDraft) children.push(h("div.card.models-card.models-editor", null, h("div.card-body", null, providerForm())));
409
+ children.push(h("div.card.models-card", null, providerTools, providerResults));
410
+ renderProviderTools();
411
+ }
412
+ sections.providers.replaceChildren.apply(sections.providers, children.filter(Boolean));
413
+ }
414
+
415
+ // ---- Observations -----------------------------------------------------
416
+ function expired(observedAt) {
417
+ return !!observedAt && now() - Date.parse(observedAt) > 60000;
418
+ }
419
+ // Update only the badge: expiry must not rebuild forms, steal focus or
420
+ // query an upstream service. The entry module supplies the local clock tick.
421
+ function updateFreshness() {
422
+ root.querySelectorAll("[data-observation-time]").forEach(function (badge) {
423
+ badge.hidden = !(badge.dataset.serverStale === "true" || expired(badge.dataset.observationTime));
424
+ });
425
+ }
426
+ function observationLine(observation) {
427
+ const status = observation.status;
428
+ const stale = tone("warn", t("models.stale"));
429
+ stale.dataset.observationTime = observation.observedAt || "";
430
+ stale.dataset.serverStale = String(observation.stale);
431
+ stale.hidden = !(observation.stale || expired(observation.observedAt));
432
+ return h("div.models-facts", { role: "status" },
433
+ tone(OBSERVATION_TONES[status] || "idle", t("models.status." + status, status)),
434
+ stale,
435
+ h("small.faint", null, t("models.attempted") + " " + when(observation.attemptedAt) + " · " + t("models.observed") + " " + when(observation.observedAt)));
436
+ }
437
+ function observationError(observation) {
438
+ return observation.error ? h("p.settings-error", null, observation.error) : null;
439
+ }
440
+
441
+ const catalogResults = h("div.models-results.models-catalog-results");
442
+ function catalogMatches(item) {
443
+ const catalog = state.catalog;
444
+ if (catalog.source !== "all" && item.source !== catalog.source) return false;
445
+ const query = catalog.query.trim().toLowerCase();
446
+ return !query || item.id.toLowerCase().includes(query);
447
+ }
448
+ function renderCatalogResults() {
449
+ const catalog = state.catalog;
450
+ const page = catalog.page;
451
+ if (!page || !catalog.items.length) { catalogResults.replaceChildren(); return; }
452
+ const rows = catalog.items.filter(catalogMatches);
453
+ // Search covers only the pages already read; the count says so.
454
+ const count = t("models.catalogCount").replace("{shown}", String(catalog.items.length)).replace("{total}", String(page.total))
455
+ + (rows.length === catalog.items.length ? "" : " · " + t("models.catalogMatches").replace("{count}", String(rows.length)));
456
+ catalogResults.replaceChildren(h("p.faint.models-count", { role: "status" }, count),
457
+ rows.length ? h("ul.models-catalog", null, rows.map(function (item) {
458
+ return h("li.models-catalog-item", null, h("code", null, item.id), h("span.chip", null, item.source));
459
+ })) : h("p.models-empty", null, t("models.noCatalogMatches")));
460
+ }
461
+ function renderCatalog() {
462
+ const catalog = state.catalog;
463
+ const page = catalog.page;
464
+ const refresh = h("button.btn", { type: "button", disabled: catalog.loading || !state.view || !(state.view.connection), onclick: function () { void refreshCatalog(); } },
465
+ icon("refresh"), t("models.refreshCatalog"));
466
+ const children = [head("catalog", t("models.catalogHelp"), [refresh])];
467
+ const body = h("div.card-body");
468
+ if (catalog.loading) body.append(h("p", { role: "status" }, t("models.loading")));
469
+ if (catalog.error) body.append(h("p.settings-error", { role: "alert" }, t("models.catalogFailed") + " " + catalog.error));
470
+ if (catalog.note) body.append(h("p", { role: "status" }, catalog.note));
471
+ if (page) {
472
+ body.append(observationLine(page));
473
+ const error = observationError(page);
474
+ if (error) body.append(error);
475
+ // Only a successful observation can say the catalog is really empty.
476
+ if (!catalog.items.length) body.append(h("p.models-empty", null, t(page.status === "not-queried" ? "models.catalogNotQueried"
477
+ : page.status === "success" ? "models.catalogEmpty" : "models.catalogItemsUnknown")));
478
+ else {
479
+ const sources = [];
480
+ catalog.items.forEach(function (item) { if (!sources.includes(item.source)) sources.push(item.source); });
481
+ if (catalog.source !== "all" && !sources.includes(catalog.source)) catalog.source = "all";
482
+ body.append(h("div.models-toolbar.models-toolbar-flat", null,
483
+ searchBox("models.searchCatalog", catalog.query, function (value) { catalog.query = value; renderCatalogResults(); }),
484
+ sources.length > 1 ? filterGroup("models.filter.source", ["all"].concat(sources), catalog.source, function (value) {
485
+ return value === "all" ? t("models.filter.all") : value;
486
+ }, function (value) { catalog.source = value; renderCatalog(); }) : null), catalogResults);
487
+ renderCatalogResults();
488
+ }
489
+ }
490
+ const more = page && page.nextCursor
491
+ ? h("button.btn", { type: "button", disabled: catalog.loading, onclick: function () { void loadCatalog(page.nextCursor); } }, t("models.moreCatalog"))
492
+ : null;
493
+ if (more) body.append(h("div.settings-actions", null, more));
494
+ children.push(h("div.card.models-card", null, body));
495
+ sections.catalog.replaceChildren.apply(sections.catalog, children.filter(Boolean));
496
+ }
497
+
498
+ // The page CSP forbids style attributes; the width is set through CSSOM.
499
+ function meterFill(fraction) {
500
+ const fill = h("span");
501
+ if (fill.style) fill.style.width = Math.max(0, Math.min(100, fraction * 100)) + "%";
502
+ return fill;
503
+ }
504
+ function windowRow(entry) {
505
+ const parts = splitWindowLabel(entry.label);
506
+ const fraction = entry.remainingFraction;
507
+ const level = fraction === null ? "idle" : fraction >= 0.5 ? "ok" : fraction >= 0.2 ? "warn" : "bad";
508
+ const name = t("models.window." + (parts.window || entry.label), parts.window || entry.label);
509
+ // An unknown fraction draws an empty, unlabelled track, never 0%.
510
+ const bar = fraction === null
511
+ ? h("div.models-meter.is-unknown", { "aria-hidden": "true" })
512
+ : h("div.models-meter.tone-" + level, { role: "progressbar", "aria-label": name, "aria-valuemin": "0", "aria-valuemax": "100",
513
+ "aria-valuenow": String(Math.round(fraction * 100)), "aria-valuetext": t("models.remaining") + " " + percent(fraction) },
514
+ meterFill(fraction));
515
+ const details = [entry.resetTime ? t("models.resets") + " " + when(entry.resetTime) : t("models.resetUnknown")];
516
+ if (entry.used !== null || entry.cap !== null) details.push(t("models.used") + " " + known(entry.used, number) + " / " + known(entry.cap, number));
517
+ return h("li.models-window", null,
518
+ h("div.models-window-line", null, h("span.models-window-name", null, name),
519
+ h("span.models-window-value", null, t("models.remaining") + " " + known(fraction, percent))),
520
+ bar, h("small.faint", null, details.join(" · ")));
521
+ }
522
+ function quotaGroups(windows) {
523
+ const groups = [];
524
+ windows.forEach(function (entry) {
525
+ const name = splitWindowLabel(entry.label).group;
526
+ let group = groups.find(function (g) { return g.name === name; });
527
+ if (!group) { group = { name: name, windows: [] }; groups.push(group); }
528
+ group.windows.push(entry);
529
+ });
530
+ return groups;
531
+ }
532
+ function quotaItem(item, index) {
533
+ const facts = (item.balanceDetails || []).map(function (entry) {
534
+ return h("div", null, h("dt", null, t("models.balance." + entry.kind)), h("dd", { title: String(entry.value) }, known(entry.value, number)));
535
+ });
536
+ // Only Command Code reports a total and a period; Factory has neither, so
537
+ // its absence there is not repeated as an unknown on every account.
538
+ if (item.source === "command-code" || item.balance !== null) {
539
+ facts.push(h("div", null, h("dt", null, t("models.balance")), h("dd", { title: String(item.balance ?? "") }, known(item.balance, number))));
540
+ }
541
+ if (item.source === "command-code" || item.periodEnd) {
542
+ facts.push(h("div", null, h("dt", null, t("models.periodEnd")), h("dd", null, when(item.periodEnd))));
543
+ }
544
+ return h("li.card.models-account", null,
545
+ h("div.card-head", null, h("div.card-heading", null,
546
+ h("div.card-title", null, h("span", { title: item.id }, t("models.source." + item.source, item.source) +
547
+ (item.source === "factory" ? " · " + t("models.account") + " " + (index + 1) : "")),
548
+ tone(OBSERVATION_TONES[item.status] || "idle", t("models.status." + item.status, item.status))),
549
+ item.source === "factory" ? h("span.card-hint", { title: t("models.accountIdHelp") }, item.id) : null)),
550
+ h("div.card-body", null,
551
+ item.error ? h("p.settings-error", null, item.error) : null,
552
+ item.windows.length ? h("div.models-groups", null, quotaGroups(item.windows).map(function (group) {
553
+ return h("div.models-group", null, group.name ? h("h3", null, group.name) : null,
554
+ h("ul.models-windows", null, group.windows.map(windowRow)));
555
+ })) : h("p.faint", null, t("models.noWindows")),
556
+ facts.length ? h("dl.models-balances", null, facts) : null));
557
+ }
558
+ function quotaReady() {
559
+ const connection = state.view && state.view.connection;
560
+ return !!(connection && connection.cpaUrl && connection.managementKeyFile);
561
+ }
562
+ function renderQuota() {
563
+ const quota = state.quota;
564
+ const tabs = h("div.seg", { role: "group", "aria-label": t("models.source") }, QUOTA_SOURCES.map(function (source) {
565
+ return h("button.seg-btn", { type: "button", "aria-pressed": String(source === quota.source), disabled: quota.loading && source !== quota.source,
566
+ onclick: function () { if (source !== quota.source) go({ section: "quota", source: source }); } }, t("models.source." + source, source));
567
+ }));
568
+ const refresh = h("button.btn", { type: "button", disabled: quota.loading || !quotaReady(), onclick: function () { void refreshQuota(); } },
569
+ icon("refresh"), t("models.refreshQuota"));
570
+ const children = [head("quota", t("models.quotaHelp"), [refresh]), h("div.models-toolbar.models-toolbar-flat", null, tabs)];
571
+ if (!quotaReady()) children.push(h("p.models-note", null, icon("info"), t("models.quotaNeedsCpa") + " ", sectionLink("connection", t("models.openConnection"))));
572
+ const body = [];
573
+ if (quota.loading) body.push(h("p", { role: "status" }, t("models.loading")));
574
+ if (quota.unavailable) body.push(h("p", { role: "status" }, t("models.quotaUnavailable")));
575
+ else if (quota.error) body.push(h("p.settings-error", { role: "alert" }, t("models.quotaFailed") + " " + quota.error));
576
+ const observation = quota.observation;
577
+ if (observation) {
578
+ body.push(observationLine(observation), observationError(observation));
579
+ if (observation.status === "not-queried") body.push(h("p.models-empty", null, t("models.quotaNotQueried")));
580
+ else if (observation.status === "unsupported") body.push(h("p.models-empty", null, t("models.quotaUnsupported")));
581
+ else if (!observation.items.length) body.push(h("p.models-empty", null, t(observation.status === "success" ? "models.quotaEmpty" : "models.quotaItemsUnknown")));
582
+ }
583
+ children.push(h("div.card.models-card", null, h("div.card-body", null, body.filter(Boolean))));
584
+ if (observation && observation.items.length && observation.status !== "not-queried" && observation.status !== "unsupported") {
585
+ children.push(h("ul.models-accounts", null, observation.items.map(quotaItem)));
586
+ }
587
+ sections.quota.replaceChildren.apply(sections.quota, children.filter(Boolean));
588
+ }
589
+
590
+ // ---- Reads and writes -------------------------------------------------
591
+ function setReceipt(text, receiptState, scope) { state.receipt = { text: text, state: receiptState, scope: scope }; }
592
+
593
+ // Observations belong to one configuration revision: the server drops its
594
+ // caches when the revision changes, so the page does too. Bumping each
595
+ // generation fences responses still in flight for the old revision, and only
596
+ // the new caches are read (GET); no external refresh is sent automatically.
597
+ function adoptView(view) {
598
+ const previous = state.view;
599
+ state.view = view;
600
+ if (previous && previous.revision === view.revision) return;
601
+ return invalidateObservations();
602
+ }
603
+ function invalidateObservations() {
604
+ const catalog = state.catalog;
605
+ catalog.generation++;
606
+ catalog.page = null; catalog.items = []; catalog.error = null; catalog.note = null; catalog.loading = false;
607
+ const quota = state.quota;
608
+ quota.generation++;
609
+ quota.observation = null; quota.error = null; quota.unavailable = false; quota.loading = false;
610
+ return Promise.all([loadCatalog(null), loadQuota()]);
611
+ }
612
+
613
+ async function load() {
614
+ state.loading = true; state.loadError = null; renderAll();
615
+ try { await adoptView(await api.models()); }
616
+ catch (error) { state.loadError = error.message; }
617
+ finally { state.loading = false; renderAll(); }
618
+ return !state.loadError;
619
+ }
620
+
621
+ // An unknown write is never resent. Reading current facts again lets the
622
+ // reader see whether it landed; later writes carry that fresh revision, so
623
+ // the server's revision check rejects anything based on the old view.
624
+ // After a rejection the same explicit read refreshes the revision while the
625
+ // non-secret drafts stay for the reader to review and save again.
626
+ async function reconcile() {
627
+ if (state.pending || state.loading) return;
628
+ const view = state.view;
629
+ const wasUnknown = state.unknown;
630
+ if (!(await load())) return;
631
+ const unchanged = !!view && state.view.revision === view.revision;
632
+ if (wasUnknown) {
633
+ setReceipt(t(unchanged ? "models.reconciledUnchanged" : "models.reconciledChanged"), "warn", "summary");
634
+ state.unknown = false;
635
+ api.releaseModelWrites();
636
+ state.connectionDraft = null;
637
+ } else {
638
+ setReceipt(t(unchanged ? "models.rereadUnchanged" : "models.rereadChanged"), unchanged ? "ok" : "warn", "summary");
639
+ }
640
+ state.rejected = false;
641
+ renderAll();
642
+ }
643
+
644
+ async function write(send, options) {
645
+ if (writesBlocked()) return;
646
+ state.pending = true;
647
+ state.rejected = false;
648
+ setReceipt(t("models.saving"), "pending", options.scope);
649
+ renderAll();
650
+ try {
651
+ const view = await send(state.view.revision);
652
+ if (options.clearConnection) state.connectionDraft = null;
653
+ if (options.clearProvider) state.providerDraft = null;
654
+ setReceipt(t("models.saved"), "ok", options.scope);
655
+ await adoptView(view);
656
+ } catch (error) {
657
+ if (error.disposition === "not-submitted") {
658
+ state.rejected = true;
659
+ setReceipt(t("models.rejected") + " " + error.message, "bad", options.scope);
660
+ state.receipt.rejected = true;
661
+ } else {
662
+ state.unknown = true;
663
+ // The blocking alert is page-wide; the receipt stays where it was made.
664
+ setReceipt(t("models.unknownWrite") + " " + error.message, "warn", options.scope);
665
+ }
666
+ } finally {
667
+ state.pending = false;
668
+ renderAll();
669
+ }
670
+ }
671
+
672
+ async function loadCatalog(cursor) {
673
+ if (!state.view) return;
674
+ const catalog = state.catalog;
675
+ const generation = ++catalog.generation;
676
+ catalog.loading = true; catalog.error = null; catalog.note = null;
677
+ // A first-page read replaces the observation; the old one is never kept on
678
+ // screen while loading or after a failure.
679
+ if (!cursor) { catalog.page = null; catalog.items = []; }
680
+ renderCatalog();
681
+ try {
682
+ const page = await api.modelCatalog(cursor || "0");
683
+ if (generation !== catalog.generation) return;
684
+ // Pages from different observations are never mixed.
685
+ if (cursor && catalog.page && page.observedAt !== catalog.page.observedAt) {
686
+ catalog.loading = false;
687
+ await loadCatalog(null);
688
+ catalog.note = t("models.catalogChanged");
689
+ renderCatalog();
690
+ return;
691
+ }
692
+ catalog.items = cursor ? catalog.items.concat(page.items) : page.items.slice();
693
+ catalog.page = page;
694
+ } catch (error) {
695
+ if (generation === catalog.generation) catalog.error = error.message;
696
+ } finally {
697
+ if (generation === catalog.generation) { catalog.loading = false; renderCatalog(); }
698
+ }
699
+ }
700
+
701
+ async function refreshCatalog() {
702
+ const catalog = state.catalog;
703
+ if (catalog.loading || !state.view) return;
704
+ const generation = ++catalog.generation;
705
+ catalog.loading = true; catalog.error = null; catalog.note = null; renderCatalog();
706
+ try {
707
+ const page = await api.refreshModels({ revision: state.view.revision, kind: "catalog" });
708
+ if (generation !== catalog.generation) return;
709
+ catalog.page = page; catalog.items = page.items.slice();
710
+ } catch (error) {
711
+ // Like quota: an unanswered refresh leaves the catalog unknown.
712
+ if (generation === catalog.generation) { catalog.error = error.message; catalog.page = null; catalog.items = []; }
713
+ } finally {
714
+ if (generation === catalog.generation) { catalog.loading = false; renderCatalog(); }
715
+ }
716
+ }
717
+
718
+ function quotaFailure(quota, error) {
719
+ // A 404 says only that this route is unavailable, not that the plugin is
720
+ // uninstalled; any other failure leaves the quota unknown.
721
+ if (error.status === 404) quota.unavailable = true;
722
+ else quota.error = error.message;
723
+ quota.observation = null;
724
+ }
725
+
726
+ async function loadQuota() {
727
+ if (!state.view) return;
728
+ const quota = state.quota;
729
+ const generation = ++quota.generation;
730
+ const source = quota.source;
731
+ quota.loading = true; quota.error = null; quota.unavailable = false; quota.observation = null; renderQuota();
732
+ try {
733
+ const observation = await api.modelQuota(source);
734
+ if (generation === quota.generation) quota.observation = observation;
735
+ } catch (error) {
736
+ if (generation === quota.generation) quotaFailure(quota, error);
737
+ } finally {
738
+ if (generation === quota.generation) { quota.loading = false; renderQuota(); }
739
+ }
740
+ }
741
+
742
+ async function refreshQuota() {
743
+ const quota = state.quota;
744
+ if (quota.loading || !state.view) return;
745
+ const generation = ++quota.generation;
746
+ const source = quota.source;
747
+ quota.loading = true; quota.error = null; quota.unavailable = false; renderQuota();
748
+ try {
749
+ const observation = await api.refreshModels({ revision: state.view.revision, kind: "quota", source: source });
750
+ if (generation === quota.generation) quota.observation = observation;
751
+ } catch (error) {
752
+ if (generation === quota.generation) quotaFailure(quota, error);
753
+ } finally {
754
+ if (generation === quota.generation) { quota.loading = false; renderQuota(); }
755
+ }
756
+ }
757
+
758
+ renderAll();
759
+ // Establish the connection/revision before requesting its cached facts.
760
+ const ready = load();
761
+ return {
762
+ ready: ready,
763
+ state: state,
764
+ show: show,
765
+ translate: function () {
766
+ // Keep data and drafts in this mount; never reload on language change.
767
+ const password = root.querySelector?.('input[type="password"]');
768
+ const value = password?.value;
769
+ renderAll();
770
+ const replacement = root.querySelector?.('input[type="password"]');
771
+ if (replacement && value !== undefined) replacement.value = value;
772
+ },
773
+ updateFreshness: updateFreshness,
774
+ dirty: function () { return !!state.connectionDraft || !!state.providerDraft || state.pending; }
775
+ };
776
+ }
777
+ `;