@worca/app 1.5.0 → 1.6.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/README.md +8 -2
- package/docker/compose.broker.yml +79 -0
- package/docker/compose.isolation.yml +40 -0
- package/package.json +2 -1
- package/src/broker/config.mjs +200 -0
- package/src/broker/copilot.mjs +124 -0
- package/src/broker/limits.mjs +88 -0
- package/src/broker/main.mjs +129 -0
- package/src/broker/scrub.mjs +45 -0
- package/src/broker/service.mjs +558 -0
- package/src/broker/slots.mjs +180 -0
- package/src/broker/store.mjs +171 -0
- package/src/broker/tokens.mjs +116 -0
- package/src/broker/ui/page.css +54 -0
- package/src/broker/ui/page.html +25 -0
- package/src/broker/ui/page.mjs +202 -0
- package/src/broker/ui-server.mjs +217 -0
- package/src/broker/usage.mjs +108 -0
- package/src/broker/vault.mjs +37 -0
- package/src/cli/models.mjs +46 -14
- package/src/cli/render.mjs +21 -0
- package/src/cli/runs.mjs +296 -0
- package/src/cli/worca-cc.mjs +14 -2
- package/src/core/agent-pool.mjs +76 -0
- package/src/core/artifacts.mjs +40 -8
- package/src/core/ask/events.mjs +11 -0
- package/src/core/ask/html-text.mjs +113 -0
- package/src/core/ask/limits.mjs +5 -0
- package/src/core/ask/mcp-stdio.mjs +74 -20
- package/src/core/ask/prompt.mjs +25 -6
- package/src/core/ask/spawn.mjs +55 -5
- package/src/core/ask/store.mjs +1 -1
- package/src/core/ask/tools.mjs +66 -1
- package/src/core/ask/turn.mjs +56 -3
- package/src/core/ask/web-access.mjs +26 -0
- package/src/core/ask/web-deps.mjs +56 -0
- package/src/core/ask/web-fetch.mjs +271 -0
- package/src/core/ask/web-proposal.mjs +76 -0
- package/src/core/auto/classify.mjs +20 -8
- package/src/core/auto/runnable.mjs +28 -0
- package/src/core/billing.mjs +53 -0
- package/src/core/bridge/errors.mjs +77 -5
- package/src/core/bridge/openrouter.mjs +59 -0
- package/src/core/bridge/provider-ops.mjs +202 -10
- package/src/core/bridge/providers/endpoint.mjs +112 -7
- package/src/core/bridge/registry.mjs +13 -0
- package/src/core/bridge/server.mjs +16 -3
- package/src/core/bridge/telemetry.mjs +44 -12
- package/src/core/bridge/translate/common.mjs +31 -0
- package/src/core/bridge/translate/request.mjs +4 -1
- package/src/core/bridge/translate/response.mjs +3 -0
- package/src/core/bridge/translate/schema-keywords.mjs +75 -0
- package/src/core/bridge/translate/stream.mjs +50 -4
- package/src/core/bridge/upstream.mjs +102 -17
- package/src/core/broker-boot.mjs +57 -0
- package/src/core/broker-client.mjs +206 -0
- package/src/core/broker-guard.mjs +112 -0
- package/src/core/broker-routing.mjs +138 -0
- package/src/core/claude-auth.mjs +29 -0
- package/src/core/claude-runner.mjs +200 -9
- package/src/core/config.mjs +9 -12
- package/src/core/failure-policy.mjs +4 -2
- package/src/core/git-info.mjs +49 -5
- package/src/core/github-credentials.mjs +44 -1
- package/src/core/graph/script-runner.mjs +3 -1
- package/src/core/list-prices.mjs +29 -0
- package/src/core/mcp-secrets.mjs +80 -0
- package/src/core/metrics/sync.mjs +2 -1
- package/src/core/model-env.mjs +72 -0
- package/src/core/model-test.mjs +17 -5
- package/src/core/onboarding.mjs +12 -6
- package/src/core/openrouter-free.mjs +159 -0
- package/src/core/orchestrator.mjs +100 -12
- package/src/core/policy/effective.mjs +18 -1
- package/src/core/policy/local.mjs +4 -1
- package/src/core/policy/registry.mjs +12 -2
- package/src/core/preflight.mjs +116 -0
- package/src/core/recoverable-error.mjs +95 -0
- package/src/core/recovery-backoff.mjs +84 -0
- package/src/core/redact.mjs +25 -0
- package/src/core/run-context.mjs +6 -0
- package/src/core/run-harness.mjs +112 -30
- package/src/core/run-report.mjs +2 -1
- package/src/core/settings.mjs +90 -5
- package/src/core/title.mjs +7 -2
- package/src/core/web-allowlist.mjs +95 -0
- package/ui/public/app.js +271 -54
- package/ui/public/ask-model.mjs +1 -0
- package/ui/public/ask-panel.mjs +160 -23
- package/ui/public/bridge-view.mjs +216 -9
- package/ui/public/chat-settings-view.mjs +52 -1
- package/ui/public/credential-badges.mjs +63 -0
- package/ui/public/credentials-view.mjs +57 -0
- package/ui/public/index.html +303 -244
- package/ui/public/models-view.mjs +19 -1
- package/ui/public/openrouter-free-view.mjs +118 -0
- package/ui/public/stats-view.mjs +56 -0
- package/ui/public/style.css +110 -54
- package/ui/public/team-policy-view.mjs +2 -1
- package/ui/public/ws-seq.mjs +24 -0
- package/ui/server.mjs +319 -23
|
@@ -74,7 +74,10 @@ export function degradationLine(m) {
|
|
|
74
74
|
const limit = caps.maxPromptTokens ? `, prompt limit ~${Math.round(caps.maxPromptTokens / 1000)}k tokens` : '';
|
|
75
75
|
const lead = m.upstream.api === 'openai-responses'
|
|
76
76
|
? 'translated — reasoning summaries only, no WebSearch/WebFetch'
|
|
77
|
-
:
|
|
77
|
+
: caps.reasoning
|
|
78
|
+
// A reasoning model's streamed reasoning arrives as thinking; chat completions takes none back.
|
|
79
|
+
? 'translated — reasoning shown but not carried across turns, no WebSearch/WebFetch'
|
|
80
|
+
: 'translated — no thinking blocks, no WebSearch/WebFetch';
|
|
78
81
|
return `${lead}${limit}`;
|
|
79
82
|
}
|
|
80
83
|
|
|
@@ -103,11 +106,48 @@ export function needsSignInPill(m, { doc = globalThis.document } = {}) {
|
|
|
103
106
|
// ── Providers card (§8.1) ────────────────────────────────────────────────────
|
|
104
107
|
|
|
105
108
|
function providerStatePill(doc, c) {
|
|
106
|
-
if (!c.termsCurrent)
|
|
109
|
+
if (!c.termsCurrent) {
|
|
110
|
+
// What unblocks sign-in, in the "Read notice" button's own words — and it opens that notice.
|
|
111
|
+
const pill = h(doc, 'button', 'badge grey mv-cp-terms mv-cp-terms-pill',
|
|
112
|
+
c.acknowledgedTerms ? 'please re-read the updated notice' : 'please read the notice first');
|
|
113
|
+
pill.type = 'button';
|
|
114
|
+
pill.title = 'Sign-in is blocked until you acknowledge the GitHub Copilot notice. Click to read it.';
|
|
115
|
+
return pill;
|
|
116
|
+
}
|
|
107
117
|
if (!c.connected) return h(doc, 'span', 'badge grey', 'not connected');
|
|
108
118
|
return h(doc, 'span', 'badge green', `connected${c.login ? ` as @${c.login}` : ''}`);
|
|
109
119
|
}
|
|
110
120
|
|
|
121
|
+
/**
|
|
122
|
+
* With the credential broker: the viewer's own state in the slot `name` spends from (key page),
|
|
123
|
+
* not worca's settings, which hold no key. A missing key links to the key page.
|
|
124
|
+
*/
|
|
125
|
+
function brokerPill(doc, b, name) {
|
|
126
|
+
const p = (b.providers || {})[name] || {};
|
|
127
|
+
const what = name === 'copilot' ? 'sign-in' : 'key';
|
|
128
|
+
if (b.signInNeeded) return h(doc, 'span', 'badge grey', `sign in to see your ${what}`);
|
|
129
|
+
if (p.state === 'none') {
|
|
130
|
+
const x = h(doc, 'span', 'badge red', 'no broker slot for this URL');
|
|
131
|
+
x.title = p.error || 'The credential broker has no slot for this base URL.';
|
|
132
|
+
return x;
|
|
133
|
+
}
|
|
134
|
+
if (p.state === 'keyless') return h(doc, 'span', 'badge green', 'local — no key needed');
|
|
135
|
+
if (b.error) {
|
|
136
|
+
const x = h(doc, 'span', 'badge grey', 'key page unreachable');
|
|
137
|
+
x.title = b.error;
|
|
138
|
+
return x;
|
|
139
|
+
}
|
|
140
|
+
if (p.state === 'set') return h(doc, 'span', 'badge green', `your ${what}${p.suffix ? ` ••••${p.suffix}` : ''}`);
|
|
141
|
+
if (p.state === 'operator') return h(doc, 'span', 'badge green', 'team key');
|
|
142
|
+
const label = p.state === 'invalid' ? `your ${what} was rejected` : `add your ${what} on the key page`;
|
|
143
|
+
const cls = p.state === 'invalid' ? 'badge red' : 'badge grey';
|
|
144
|
+
if (!b.keyPage) return h(doc, 'span', cls, label);
|
|
145
|
+
const a = h(doc, 'a', `${cls} mv-broker-pill`, label);
|
|
146
|
+
a.href = b.keyPage; a.target = '_blank'; a.rel = 'noopener';
|
|
147
|
+
a.title = 'Opens the key page in a new tab. Reopen this tab afterwards to see the change.';
|
|
148
|
+
return a;
|
|
149
|
+
}
|
|
150
|
+
|
|
111
151
|
function quotaLine(q) {
|
|
112
152
|
if (!q) return '';
|
|
113
153
|
if (q.unlimited) return 'Premium requests: unlimited on this plan';
|
|
@@ -148,6 +188,19 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
|
|
|
148
188
|
}
|
|
149
189
|
root.appendChild(h(doc, 'small', 'hint mv-providers-hint',
|
|
150
190
|
"Providers let Worca run models that don't speak the Anthropic API — through its own in-process bridge. Sign in or set a key once; then import or add models on the Models tab and pick them anywhere a model is picked."));
|
|
191
|
+
// Credential broker: keys and the Copilot sign-in are per person, on the broker's key page.
|
|
192
|
+
// This card keeps the shared settings (base URLs, concurrency, the Copilot notice, imports);
|
|
193
|
+
// its key fields, sign-in and key test are hidden (style.css .mv-providers--broker).
|
|
194
|
+
if (p.broker && p.broker.enabled) {
|
|
195
|
+
root.classList.add('mv-providers--broker');
|
|
196
|
+
const note = h(doc, 'p', 'hint mv-broker-note', 'Keys and the GitHub Copilot sign-in are held by the credential broker: each person adds their own on the key page. Imports list models with your own key.');
|
|
197
|
+
if (p.broker.keyPage) {
|
|
198
|
+
const a = h(doc, 'a', 'mv-broker-link', 'Open the key page');
|
|
199
|
+
a.href = p.broker.keyPage; a.target = '_blank'; a.rel = 'noopener';
|
|
200
|
+
note.append(' ', a);
|
|
201
|
+
}
|
|
202
|
+
root.appendChild(note);
|
|
203
|
+
}
|
|
151
204
|
/** One provider's row, in its own card when the tab hosts it. */
|
|
152
205
|
const place = (row, title) => {
|
|
153
206
|
if (!split) { root.appendChild(row); return; }
|
|
@@ -165,7 +218,9 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
|
|
|
165
218
|
const cpMain = h(doc, 'div', 'mv-pv-main');
|
|
166
219
|
const cpHead = h(doc, 'div', 'mv-head');
|
|
167
220
|
cpHead.appendChild(h(doc, 'b', 'mv-name', 'GitHub Copilot'));
|
|
168
|
-
|
|
221
|
+
const brokered = !!(p.broker && p.broker.enabled);
|
|
222
|
+
// The notice gates Copilot models either way; past it, the broker's view of your sign-in.
|
|
223
|
+
cpHead.appendChild(brokered && c.termsCurrent ? brokerPill(doc, p.broker, 'copilot') : providerStatePill(doc, c));
|
|
169
224
|
cpMain.appendChild(cpHead);
|
|
170
225
|
cpMain.appendChild(h(doc, 'small', 'hint',
|
|
171
226
|
c.acknowledgedTerms
|
|
@@ -198,8 +253,9 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
|
|
|
198
253
|
|
|
199
254
|
const cpBtns = h(doc, 'div', 'mv-pv-btns');
|
|
200
255
|
const imp = h(doc, 'button', 'btn-ghost mv-cp-fetch-models', 'Import models…');
|
|
201
|
-
|
|
202
|
-
|
|
256
|
+
// With the broker, the import runs with the viewer's own Copilot sign-in (on the key page).
|
|
257
|
+
imp.type = 'button'; imp.disabled = !c.connected && !brokered;
|
|
258
|
+
if (imp.disabled) imp.title = 'Sign in first';
|
|
203
259
|
cpBtns.appendChild(imp);
|
|
204
260
|
const quotaBtn = h(doc, 'button', 'btn-ghost mv-cp-quota-refresh', 'Refresh usage');
|
|
205
261
|
quotaBtn.type = 'button'; quotaBtn.disabled = !c.connected;
|
|
@@ -227,7 +283,7 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
|
|
|
227
283
|
const main = h(doc, 'div', 'mv-pv-main');
|
|
228
284
|
const rh = h(doc, 'div', 'mv-head');
|
|
229
285
|
rh.appendChild(h(doc, 'b', 'mv-name', PROVIDER_LABELS[name]));
|
|
230
|
-
rh.appendChild(k.configured ? h(doc, 'span', 'badge green', 'key set')
|
|
286
|
+
rh.appendChild(p.broker && p.broker.enabled ? brokerPill(doc, p.broker, name) : k.configured ? h(doc, 'span', 'badge green', 'key set')
|
|
231
287
|
: k.keySet ? h(doc, 'span', 'badge red', 'key ${VAR} not set')
|
|
232
288
|
: k.keyOptional ? h(doc, 'span', 'badge green', 'local — no key needed') : h(doc, 'span', 'badge grey', 'no key'));
|
|
233
289
|
main.appendChild(rh);
|
|
@@ -245,6 +301,16 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
|
|
|
245
301
|
buIn.type = 'text'; buIn.value = k.baseUrl || ''; buIn.dataset.provider = name;
|
|
246
302
|
buIn.placeholder = name === 'openai' ? 'https://api.openai.com/v1' : 'https://api.anthropic.com';
|
|
247
303
|
bu.appendChild(buIn);
|
|
304
|
+
if (name === 'openai') {
|
|
305
|
+
// One click to a hosted catalog: fills the URL (and a ${VAR} key when none is set), then the
|
|
306
|
+
// user Tests and Saves as for any endpoint. Nothing is stored until Save.
|
|
307
|
+
const pre = h(doc, 'small', 'hint mv-pv-presets', 'Preset: ');
|
|
308
|
+
const or = h(doc, 'button', 'link-btn mv-pv-preset', 'OpenRouter');
|
|
309
|
+
or.type = 'button'; or.dataset.provider = name; or.dataset.preset = 'openrouter';
|
|
310
|
+
or.title = `Point this provider at OpenRouter (${OPENROUTER_PRESET.baseUrl})`;
|
|
311
|
+
pre.appendChild(or);
|
|
312
|
+
bu.appendChild(pre);
|
|
313
|
+
}
|
|
248
314
|
ctl.appendChild(bu);
|
|
249
315
|
const key = h(doc, 'label', 'mv-field');
|
|
250
316
|
key.appendChild(h(doc, 'span', 'mv-field-label', 'API key'));
|
|
@@ -282,6 +348,25 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
|
|
|
282
348
|
return root;
|
|
283
349
|
}
|
|
284
350
|
|
|
351
|
+
/** The OpenRouter preset (docs/models.md "OpenRouter"); mirrors provider-ops OPENROUTER_BASE_URL. */
|
|
352
|
+
export const OPENROUTER_PRESET = Object.freeze({ baseUrl: 'https://openrouter.ai/api/v1', keyRef: '${OPENROUTER_KEY}' });
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Apply a preset to a provider row's fields — the base URL always, the key only when the row has
|
|
356
|
+
* none (a stored or typed key is never replaced). Returns whether anything changed.
|
|
357
|
+
*/
|
|
358
|
+
export function applyProviderPreset(rootEl, name, preset) {
|
|
359
|
+
const p = preset === 'openrouter' ? OPENROUTER_PRESET : null;
|
|
360
|
+
const row = p && rootEl.querySelector(`.mv-pv-row[data-provider="${name}"]`);
|
|
361
|
+
if (!row) return false;
|
|
362
|
+
const bu = row.querySelector('.mv-pv-baseurl');
|
|
363
|
+
const key = row.querySelector('.mv-pv-key');
|
|
364
|
+
let changed = false;
|
|
365
|
+
if (bu && bu.value.trim() !== p.baseUrl) { bu.value = p.baseUrl; changed = true; }
|
|
366
|
+
if (key && !key.value.trim()) { key.value = p.keyRef; changed = true; }
|
|
367
|
+
return changed;
|
|
368
|
+
}
|
|
369
|
+
|
|
285
370
|
/** Collect a key-based provider row into a PATCH body (masked echo dropped). */
|
|
286
371
|
export function collectProviderRow(rootEl, name) {
|
|
287
372
|
const row = rootEl.querySelector(`.mv-pv-row[data-provider="${name}"]`);
|
|
@@ -398,12 +483,38 @@ export function renderImportSheet(models, { doc = globalThis.document } = {}) {
|
|
|
398
483
|
export function renderEndpointSheet(payload, { doc = globalThis.document } = {}) {
|
|
399
484
|
const p = payload || {};
|
|
400
485
|
const list = Array.isArray(p.models) ? p.models : [];
|
|
486
|
+
const hosted = p.server === 'openrouter';
|
|
401
487
|
const root = h(doc, 'section', 'card mv-editor mvi mvi-ep');
|
|
402
488
|
root.dataset.source = 'endpoint';
|
|
403
489
|
root.dataset.baseurl = p.baseUrl || '';
|
|
490
|
+
if (p.server) root.dataset.server = p.server;
|
|
404
491
|
root.appendChild(h(doc, 'h3', 'mv-editor-title', `Import from ${p.serverLabel || 'this endpoint'}`));
|
|
405
|
-
root.appendChild(h(doc, 'small', 'hint',
|
|
492
|
+
root.appendChild(h(doc, 'small', 'hint', hosted
|
|
493
|
+
? `${p.baseUrl || ''} — these run through the translation layer (no web tools). Worca reads each model's window, output cap, tool and reasoning support and its listed price, and pins them on the entry.`
|
|
494
|
+
: `${p.baseUrl || ''} — these run through the translation layer (no thinking blocks, no web tools) and are priced free: a model on your own machine bills nothing. Worca pins the prompt limit only when the server reports the window it really serves.`));
|
|
406
495
|
for (const w of Array.isArray(p.warnings) ? p.warnings : []) root.appendChild(h(doc, 'small', 'hint mvi-warn', w));
|
|
496
|
+
// A hosted catalog lists hundreds of models: beside the dialog's text filter, narrow by what a
|
|
497
|
+
// pipeline decides on. The controls only mark the view — endpointRowMatches reads them.
|
|
498
|
+
if (hosted && list.length) {
|
|
499
|
+
const bar = h(doc, 'div', 'chip-select mvi-or-filters');
|
|
500
|
+
const chip = (cls, text) => {
|
|
501
|
+
const l = h(doc, 'label', 'mvi-chip');
|
|
502
|
+
const cb = h(doc, 'input', cls); cb.type = 'checkbox';
|
|
503
|
+
l.appendChild(cb); l.appendChild(h(doc, 'span', null, text));
|
|
504
|
+
bar.appendChild(l);
|
|
505
|
+
};
|
|
506
|
+
chip('mvi-f-free', 'Free');
|
|
507
|
+
chip('mvi-f-tools', 'Tools');
|
|
508
|
+
const ctxL = h(doc, 'label', 'mvi-chip');
|
|
509
|
+
ctxL.appendChild(h(doc, 'span', null, 'Window ≥'));
|
|
510
|
+
const ctx = h(doc, 'select', 'select mvi-f-ctx');
|
|
511
|
+
for (const [v, t] of [['0', 'any'], ['65536', '64k'], ['131072', '128k'], ['262144', '256k'], ['1000000', '1M']]) {
|
|
512
|
+
const o = h(doc, 'option', null, t); o.value = v; ctx.appendChild(o);
|
|
513
|
+
}
|
|
514
|
+
ctxL.appendChild(ctx);
|
|
515
|
+
bar.appendChild(ctxL);
|
|
516
|
+
root.appendChild(bar);
|
|
517
|
+
}
|
|
407
518
|
if (!list.length) {
|
|
408
519
|
root.appendChild(h(doc, 'div', 'hist-empty', 'This endpoint lists no models.'));
|
|
409
520
|
} else {
|
|
@@ -421,6 +532,9 @@ export function renderEndpointSheet(payload, { doc = globalThis.document } = {})
|
|
|
421
532
|
for (const m of list) {
|
|
422
533
|
const tr = h(doc, 'tr');
|
|
423
534
|
tr.dataset.id = m.id;
|
|
535
|
+
tr.dataset.free = m.free ? '1' : '0';
|
|
536
|
+
tr.dataset.tools = m.toolCalls === true ? '1' : '0';
|
|
537
|
+
tr.dataset.ctx = String(m.servedContext || 0);
|
|
424
538
|
const blocked = m.importable === false;
|
|
425
539
|
if (blocked) tr.className = 'mvi-disabled';
|
|
426
540
|
const cbTd = h(doc, 'td');
|
|
@@ -470,6 +584,21 @@ export function collectImportSheet(rootEl) {
|
|
|
470
584
|
return [...rootEl.querySelectorAll('.mvi-cb')].filter((c) => c.checked && !c.disabled).map((c) => c.value);
|
|
471
585
|
}
|
|
472
586
|
|
|
587
|
+
/**
|
|
588
|
+
* Whether an import-sheet row passes the dialog's text query and, on a hosted sheet, its Free /
|
|
589
|
+
* Tools / window filters. A view filter only — the tick on a hidden row is kept. Pure over the DOM.
|
|
590
|
+
*/
|
|
591
|
+
export function endpointRowMatches(tr, sheet, query = '') {
|
|
592
|
+
const q = String(query || '').trim().toLowerCase();
|
|
593
|
+
if (q && !tr.textContent.toLowerCase().includes(q) && !String(tr.dataset.id || '').toLowerCase().includes(q)) return false;
|
|
594
|
+
const bar = sheet && sheet.querySelector('.mvi-or-filters');
|
|
595
|
+
if (!bar) return true;
|
|
596
|
+
if (bar.querySelector('.mvi-f-free')?.checked && tr.dataset.free !== '1') return false;
|
|
597
|
+
if (bar.querySelector('.mvi-f-tools')?.checked && tr.dataset.tools !== '1') return false;
|
|
598
|
+
const min = Number(bar.querySelector('.mvi-f-ctx')?.value || 0);
|
|
599
|
+
return !(min > 0 && Number(tr.dataset.ctx || 0) < min);
|
|
600
|
+
}
|
|
601
|
+
|
|
473
602
|
/** The select-all box: tick every importable row. */
|
|
474
603
|
export function applyImportSelectAll(rootEl, on) {
|
|
475
604
|
for (const c of rootEl.querySelectorAll('.mvi-cb')) if (!c.disabled) c.checked = !!on;
|
|
@@ -477,6 +606,50 @@ export function applyImportSelectAll(rootEl, on) {
|
|
|
477
606
|
|
|
478
607
|
// ── Editor: Connection section (§8.3) ───────────────────────────────────────
|
|
479
608
|
|
|
609
|
+
/** Whether a base URL is OpenRouter's (mirrors model-env.mjs isOpenRouterBaseUrl; this module imports nothing). */
|
|
610
|
+
export function isOpenRouterUrl(v) {
|
|
611
|
+
if (typeof v !== 'string' || !v.trim()) return false;
|
|
612
|
+
let host;
|
|
613
|
+
try { host = new URL(v.trim()).hostname.toLowerCase(); } catch { return false; }
|
|
614
|
+
return host === 'openrouter.ai' || host.endsWith('.openrouter.ai');
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Whether the Connection form resolves to OpenRouter chat completions: its own base URL, else the provider's. */
|
|
618
|
+
function openRouterApplies(conn) {
|
|
619
|
+
if ((conn.querySelector('.mv-conn-mode-rb:checked')?.value || 'direct') !== 'provider') return false;
|
|
620
|
+
if (conn.querySelector('.mv-conn-provider')?.value !== 'openai' || conn.querySelector('.mv-conn-api')?.value !== 'openai-chat') return false;
|
|
621
|
+
const own = (conn.querySelector('.mv-conn-baseurl')?.value || '').trim();
|
|
622
|
+
return isOpenRouterUrl(own || conn._providers?.openai?.baseUrl || '');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Load an `upstream.openrouter` block (or nothing: the defaults) into the routing fields. */
|
|
626
|
+
function fillOpenRouter(root, or) {
|
|
627
|
+
const o = or || {};
|
|
628
|
+
const p = o.provider || {};
|
|
629
|
+
const set = (sel, v) => { const el = root.querySelector(sel); if (el) el.value = v; };
|
|
630
|
+
set('.mv-conn-or-models', Array.isArray(o.models) ? o.models.join(', ') : '');
|
|
631
|
+
set('.mv-conn-or-order', Array.isArray(p.order) ? p.order.join(', ') : '');
|
|
632
|
+
set('.mv-conn-or-sort', p.sort || '');
|
|
633
|
+
const fb = root.querySelector('.mv-conn-or-fallbacks');
|
|
634
|
+
if (fb) fb.checked = p.allow_fallbacks !== false; // OpenRouter's default is on
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** The routing fields as an `upstream.openrouter` block, or undefined when all are at their defaults. */
|
|
638
|
+
function collectOpenRouter(conn) {
|
|
639
|
+
const list = (sel) => (conn.querySelector(sel)?.value || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
640
|
+
const out = {};
|
|
641
|
+
const models = list('.mv-conn-or-models');
|
|
642
|
+
if (models.length) out.models = models;
|
|
643
|
+
const provider = {};
|
|
644
|
+
const order = list('.mv-conn-or-order');
|
|
645
|
+
if (order.length) provider.order = order;
|
|
646
|
+
if (conn.querySelector('.mv-conn-or-fallbacks')?.checked === false) provider.allow_fallbacks = false;
|
|
647
|
+
const sort = conn.querySelector('.mv-conn-or-sort')?.value || '';
|
|
648
|
+
if (sort) provider.sort = sort;
|
|
649
|
+
if (Object.keys(provider).length) out.provider = provider;
|
|
650
|
+
return Object.keys(out).length ? out : undefined;
|
|
651
|
+
}
|
|
652
|
+
|
|
480
653
|
/**
|
|
481
654
|
* The Connection block for the model editor. `model` is the MASKED entry or
|
|
482
655
|
* null; `providers` the GET /api/providers payload (for the readiness hints);
|
|
@@ -552,7 +725,13 @@ export function renderConnectionSection(model, { doc = globalThis.document, prov
|
|
|
552
725
|
|
|
553
726
|
// Advanced: per-entry overrides for the key-based providers.
|
|
554
727
|
const adv = h(doc, 'details', 'advanced mv-conn-adv');
|
|
555
|
-
|
|
728
|
+
// Same summary as New pipeline's Advanced (index.html #advanced-config): the chevron is
|
|
729
|
+
// what says "expandable" — without it the label reads as a plain heading.
|
|
730
|
+
const sum = h(doc, 'summary');
|
|
731
|
+
const chev = h(doc, 'span', 'adv-chev');
|
|
732
|
+
chev.setAttribute('aria-hidden', 'true');
|
|
733
|
+
sum.appendChild(chev);
|
|
734
|
+
sum.appendChild(h(doc, 'span', null, 'Advanced — base URL, API key, extra headers'));
|
|
556
735
|
adv.appendChild(sum);
|
|
557
736
|
const advBody = h(doc, 'div', 'advanced-body mv-conn-adv-body');
|
|
558
737
|
const bu = h(doc, 'input', 'input mv-conn-baseurl');
|
|
@@ -567,6 +746,28 @@ export function renderConnectionSection(model, { doc = globalThis.document, prov
|
|
|
567
746
|
hdr.rows = 2; hdr.placeholder = 'One per line: Header-Name: value';
|
|
568
747
|
hdr.value = upstream && upstream.headers ? Object.entries(upstream.headers).map(([k, v]) => `${k}: ${v}`).join('\n') : '';
|
|
569
748
|
advBody.appendChild(field('Extra headers', hdr));
|
|
749
|
+
// OpenRouter routing: shown only while the entry resolves to OpenRouter (applyConnectionMode).
|
|
750
|
+
const orBox = h(doc, 'div', 'mv-conn-or');
|
|
751
|
+
orBox.appendChild(h(doc, 'small', 'hint', 'OpenRouter routing — fallback models are tried in order when this one is rate-limited or down (a :free model’s shared pool often is); provider order and sort pick who serves it.'));
|
|
752
|
+
const orModels = h(doc, 'input', 'input mv-conn-or-models');
|
|
753
|
+
orModels.type = 'text'; orModels.placeholder = 'e.g. qwen/qwen3.8-27b (comma-separated)';
|
|
754
|
+
orBox.appendChild(field('Fallback models', orModels));
|
|
755
|
+
const orOrder = h(doc, 'input', 'input mv-conn-or-order');
|
|
756
|
+
orOrder.type = 'text'; orOrder.placeholder = 'provider names, comma-separated — empty lets OpenRouter choose';
|
|
757
|
+
orBox.appendChild(field('Provider order', orOrder));
|
|
758
|
+
const orSort = h(doc, 'select', 'select mv-conn-or-sort');
|
|
759
|
+
for (const [value, text] of [['', 'OpenRouter default'], ['price', 'Price'], ['throughput', 'Throughput'], ['latency', 'Latency']]) {
|
|
760
|
+
const o = h(doc, 'option', null, text); o.value = value; orSort.appendChild(o);
|
|
761
|
+
}
|
|
762
|
+
orBox.appendChild(field('Sort providers by', orSort));
|
|
763
|
+
const orFbLab = h(doc, 'label', 'mv-conn-cap');
|
|
764
|
+
const orFb = h(doc, 'input', 'mv-conn-or-fallbacks');
|
|
765
|
+
orFb.type = 'checkbox';
|
|
766
|
+
orFbLab.appendChild(orFb);
|
|
767
|
+
orFbLab.appendChild(h(doc, 'span', null, 'Allow other providers when these are unavailable'));
|
|
768
|
+
orBox.appendChild(orFbLab);
|
|
769
|
+
advBody.appendChild(orBox);
|
|
770
|
+
fillOpenRouter(orBox, upstream && upstream.openrouter);
|
|
570
771
|
adv.appendChild(advBody);
|
|
571
772
|
body.appendChild(adv);
|
|
572
773
|
|
|
@@ -640,6 +841,8 @@ export function applyConnectionMode(connEl) {
|
|
|
640
841
|
const api = apiSel ? apiSel.value : '';
|
|
641
842
|
const adv = conn.querySelector('.mv-conn-adv');
|
|
642
843
|
if (adv) adv.hidden = provider === 'copilot';
|
|
844
|
+
const orBox = conn.querySelector('.mv-conn-or');
|
|
845
|
+
if (orBox) orBox.hidden = !openRouterApplies(conn);
|
|
643
846
|
const hint = conn.querySelector('.mv-conn-provider-hint');
|
|
644
847
|
const p = conn._providers;
|
|
645
848
|
if (hint) {
|
|
@@ -652,7 +855,7 @@ export function applyConnectionMode(connEl) {
|
|
|
652
855
|
if (note) {
|
|
653
856
|
const copilotPricing = provider === 'copilot' ? ' Copilot bills premium requests, so Pricing defaults to Free.' : '';
|
|
654
857
|
note.textContent = mode !== 'provider' ? '' : api === 'openai-chat'
|
|
655
|
-
? `Translated:
|
|
858
|
+
? `Translated: reasoning the endpoint streams (OpenRouter, vLLM) is shown as thinking but not carried across turns, WebSearch/WebFetch withheld, prompt limit per the capabilities above.${copilotPricing}`
|
|
656
859
|
: api === 'openai-responses'
|
|
657
860
|
? `Translated to the Responses API: reasoning summaries arrive as thinking, WebSearch/WebFetch withheld, prompt limit per the capabilities above.${copilotPricing}`
|
|
658
861
|
: provider === 'copilot' ? 'Copilot’s native Anthropic endpoint: thinking blocks and cache accounting arrive intact. Copilot bills premium requests, so Pricing defaults to Free.'
|
|
@@ -704,6 +907,7 @@ export function setModelUpstream(connEl, upstream) {
|
|
|
704
907
|
const bu = conn.querySelector('.mv-conn-baseurl'); if (bu) bu.value = upstream.baseUrl || '';
|
|
705
908
|
const key = conn.querySelector('.mv-conn-key'); if (key) { key.value = upstream.apiKey || ''; if (key.value) key.dataset.original = key.value; }
|
|
706
909
|
const hdr = conn.querySelector('.mv-conn-headers'); if (hdr) hdr.value = upstream.headers ? Object.entries(upstream.headers).map(([k, v]) => `${k}: ${v}`).join('\n') : '';
|
|
910
|
+
fillOpenRouter(conn, upstream.openrouter);
|
|
707
911
|
const caps = upstream.capabilities || {};
|
|
708
912
|
for (const cb of conn.querySelectorAll('.mv-conn-cap-cb')) cb.checked = caps[cb.dataset.cap] === undefined ? cb.dataset.cap === 'toolCalls' : !!caps[cb.dataset.cap];
|
|
709
913
|
for (const inp of conn.querySelectorAll('.mv-conn-limit-in')) inp.value = caps[inp.dataset.limit] ? String(caps[inp.dataset.limit]) : '';
|
|
@@ -737,6 +941,9 @@ export function collectConnection(connEl) {
|
|
|
737
941
|
if (i > 0) headers[l.slice(0, i).trim()] = l.slice(i + 1).trim();
|
|
738
942
|
}
|
|
739
943
|
if (Object.keys(headers).length) upstream.headers = headers;
|
|
944
|
+
// Only while the entry resolves to OpenRouter: another endpoint would reject the fields.
|
|
945
|
+
const or = openRouterApplies(conn) ? collectOpenRouter(conn) : undefined;
|
|
946
|
+
if (or) upstream.openrouter = or;
|
|
740
947
|
}
|
|
741
948
|
const capabilities = {};
|
|
742
949
|
for (const cb of conn.querySelectorAll('.mv-conn-cap-cb')) capabilities[cb.dataset.cap] = !!cb.checked;
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Pure DOM renderers for the Settings "Chat notifications" card
|
|
3
3
|
// (chat-connectivity-design.md §4.8). Same contract as plugins-view.mjs:
|
|
4
4
|
// detached elements, no fetch, no listeners — app.js owns I/O and mounting;
|
|
5
|
-
// node:test drives these via jsdom.
|
|
5
|
+
// node:test drives these via jsdom. (One exception: the Ask web fields keep their own
|
|
6
|
+
// dirty flag with input/change listeners on their detached wrapper — no I/O.)
|
|
6
7
|
|
|
7
8
|
function h(doc, tag, cls, text) {
|
|
8
9
|
const n = doc.createElement(tag);
|
|
@@ -115,3 +116,53 @@ export function collectScriptToolsToggle(root) {
|
|
|
115
116
|
const cb = root && typeof root.querySelector === 'function' ? root.querySelector('input.ask-script-tools') : null;
|
|
116
117
|
return { scriptTools: cb ? cb.checked : true };
|
|
117
118
|
}
|
|
119
|
+
|
|
120
|
+
/** Ask Worca → Web access (docs/guardrails.md "Web access"): off by default; the allowlist is enforced by worca's server. */
|
|
121
|
+
export function renderAskWebFields({ askWeb } = {}, { doc = globalThis.document } = {}) {
|
|
122
|
+
const w = askWeb || { enabled: false, anyHost: false, allowedDomains: [], search: null };
|
|
123
|
+
const wrap = h(doc, 'div', 'ask-web');
|
|
124
|
+
wrap.append(h(doc, 'div', 'label-row', 'Web access'));
|
|
125
|
+
const row = h(doc, 'label', 'check-row'); row.setAttribute('for', 'askWebEnabled');
|
|
126
|
+
const cb = h(doc, 'input'); cb.type = 'checkbox'; cb.id = 'askWebEnabled'; cb.checked = w.enabled === true;
|
|
127
|
+
row.append(cb, doc.createTextNode(' Let Ask Worca read web pages'));
|
|
128
|
+
const anyRow = h(doc, 'label', 'check-row'); anyRow.setAttribute('for', 'askWebAnyHost');
|
|
129
|
+
const any = h(doc, 'input'); any.type = 'checkbox'; any.id = 'askWebAnyHost'; any.checked = w.anyHost === true;
|
|
130
|
+
anyRow.append(any, doc.createTextNode(' Any site, without asking'));
|
|
131
|
+
const anyHint = h(doc, 'small', 'hint', 'Risky: a web page or a file Ask reads can then make it send data to any site in a URL. Leave off to approve each new site from the chat.');
|
|
132
|
+
const domains = h(doc, 'textarea', 'input'); domains.id = 'askWebDomains'; domains.rows = 4;
|
|
133
|
+
domains.placeholder = 'docs.python.org\n*.mozilla.org'; domains.value = (w.allowedDomains || []).join('\n');
|
|
134
|
+
const dHint = h(doc, 'small', 'hint', 'Sites Ask may read without asking. One host per line: example.com, or *.example.com for its subdomains. For any other site Ask shows a card in the chat: allow it for that chat, always (it is added here), or deny. https only. A site sees every URL Ask requests from it, so only allow sites you trust.');
|
|
135
|
+
const field = (id, label, placeholder, value) => {
|
|
136
|
+
const box = h(doc, 'div', 'field');
|
|
137
|
+
const l = h(doc, 'label', null, label); l.setAttribute('for', id);
|
|
138
|
+
const i = h(doc, 'input', 'input'); i.id = id; i.placeholder = placeholder; i.value = value || '';
|
|
139
|
+
box.append(l, i); return box;
|
|
140
|
+
};
|
|
141
|
+
const s = w.search || {};
|
|
142
|
+
// Dirty flag: the card posts askWeb only after the user touched a web field, so saving the other
|
|
143
|
+
// Ask limits never rewrites the stored web settings.
|
|
144
|
+
const markDirty = () => { wrap.dataset.dirty = '1'; };
|
|
145
|
+
wrap.addEventListener('input', markDirty);
|
|
146
|
+
wrap.addEventListener('change', markDirty);
|
|
147
|
+
wrap.append(row, anyRow, anyHint, domains, dHint,
|
|
148
|
+
field('askWebSearchUrl', 'Search endpoint (optional)', 'https://api.search.brave.com/res/v1/web/search?q={query}', s.url),
|
|
149
|
+
field('askWebSearchKey', 'Search key variable', '${BRAVE_API_KEY}', s.key),
|
|
150
|
+
field('askWebSearchHeader', 'Key header', 'X-Subscription-Token', s.keyHeader),
|
|
151
|
+
field('askWebSearchPrefix', 'Key prefix', 'Bearer ', s.keyPrefix),
|
|
152
|
+
h(doc, 'small', 'hint', 'Any GET search API that returns JSON. Use {query} (and optionally {key}) in the URL; the key is always a ${VAR} read from worca\'s environment.'));
|
|
153
|
+
return wrap;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The askWeb POST value, or null when the user did not touch the web fields (nothing to send). */
|
|
157
|
+
export function collectAskWebFields(root) {
|
|
158
|
+
if (root.querySelector('.ask-web')?.dataset.dirty !== '1') return null;
|
|
159
|
+
const q = (id) => root.querySelector(`#${id}`);
|
|
160
|
+
const val = (id) => (q(id) ? q(id).value.trim() : '');
|
|
161
|
+
const url = val('askWebSearchUrl');
|
|
162
|
+
return {
|
|
163
|
+
enabled: !!q('askWebEnabled')?.checked,
|
|
164
|
+
anyHost: !!q('askWebAnyHost')?.checked,
|
|
165
|
+
allowedDomains: (q('askWebDomains')?.value || '').split('\n').map((x) => x.trim()).filter(Boolean),
|
|
166
|
+
search: url ? { url, key: val('askWebSearchKey'), keyHeader: val('askWebSearchHeader'), keyPrefix: q('askWebSearchPrefix') ? q('askWebSearchPrefix').value : '' } : null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ui/public/credential-badges.mjs
|
|
2
|
+
// "Your key / No key" badges on model pickers (credential broker, docs/credential-broker.md).
|
|
3
|
+
// One cache of GET /api/credentials — which slot each catalog model spends from, and the
|
|
4
|
+
// signed-in person's key state per slot — that every picker asks for a model's badge.
|
|
5
|
+
// Nothing here without a broker: credentialBadge() returns null and no picker changes.
|
|
6
|
+
|
|
7
|
+
let data = null;
|
|
8
|
+
let pending = null;
|
|
9
|
+
const listeners = new Set();
|
|
10
|
+
|
|
11
|
+
/** Replace the cached payload (app.js after a fetch; tests directly). */
|
|
12
|
+
export function setCredentialData(d) {
|
|
13
|
+
data = d && d.enabled ? d : null;
|
|
14
|
+
for (const fn of listeners) { try { fn(); } catch { /* a listener must not break the rest */ } }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Repaint hook: called whenever the cached payload changes. */
|
|
18
|
+
export function onCredentialsChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }
|
|
19
|
+
|
|
20
|
+
/** Fetch /api/credentials once at a time; never throws (no broker or no answer = no badges). */
|
|
21
|
+
export function loadCredentials(fetchImpl = globalThis.fetch) {
|
|
22
|
+
if (pending) return pending;
|
|
23
|
+
pending = (async () => {
|
|
24
|
+
try {
|
|
25
|
+
const r = await fetchImpl('/api/credentials');
|
|
26
|
+
setCredentialData(r.ok ? await r.json() : null);
|
|
27
|
+
} catch { setCredentialData(null); } finally { pending = null; }
|
|
28
|
+
return data;
|
|
29
|
+
})();
|
|
30
|
+
return pending;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The key page URL, when the broker has one. */
|
|
34
|
+
export function keyPageUrl() { return data?.keyPage || null; }
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The badge for one model, or null (no broker, unknown model, nobody signed in).
|
|
38
|
+
* @returns {{text:string, tone:'green'|'blue'|'red', title:string, missing:boolean}|null}
|
|
39
|
+
*/
|
|
40
|
+
export function credentialBadge(modelId, d = data) {
|
|
41
|
+
if (!d || !d.enabled || !d.models) return null;
|
|
42
|
+
const m = d.models[modelId];
|
|
43
|
+
if (!m) return null;
|
|
44
|
+
if (m.keyless) return { text: 'local', tone: 'blue', title: 'A local model: no key needed.', missing: false };
|
|
45
|
+
if (m.error) return { text: 'no route', tone: 'red', title: m.error, missing: true };
|
|
46
|
+
if (d.mode === 'multi' && !d.person) return null;
|
|
47
|
+
const s = (d.slots || []).find((x) => x.id === m.slot);
|
|
48
|
+
const label = s?.label || m.slot;
|
|
49
|
+
const where = d.keyPage ? ' on the key page' : ' to the credential broker';
|
|
50
|
+
switch (s?.state) {
|
|
51
|
+
case 'set': return { text: 'your key', tone: 'green', title: `Runs with your ${label}.`, missing: false };
|
|
52
|
+
case 'operator': return { text: 'team key', tone: 'blue', title: `Runs with the team's ${label}.`, missing: false };
|
|
53
|
+
case 'keyless': return { text: 'local', tone: 'blue', title: 'No key needed.', missing: false };
|
|
54
|
+
case 'invalid': return { text: 'key rejected', tone: 'red', title: `The provider rejected your ${label}: replace it${where}.`, missing: true };
|
|
55
|
+
default: return { text: 'no key', tone: 'red', title: `Add your ${label}${where} to use this model.`, missing: true };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A short suffix for a plain <option> label (a select can't hold a styled badge). */
|
|
60
|
+
export function credentialSuffix(modelId, d = data) {
|
|
61
|
+
const b = credentialBadge(modelId, d);
|
|
62
|
+
return b && b.missing ? ` · ${b.text}` : '';
|
|
63
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ui/public/credentials-view.mjs
|
|
2
|
+
// Pure DOM renderer for the Settings "My model credentials" card
|
|
3
|
+
// (docs/credential-broker.md). Same contract as chat-settings-view.mjs: a detached
|
|
4
|
+
// element, no fetch, no listeners — app.js owns I/O and mounting; node:test drives
|
|
5
|
+
// it via jsdom. Status only: which of the signed-in person's credential slots hold a
|
|
6
|
+
// key, as the credential broker reports it (GET /api/credentials). Keys are entered
|
|
7
|
+
// on the broker's own key page, never here.
|
|
8
|
+
|
|
9
|
+
function h(doc, tag, cls, text) {
|
|
10
|
+
const n = doc.createElement(tag);
|
|
11
|
+
if (cls) n.className = cls;
|
|
12
|
+
if (text != null) n.textContent = text;
|
|
13
|
+
return n;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const CREDENTIAL_STATE_TEXT = Object.freeze({
|
|
17
|
+
set: 'Set', missing: 'Not set', invalid: 'Rejected', keyless: 'No key needed', operator: 'Provided by the team',
|
|
18
|
+
});
|
|
19
|
+
const STATE_TONE = Object.freeze({ set: 'green', missing: 'slate', invalid: 'red', keyless: 'blue', operator: 'blue' });
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{enabled:boolean, mode?:string, keyPage?:string|null, person?:string|null, slots?:object[], error?:string}} data
|
|
23
|
+
* @param {Document} [doc]
|
|
24
|
+
* @returns {HTMLElement}
|
|
25
|
+
*/
|
|
26
|
+
export function renderCredentials(data, doc = document) {
|
|
27
|
+
const box = h(doc, 'div', 'cred-view');
|
|
28
|
+
if (data?.error) {
|
|
29
|
+
box.append(h(doc, 'p', 'hint err', `The credential broker didn't answer: ${data.error}`));
|
|
30
|
+
return box;
|
|
31
|
+
}
|
|
32
|
+
if (data?.mode === 'single') {
|
|
33
|
+
box.append(h(doc, 'p', 'hint', 'Model keys are held by the credential broker. To change them, update the broker\'s secrets and restart it.'));
|
|
34
|
+
} else if (!data?.person) {
|
|
35
|
+
box.append(h(doc, 'p', 'hint', 'Sign in through your team\'s sign-in page to see your credentials.'));
|
|
36
|
+
return box;
|
|
37
|
+
}
|
|
38
|
+
const list = h(doc, 'ul', 'cred-list');
|
|
39
|
+
for (const s of data.slots || []) {
|
|
40
|
+
const text = s.state === 'set' && s.suffix ? `Set ••••${s.suffix}` : (CREDENTIAL_STATE_TEXT[s.state] || s.state);
|
|
41
|
+
const row = h(doc, 'li', 'cred-row');
|
|
42
|
+
row.append(h(doc, 'span', 'cred-name', s.label || s.id), h(doc, 'span', `cred-chip tone-${STATE_TONE[s.state] || 'slate'}`, text));
|
|
43
|
+
list.append(row);
|
|
44
|
+
}
|
|
45
|
+
box.append(list);
|
|
46
|
+
if (data.mode === 'multi' && data.keyPage) {
|
|
47
|
+
const missing = (data.slots || []).some((s) => s.state === 'missing' || s.state === 'invalid');
|
|
48
|
+
const actions = h(doc, 'div', 'cred-actions');
|
|
49
|
+
const a = h(doc, 'a', `btn ${missing ? 'btn-primary' : 'btn-ghost'} btn-mini`, 'Manage keys');
|
|
50
|
+
a.href = data.keyPage;
|
|
51
|
+
a.target = '_blank';
|
|
52
|
+
a.rel = 'noopener';
|
|
53
|
+
actions.append(a, h(doc, 'small', 'hint', 'Opens the key page in a new tab. Reopen Settings afterwards to see the change.'));
|
|
54
|
+
box.append(actions);
|
|
55
|
+
}
|
|
56
|
+
return box;
|
|
57
|
+
}
|