@worca/app 1.5.0 → 1.6.0-rc.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.
- package/README.md +7 -1
- 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 +52 -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 +165 -9
- 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 +244 -43
- package/ui/public/ask-model.mjs +1 -0
- package/ui/public/ask-panel.mjs +160 -23
- package/ui/public/bridge-view.mjs +175 -6
- 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 +33 -6
- 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 +99 -50
- package/ui/public/team-policy-view.mjs +2 -1
- package/ui/public/ws-seq.mjs +24 -0
- package/ui/server.mjs +319 -23
package/ui/public/ask-panel.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { openScheduleSheet, browserTimeZone } from './schedule-sheet.mjs';
|
|
8
8
|
import { formatInstant, describeRule } from '../../src/shared/schedule/recurrence.mjs';
|
|
9
9
|
import { createThreadModel } from './ask-model.mjs';
|
|
10
|
+
import { credentialBadge } from './credential-badges.mjs';
|
|
10
11
|
import { createMarkdownRenderer } from './ask-markdown.mjs';
|
|
11
12
|
import { createThinkingOrb } from './thinking-orb.mjs';
|
|
12
13
|
import { workflowPickerLabel } from './results-view.mjs';
|
|
@@ -164,21 +165,22 @@ const PILL_MORPH_IN_MS = 520;
|
|
|
164
165
|
const PILL_MORPH_OUT_MS = 800;
|
|
165
166
|
const PILL_SETTLE_FALLBACK_MS = PILL_MORPH_OUT_MS + 150;
|
|
166
167
|
|
|
167
|
-
export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContext, openNewPipeline, openComposer = null, loadMarkdown, hljsLoader, storage, raf, now, runStore = null }) {
|
|
168
|
-
const
|
|
168
|
+
export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContext, openNewPipeline, openComposer = null, openClaudeSetup = null, loadMarkdown, hljsLoader, storage, raf, now, runStore = null }) {
|
|
169
|
+
const homePick = browserPick(); // hoisted declaration (defined below)
|
|
169
170
|
const st = {
|
|
170
171
|
open: false,
|
|
171
172
|
threadId: null,
|
|
172
173
|
model: null, // createThreadModel for the active thread (Task 4+)
|
|
173
|
-
picker:
|
|
174
|
-
model: storedPick && storedPick.model ? storedPick.model : FALLBACK_PICK.model,
|
|
175
|
-
effort: storedPick ? storedPick.effort : FALLBACK_PICK.effort,
|
|
176
|
-
},
|
|
174
|
+
picker: homePick.picker,
|
|
177
175
|
// D11 provenance, tracked per slot: only a MODEL the user actually picked
|
|
178
176
|
// outranks the backend default. An effort-only record leaves the model slot
|
|
179
177
|
// unclaimed, so a later change to ASK_LIMITS.defaultModel still reaches here.
|
|
180
|
-
pickerFromStore:
|
|
181
|
-
effortFromStore:
|
|
178
|
+
pickerFromStore: homePick.pickerFromStore,
|
|
179
|
+
effortFromStore: homePick.effortFromStore,
|
|
180
|
+
// The picker shows the open thread's own last pick (its row's model/effort).
|
|
181
|
+
// It outranks the backend default like a stored pick, but it is the chat's,
|
|
182
|
+
// not the browser's: nothing sourced from a thread reaches worca-cc.ask.model.
|
|
183
|
+
pickerFromThread: false,
|
|
182
184
|
catalog: null,
|
|
183
185
|
// #397: the thread's project/workspace scope. pinned:false = Auto (follow the
|
|
184
186
|
// page — today's behaviour). label caches the display name once resolved.
|
|
@@ -237,11 +239,28 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
237
239
|
} catch { /* storage unavailable */ }
|
|
238
240
|
return null; // no stored pick — the catalog decides (D5/D6/D11)
|
|
239
241
|
}
|
|
242
|
+
/** The browser-level pick — what a new chat starts with: the stored record over the cold-start literal, with each slot's provenance. */
|
|
243
|
+
function browserPick() {
|
|
244
|
+
const stored = readStoredModel();
|
|
245
|
+
return {
|
|
246
|
+
picker: {
|
|
247
|
+
model: stored && stored.model ? stored.model : FALLBACK_PICK.model,
|
|
248
|
+
effort: stored ? stored.effort : FALLBACK_PICK.effort,
|
|
249
|
+
},
|
|
250
|
+
pickerFromStore: !!(stored && stored.model),
|
|
251
|
+
effortFromStore: stored !== null,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
240
254
|
function storeModel() {
|
|
241
255
|
// Provenance travels with the record: writing st.picker.model when the user never
|
|
242
256
|
// chose one would pin the cold-start literal (or a default they merely saw), and
|
|
243
|
-
// the backend would be authoritative exactly once per browser.
|
|
244
|
-
|
|
257
|
+
// the backend would be authoritative exactly once per browser. A thread's model
|
|
258
|
+
// is not the user's browser-level choice either: an effort picked on it leaves
|
|
259
|
+
// the record's model slot as it was.
|
|
260
|
+
const model = st.pickerFromThread
|
|
261
|
+
? (readStoredModel() || { model: null }).model
|
|
262
|
+
: (st.pickerFromStore ? st.picker.model : null);
|
|
263
|
+
const rec = { model, effort: st.picker.effort };
|
|
245
264
|
try { storage.setItem('worca-cc.ask.model', JSON.stringify(rec)); } catch { /* ignore */ }
|
|
246
265
|
}
|
|
247
266
|
function readStoredThread() { try { return storage.getItem('worca-cc.ask.thread') || null; } catch { return null; } }
|
|
@@ -1231,15 +1250,20 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1231
1250
|
}
|
|
1232
1251
|
|
|
1233
1252
|
function applyCatalogToPicker() {
|
|
1253
|
+
const list = st.catalog && Array.isArray(st.catalog.models) ? st.catalog.models : [];
|
|
1254
|
+
// A thread's model the catalog no longer has (a removed user model) falls back
|
|
1255
|
+
// to the browser-level pick, then the backend default. The thread is not
|
|
1256
|
+
// patched: its next send stores whatever is used.
|
|
1257
|
+
if (st.pickerFromThread && list.length && !catalogEntry(st.picker.model)) { restoreBrowserPick(); return; }
|
|
1234
1258
|
const fallback = catalogDefault();
|
|
1235
1259
|
// Each slot is decided by its own provenance: a stored MODEL outranks the backend
|
|
1236
1260
|
// default, and a stored EFFORT survives even when the model comes from the default.
|
|
1237
1261
|
// (effortFromStore ⊇ pickerFromStore — a stored model always carries its effort.)
|
|
1262
|
+
// A thread's pick claims both slots.
|
|
1238
1263
|
const wanted = {
|
|
1239
|
-
model: st.pickerFromStore ? st.picker.model : fallback.model,
|
|
1240
|
-
effort: st.effortFromStore ? st.picker.effort : fallback.effort,
|
|
1264
|
+
model: st.pickerFromThread || st.pickerFromStore ? st.picker.model : fallback.model,
|
|
1265
|
+
effort: st.pickerFromThread || st.effortFromStore ? st.picker.effort : fallback.effort,
|
|
1241
1266
|
};
|
|
1242
|
-
const list = st.catalog && Array.isArray(st.catalog.models) ? st.catalog.models : [];
|
|
1243
1267
|
const wantedEntry = catalogEntry(wanted.model);
|
|
1244
1268
|
// Unknown stored/default id -> the backend default -> the first model we do
|
|
1245
1269
|
// have that is not a hidden built-in (#422; a hidden id is still a valid pick).
|
|
@@ -1250,11 +1274,43 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1250
1274
|
const changed = next.model !== st.picker.model || next.effort !== st.picker.effort;
|
|
1251
1275
|
st.picker = next;
|
|
1252
1276
|
// D11: persist ONLY a repair of a pick the user actually made. Writing the
|
|
1253
|
-
// backend default here would make it authoritative exactly once, ever
|
|
1254
|
-
|
|
1277
|
+
// backend default here would make it authoritative exactly once, ever — and a
|
|
1278
|
+
// thread's pick is never the browser's.
|
|
1279
|
+
if (changed && st.pickerFromStore && !st.pickerFromThread) storeModel();
|
|
1255
1280
|
updatePickerButton();
|
|
1256
1281
|
}
|
|
1257
1282
|
|
|
1283
|
+
/** Back to the browser-level pick (new chat, a thread with no model of its own). */
|
|
1284
|
+
function restoreBrowserPick() {
|
|
1285
|
+
const home = browserPick();
|
|
1286
|
+
st.picker = home.picker;
|
|
1287
|
+
st.pickerFromStore = home.pickerFromStore;
|
|
1288
|
+
st.effortFromStore = home.effortFromStore;
|
|
1289
|
+
st.pickerFromThread = false;
|
|
1290
|
+
if (st.catalog) applyCatalogToPicker();
|
|
1291
|
+
else updatePickerButton();
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/** A thread SWITCH shows that thread's last model/effort; a row without one gets the browser-level pick. */
|
|
1295
|
+
function applyThreadPick(thread) {
|
|
1296
|
+
const t = thread && typeof thread === 'object' ? thread : null;
|
|
1297
|
+
if (!t || typeof t.model !== 'string' || !t.model || typeof t.effort !== 'string' || !t.effort) { restoreBrowserPick(); return; }
|
|
1298
|
+
st.picker = { model: t.model, effort: t.effort };
|
|
1299
|
+
st.pickerFromThread = true;
|
|
1300
|
+
if (st.catalog) applyCatalogToPicker(); // effort coerced against the entry; a dropped model falls back
|
|
1301
|
+
else updatePickerButton(); // the raw id shows until the catalog lands
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/** Keep the pick on the open thread, so switching away and back finds it before any send. */
|
|
1305
|
+
function persistThreadPick() {
|
|
1306
|
+
const id = st.threadId;
|
|
1307
|
+
if (!id) return; // a brand-new chat: the first send stores it
|
|
1308
|
+
const body = JSON.stringify({ model: st.picker.model, effort: st.picker.effort });
|
|
1309
|
+
Promise.resolve()
|
|
1310
|
+
.then(() => fetch(`/api/ask/threads/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body }))
|
|
1311
|
+
.catch(() => { /* the next message stores the pick anyway */ });
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1258
1314
|
function loadCatalog() {
|
|
1259
1315
|
if (st.catalog) return Promise.resolve(st.catalog);
|
|
1260
1316
|
if (st.catalogLoading) return st.catalogLoading;
|
|
@@ -1313,7 +1369,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1313
1369
|
st.picker = { model: id, effort: coerceEffort(catalogEntry(id), st.picker.effort) };
|
|
1314
1370
|
st.pickerFromStore = true; // an explicit model choice claims the slot (D11)
|
|
1315
1371
|
st.effortFromStore = true;
|
|
1372
|
+
st.pickerFromThread = false; // the user's own pick now, not the thread's
|
|
1316
1373
|
storeModel();
|
|
1374
|
+
persistThreadPick();
|
|
1317
1375
|
updatePickerButton();
|
|
1318
1376
|
closePopover({ focusTrigger: false });
|
|
1319
1377
|
focusComposer();
|
|
@@ -1323,6 +1381,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1323
1381
|
st.picker = { ...st.picker, effort };
|
|
1324
1382
|
st.effortFromStore = true; // the effort only — the model slot is untouched (D11)
|
|
1325
1383
|
storeModel();
|
|
1384
|
+
persistThreadPick();
|
|
1326
1385
|
updatePickerButton();
|
|
1327
1386
|
closePopover({ focusTrigger: false });
|
|
1328
1387
|
focusComposer();
|
|
@@ -1355,6 +1414,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1355
1414
|
if (m.needsSignIn) {
|
|
1356
1415
|
item.appendChild(tag('needs sign-in', 'is-err', m.signInMessage || 'The provider behind this model is not usable yet — Settings › Models › Providers.'));
|
|
1357
1416
|
}
|
|
1417
|
+
// Credential broker: whether the signed-in person has the key this model spends from.
|
|
1418
|
+
const cb = credentialBadge(m.id);
|
|
1419
|
+
if (cb) item.appendChild(tag(cb.text, cb.missing ? 'is-err' : 'is-key', cb.title));
|
|
1358
1420
|
if (m.id === st.picker.model) item.appendChild(make('span', 'ask-model-check', '✓'));
|
|
1359
1421
|
return item;
|
|
1360
1422
|
};
|
|
@@ -1640,6 +1702,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
1640
1702
|
storeThread(null);
|
|
1641
1703
|
el.title.textContent = 'Ask Worca';
|
|
1642
1704
|
applyThreadScope(null); // #397: a brand-new chat starts on Auto
|
|
1705
|
+
restoreBrowserPick(); // …and on the browser-level pick, not the last chat's
|
|
1643
1706
|
pruneCardEls(); // st.model is already null — renderTranscript's keep set cannot see the old ids
|
|
1644
1707
|
renderTranscript();
|
|
1645
1708
|
updateMeters();
|
|
@@ -2404,6 +2467,55 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
2404
2467
|
return { el: rootEl };
|
|
2405
2468
|
}
|
|
2406
2469
|
|
|
2470
|
+
/** The web card (propose_web_access): proposed → applied | failed, or declined. The exact URL is shown, so a
|
|
2471
|
+
* request that smuggles data is visible before the click. Every value is text. */
|
|
2472
|
+
function buildWebCard(block) {
|
|
2473
|
+
const card = block.card || {};
|
|
2474
|
+
const host = card.host || 'a website';
|
|
2475
|
+
if (block.state === 'declined') return { el: make('div', 'ask-card-stub', `Declined — ${card.summary || `Read ${host}`}`) };
|
|
2476
|
+
const rootEl = make('div', `ask-card ask-mcard ask-webcard is-${block.state}`);
|
|
2477
|
+
rootEl.setAttribute('data-ask-webcard', block.state);
|
|
2478
|
+
const result = card.result || null;
|
|
2479
|
+
const head = make('div', 'ask-mcard-head');
|
|
2480
|
+
const title = block.state === 'failed' ? 'Web access not granted'
|
|
2481
|
+
: block.state === 'applied' ? (result && result.scope === 'always' ? 'Always allowed' : 'Allowed for this chat')
|
|
2482
|
+
: 'Ask Worca wants to read a new site';
|
|
2483
|
+
head.appendChild(make('span', 'ask-mcard-title', title));
|
|
2484
|
+
head.appendChild(make('span', 'ask-mcard-kind', 'Web'));
|
|
2485
|
+
rootEl.appendChild(head);
|
|
2486
|
+
const body = make('div', 'ask-mcard-body');
|
|
2487
|
+
const sum = make('div', 'ask-mcard-summary');
|
|
2488
|
+
if (block.state === 'applied') sum.appendChild(svgIcon(WF_ICO.check, 15, 2.4));
|
|
2489
|
+
sum.appendChild(make('span', null, host));
|
|
2490
|
+
body.appendChild(sum);
|
|
2491
|
+
if (card.reason) body.appendChild(make('div', 'ask-mcard-note', card.reason));
|
|
2492
|
+
const ul = make('ul', 'ask-mcard-changes');
|
|
2493
|
+
const li = make('li');
|
|
2494
|
+
li.appendChild(make('span', 'ask-mcard-change-label', 'URL'));
|
|
2495
|
+
const val = make('span', 'ask-mcard-change-val');
|
|
2496
|
+
val.appendChild(make('span', 'ask-mcard-after', String(card.url || '')));
|
|
2497
|
+
li.appendChild(val);
|
|
2498
|
+
ul.appendChild(li);
|
|
2499
|
+
body.appendChild(ul);
|
|
2500
|
+
if (block.state === 'failed') body.appendChild(make('div', 'ask-mcard-failed', `Could not allow: ${block.error || (result && result.error) || 'unknown error'}`));
|
|
2501
|
+
rootEl.appendChild(body);
|
|
2502
|
+
rootEl.appendChild(make('div', 'ask-card-err'));
|
|
2503
|
+
if (block.state === 'proposed') {
|
|
2504
|
+
const actions = make('div', 'ask-mcard-actions');
|
|
2505
|
+
const btn = (cls, text, attr) => { const b = make('button', cls, text); b.type = 'button'; b.setAttribute(attr, ''); return b; };
|
|
2506
|
+
const decline = btn('ask-card-not-now', 'Deny', 'data-ask-web-decline');
|
|
2507
|
+
decline.addEventListener('click', () => postCard(block, rootEl, { state: 'declined' }, decline));
|
|
2508
|
+
const always = btn('ask-card-not-now', 'Always allow', 'data-ask-web-always');
|
|
2509
|
+
always.title = `Adds ${host} to Settings → Ask Worca → Web access`;
|
|
2510
|
+
always.addEventListener('click', () => postCard(block, rootEl, { state: 'applied', scope: 'always' }, always));
|
|
2511
|
+
const chat = btn('ask-card-start', 'Allow for this chat', 'data-ask-web-chat');
|
|
2512
|
+
chat.addEventListener('click', () => postCard(block, rootEl, { state: 'applied', scope: 'chat' }, chat));
|
|
2513
|
+
actions.append(make('span', 'ask-card-actions-spacer'), decline, always, chat);
|
|
2514
|
+
rootEl.appendChild(actions);
|
|
2515
|
+
}
|
|
2516
|
+
return { el: rootEl };
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2407
2519
|
/** The clone card (propose_clone_project): proposed → cloning → applied | failed, or declined. Every value is text. */
|
|
2408
2520
|
function buildCloneCard(block) {
|
|
2409
2521
|
const card = block.card || {};
|
|
@@ -2480,6 +2592,12 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
2480
2592
|
closePopover({ focusTrigger: true });
|
|
2481
2593
|
});
|
|
2482
2594
|
item.appendChild(make('span', 'ask-model-name', m.label || m.id));
|
|
2595
|
+
const cb = credentialBadge(m.id);
|
|
2596
|
+
if (cb) {
|
|
2597
|
+
const t = make('span', `ask-model-tag ${cb.missing ? 'is-err' : 'is-key'}`, cb.text);
|
|
2598
|
+
t.title = cb.title;
|
|
2599
|
+
item.appendChild(t);
|
|
2600
|
+
}
|
|
2483
2601
|
if (m.id === cur().model) item.appendChild(make('span', 'ask-model-check', '✓'));
|
|
2484
2602
|
p.appendChild(item);
|
|
2485
2603
|
}
|
|
@@ -3122,7 +3240,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
3122
3240
|
function isProgressBlock(block) {
|
|
3123
3241
|
const card = block.card || {};
|
|
3124
3242
|
if (card.type === PROGRESS_CARD_TYPE) return true;
|
|
3125
|
-
if (card.type === 'workflow' || card.type === 'metrics' || card.type === 'policy' || card.type === 'schedule' || card.type === 'model' || card.type === 'clone') return false;
|
|
3243
|
+
if (card.type === 'workflow' || card.type === 'metrics' || card.type === 'policy' || card.type === 'schedule' || card.type === 'model' || card.type === 'clone' || card.type === 'web') return false;
|
|
3126
3244
|
return block.state === 'started' || (block.state === 'failed' && !!block.runId);
|
|
3127
3245
|
}
|
|
3128
3246
|
function buildCard(block) {
|
|
@@ -3134,14 +3252,16 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
3134
3252
|
const isSchedule = !!(block.card && block.card.type === 'schedule');
|
|
3135
3253
|
const isModel = !!(block.card && block.card.type === 'model');
|
|
3136
3254
|
const isClone = !!(block.card && block.card.type === 'clone');
|
|
3255
|
+
const isWeb = !!(block.card && block.card.type === 'web');
|
|
3137
3256
|
const isProgress = isProgressBlock(block);
|
|
3138
|
-
if (cached && cached.state === block.state && (isWorkflow || isMetrics || isSchedule || isModel || isClone || isProgress || block.state === 'proposed')) return cached.el;
|
|
3257
|
+
if (cached && cached.state === block.state && (isWorkflow || isMetrics || isSchedule || isModel || isClone || isWeb || isProgress || block.state === 'proposed')) return cached.el;
|
|
3139
3258
|
if (cached) disposeCardEntry(cached);
|
|
3140
3259
|
const built = isWorkflow ? buildWorkflowCard(block, cached)
|
|
3141
3260
|
: isMetrics ? buildMetricsCard(block)
|
|
3142
3261
|
: isSchedule ? buildScheduleCard(block)
|
|
3143
3262
|
: isModel ? buildModelCard(block)
|
|
3144
3263
|
: isClone ? buildCloneCard(block)
|
|
3264
|
+
: isWeb ? buildWebCard(block)
|
|
3145
3265
|
: isProgress ? buildProgressCard(block)
|
|
3146
3266
|
: { el: block.state === 'proposed' ? buildCardForm(block) : buildCardTerminal(block) };
|
|
3147
3267
|
st.cardEls.set(block.id, { el: built.el, state: block.state, handle: built.handle || null, dispose: built.dispose || null, animate: !!built.animate, cancelAnim: null, lastW: -1 });
|
|
@@ -3386,10 +3506,15 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
3386
3506
|
// Stopped after — and nothing more while the turn is live: the orb row at the
|
|
3387
3507
|
// bottom of the message owns the elapsed and the meter, and printing either
|
|
3388
3508
|
// set twice is the noise this replaced. A turn that ended badly says so
|
|
3389
|
-
// instead of Done; nothing else marks a stop.
|
|
3509
|
+
// instead of Done; nothing else marks a stop. A turn that ended before any
|
|
3510
|
+
// result, or within a few ms (a signed-out CLI answers in ~20 ms), has no
|
|
3511
|
+
// duration worth printing: plain Stopped, never a dangling "Stopped after"
|
|
3512
|
+
// or "Stopped after 0.0s".
|
|
3390
3513
|
if (!isLive) {
|
|
3391
|
-
|
|
3392
|
-
|
|
3514
|
+
const shown = fmtElapsed(r.durationMs);
|
|
3515
|
+
const elapsed = shown && shown !== '0.0s' ? shown : '';
|
|
3516
|
+
if (stopped) parts.push(make('span', 'ask-activity-label', elapsed ? 'Stopped after' : 'Stopped'));
|
|
3517
|
+
parts.push(make('span', 'ask-activity-elapsed', elapsed));
|
|
3393
3518
|
parts.push(make('span', 'ask-activity-spacer'));
|
|
3394
3519
|
const meter = [fmtCtx(r.usage && r.usage.ctx), fmtUsd(r.costUsd)].filter(Boolean).join(' · ');
|
|
3395
3520
|
parts.push(make('span', 'ask-activity-meter', meter));
|
|
@@ -3515,7 +3640,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
3515
3640
|
if (b.kind === 'notice') parts.push(`n:${b.id ?? ''}:${b.text || ''}:${b.href || ''}`);
|
|
3516
3641
|
else if (b.kind === 'card') parts.push(`c:${b.id}:${b.state || ''}:${(b.card && b.card.type) || ''}:${b.runId || ''}:${b.error || ''}`);
|
|
3517
3642
|
}
|
|
3518
|
-
parts.push(`r:${row.status || ''}:${row.errorMessage || ''}:${isLiveRow(row) ? 1 : 0}`);
|
|
3643
|
+
parts.push(`r:${row.status || ''}:${row.errorMessage || ''}:${row.errorCode || ''}:${isLiveRow(row) ? 1 : 0}`);
|
|
3519
3644
|
return parts.join('|');
|
|
3520
3645
|
}
|
|
3521
3646
|
|
|
@@ -3565,7 +3690,15 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
3565
3690
|
}
|
|
3566
3691
|
if (cur.status === 'error') {
|
|
3567
3692
|
const explained = (cur.blocks || []).some((b) => b && b.kind === 'notice');
|
|
3568
|
-
if (cur.
|
|
3693
|
+
if (cur.errorCode === 'claude-signed-out' && typeof openClaudeSetup === 'function') {
|
|
3694
|
+
// The CLI's raw "Not logged in" → one line whose link opens Connect Claude Code.
|
|
3695
|
+
const line = make('div', 'ask-error-line', "Claude Code isn't signed in. ");
|
|
3696
|
+
const link = make('a', '', 'Sign in…');
|
|
3697
|
+
link.href = '#';
|
|
3698
|
+
link.addEventListener('click', (e) => { e.preventDefault(); openClaudeSetup(); });
|
|
3699
|
+
line.appendChild(link);
|
|
3700
|
+
wrap.appendChild(line);
|
|
3701
|
+
} else if (cur.errorMessage) wrap.appendChild(make('div', 'ask-error-line', cur.errorMessage));
|
|
3569
3702
|
else if (!explained) wrap.appendChild(make('div', 'ask-error-line', 'This turn ended with an error.'));
|
|
3570
3703
|
}
|
|
3571
3704
|
if (isLiveRow(cur)) {
|
|
@@ -3643,12 +3776,16 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
|
|
|
3643
3776
|
// A SWITCH starts a fresh ledger, so the new chat rises in. A resync or a
|
|
3644
3777
|
// reconnect re-loads the SAME thread and must keep it: those repaint rows the
|
|
3645
3778
|
// user is already reading, mid-turn.
|
|
3646
|
-
|
|
3779
|
+
const switched = st.threadId !== id;
|
|
3780
|
+
if (switched) st.seenRows = new Set();
|
|
3647
3781
|
st.threadId = id;
|
|
3648
3782
|
st.model = createThreadModel({ threadId: id });
|
|
3649
3783
|
st.model.load(snap);
|
|
3650
3784
|
el.title.textContent = (snap.thread && snap.thread.title) || 'Ask Worca';
|
|
3651
3785
|
applyThreadScope(snap.thread && snap.thread.context); // #397: restore the pin
|
|
3786
|
+
// The picker follows the chat — on a SWITCH only: a resync of the same thread
|
|
3787
|
+
// would otherwise clobber a pick the user just made (its PATCH may not have landed).
|
|
3788
|
+
if (switched) applyThreadPick(snap.thread);
|
|
3652
3789
|
renderTranscript();
|
|
3653
3790
|
updateMeters();
|
|
3654
3791
|
// P4: the count rides the snapshot loadThread ALREADY fetched — no extra GET.
|
|
@@ -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
|
|
|
@@ -148,6 +151,19 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
|
|
|
148
151
|
}
|
|
149
152
|
root.appendChild(h(doc, 'small', 'hint mv-providers-hint',
|
|
150
153
|
"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."));
|
|
154
|
+
// Credential broker: keys and the Copilot sign-in are per person, on the broker's key page.
|
|
155
|
+
// This card keeps the shared settings (base URLs, concurrency, the Copilot notice, imports);
|
|
156
|
+
// its key fields, sign-in and key test are hidden (style.css .mv-providers--broker).
|
|
157
|
+
if (p.broker && p.broker.enabled) {
|
|
158
|
+
root.classList.add('mv-providers--broker');
|
|
159
|
+
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.');
|
|
160
|
+
if (p.broker.keyPage) {
|
|
161
|
+
const a = h(doc, 'a', 'mv-broker-link', 'Open the key page');
|
|
162
|
+
a.href = p.broker.keyPage; a.target = '_blank'; a.rel = 'noopener';
|
|
163
|
+
note.append(' ', a);
|
|
164
|
+
}
|
|
165
|
+
root.appendChild(note);
|
|
166
|
+
}
|
|
151
167
|
/** One provider's row, in its own card when the tab hosts it. */
|
|
152
168
|
const place = (row, title) => {
|
|
153
169
|
if (!split) { root.appendChild(row); return; }
|
|
@@ -198,8 +214,10 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
|
|
|
198
214
|
|
|
199
215
|
const cpBtns = h(doc, 'div', 'mv-pv-btns');
|
|
200
216
|
const imp = h(doc, 'button', 'btn-ghost mv-cp-fetch-models', 'Import models…');
|
|
201
|
-
|
|
202
|
-
|
|
217
|
+
// With the broker, the import runs with the viewer's own Copilot sign-in (on the key page).
|
|
218
|
+
const brokered = !!(p.broker && p.broker.enabled);
|
|
219
|
+
imp.type = 'button'; imp.disabled = !c.connected && !brokered;
|
|
220
|
+
if (imp.disabled) imp.title = 'Sign in first';
|
|
203
221
|
cpBtns.appendChild(imp);
|
|
204
222
|
const quotaBtn = h(doc, 'button', 'btn-ghost mv-cp-quota-refresh', 'Refresh usage');
|
|
205
223
|
quotaBtn.type = 'button'; quotaBtn.disabled = !c.connected;
|
|
@@ -245,6 +263,16 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
|
|
|
245
263
|
buIn.type = 'text'; buIn.value = k.baseUrl || ''; buIn.dataset.provider = name;
|
|
246
264
|
buIn.placeholder = name === 'openai' ? 'https://api.openai.com/v1' : 'https://api.anthropic.com';
|
|
247
265
|
bu.appendChild(buIn);
|
|
266
|
+
if (name === 'openai') {
|
|
267
|
+
// One click to a hosted catalog: fills the URL (and a ${VAR} key when none is set), then the
|
|
268
|
+
// user Tests and Saves as for any endpoint. Nothing is stored until Save.
|
|
269
|
+
const pre = h(doc, 'small', 'hint mv-pv-presets', 'Preset: ');
|
|
270
|
+
const or = h(doc, 'button', 'link-btn mv-pv-preset', 'OpenRouter');
|
|
271
|
+
or.type = 'button'; or.dataset.provider = name; or.dataset.preset = 'openrouter';
|
|
272
|
+
or.title = `Point this provider at OpenRouter (${OPENROUTER_PRESET.baseUrl})`;
|
|
273
|
+
pre.appendChild(or);
|
|
274
|
+
bu.appendChild(pre);
|
|
275
|
+
}
|
|
248
276
|
ctl.appendChild(bu);
|
|
249
277
|
const key = h(doc, 'label', 'mv-field');
|
|
250
278
|
key.appendChild(h(doc, 'span', 'mv-field-label', 'API key'));
|
|
@@ -282,6 +310,25 @@ export function renderProvidersCard(providers, { doc = globalThis.document, sign
|
|
|
282
310
|
return root;
|
|
283
311
|
}
|
|
284
312
|
|
|
313
|
+
/** The OpenRouter preset (docs/models.md "OpenRouter"); mirrors provider-ops OPENROUTER_BASE_URL. */
|
|
314
|
+
export const OPENROUTER_PRESET = Object.freeze({ baseUrl: 'https://openrouter.ai/api/v1', keyRef: '${OPENROUTER_KEY}' });
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Apply a preset to a provider row's fields — the base URL always, the key only when the row has
|
|
318
|
+
* none (a stored or typed key is never replaced). Returns whether anything changed.
|
|
319
|
+
*/
|
|
320
|
+
export function applyProviderPreset(rootEl, name, preset) {
|
|
321
|
+
const p = preset === 'openrouter' ? OPENROUTER_PRESET : null;
|
|
322
|
+
const row = p && rootEl.querySelector(`.mv-pv-row[data-provider="${name}"]`);
|
|
323
|
+
if (!row) return false;
|
|
324
|
+
const bu = row.querySelector('.mv-pv-baseurl');
|
|
325
|
+
const key = row.querySelector('.mv-pv-key');
|
|
326
|
+
let changed = false;
|
|
327
|
+
if (bu && bu.value.trim() !== p.baseUrl) { bu.value = p.baseUrl; changed = true; }
|
|
328
|
+
if (key && !key.value.trim()) { key.value = p.keyRef; changed = true; }
|
|
329
|
+
return changed;
|
|
330
|
+
}
|
|
331
|
+
|
|
285
332
|
/** Collect a key-based provider row into a PATCH body (masked echo dropped). */
|
|
286
333
|
export function collectProviderRow(rootEl, name) {
|
|
287
334
|
const row = rootEl.querySelector(`.mv-pv-row[data-provider="${name}"]`);
|
|
@@ -398,12 +445,38 @@ export function renderImportSheet(models, { doc = globalThis.document } = {}) {
|
|
|
398
445
|
export function renderEndpointSheet(payload, { doc = globalThis.document } = {}) {
|
|
399
446
|
const p = payload || {};
|
|
400
447
|
const list = Array.isArray(p.models) ? p.models : [];
|
|
448
|
+
const hosted = p.server === 'openrouter';
|
|
401
449
|
const root = h(doc, 'section', 'card mv-editor mvi mvi-ep');
|
|
402
450
|
root.dataset.source = 'endpoint';
|
|
403
451
|
root.dataset.baseurl = p.baseUrl || '';
|
|
452
|
+
if (p.server) root.dataset.server = p.server;
|
|
404
453
|
root.appendChild(h(doc, 'h3', 'mv-editor-title', `Import from ${p.serverLabel || 'this endpoint'}`));
|
|
405
|
-
root.appendChild(h(doc, 'small', 'hint',
|
|
454
|
+
root.appendChild(h(doc, 'small', 'hint', hosted
|
|
455
|
+
? `${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.`
|
|
456
|
+
: `${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
457
|
for (const w of Array.isArray(p.warnings) ? p.warnings : []) root.appendChild(h(doc, 'small', 'hint mvi-warn', w));
|
|
458
|
+
// A hosted catalog lists hundreds of models: beside the dialog's text filter, narrow by what a
|
|
459
|
+
// pipeline decides on. The controls only mark the view — endpointRowMatches reads them.
|
|
460
|
+
if (hosted && list.length) {
|
|
461
|
+
const bar = h(doc, 'div', 'chip-select mvi-or-filters');
|
|
462
|
+
const chip = (cls, text) => {
|
|
463
|
+
const l = h(doc, 'label', 'mvi-chip');
|
|
464
|
+
const cb = h(doc, 'input', cls); cb.type = 'checkbox';
|
|
465
|
+
l.appendChild(cb); l.appendChild(h(doc, 'span', null, text));
|
|
466
|
+
bar.appendChild(l);
|
|
467
|
+
};
|
|
468
|
+
chip('mvi-f-free', 'Free');
|
|
469
|
+
chip('mvi-f-tools', 'Tools');
|
|
470
|
+
const ctxL = h(doc, 'label', 'mvi-chip');
|
|
471
|
+
ctxL.appendChild(h(doc, 'span', null, 'Window ≥'));
|
|
472
|
+
const ctx = h(doc, 'select', 'select mvi-f-ctx');
|
|
473
|
+
for (const [v, t] of [['0', 'any'], ['65536', '64k'], ['131072', '128k'], ['262144', '256k'], ['1000000', '1M']]) {
|
|
474
|
+
const o = h(doc, 'option', null, t); o.value = v; ctx.appendChild(o);
|
|
475
|
+
}
|
|
476
|
+
ctxL.appendChild(ctx);
|
|
477
|
+
bar.appendChild(ctxL);
|
|
478
|
+
root.appendChild(bar);
|
|
479
|
+
}
|
|
407
480
|
if (!list.length) {
|
|
408
481
|
root.appendChild(h(doc, 'div', 'hist-empty', 'This endpoint lists no models.'));
|
|
409
482
|
} else {
|
|
@@ -421,6 +494,9 @@ export function renderEndpointSheet(payload, { doc = globalThis.document } = {})
|
|
|
421
494
|
for (const m of list) {
|
|
422
495
|
const tr = h(doc, 'tr');
|
|
423
496
|
tr.dataset.id = m.id;
|
|
497
|
+
tr.dataset.free = m.free ? '1' : '0';
|
|
498
|
+
tr.dataset.tools = m.toolCalls === true ? '1' : '0';
|
|
499
|
+
tr.dataset.ctx = String(m.servedContext || 0);
|
|
424
500
|
const blocked = m.importable === false;
|
|
425
501
|
if (blocked) tr.className = 'mvi-disabled';
|
|
426
502
|
const cbTd = h(doc, 'td');
|
|
@@ -470,6 +546,21 @@ export function collectImportSheet(rootEl) {
|
|
|
470
546
|
return [...rootEl.querySelectorAll('.mvi-cb')].filter((c) => c.checked && !c.disabled).map((c) => c.value);
|
|
471
547
|
}
|
|
472
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Whether an import-sheet row passes the dialog's text query and, on a hosted sheet, its Free /
|
|
551
|
+
* Tools / window filters. A view filter only — the tick on a hidden row is kept. Pure over the DOM.
|
|
552
|
+
*/
|
|
553
|
+
export function endpointRowMatches(tr, sheet, query = '') {
|
|
554
|
+
const q = String(query || '').trim().toLowerCase();
|
|
555
|
+
if (q && !tr.textContent.toLowerCase().includes(q) && !String(tr.dataset.id || '').toLowerCase().includes(q)) return false;
|
|
556
|
+
const bar = sheet && sheet.querySelector('.mvi-or-filters');
|
|
557
|
+
if (!bar) return true;
|
|
558
|
+
if (bar.querySelector('.mvi-f-free')?.checked && tr.dataset.free !== '1') return false;
|
|
559
|
+
if (bar.querySelector('.mvi-f-tools')?.checked && tr.dataset.tools !== '1') return false;
|
|
560
|
+
const min = Number(bar.querySelector('.mvi-f-ctx')?.value || 0);
|
|
561
|
+
return !(min > 0 && Number(tr.dataset.ctx || 0) < min);
|
|
562
|
+
}
|
|
563
|
+
|
|
473
564
|
/** The select-all box: tick every importable row. */
|
|
474
565
|
export function applyImportSelectAll(rootEl, on) {
|
|
475
566
|
for (const c of rootEl.querySelectorAll('.mvi-cb')) if (!c.disabled) c.checked = !!on;
|
|
@@ -477,6 +568,50 @@ export function applyImportSelectAll(rootEl, on) {
|
|
|
477
568
|
|
|
478
569
|
// ── Editor: Connection section (§8.3) ───────────────────────────────────────
|
|
479
570
|
|
|
571
|
+
/** Whether a base URL is OpenRouter's (mirrors model-env.mjs isOpenRouterBaseUrl; this module imports nothing). */
|
|
572
|
+
export function isOpenRouterUrl(v) {
|
|
573
|
+
if (typeof v !== 'string' || !v.trim()) return false;
|
|
574
|
+
let host;
|
|
575
|
+
try { host = new URL(v.trim()).hostname.toLowerCase(); } catch { return false; }
|
|
576
|
+
return host === 'openrouter.ai' || host.endsWith('.openrouter.ai');
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Whether the Connection form resolves to OpenRouter chat completions: its own base URL, else the provider's. */
|
|
580
|
+
function openRouterApplies(conn) {
|
|
581
|
+
if ((conn.querySelector('.mv-conn-mode-rb:checked')?.value || 'direct') !== 'provider') return false;
|
|
582
|
+
if (conn.querySelector('.mv-conn-provider')?.value !== 'openai' || conn.querySelector('.mv-conn-api')?.value !== 'openai-chat') return false;
|
|
583
|
+
const own = (conn.querySelector('.mv-conn-baseurl')?.value || '').trim();
|
|
584
|
+
return isOpenRouterUrl(own || conn._providers?.openai?.baseUrl || '');
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Load an `upstream.openrouter` block (or nothing: the defaults) into the routing fields. */
|
|
588
|
+
function fillOpenRouter(root, or) {
|
|
589
|
+
const o = or || {};
|
|
590
|
+
const p = o.provider || {};
|
|
591
|
+
const set = (sel, v) => { const el = root.querySelector(sel); if (el) el.value = v; };
|
|
592
|
+
set('.mv-conn-or-models', Array.isArray(o.models) ? o.models.join(', ') : '');
|
|
593
|
+
set('.mv-conn-or-order', Array.isArray(p.order) ? p.order.join(', ') : '');
|
|
594
|
+
set('.mv-conn-or-sort', p.sort || '');
|
|
595
|
+
const fb = root.querySelector('.mv-conn-or-fallbacks');
|
|
596
|
+
if (fb) fb.checked = p.allow_fallbacks !== false; // OpenRouter's default is on
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** The routing fields as an `upstream.openrouter` block, or undefined when all are at their defaults. */
|
|
600
|
+
function collectOpenRouter(conn) {
|
|
601
|
+
const list = (sel) => (conn.querySelector(sel)?.value || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
602
|
+
const out = {};
|
|
603
|
+
const models = list('.mv-conn-or-models');
|
|
604
|
+
if (models.length) out.models = models;
|
|
605
|
+
const provider = {};
|
|
606
|
+
const order = list('.mv-conn-or-order');
|
|
607
|
+
if (order.length) provider.order = order;
|
|
608
|
+
if (conn.querySelector('.mv-conn-or-fallbacks')?.checked === false) provider.allow_fallbacks = false;
|
|
609
|
+
const sort = conn.querySelector('.mv-conn-or-sort')?.value || '';
|
|
610
|
+
if (sort) provider.sort = sort;
|
|
611
|
+
if (Object.keys(provider).length) out.provider = provider;
|
|
612
|
+
return Object.keys(out).length ? out : undefined;
|
|
613
|
+
}
|
|
614
|
+
|
|
480
615
|
/**
|
|
481
616
|
* The Connection block for the model editor. `model` is the MASKED entry or
|
|
482
617
|
* null; `providers` the GET /api/providers payload (for the readiness hints);
|
|
@@ -552,7 +687,13 @@ export function renderConnectionSection(model, { doc = globalThis.document, prov
|
|
|
552
687
|
|
|
553
688
|
// Advanced: per-entry overrides for the key-based providers.
|
|
554
689
|
const adv = h(doc, 'details', 'advanced mv-conn-adv');
|
|
555
|
-
|
|
690
|
+
// Same summary as New pipeline's Advanced (index.html #advanced-config): the chevron is
|
|
691
|
+
// what says "expandable" — without it the label reads as a plain heading.
|
|
692
|
+
const sum = h(doc, 'summary');
|
|
693
|
+
const chev = h(doc, 'span', 'adv-chev');
|
|
694
|
+
chev.setAttribute('aria-hidden', 'true');
|
|
695
|
+
sum.appendChild(chev);
|
|
696
|
+
sum.appendChild(h(doc, 'span', null, 'Advanced — base URL, API key, extra headers'));
|
|
556
697
|
adv.appendChild(sum);
|
|
557
698
|
const advBody = h(doc, 'div', 'advanced-body mv-conn-adv-body');
|
|
558
699
|
const bu = h(doc, 'input', 'input mv-conn-baseurl');
|
|
@@ -567,6 +708,28 @@ export function renderConnectionSection(model, { doc = globalThis.document, prov
|
|
|
567
708
|
hdr.rows = 2; hdr.placeholder = 'One per line: Header-Name: value';
|
|
568
709
|
hdr.value = upstream && upstream.headers ? Object.entries(upstream.headers).map(([k, v]) => `${k}: ${v}`).join('\n') : '';
|
|
569
710
|
advBody.appendChild(field('Extra headers', hdr));
|
|
711
|
+
// OpenRouter routing: shown only while the entry resolves to OpenRouter (applyConnectionMode).
|
|
712
|
+
const orBox = h(doc, 'div', 'mv-conn-or');
|
|
713
|
+
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.'));
|
|
714
|
+
const orModels = h(doc, 'input', 'input mv-conn-or-models');
|
|
715
|
+
orModels.type = 'text'; orModels.placeholder = 'e.g. qwen/qwen3.8-27b (comma-separated)';
|
|
716
|
+
orBox.appendChild(field('Fallback models', orModels));
|
|
717
|
+
const orOrder = h(doc, 'input', 'input mv-conn-or-order');
|
|
718
|
+
orOrder.type = 'text'; orOrder.placeholder = 'provider names, comma-separated — empty lets OpenRouter choose';
|
|
719
|
+
orBox.appendChild(field('Provider order', orOrder));
|
|
720
|
+
const orSort = h(doc, 'select', 'select mv-conn-or-sort');
|
|
721
|
+
for (const [value, text] of [['', 'OpenRouter default'], ['price', 'Price'], ['throughput', 'Throughput'], ['latency', 'Latency']]) {
|
|
722
|
+
const o = h(doc, 'option', null, text); o.value = value; orSort.appendChild(o);
|
|
723
|
+
}
|
|
724
|
+
orBox.appendChild(field('Sort providers by', orSort));
|
|
725
|
+
const orFbLab = h(doc, 'label', 'mv-conn-cap');
|
|
726
|
+
const orFb = h(doc, 'input', 'mv-conn-or-fallbacks');
|
|
727
|
+
orFb.type = 'checkbox';
|
|
728
|
+
orFbLab.appendChild(orFb);
|
|
729
|
+
orFbLab.appendChild(h(doc, 'span', null, 'Allow other providers when these are unavailable'));
|
|
730
|
+
orBox.appendChild(orFbLab);
|
|
731
|
+
advBody.appendChild(orBox);
|
|
732
|
+
fillOpenRouter(orBox, upstream && upstream.openrouter);
|
|
570
733
|
adv.appendChild(advBody);
|
|
571
734
|
body.appendChild(adv);
|
|
572
735
|
|
|
@@ -640,6 +803,8 @@ export function applyConnectionMode(connEl) {
|
|
|
640
803
|
const api = apiSel ? apiSel.value : '';
|
|
641
804
|
const adv = conn.querySelector('.mv-conn-adv');
|
|
642
805
|
if (adv) adv.hidden = provider === 'copilot';
|
|
806
|
+
const orBox = conn.querySelector('.mv-conn-or');
|
|
807
|
+
if (orBox) orBox.hidden = !openRouterApplies(conn);
|
|
643
808
|
const hint = conn.querySelector('.mv-conn-provider-hint');
|
|
644
809
|
const p = conn._providers;
|
|
645
810
|
if (hint) {
|
|
@@ -652,7 +817,7 @@ export function applyConnectionMode(connEl) {
|
|
|
652
817
|
if (note) {
|
|
653
818
|
const copilotPricing = provider === 'copilot' ? ' Copilot bills premium requests, so Pricing defaults to Free.' : '';
|
|
654
819
|
note.textContent = mode !== 'provider' ? '' : api === 'openai-chat'
|
|
655
|
-
? `Translated:
|
|
820
|
+
? `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
821
|
: api === 'openai-responses'
|
|
657
822
|
? `Translated to the Responses API: reasoning summaries arrive as thinking, WebSearch/WebFetch withheld, prompt limit per the capabilities above.${copilotPricing}`
|
|
658
823
|
: 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 +869,7 @@ export function setModelUpstream(connEl, upstream) {
|
|
|
704
869
|
const bu = conn.querySelector('.mv-conn-baseurl'); if (bu) bu.value = upstream.baseUrl || '';
|
|
705
870
|
const key = conn.querySelector('.mv-conn-key'); if (key) { key.value = upstream.apiKey || ''; if (key.value) key.dataset.original = key.value; }
|
|
706
871
|
const hdr = conn.querySelector('.mv-conn-headers'); if (hdr) hdr.value = upstream.headers ? Object.entries(upstream.headers).map(([k, v]) => `${k}: ${v}`).join('\n') : '';
|
|
872
|
+
fillOpenRouter(conn, upstream.openrouter);
|
|
707
873
|
const caps = upstream.capabilities || {};
|
|
708
874
|
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
875
|
for (const inp of conn.querySelectorAll('.mv-conn-limit-in')) inp.value = caps[inp.dataset.limit] ? String(caps[inp.dataset.limit]) : '';
|
|
@@ -737,6 +903,9 @@ export function collectConnection(connEl) {
|
|
|
737
903
|
if (i > 0) headers[l.slice(0, i).trim()] = l.slice(i + 1).trim();
|
|
738
904
|
}
|
|
739
905
|
if (Object.keys(headers).length) upstream.headers = headers;
|
|
906
|
+
// Only while the entry resolves to OpenRouter: another endpoint would reject the fields.
|
|
907
|
+
const or = openRouterApplies(conn) ? collectOpenRouter(conn) : undefined;
|
|
908
|
+
if (or) upstream.openrouter = or;
|
|
740
909
|
}
|
|
741
910
|
const capabilities = {};
|
|
742
911
|
for (const cb of conn.querySelectorAll('.mv-conn-cap-cb')) capabilities[cb.dataset.cap] = !!cb.checked;
|