@aria-framework/ai 0.26.0 → 0.26.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 +6 -0
- package/browser/ai-panels.js +138 -138
- package/index.js +20 -1
- package/lmxVerify.js +402 -402
- package/package.json +2 -2
- package/providers/anthropic.js +11 -4
- package/providers/openai-compatible.js +18 -7
- package/views/ai/job-card.ejs +182 -182
- package/views/ai/lmx-stack.ejs +426 -426
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aria-framework/ai",
|
|
3
3
|
"description": "Aria App Framework \u2014 AI module. A dependency-injected model seam (createAiClient) over several providers (LM Studio / OpenAI-compatible / Anthropic), with a fact-preservation guard, generic Polish and Generate writing engines, and a browser polish widget. Prompts and config stay in the consuming app.",
|
|
4
|
-
"version": "0.26.
|
|
4
|
+
"version": "0.26.1",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
7
7
|
"publishConfig": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
}
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
|
-
"test": "node test/smoke.js && node test/usageStore.js && node test/providerStore.js && node test/speedStore.js && node test/health.js && node test/listModels.js && node test/benchmark.js && node test/lmxDiscovery.js && node test/lmx.js && node test/lmxVerify.js && node test/lmxStore.js && node test/lmxStatus.js && node test/jobCard.js && node test/untrusted.js && node test/packaging.js && node test/views.js && node test/fenced.js && node test/polish.js && node test/reasoning.js",
|
|
50
|
+
"test": "node test/smoke.js && node test/usageStore.js && node test/providerStore.js && node test/speedStore.js && node test/health.js && node test/listModels.js && node test/benchmark.js && node test/lmxDiscovery.js && node test/lmx.js && node test/lmxVerify.js && node test/lmxStore.js && node test/lmxStatus.js && node test/jobCard.js && node test/untrusted.js && node test/packaging.js && node test/views.js && node test/fenced.js && node test/polish.js && node test/reasoning.js && node test/usageOnFailure.js",
|
|
51
51
|
"prepublishOnly": "node ../../test/packaging.js ai"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
package/providers/anthropic.js
CHANGED
|
@@ -97,6 +97,13 @@ async function complete(cfg, opts) {
|
|
|
97
97
|
clearTimeout(timer);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// A 200 WHOSE BODY IS `null` (0.26.1) is a typed failure, not a TypeError further down.
|
|
101
|
+
if (!payload || typeof payload !== 'object') {
|
|
102
|
+
throw new AiError('bad_response', `${label} answered with an empty body.`);
|
|
103
|
+
}
|
|
104
|
+
// Spent tokens ride on every later error so the client can meter a failed call (0.26.1).
|
|
105
|
+
const usage = normaliseUsage(payload.usage);
|
|
106
|
+
|
|
100
107
|
// Difference 4: blocks, not a single string. Concatenated so a model that splits its answer over
|
|
101
108
|
// two text blocks does not silently lose the second one.
|
|
102
109
|
// Thinking arrives as its own block TYPE here rather than a field, so filtering to 'text' already
|
|
@@ -109,7 +116,7 @@ async function complete(cfg, opts) {
|
|
|
109
116
|
const thought = (payload.content || []).some((b) => b && b.type === 'thinking');
|
|
110
117
|
throw new AiError('bad_response', thought
|
|
111
118
|
? `${label} returned only its internal reasoning. Raise the reply limit and try again.`
|
|
112
|
-
: `${label} returned no text (stop reason: ${payload.stop_reason || 'none given'})
|
|
119
|
+
: `${label} returned no text (stop reason: ${payload.stop_reason || 'none given'}).`, { usage });
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
let json = null;
|
|
@@ -121,10 +128,10 @@ async function complete(cfg, opts) {
|
|
|
121
128
|
if (payload.stop_reason === 'max_tokens') {
|
|
122
129
|
throw new AiError('bad_response',
|
|
123
130
|
`${label} ran out of room mid-answer — the structured reply was cut off before it was ` +
|
|
124
|
-
'finished. A Regenerate usually succeeds.', { cause: err });
|
|
131
|
+
'finished. A Regenerate usually succeeds.', { cause: err, usage });
|
|
125
132
|
}
|
|
126
133
|
throw new AiError('bad_response',
|
|
127
|
-
`${label} was asked for structured output and returned text that will not parse.`, { cause: err });
|
|
134
|
+
`${label} was asked for structured output and returned text that will not parse.`, { cause: err, usage });
|
|
128
135
|
}
|
|
129
136
|
}
|
|
130
137
|
|
|
@@ -132,7 +139,7 @@ async function complete(cfg, opts) {
|
|
|
132
139
|
text,
|
|
133
140
|
json,
|
|
134
141
|
model: payload.model || cfg.model,
|
|
135
|
-
usage
|
|
142
|
+
usage,
|
|
136
143
|
ms: Date.now() - started,
|
|
137
144
|
finishReason: payload.stop_reason || null,
|
|
138
145
|
truncated: payload.stop_reason === 'max_tokens',
|
|
@@ -133,7 +133,13 @@ async function complete(cfg, opts) {
|
|
|
133
133
|
// completion" — which sent this investigation after a thinking-model theory that was not the
|
|
134
134
|
// cause. Verified against the real server: the same request on /v1/chat/completions answers
|
|
135
135
|
// normally.
|
|
136
|
-
|
|
136
|
+
// A 200 WHOSE BODY IS `null` (0.26.1) — valid JSON, no object. It used to reach `payload.usage`
|
|
137
|
+
// below as a TypeError, which a router reads as an unknown fault.
|
|
138
|
+
if (!payload || typeof payload !== 'object') {
|
|
139
|
+
throw new AiError('bad_response', `${label} answered with an empty body.`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (payload.error && !payload.choices) {
|
|
137
143
|
const said = typeof payload.error === 'string'
|
|
138
144
|
? payload.error
|
|
139
145
|
: (payload.error.message || JSON.stringify(payload.error));
|
|
@@ -170,6 +176,10 @@ async function complete(cfg, opts) {
|
|
|
170
176
|
throw emptyCompletion({ label, finishReason, reasoning, maxTokens: body.max_tokens, usage: payload.usage });
|
|
171
177
|
}
|
|
172
178
|
|
|
179
|
+
// TOKENS WERE SPENT EVEN WHEN THE ANSWER IS UNUSABLE (0.26.1). Carried on every error raised after
|
|
180
|
+
// this point, normalised, so the client can meter a failed call — see index.js complete().
|
|
181
|
+
const usage = normaliseUsage(payload.usage);
|
|
182
|
+
|
|
173
183
|
// With a schema the content is still a STRING containing JSON — the server constrains the shape,
|
|
174
184
|
// it does not parse for you. Parsing here rather than at each call site means a malformed body is
|
|
175
185
|
// one error type instead of a surprise in a page render.
|
|
@@ -188,10 +198,10 @@ async function complete(cfg, opts) {
|
|
|
188
198
|
throw new AiError('bad_response',
|
|
189
199
|
`${label} ran out of room mid-answer (${body.max_tokens} tokens) — the structured reply ` +
|
|
190
200
|
'was cut off before it was finished. If this repeats, the schema may be letting the ' +
|
|
191
|
-
'model ramble; a Regenerate usually succeeds.', { cause: err });
|
|
201
|
+
'model ramble; a Regenerate usually succeeds.', { cause: err, usage });
|
|
192
202
|
}
|
|
193
203
|
throw new AiError('bad_response',
|
|
194
|
-
`${label} was asked for structured output and returned text that will not parse.`, { cause: err });
|
|
204
|
+
`${label} was asked for structured output and returned text that will not parse.`, { cause: err, usage });
|
|
195
205
|
}
|
|
196
206
|
}
|
|
197
207
|
|
|
@@ -199,7 +209,7 @@ async function complete(cfg, opts) {
|
|
|
199
209
|
text,
|
|
200
210
|
json,
|
|
201
211
|
model: payload.model || cfg.model,
|
|
202
|
-
usage
|
|
212
|
+
usage,
|
|
203
213
|
ms: Date.now() - started,
|
|
204
214
|
finishReason,
|
|
205
215
|
// A caller that cares (the summary's coverage line) can tell a complete answer from a cut-off
|
|
@@ -236,8 +246,9 @@ function unclosedThinking(text) {
|
|
|
236
246
|
* payload, so they are distinguished: the budget ran out, the budget went on thinking, or the server
|
|
237
247
|
* genuinely said nothing.
|
|
238
248
|
*/
|
|
239
|
-
function emptyCompletion({ label, finishReason, reasoning, maxTokens, usage }) {
|
|
240
|
-
const spent = (
|
|
249
|
+
function emptyCompletion({ label, finishReason, reasoning, maxTokens, usage: raw }) {
|
|
250
|
+
const spent = (raw && raw.completion_tokens) || 0;
|
|
251
|
+
const usage = normaliseUsage(raw);
|
|
241
252
|
if (finishReason === 'length' || (reasoning && !spentLeftRoom(spent, maxTokens))) {
|
|
242
253
|
return new AiError('bad_response',
|
|
243
254
|
`${label} ran out of room before it answered — it used all ${maxTokens} reply tokens` +
|
|
@@ -253,7 +264,7 @@ function emptyCompletion({ label, finishReason, reasoning, maxTokens, usage }) {
|
|
|
253
264
|
}
|
|
254
265
|
return new AiError('bad_response',
|
|
255
266
|
`${label} returned an empty completion (finish reason: ${finishReason || 'none given'}). ` +
|
|
256
|
-
'Check the model is fully loaded on the server.');
|
|
267
|
+
'Check the model is fully loaded on the server.', { usage });
|
|
257
268
|
}
|
|
258
269
|
|
|
259
270
|
/** Did the completion stop well short of the ceiling? Then the ceiling was not the problem. */
|
package/views/ai/job-card.ejs
CHANGED
|
@@ -1,182 +1,182 @@
|
|
|
1
|
-
<%# One job, and the path of endpoints that serves it.
|
|
2
|
-
────────────────────────────────────────────────────────────────────────────────────────────
|
|
3
|
-
TWO MODES, NOT A FOLD. A stack panel folds because its detail is worth reading occasionally; a
|
|
4
|
-
job's detail is a thing you EDIT, and the editing controls are useless the other 99% of the
|
|
5
|
-
time. So the card reads as a sentence — this job, this path, this is what it is for — until
|
|
6
|
-
somebody says they want to change it.
|
|
7
|
-
|
|
8
|
-
THE PATH IS THE HEADLINE, because it is the only part that varies and the only part anybody
|
|
9
|
-
comes here to check. `a → b → c` is the whole configuration of a job in one line: the first
|
|
10
|
-
that answers serves, the rest are its fallbacks in order.
|
|
11
|
-
|
|
12
|
-
ORDER IS CARRIED IN TYPED POSITIONS, never in the order the browser serialises fields. A form
|
|
13
|
-
posts in document order, so a list that relied on that would be silently un-reorderable — and
|
|
14
|
-
the handler this posts to already reads `position_<id>` for exactly that reason. The list is
|
|
15
|
-
renumbered from its own DOM order on submit, so what you see is what is sent.
|
|
16
|
-
|
|
17
|
-
ASSIGNED IS NOT THE SAME AS RESOLVED. A job with no assignment is not unconfigured, it is
|
|
18
|
-
following the default — every enabled endpoint in listed order — and saying so stops somebody
|
|
19
|
-
"fixing" a job that is already doing the right thing. A job whose assigned endpoints are all
|
|
20
|
-
disabled resolves to nothing at all, which is a different and much worse state.
|
|
21
|
-
|
|
22
|
-
Locals:
|
|
23
|
-
job { id, label, character, detail }
|
|
24
|
-
assigned [endpointId] explicitly chosen for this job, in order (may be empty)
|
|
25
|
-
resolved [endpointId] what would actually serve right now, in order
|
|
26
|
-
endpoints [{ id, label, enabled }] every endpoint that exists
|
|
27
|
-
spend { calls, total_tokens } | null
|
|
28
|
-
spendDays how many days `spend` covers, for the label beside it (default 14)
|
|
29
|
-
canEdit render the edit affordance at all
|
|
30
|
-
csrfToken () => token
|
|
31
|
-
basePath where this app mounts its AI admin, e.g. '/admin/ai'
|
|
32
|
-
%>
|
|
33
|
-
<%
|
|
34
|
-
var _edit = (typeof canEdit !== 'undefined') && canEdit;
|
|
35
|
-
var _base = (typeof basePath !== 'undefined' && basePath) ? basePath : '/admin/ai';
|
|
36
|
-
var _csrf = (typeof csrfToken === 'function') ? csrfToken : function () { return ''; };
|
|
37
|
-
var _assigned = assigned || [];
|
|
38
|
-
var _resolved = resolved || [];
|
|
39
|
-
var _eps = endpoints || [];
|
|
40
|
-
var _spend = (typeof spend !== 'undefined') ? spend : null;
|
|
41
|
-
// THE WINDOW THAT PRODUCED `spend`. Defaulted to what the label used to say as a literal, so a
|
|
42
|
-
// consumer that passes nothing renders exactly as it did before.
|
|
43
|
-
var _spendDays = (typeof spendDays !== 'undefined' && Number(spendDays) > 0) ? Number(spendDays) : 14;
|
|
44
|
-
var _isDefault = !_assigned.length;
|
|
45
|
-
var _byId = {};
|
|
46
|
-
_eps.forEach(function (e) { _byId[e.id] = e; });
|
|
47
|
-
// The path being EDITED is the assignment, not the resolution: editing what is currently serving
|
|
48
|
-
// would silently drop a disabled endpoint from the assignment the moment anybody pressed Save.
|
|
49
|
-
var _path = _assigned.slice();
|
|
50
|
-
var _rest = _eps.filter(function (e) { return _path.indexOf(e.id) === -1; });
|
|
51
|
-
%>
|
|
52
|
-
<div class="border rounded mb-2" data-job="<%= job.id %>">
|
|
53
|
-
|
|
54
|
-
<%# ── READING MODE ────────────────────────────────────────────────────────────────────────── %>
|
|
55
|
-
<div class="p-3" data-job-view>
|
|
56
|
-
<%# THE ACTIONS STAY ON THE RIGHT, whatever the description does.
|
|
57
|
-
`flex-wrap` plus a growing child with no `min-width: 0` is the classic way to lose a
|
|
58
|
-
button: a flex item will not shrink below its content's intrinsic width, so a long
|
|
59
|
-
description keeps the text column at full width, the actions wrap onto a line of their own,
|
|
60
|
-
and `justify-content-between` then puts that lone item at the START — bottom LEFT, which is
|
|
61
|
-
precisely where the Edit button went.
|
|
62
|
-
`min-width: 0` lets the text column shrink so its own text wraps instead; `flex-shrink-0`
|
|
63
|
-
keeps the buttons their natural size; and with no wrapping there is no second line to fall
|
|
64
|
-
onto. %>
|
|
65
|
-
<div class="d-flex justify-content-between align-items-start gap-3">
|
|
66
|
-
<div class="flex-grow-1" style="min-width:0">
|
|
67
|
-
<div class="d-flex align-items-center gap-2 flex-wrap">
|
|
68
|
-
<strong><%= job.label %></strong>
|
|
69
|
-
<span class="badge border bg-body-tertiary text-body-secondary text-uppercase fw-semibold"><%= job.character %></span>
|
|
70
|
-
<% if (_isDefault) { %>
|
|
71
|
-
<span class="badge rounded-pill border bg-body-secondary text-body-secondary fw-semibold">default</span>
|
|
72
|
-
<% } %>
|
|
73
|
-
</div>
|
|
74
|
-
<%# THE PATH, directly under the name — the one line that says how this job is configured. %>
|
|
75
|
-
<div class="font-monospace small mt-1" style="word-break:break-all">
|
|
76
|
-
<% if (_resolved.length) { %>
|
|
77
|
-
<% _resolved.forEach(function (id, i) { %><% if (i) { %><span class="text-body-tertiary"> → </span><% } %><span class="<%= i ? 'text-body-secondary' : '' %>"><%= id %></span><% }) %>
|
|
78
|
-
<% } else { %>
|
|
79
|
-
<span class="text-danger">nothing available — every endpoint for this job is disabled or missing</span>
|
|
80
|
-
<% } %>
|
|
81
|
-
</div>
|
|
82
|
-
<div class="form-text mt-1 mb-0"><%= job.detail %></div>
|
|
83
|
-
</div>
|
|
84
|
-
|
|
85
|
-
<div class="text-end d-flex flex-column align-items-end gap-2 flex-shrink-0">
|
|
86
|
-
<%# WHAT THIS JOB COSTS, which is the number worth acting on: a job can be pointed at a
|
|
87
|
-
cheaper machine, run less often, or switched off. An endpoint total only says where the
|
|
88
|
-
money went, never what asked for it.
|
|
89
|
-
|
|
90
|
-
THE WINDOW IS PRINTED FROM THE SAME PLACE THE FIGURE COMES FROM. It used to be the
|
|
91
|
-
literal "14 days" here, while the figure beside it was aggregated by the consuming app
|
|
92
|
-
over a window only that app knew — two halves of one sentence in two repositories. A
|
|
93
|
-
consumer changing its window relabelled nothing, and every card then stated a period
|
|
94
|
-
its own number had never covered. Support101 hit exactly that on the other side of the
|
|
95
|
-
same screen, where a total labelled "14 days" came from an unwindowed query and ran
|
|
96
|
-
139,792 tokens adrift of the tab beside it. %>
|
|
97
|
-
<% if (_spend && _spend.total_tokens) { %>
|
|
98
|
-
<div class="small text-body-secondary" style="font-variant-numeric: tabular-nums">
|
|
99
|
-
<%= Number(_spend.total_tokens).toLocaleString() %> tokens in <%= _spend.calls %> call<%= _spend.calls === 1 ? '' : 's' %>
|
|
100
|
-
<span class="text-body-tertiary">· <%= _spendDays %> days</span>
|
|
101
|
-
</div>
|
|
102
|
-
<% } %>
|
|
103
|
-
<% if (_edit && _eps.length) { %>
|
|
104
|
-
<button type="button" class="btn btn-sm btn-outline-secondary" data-job-edit-open="<%= job.id %>">Edit</button>
|
|
105
|
-
<% } %>
|
|
106
|
-
</div>
|
|
107
|
-
</div>
|
|
108
|
-
</div>
|
|
109
|
-
|
|
110
|
-
<%# ── EDITING MODE ───────────────────────────────────────────────────────────────────────────
|
|
111
|
-
Rendered by the server and hidden, rather than built by script when Edit is pressed: the
|
|
112
|
-
whole list is here in the markup, so it works from a keyboard, survives a script that fails
|
|
113
|
-
to load badly enough to matter, and posts the same fields the handler has always read. %>
|
|
114
|
-
<% if (_edit && _eps.length) { %>
|
|
115
|
-
<form method="POST" action="<%= _base %>/routes/<%= encodeURIComponent(job.id) %>"
|
|
116
|
-
class="p-3 border-top" data-job-edit hidden>
|
|
117
|
-
<input type="hidden" name="_csrf" value="<%= _csrf() %>">
|
|
118
|
-
|
|
119
|
-
<div class="d-flex justify-content-between align-items-baseline mb-2">
|
|
120
|
-
<span class="small text-uppercase fw-semibold text-body-secondary" style="letter-spacing:.08em">
|
|
121
|
-
<%= job.label %> — the path, in order
|
|
122
|
-
</span>
|
|
123
|
-
<span class="small text-body-tertiary">first to answer serves</span>
|
|
124
|
-
</div>
|
|
125
|
-
|
|
126
|
-
<ol class="list-unstyled mb-2" data-job-list>
|
|
127
|
-
<% _path.forEach(function (id, i) { %>
|
|
128
|
-
<% var ep = _byId[id]; %>
|
|
129
|
-
<li class="d-flex align-items-center gap-2 border rounded px-2 py-2 mb-1 bg-body-tertiary"
|
|
130
|
-
draggable="true" data-job-item="<%= id %>">
|
|
131
|
-
<span class="text-body-tertiary" aria-hidden="true" style="cursor:grab">⠿</span>
|
|
132
|
-
<span class="badge rounded-pill border bg-body-secondary text-body-secondary font-monospace fw-normal"
|
|
133
|
-
data-job-rank><%= i + 1 %></span>
|
|
134
|
-
<span class="flex-grow-1 font-monospace small">
|
|
135
|
-
<%= id %>
|
|
136
|
-
<% if (ep && ep.label && ep.label !== id) { %><span class="text-body-tertiary"> · <%= ep.label %></span><% } %>
|
|
137
|
-
<%# AN ENDPOINT IN THE PATH BUT DISABLED IS KEPT, and said out loud. Dropping it on
|
|
138
|
-
save would quietly rewrite somebody's configuration because a machine happened to
|
|
139
|
-
be switched off this afternoon. %>
|
|
140
|
-
<% if (ep && !ep.enabled) { %>
|
|
141
|
-
<span class="badge rounded-pill border bg-warning-subtle text-warning-emphasis border-warning-subtle ms-1">disabled — skipped while off</span>
|
|
142
|
-
<% } %>
|
|
143
|
-
<% if (!ep) { %>
|
|
144
|
-
<span class="badge rounded-pill border bg-danger-subtle text-danger-emphasis border-danger-subtle ms-1">no such endpoint</span>
|
|
145
|
-
<% } %>
|
|
146
|
-
</span>
|
|
147
|
-
<span class="btn-group btn-group-sm" role="group">
|
|
148
|
-
<button type="button" class="btn btn-outline-secondary" data-job-up
|
|
149
|
-
aria-label="Move <%= id %> earlier">↑</button>
|
|
150
|
-
<button type="button" class="btn btn-outline-secondary" data-job-down
|
|
151
|
-
aria-label="Move <%= id %> later">↓</button>
|
|
152
|
-
<button type="button" class="btn btn-outline-secondary" data-job-remove
|
|
153
|
-
aria-label="Remove <%= id %> from this job">✕</button>
|
|
154
|
-
</span>
|
|
155
|
-
</li>
|
|
156
|
-
<% }) %>
|
|
157
|
-
</ol>
|
|
158
|
-
|
|
159
|
-
<p class="form-text mt-0" data-job-empty<% if (_path.length) { %> hidden<% } %>>
|
|
160
|
-
Nothing chosen, so this job uses <strong>every enabled endpoint</strong> in the order they
|
|
161
|
-
are listed above. That is a working configuration, not a missing one.
|
|
162
|
-
</p>
|
|
163
|
-
|
|
164
|
-
<% if (_rest.length) { %>
|
|
165
|
-
<div class="small text-uppercase fw-semibold text-body-secondary mt-3 mb-2" style="letter-spacing:.08em">Not in this job</div>
|
|
166
|
-
<div class="d-flex flex-wrap gap-2" data-job-pool>
|
|
167
|
-
<% _rest.forEach(function (e) { %>
|
|
168
|
-
<button type="button" class="btn btn-sm btn-outline-secondary font-monospace"
|
|
169
|
-
data-job-add="<%= e.id %>"<% if (!e.enabled) { %> disabled title="This endpoint is switched off"<% } %>>
|
|
170
|
-
+ <%= e.id %><% if (!e.enabled) { %> (disabled)<% } %>
|
|
171
|
-
</button>
|
|
172
|
-
<% }) %>
|
|
173
|
-
</div>
|
|
174
|
-
<% } %>
|
|
175
|
-
|
|
176
|
-
<div class="d-flex gap-2 mt-3">
|
|
177
|
-
<button type="submit" class="btn btn-sm btn-primary">Save</button>
|
|
178
|
-
<button type="button" class="btn btn-sm btn-outline-secondary" data-job-cancel>Cancel</button>
|
|
179
|
-
</div>
|
|
180
|
-
</form>
|
|
181
|
-
<% } %>
|
|
182
|
-
</div>
|
|
1
|
+
<%# One job, and the path of endpoints that serves it.
|
|
2
|
+
────────────────────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
TWO MODES, NOT A FOLD. A stack panel folds because its detail is worth reading occasionally; a
|
|
4
|
+
job's detail is a thing you EDIT, and the editing controls are useless the other 99% of the
|
|
5
|
+
time. So the card reads as a sentence — this job, this path, this is what it is for — until
|
|
6
|
+
somebody says they want to change it.
|
|
7
|
+
|
|
8
|
+
THE PATH IS THE HEADLINE, because it is the only part that varies and the only part anybody
|
|
9
|
+
comes here to check. `a → b → c` is the whole configuration of a job in one line: the first
|
|
10
|
+
that answers serves, the rest are its fallbacks in order.
|
|
11
|
+
|
|
12
|
+
ORDER IS CARRIED IN TYPED POSITIONS, never in the order the browser serialises fields. A form
|
|
13
|
+
posts in document order, so a list that relied on that would be silently un-reorderable — and
|
|
14
|
+
the handler this posts to already reads `position_<id>` for exactly that reason. The list is
|
|
15
|
+
renumbered from its own DOM order on submit, so what you see is what is sent.
|
|
16
|
+
|
|
17
|
+
ASSIGNED IS NOT THE SAME AS RESOLVED. A job with no assignment is not unconfigured, it is
|
|
18
|
+
following the default — every enabled endpoint in listed order — and saying so stops somebody
|
|
19
|
+
"fixing" a job that is already doing the right thing. A job whose assigned endpoints are all
|
|
20
|
+
disabled resolves to nothing at all, which is a different and much worse state.
|
|
21
|
+
|
|
22
|
+
Locals:
|
|
23
|
+
job { id, label, character, detail }
|
|
24
|
+
assigned [endpointId] explicitly chosen for this job, in order (may be empty)
|
|
25
|
+
resolved [endpointId] what would actually serve right now, in order
|
|
26
|
+
endpoints [{ id, label, enabled }] every endpoint that exists
|
|
27
|
+
spend { calls, total_tokens } | null
|
|
28
|
+
spendDays how many days `spend` covers, for the label beside it (default 14)
|
|
29
|
+
canEdit render the edit affordance at all
|
|
30
|
+
csrfToken () => token
|
|
31
|
+
basePath where this app mounts its AI admin, e.g. '/admin/ai'
|
|
32
|
+
%>
|
|
33
|
+
<%
|
|
34
|
+
var _edit = (typeof canEdit !== 'undefined') && canEdit;
|
|
35
|
+
var _base = (typeof basePath !== 'undefined' && basePath) ? basePath : '/admin/ai';
|
|
36
|
+
var _csrf = (typeof csrfToken === 'function') ? csrfToken : function () { return ''; };
|
|
37
|
+
var _assigned = assigned || [];
|
|
38
|
+
var _resolved = resolved || [];
|
|
39
|
+
var _eps = endpoints || [];
|
|
40
|
+
var _spend = (typeof spend !== 'undefined') ? spend : null;
|
|
41
|
+
// THE WINDOW THAT PRODUCED `spend`. Defaulted to what the label used to say as a literal, so a
|
|
42
|
+
// consumer that passes nothing renders exactly as it did before.
|
|
43
|
+
var _spendDays = (typeof spendDays !== 'undefined' && Number(spendDays) > 0) ? Number(spendDays) : 14;
|
|
44
|
+
var _isDefault = !_assigned.length;
|
|
45
|
+
var _byId = {};
|
|
46
|
+
_eps.forEach(function (e) { _byId[e.id] = e; });
|
|
47
|
+
// The path being EDITED is the assignment, not the resolution: editing what is currently serving
|
|
48
|
+
// would silently drop a disabled endpoint from the assignment the moment anybody pressed Save.
|
|
49
|
+
var _path = _assigned.slice();
|
|
50
|
+
var _rest = _eps.filter(function (e) { return _path.indexOf(e.id) === -1; });
|
|
51
|
+
%>
|
|
52
|
+
<div class="border rounded mb-2" data-job="<%= job.id %>">
|
|
53
|
+
|
|
54
|
+
<%# ── READING MODE ────────────────────────────────────────────────────────────────────────── %>
|
|
55
|
+
<div class="p-3" data-job-view>
|
|
56
|
+
<%# THE ACTIONS STAY ON THE RIGHT, whatever the description does.
|
|
57
|
+
`flex-wrap` plus a growing child with no `min-width: 0` is the classic way to lose a
|
|
58
|
+
button: a flex item will not shrink below its content's intrinsic width, so a long
|
|
59
|
+
description keeps the text column at full width, the actions wrap onto a line of their own,
|
|
60
|
+
and `justify-content-between` then puts that lone item at the START — bottom LEFT, which is
|
|
61
|
+
precisely where the Edit button went.
|
|
62
|
+
`min-width: 0` lets the text column shrink so its own text wraps instead; `flex-shrink-0`
|
|
63
|
+
keeps the buttons their natural size; and with no wrapping there is no second line to fall
|
|
64
|
+
onto. %>
|
|
65
|
+
<div class="d-flex justify-content-between align-items-start gap-3">
|
|
66
|
+
<div class="flex-grow-1" style="min-width:0">
|
|
67
|
+
<div class="d-flex align-items-center gap-2 flex-wrap">
|
|
68
|
+
<strong><%= job.label %></strong>
|
|
69
|
+
<span class="badge border bg-body-tertiary text-body-secondary text-uppercase fw-semibold"><%= job.character %></span>
|
|
70
|
+
<% if (_isDefault) { %>
|
|
71
|
+
<span class="badge rounded-pill border bg-body-secondary text-body-secondary fw-semibold">default</span>
|
|
72
|
+
<% } %>
|
|
73
|
+
</div>
|
|
74
|
+
<%# THE PATH, directly under the name — the one line that says how this job is configured. %>
|
|
75
|
+
<div class="font-monospace small mt-1" style="word-break:break-all">
|
|
76
|
+
<% if (_resolved.length) { %>
|
|
77
|
+
<% _resolved.forEach(function (id, i) { %><% if (i) { %><span class="text-body-tertiary"> → </span><% } %><span class="<%= i ? 'text-body-secondary' : '' %>"><%= id %></span><% }) %>
|
|
78
|
+
<% } else { %>
|
|
79
|
+
<span class="text-danger">nothing available — every endpoint for this job is disabled or missing</span>
|
|
80
|
+
<% } %>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="form-text mt-1 mb-0"><%= job.detail %></div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div class="text-end d-flex flex-column align-items-end gap-2 flex-shrink-0">
|
|
86
|
+
<%# WHAT THIS JOB COSTS, which is the number worth acting on: a job can be pointed at a
|
|
87
|
+
cheaper machine, run less often, or switched off. An endpoint total only says where the
|
|
88
|
+
money went, never what asked for it.
|
|
89
|
+
|
|
90
|
+
THE WINDOW IS PRINTED FROM THE SAME PLACE THE FIGURE COMES FROM. It used to be the
|
|
91
|
+
literal "14 days" here, while the figure beside it was aggregated by the consuming app
|
|
92
|
+
over a window only that app knew — two halves of one sentence in two repositories. A
|
|
93
|
+
consumer changing its window relabelled nothing, and every card then stated a period
|
|
94
|
+
its own number had never covered. Support101 hit exactly that on the other side of the
|
|
95
|
+
same screen, where a total labelled "14 days" came from an unwindowed query and ran
|
|
96
|
+
139,792 tokens adrift of the tab beside it. %>
|
|
97
|
+
<% if (_spend && _spend.total_tokens) { %>
|
|
98
|
+
<div class="small text-body-secondary" style="font-variant-numeric: tabular-nums">
|
|
99
|
+
<%= Number(_spend.total_tokens).toLocaleString() %> tokens in <%= _spend.calls %> call<%= _spend.calls === 1 ? '' : 's' %>
|
|
100
|
+
<span class="text-body-tertiary">· <%= _spendDays %> days</span>
|
|
101
|
+
</div>
|
|
102
|
+
<% } %>
|
|
103
|
+
<% if (_edit && _eps.length) { %>
|
|
104
|
+
<button type="button" class="btn btn-sm btn-outline-secondary" data-job-edit-open="<%= job.id %>">Edit</button>
|
|
105
|
+
<% } %>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<%# ── EDITING MODE ───────────────────────────────────────────────────────────────────────────
|
|
111
|
+
Rendered by the server and hidden, rather than built by script when Edit is pressed: the
|
|
112
|
+
whole list is here in the markup, so it works from a keyboard, survives a script that fails
|
|
113
|
+
to load badly enough to matter, and posts the same fields the handler has always read. %>
|
|
114
|
+
<% if (_edit && _eps.length) { %>
|
|
115
|
+
<form method="POST" action="<%= _base %>/routes/<%= encodeURIComponent(job.id) %>"
|
|
116
|
+
class="p-3 border-top" data-job-edit hidden>
|
|
117
|
+
<input type="hidden" name="_csrf" value="<%= _csrf() %>">
|
|
118
|
+
|
|
119
|
+
<div class="d-flex justify-content-between align-items-baseline mb-2">
|
|
120
|
+
<span class="small text-uppercase fw-semibold text-body-secondary" style="letter-spacing:.08em">
|
|
121
|
+
<%= job.label %> — the path, in order
|
|
122
|
+
</span>
|
|
123
|
+
<span class="small text-body-tertiary">first to answer serves</span>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<ol class="list-unstyled mb-2" data-job-list>
|
|
127
|
+
<% _path.forEach(function (id, i) { %>
|
|
128
|
+
<% var ep = _byId[id]; %>
|
|
129
|
+
<li class="d-flex align-items-center gap-2 border rounded px-2 py-2 mb-1 bg-body-tertiary"
|
|
130
|
+
draggable="true" data-job-item="<%= id %>">
|
|
131
|
+
<span class="text-body-tertiary" aria-hidden="true" style="cursor:grab">⠿</span>
|
|
132
|
+
<span class="badge rounded-pill border bg-body-secondary text-body-secondary font-monospace fw-normal"
|
|
133
|
+
data-job-rank><%= i + 1 %></span>
|
|
134
|
+
<span class="flex-grow-1 font-monospace small">
|
|
135
|
+
<%= id %>
|
|
136
|
+
<% if (ep && ep.label && ep.label !== id) { %><span class="text-body-tertiary"> · <%= ep.label %></span><% } %>
|
|
137
|
+
<%# AN ENDPOINT IN THE PATH BUT DISABLED IS KEPT, and said out loud. Dropping it on
|
|
138
|
+
save would quietly rewrite somebody's configuration because a machine happened to
|
|
139
|
+
be switched off this afternoon. %>
|
|
140
|
+
<% if (ep && !ep.enabled) { %>
|
|
141
|
+
<span class="badge rounded-pill border bg-warning-subtle text-warning-emphasis border-warning-subtle ms-1">disabled — skipped while off</span>
|
|
142
|
+
<% } %>
|
|
143
|
+
<% if (!ep) { %>
|
|
144
|
+
<span class="badge rounded-pill border bg-danger-subtle text-danger-emphasis border-danger-subtle ms-1">no such endpoint</span>
|
|
145
|
+
<% } %>
|
|
146
|
+
</span>
|
|
147
|
+
<span class="btn-group btn-group-sm" role="group">
|
|
148
|
+
<button type="button" class="btn btn-outline-secondary" data-job-up
|
|
149
|
+
aria-label="Move <%= id %> earlier">↑</button>
|
|
150
|
+
<button type="button" class="btn btn-outline-secondary" data-job-down
|
|
151
|
+
aria-label="Move <%= id %> later">↓</button>
|
|
152
|
+
<button type="button" class="btn btn-outline-secondary" data-job-remove
|
|
153
|
+
aria-label="Remove <%= id %> from this job">✕</button>
|
|
154
|
+
</span>
|
|
155
|
+
</li>
|
|
156
|
+
<% }) %>
|
|
157
|
+
</ol>
|
|
158
|
+
|
|
159
|
+
<p class="form-text mt-0" data-job-empty<% if (_path.length) { %> hidden<% } %>>
|
|
160
|
+
Nothing chosen, so this job uses <strong>every enabled endpoint</strong> in the order they
|
|
161
|
+
are listed above. That is a working configuration, not a missing one.
|
|
162
|
+
</p>
|
|
163
|
+
|
|
164
|
+
<% if (_rest.length) { %>
|
|
165
|
+
<div class="small text-uppercase fw-semibold text-body-secondary mt-3 mb-2" style="letter-spacing:.08em">Not in this job</div>
|
|
166
|
+
<div class="d-flex flex-wrap gap-2" data-job-pool>
|
|
167
|
+
<% _rest.forEach(function (e) { %>
|
|
168
|
+
<button type="button" class="btn btn-sm btn-outline-secondary font-monospace"
|
|
169
|
+
data-job-add="<%= e.id %>"<% if (!e.enabled) { %> disabled title="This endpoint is switched off"<% } %>>
|
|
170
|
+
+ <%= e.id %><% if (!e.enabled) { %> (disabled)<% } %>
|
|
171
|
+
</button>
|
|
172
|
+
<% }) %>
|
|
173
|
+
</div>
|
|
174
|
+
<% } %>
|
|
175
|
+
|
|
176
|
+
<div class="d-flex gap-2 mt-3">
|
|
177
|
+
<button type="submit" class="btn btn-sm btn-primary">Save</button>
|
|
178
|
+
<button type="button" class="btn btn-sm btn-outline-secondary" data-job-cancel>Cancel</button>
|
|
179
|
+
</div>
|
|
180
|
+
</form>
|
|
181
|
+
<% } %>
|
|
182
|
+
</div>
|