@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
|
@@ -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
|
+
}
|
package/ui/public/index.html
CHANGED
|
@@ -408,6 +408,8 @@
|
|
|
408
408
|
|
|
409
409
|
<!-- Actions -->
|
|
410
410
|
<div class="hint err" id="newBlockedNote" hidden></div>
|
|
411
|
+
<!-- OpenRouter free requests left today vs what a run here takes (openrouter-free-view.mjs). -->
|
|
412
|
+
<div class="hint warn" id="newFreeNote" hidden></div>
|
|
411
413
|
<div class="actions" style="justify-content:flex-end">
|
|
412
414
|
<!-- A time picked BEFORE the task (Schedules › Schedule a run, or Change… here):
|
|
413
415
|
it waits on the form and Start run becomes Schedule. -->
|
|
@@ -988,7 +990,11 @@
|
|
|
988
990
|
<input id="wiz-name" class="input" type="text" placeholder="e.g. IoT SP Platform" spellcheck="false" />
|
|
989
991
|
</div>
|
|
990
992
|
<div class="field">
|
|
991
|
-
<
|
|
993
|
+
<div class="wiz-proj-head">
|
|
994
|
+
<label class="label">Projects <span class="opt">(select 2 or more)</span></label>
|
|
995
|
+
<!-- Outside #wiz-projects so every checkbox in the list stays a project. -->
|
|
996
|
+
<label class="wiz-select-all"><input type="checkbox" id="wiz-select-all" /> Select all</label>
|
|
997
|
+
</div>
|
|
992
998
|
<div class="wiz-proj-list" id="wiz-projects"></div>
|
|
993
999
|
<small class="hint" id="wiz-step1-hint">Onboard at least two projects to create a workspace.</small>
|
|
994
1000
|
</div>
|
|
@@ -1377,6 +1383,20 @@
|
|
|
1377
1383
|
<small class="hint" id="themeMsg"></small>
|
|
1378
1384
|
</section>
|
|
1379
1385
|
|
|
1386
|
+
<!-- Credential broker (docs/credential-broker.md): shown only when worca runs with one. -->
|
|
1387
|
+
<section class="card settings-card" id="credentials-card" data-min-level="simple" hidden>
|
|
1388
|
+
<div class="card-head">
|
|
1389
|
+
<div class="label-row">
|
|
1390
|
+
<h2>My model credentials</h2>
|
|
1391
|
+
<button type="button" class="info-tip" aria-label="About model credentials">i<span class="tip-content hidden">
|
|
1392
|
+
Model keys are held by the credential broker, not by Worca. Agents never see them.
|
|
1393
|
+
Each person uses their own keys, and what a run costs is charged to whoever started it.
|
|
1394
|
+
</span></button>
|
|
1395
|
+
</div>
|
|
1396
|
+
</div>
|
|
1397
|
+
<div id="credentialsHost"></div>
|
|
1398
|
+
</section>
|
|
1399
|
+
|
|
1380
1400
|
<!-- Interface mode (docs/ui-levels.md): the same setting as the sidebar item, same dialog. -->
|
|
1381
1401
|
<section class="card settings-card" id="mode-settings-card" data-min-level="simple">
|
|
1382
1402
|
<div class="card-head">
|
|
@@ -1506,24 +1526,27 @@
|
|
|
1506
1526
|
<div class="label-row">
|
|
1507
1527
|
<label for="askMaxTurns">Turn limit</label>
|
|
1508
1528
|
<button type="button" class="info-tip" aria-label="About Turn limit">i<span class="tip-content hidden">
|
|
1509
|
-
Maximum assistant/tool turns one chat message may use (1–500). Leave empty to restore the default of
|
|
1529
|
+
Maximum assistant/tool turns one chat message may use (1–500). Leave empty to restore the default of 400.
|
|
1510
1530
|
</span></button>
|
|
1511
1531
|
</div>
|
|
1512
|
-
<input id="askMaxTurns" class="input" type="number" min="1" max="500" step="1" inputmode="numeric" placeholder="
|
|
1532
|
+
<input id="askMaxTurns" class="input" type="number" min="1" max="500" step="1" inputmode="numeric" placeholder="400" autocomplete="off" />
|
|
1513
1533
|
</div>
|
|
1514
1534
|
<div class="field">
|
|
1515
1535
|
<div class="label-row">
|
|
1516
1536
|
<label for="askMaxBudgetUsd">Per-turn cost cap (USD)</label>
|
|
1517
1537
|
<button type="button" class="info-tip" aria-label="About Per-turn cost cap">i<span class="tip-content hidden">
|
|
1518
|
-
|
|
1538
|
+
There is no cap by default. Untick No cap and enter an amount (0.1–100) to turn the guard on: a chat turn then stops when its estimated cost reaches it. Leaving the amount empty, or pressing Use defaults, means no cap.
|
|
1519
1539
|
</span></button>
|
|
1520
1540
|
</div>
|
|
1521
|
-
<input id="askMaxBudgetUsd" class="input" type="number" min="0.1" max="100" step="0.1" inputmode="decimal" placeholder="
|
|
1541
|
+
<input id="askMaxBudgetUsd" class="input" type="number" min="0.1" max="100" step="0.1" inputmode="decimal" placeholder="No cap" autocomplete="off" />
|
|
1522
1542
|
<label class="check-row" for="askNoCap"><input id="askNoCap" type="checkbox" /> No cap</label>
|
|
1523
1543
|
</div>
|
|
1524
1544
|
<!-- Scripts workbench W20: the chat's save_script / test_script switch. Body
|
|
1525
1545
|
rendered by chat-settings-view.mjs; saved with this card's Save button. -->
|
|
1526
1546
|
<div class="field" id="ask-script-tools-host"></div>
|
|
1547
|
+
<!-- Ask web access (off by default): rendered by chat-settings-view.mjs; saved with this
|
|
1548
|
+
card's Save button, and only when one of its fields was touched. -->
|
|
1549
|
+
<div class="field" id="ask-web-host"></div>
|
|
1527
1550
|
<div class="add-project-actions" style="margin-top:14px">
|
|
1528
1551
|
<button type="button" id="askLimitsReset" class="btn btn-ghost btn-mini">Use defaults</button>
|
|
1529
1552
|
<button type="button" id="askLimitsSave" class="btn btn-primary btn-mini">Save</button>
|
|
@@ -2235,7 +2258,8 @@
|
|
|
2235
2258
|
</div>
|
|
2236
2259
|
|
|
2237
2260
|
<!-- Connect Claude Code: the one checklist step with no control to ring. The
|
|
2238
|
-
tick is derived (the CLI resolves on PATH); "Check
|
|
2261
|
+
tick is derived (the CLI resolves on PATH and is not signed out); "Check
|
|
2262
|
+
again" refetches it with a fresh sign-in check. -->
|
|
2239
2263
|
<div id="claude-setup-modal" class="viewer-modal hidden" role="dialog" aria-modal="true" aria-labelledby="claude-setup-title">
|
|
2240
2264
|
<div class="card ob-claude">
|
|
2241
2265
|
<div class="card-head">
|
|
@@ -2245,6 +2269,9 @@
|
|
|
2245
2269
|
<p class="ob-claude-lead">Worca drives the <strong>Claude Code</strong> CLI. Real runs need it on your <code>PATH</code>; mock runs need nothing.</p>
|
|
2246
2270
|
<pre class="ob-claude-cmd"><code>npm install -g @anthropic-ai/claude-code</code></pre>
|
|
2247
2271
|
<p class="ob-claude-note">On native Windows the <a href="https://docs.anthropic.com/en/docs/claude-code/setup" target="_blank" rel="noopener noreferrer">native installer</a> works too. A binary elsewhere: set <code>WORCA_CLAUDE_BIN</code> to its full path and restart the UI.</p>
|
|
2272
|
+
<p class="ob-claude-lead ob-claude-step"><strong>Then sign it in.</strong> Start it once in a terminal and type <code>/login</code>:</p>
|
|
2273
|
+
<pre class="ob-claude-cmd"><code>claude</code></pre>
|
|
2274
|
+
<p class="ob-claude-note">Signing in to the Claude desktop app does not carry over — the CLI keeps its own sign-in.</p>
|
|
2248
2275
|
<div class="ob-claude-status" id="claude-setup-status" aria-live="polite"></div>
|
|
2249
2276
|
<div class="stop-actions">
|
|
2250
2277
|
<button type="button" id="claude-setup-check" class="btn btn-primary btn-mini">Check again</button>
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// delegated listener on the list container.
|
|
9
9
|
|
|
10
10
|
import { bridgedBadge, needsSignInPill, degradationLine, renderConnectionSection, collectConnection, applyConnectionMode } from './bridge-view.mjs';
|
|
11
|
+
import { credentialBadge } from './credential-badges.mjs';
|
|
11
12
|
|
|
12
13
|
function h(doc, tag, cls, text) {
|
|
13
14
|
const n = doc.createElement(tag);
|
|
@@ -17,6 +18,15 @@ function h(doc, tag, cls, text) {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/** "medium · high" or "all efforts" (the full set carries no signal). */
|
|
21
|
+
/** Credential broker: "your key / no key" for the signed-in person, after a model's name. */
|
|
22
|
+
function keyBadge(parent, m, doc) {
|
|
23
|
+
const b = credentialBadge(m.id);
|
|
24
|
+
if (!b) return;
|
|
25
|
+
const el = h(doc, 'span', `badge ${b.tone} mv-key-badge`, b.text);
|
|
26
|
+
el.title = b.title;
|
|
27
|
+
parent.appendChild(el);
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
export function effortsSummary(efforts, allEfforts) {
|
|
21
31
|
const list = Array.isArray(efforts) ? efforts : [];
|
|
22
32
|
if (!list.length || (allEfforts && list.length === allEfforts.length)) return 'all efforts';
|
|
@@ -112,7 +122,10 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
|
|
|
112
122
|
const btn = h(doc, 'button', 'mv-sec-toggle');
|
|
113
123
|
btn.type = 'button';
|
|
114
124
|
btn.dataset.section = key;
|
|
115
|
-
|
|
125
|
+
// The shared disclosure chevron (style.css .adv-chev), turned down while the group is open.
|
|
126
|
+
const caret = h(doc, 'span', 'mv-sec-caret adv-chev');
|
|
127
|
+
caret.setAttribute('aria-hidden', 'true');
|
|
128
|
+
btn.appendChild(caret);
|
|
116
129
|
btn.appendChild(h(doc, 'h3', 'mv-section-title', title));
|
|
117
130
|
btn.appendChild(h(doc, 'span', 'mv-sec-count', ''));
|
|
118
131
|
head.appendChild(btn);
|
|
@@ -185,6 +198,7 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
|
|
|
185
198
|
const body = h(doc, 'div', 'mv-body');
|
|
186
199
|
const head = h(doc, 'div', 'mv-head');
|
|
187
200
|
head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
|
|
201
|
+
keyBadge(head, m, doc);
|
|
188
202
|
if (predefLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadow', 'overrides built-in'));
|
|
189
203
|
else if (pluginLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadow', 'overrides plugin'));
|
|
190
204
|
const rb = routedBadge(m);
|
|
@@ -238,6 +252,7 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
|
|
|
238
252
|
const body = h(doc, 'div', 'mv-body');
|
|
239
253
|
const head = h(doc, 'div', 'mv-head');
|
|
240
254
|
head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
|
|
255
|
+
keyBadge(head, m, doc);
|
|
241
256
|
head.appendChild(h(doc, 'span', 'badge waiting mv-origin', 'project (legacy)'));
|
|
242
257
|
body.appendChild(head);
|
|
243
258
|
body.appendChild(h(doc, 'small', 'mv-summary hint', m.id));
|
|
@@ -262,6 +277,7 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
|
|
|
262
277
|
const body = h(doc, 'div', 'mv-body');
|
|
263
278
|
const head = h(doc, 'div', 'mv-head');
|
|
264
279
|
head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
|
|
280
|
+
keyBadge(head, m, doc);
|
|
265
281
|
head.appendChild(h(doc, 'span', 'badge waiting mv-origin', `plugin: ${m.plugin}`));
|
|
266
282
|
if (globalLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadowed', 'overridden by your copy'));
|
|
267
283
|
const prb = routedBadge(m);
|
|
@@ -311,6 +327,7 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
|
|
|
311
327
|
const body = h(doc, 'div', 'mv-body');
|
|
312
328
|
const head = h(doc, 'div', 'mv-head');
|
|
313
329
|
head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
|
|
330
|
+
keyBadge(head, m, doc);
|
|
314
331
|
const badge = h(doc, 'span', 'badge blue mv-origin', 'policy');
|
|
315
332
|
badge.title = `Team policy on ${m.home}`;
|
|
316
333
|
head.appendChild(badge);
|
|
@@ -339,6 +356,7 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], poli
|
|
|
339
356
|
const row = h(doc, 'div', 'mv-builtin');
|
|
340
357
|
row.dataset.id = m.id;
|
|
341
358
|
row.appendChild(h(doc, 'b', 'mv-name', m.label));
|
|
359
|
+
keyBadge(row, m, doc);
|
|
342
360
|
if (globalLc.has(m.id.toLowerCase()) || pluginLc.has(m.id.toLowerCase())) {
|
|
343
361
|
row.appendChild(h(doc, 'span', 'badge violet mv-shadowed', 'overridden'));
|
|
344
362
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// ui/public/openrouter-free-view.mjs
|
|
2
|
+
// OpenRouter's daily allowance of `:free` requests (src/core/openrouter-free.mjs), where it
|
|
3
|
+
// comes up: the sidebar line under the spend block, a run's cost pill, the new-run form's
|
|
4
|
+
// warning and the Providers card. Pure helpers plus one renderer; app.js fetches
|
|
5
|
+
// /api/openrouter/free-daily and mounts the result.
|
|
6
|
+
|
|
7
|
+
/** Below this share of the day's allowance the line turns amber. */
|
|
8
|
+
export const FREE_LOW_AT = 0.1;
|
|
9
|
+
/** A run's typical free requests when this install has no history yet (a small pipeline run). */
|
|
10
|
+
export const FREE_RUN_DEFAULT = 90;
|
|
11
|
+
|
|
12
|
+
/** "3h 12m" / "12m" / "under a minute". Pure. */
|
|
13
|
+
export function untilText(ms) {
|
|
14
|
+
const min = Math.floor(Math.max(0, ms) / 60_000);
|
|
15
|
+
if (min < 1) return 'under a minute';
|
|
16
|
+
const h = Math.floor(min / 60);
|
|
17
|
+
return h ? `${h}h ${min % 60}m` : `${min}m`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** "00:00 UTC". Pure. */
|
|
21
|
+
export function utcClock(iso) {
|
|
22
|
+
const t = Date.parse(iso || '');
|
|
23
|
+
return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 16)} UTC` : '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 'ok' | 'low' | 'out' for a known status. Pure. */
|
|
27
|
+
export function freeLevel(s) {
|
|
28
|
+
if (!s || !s.known || !(s.limit > 0)) return 'ok';
|
|
29
|
+
if (s.remaining <= 0) return 'out';
|
|
30
|
+
return s.remaining / s.limit < FREE_LOW_AT ? 'low' : 'ok';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The requests a run spent on OpenRouter `:free` models (its steps' bridgeFreeCalls). Pure. */
|
|
34
|
+
export function runFreeRequests(steps) {
|
|
35
|
+
return (Array.isArray(steps) ? steps : []).reduce((n, s) => n + (Number.isFinite(s?.bridgeFreeCalls) ? s.bridgeFreeCalls : 0), 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** " · 87 free requests" for a cost pill, '' when the run used none. Pure. */
|
|
39
|
+
export function freeRequestsSuffix(steps) {
|
|
40
|
+
const n = runFreeRequests(steps);
|
|
41
|
+
return n > 0 ? ` · ${n} free request${n === 1 ? '' : 's'}` : '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What a run usually takes on this install: the median of the last `max` finished runs that
|
|
46
|
+
* used free requests, else FREE_RUN_DEFAULT. Pure.
|
|
47
|
+
*/
|
|
48
|
+
export function typicalFreeRun(runs, { max = 10 } = {}) {
|
|
49
|
+
const done = new Set(['completed', 'failed', 'stopped']);
|
|
50
|
+
const counts = (Array.isArray(runs) ? runs : [])
|
|
51
|
+
.filter((r) => r && done.has(r.status))
|
|
52
|
+
.map((r) => runFreeRequests(r.steps))
|
|
53
|
+
.filter((n) => n > 0)
|
|
54
|
+
.slice(0, max)
|
|
55
|
+
.sort((a, b) => a - b);
|
|
56
|
+
if (!counts.length) return FREE_RUN_DEFAULT;
|
|
57
|
+
const mid = Math.floor(counts.length / 2);
|
|
58
|
+
return counts.length % 2 ? counts[mid] : Math.round((counts[mid - 1] + counts[mid]) / 2);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The new-run form's warning when a chosen model is `:free` and fewer requests are left than a
|
|
63
|
+
* run usually takes; null otherwise. Pure.
|
|
64
|
+
*/
|
|
65
|
+
export function newRunFreeWarning(s, { typical = FREE_RUN_DEFAULT, usesFree = false, now = Date.now() } = {}) {
|
|
66
|
+
if (!usesFree || !s || !s.known || s.remaining >= typical) return null;
|
|
67
|
+
const reset = Date.parse(s.resetAt || '');
|
|
68
|
+
const when = Number.isFinite(reset) ? ` (resets ${utcClock(s.resetAt)}, in ${untilText(reset - now)})` : '';
|
|
69
|
+
if (s.remaining <= 0) return `No OpenRouter free requests left today${when}: a free model would pause at its first call. Pick a paid model, or start after the reset.`;
|
|
70
|
+
return `${s.remaining} OpenRouter free request${s.remaining === 1 ? '' : 's'} left today${when}; a run here usually needs ~${typical}.`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The Providers card's line about the allowance, '' when unknown. Pure. */
|
|
74
|
+
export function providerFreeLine(s, { now = Date.now() } = {}) {
|
|
75
|
+
if (!s || !s.known) return '';
|
|
76
|
+
const reset = Date.parse(s.resetAt || '');
|
|
77
|
+
const when = Number.isFinite(reset) ? ` · resets ${utcClock(s.resetAt)} (in ${untilText(reset - now)})` : '';
|
|
78
|
+
return `Free-model requests today: ${s.remaining} of ${s.limit} left${when}.`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The sidebar block: "OpenRouter free · 941 / 1000 today", a meter of what is left, amber
|
|
83
|
+
* below 10%, red at 0, the reset on hover. null when there is nothing to show.
|
|
84
|
+
*/
|
|
85
|
+
export function renderFreeDaily(s, { doc = globalThis.document, now = Date.now() } = {}) {
|
|
86
|
+
if (!s || !s.enabled || !s.known) return null;
|
|
87
|
+
const level = freeLevel(s);
|
|
88
|
+
const btn = doc.createElement('button');
|
|
89
|
+
btn.type = 'button';
|
|
90
|
+
btn.className = `spend-ind free-ind${level === 'low' ? ' warn' : level === 'out' ? ' over' : ''}`;
|
|
91
|
+
const reset = Date.parse(s.resetAt || '');
|
|
92
|
+
btn.title = `OpenRouter free-model requests left today: ${s.remaining} of ${s.limit}` +
|
|
93
|
+
(Number.isFinite(reset) ? ` · resets ${utcClock(s.resetAt)}, in ${untilText(reset - now)}` : '') +
|
|
94
|
+
' — every model call on a :free model is one request';
|
|
95
|
+
const row = doc.createElement('span');
|
|
96
|
+
row.className = 'spend-ind-row';
|
|
97
|
+
const label = doc.createElement('span');
|
|
98
|
+
label.className = 'spend-ind-label';
|
|
99
|
+
label.textContent = 'OpenRouter free today';
|
|
100
|
+
const amt = doc.createElement('span');
|
|
101
|
+
amt.className = 'spend-ind-amt mono';
|
|
102
|
+
amt.textContent = `${s.remaining} / ${s.limit}`;
|
|
103
|
+
row.append(label, amt);
|
|
104
|
+
const meter = doc.createElement('span');
|
|
105
|
+
meter.className = 'spend-ind-meter';
|
|
106
|
+
const fill = doc.createElement('span');
|
|
107
|
+
fill.className = 'spend-ind-meter-fill';
|
|
108
|
+
fill.style.width = `${s.limit > 0 ? Math.max(0, Math.min(100, (s.remaining / s.limit) * 100)) : 0}%`;
|
|
109
|
+
meter.appendChild(fill);
|
|
110
|
+
btn.append(row, meter);
|
|
111
|
+
if (level === 'out') {
|
|
112
|
+
const sub = doc.createElement('small');
|
|
113
|
+
sub.className = 'spend-ind-sub';
|
|
114
|
+
sub.textContent = 'used up · free models pause until the reset';
|
|
115
|
+
btn.appendChild(sub);
|
|
116
|
+
}
|
|
117
|
+
return btn;
|
|
118
|
+
}
|
package/ui/public/stats-view.mjs
CHANGED
|
@@ -678,6 +678,57 @@ export function renderRunsChart(spec, { doc = globalThis.document, fmt = DEFAULT
|
|
|
678
678
|
}
|
|
679
679
|
|
|
680
680
|
/** Full Statistics body: KPI row + the two chart cards (or empty notes). */
|
|
681
|
+
const SLOT_NAMES = { anthropic: 'Anthropic', openai: 'OpenAI', openrouter: 'OpenRouter', copilot: 'Copilot', local: 'Local' };
|
|
682
|
+
const compactCount = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n));
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* "By person" (credential broker, docs/credential-broker.md): model spend per person in the
|
|
686
|
+
* stats window, as the broker metered it — every call is charged to whoever caused it.
|
|
687
|
+
* @param {{people?:object[], error?:string}} byPerson
|
|
688
|
+
*/
|
|
689
|
+
export function renderPeopleCard(byPerson, rangeLabel, { doc = globalThis.document, fmt = DEFAULT_FMT } = {}) {
|
|
690
|
+
const card = chartCard(doc, 'By person', rangeLabel);
|
|
691
|
+
card.classList.add('people-card');
|
|
692
|
+
if (byPerson?.error) {
|
|
693
|
+
card.appendChild(h(doc, 'p', 'hint err', `The credential broker didn't answer: ${byPerson.error}`));
|
|
694
|
+
return card;
|
|
695
|
+
}
|
|
696
|
+
const people = byPerson?.people || [];
|
|
697
|
+
if (!people.length) {
|
|
698
|
+
card.appendChild(h(doc, 'p', 'chart-empty hint', 'No model calls in this period.'));
|
|
699
|
+
return card;
|
|
700
|
+
}
|
|
701
|
+
const top = Math.max(...people.map((p) => p.usd), 0);
|
|
702
|
+
const total = people.reduce((a, p) => a + p.usd, 0);
|
|
703
|
+
const table = h(doc, 'table', 'people-table');
|
|
704
|
+
const head = h(doc, 'tr', null);
|
|
705
|
+
for (const t of ['Person', 'Spend', 'Requests', 'Tokens in / out', 'Providers']) head.appendChild(h(doc, 'th', null, t));
|
|
706
|
+
const thead = h(doc, 'thead', null); thead.appendChild(head); table.appendChild(thead);
|
|
707
|
+
const tbody = h(doc, 'tbody', null);
|
|
708
|
+
for (const p of people) {
|
|
709
|
+
const tr = h(doc, 'tr', null);
|
|
710
|
+
tr.appendChild(h(doc, 'td', 'people-name', p.person === 'local' ? 'No signed-in person' : p.person));
|
|
711
|
+
const spend = h(doc, 'td', 'people-spend');
|
|
712
|
+
const bar = h(doc, 'span', 'people-bar');
|
|
713
|
+
const fill = h(doc, 'span', 'people-bar-fill');
|
|
714
|
+
fill.style.width = `${top > 0 ? Math.max(2, Math.round((p.usd / top) * 100)) : 0}%`;
|
|
715
|
+
bar.appendChild(fill);
|
|
716
|
+
spend.appendChild(h(doc, 'span', 'people-usd', fmt.usd(p.usd)));
|
|
717
|
+
spend.appendChild(bar);
|
|
718
|
+
tr.appendChild(spend);
|
|
719
|
+
tr.appendChild(h(doc, 'td', 'num', compactCount(p.requests)));
|
|
720
|
+
tr.appendChild(h(doc, 'td', 'num', `${compactCount(p.inputTokens + (p.cacheReadTokens || 0))} / ${compactCount(p.outputTokens)}`));
|
|
721
|
+
tr.appendChild(h(doc, 'td', 'people-slots', [...new Set(p.slots.map((s) => `${SLOT_NAMES[s.slot] || s.slot}${s.plan === 'subscription' ? ' (subscription)' : ''}`))].join(', ')));
|
|
722
|
+
tbody.appendChild(tr);
|
|
723
|
+
}
|
|
724
|
+
table.appendChild(tbody);
|
|
725
|
+
const scroll = h(doc, 'div', 'people-scroll');
|
|
726
|
+
scroll.appendChild(table);
|
|
727
|
+
card.appendChild(scroll);
|
|
728
|
+
card.appendChild(h(doc, 'small', 'hint', `${fmt.usd(total)} in total, metered by the credential broker. Each call is charged to the person whose action caused it.`));
|
|
729
|
+
return card;
|
|
730
|
+
}
|
|
731
|
+
|
|
681
732
|
export function renderStatsBody(model, opts = {}) {
|
|
682
733
|
const { doc = globalThis.document } = opts;
|
|
683
734
|
const wrap = h(doc, 'div', null);
|
|
@@ -706,5 +757,10 @@ export function renderStatsBody(model, opts = {}) {
|
|
|
706
757
|
currentBucketStartMs, rangeLabel }, opts));
|
|
707
758
|
}
|
|
708
759
|
wrap.appendChild(grid);
|
|
760
|
+
if (model.byPerson) {
|
|
761
|
+
const people = h(doc, 'div', 'charts-grid people-grid');
|
|
762
|
+
people.appendChild(renderPeopleCard(model.byPerson, rangeLabel, opts));
|
|
763
|
+
wrap.appendChild(people);
|
|
764
|
+
}
|
|
709
765
|
return wrap;
|
|
710
766
|
}
|