tinywebui 0.1.0
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/LICENSE +661 -0
- package/README.md +586 -0
- package/bin/tinywebui.js +105 -0
- package/package.json +49 -0
- package/public/app.js +81 -0
- package/public/chat/attachments.js +304 -0
- package/public/chat/chat.js +282 -0
- package/public/chat/composer.js +289 -0
- package/public/chat/library.js +76 -0
- package/public/chat/outline.js +39 -0
- package/public/chat/queue.js +124 -0
- package/public/chat/sidebar.js +485 -0
- package/public/chat/stream.js +162 -0
- package/public/chat/tasks.js +36 -0
- package/public/chat/transcript.js +691 -0
- package/public/core/access.js +42 -0
- package/public/core/api.js +27 -0
- package/public/core/dom.js +13 -0
- package/public/core/id.js +7 -0
- package/public/core/md.js +131 -0
- package/public/core/state.js +31 -0
- package/public/favicon.svg +9 -0
- package/public/index.html +261 -0
- package/public/panels/admin.js +199 -0
- package/public/panels/automations.js +464 -0
- package/public/panels/panels.js +52 -0
- package/public/panels/settings.js +365 -0
- package/public/panels/usage.js +474 -0
- package/public/shared/attachment_note.js +23 -0
- package/public/shared/events.js +27 -0
- package/public/shared/features.js +21 -0
- package/public/shared/origins.js +8 -0
- package/public/shared/usage.js +59 -0
- package/public/styles.css +1455 -0
- package/public/themes/README.md +178 -0
- package/public/themes/cyber-grid/cyber-grid.css +59 -0
- package/public/themes/drivers/scene-kit.js +422 -0
- package/public/themes/fall-fairy/fall-fairy.css +221 -0
- package/public/themes/fall-fairy/fall-fairy.js +859 -0
- package/public/themes/manifest.json +6 -0
- package/public/themes/theme-loader.js +123 -0
- package/src/access/auth.js +228 -0
- package/src/access/auth_gate.js +67 -0
- package/src/access/policy.js +180 -0
- package/src/audit.js +7 -0
- package/src/automations/automation.js +136 -0
- package/src/automations/scheduler.js +147 -0
- package/src/chat/compact.js +242 -0
- package/src/chat/llm.js +448 -0
- package/src/chat/runs.js +180 -0
- package/src/chat/tool_executor.js +239 -0
- package/src/cli.js +478 -0
- package/src/config/approval.js +54 -0
- package/src/config/config.js +479 -0
- package/src/config/models.js +112 -0
- package/src/files/documents.js +91 -0
- package/src/files/images.js +62 -0
- package/src/harness/agent.js +115 -0
- package/src/harness/attribution.js +75 -0
- package/src/harness/checkpoint.js +96 -0
- package/src/harness/history.js +40 -0
- package/src/harness/prompt.js +93 -0
- package/src/harness/provider.js +430 -0
- package/src/http.js +95 -0
- package/src/log.js +24 -0
- package/src/mcp.js +405 -0
- package/src/retrieval/embedding.js +298 -0
- package/src/retrieval/retrieval.js +250 -0
- package/src/routes/admin.js +112 -0
- package/src/routes/auth.js +97 -0
- package/src/routes/automations.js +92 -0
- package/src/routes/chats.js +232 -0
- package/src/routes/documents.js +133 -0
- package/src/routes/library.js +51 -0
- package/src/routes/mcp.js +59 -0
- package/src/routes/settings.js +101 -0
- package/src/server.js +397 -0
- package/src/set-password.js +32 -0
- package/src/store/automations.js +119 -0
- package/src/store/chats.js +254 -0
- package/src/store/documents.js +256 -0
- package/src/store/embeddings.js +67 -0
- package/src/store/index.js +90 -0
- package/src/store/messages.js +93 -0
- package/src/store/schema.js +480 -0
- package/src/store/scope.js +43 -0
- package/src/store/search.js +56 -0
- package/src/store/turns.js +78 -0
- package/src/store/usage.js +327 -0
- package/src/store/users.js +102 -0
- package/src/store/wire.js +55 -0
- package/src/tools/ask_tool.js +93 -0
- package/src/tools/chat_search_tool.js +63 -0
- package/src/tools/context_tool.js +183 -0
- package/src/tools/document_tool.js +102 -0
- package/src/tools/task_tool.js +63 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { scope } from './scope.js';
|
|
2
|
+
|
|
3
|
+
/** What an aux_usage row was spent on. */
|
|
4
|
+
export const AUX_KIND = { CHECKPOINT: 'checkpoint' };
|
|
5
|
+
import { inputTokens, outputTokens, cacheReads, tally } from '../../public/shared/usage.js';
|
|
6
|
+
|
|
7
|
+
// Token and cost statistics over stored messages.
|
|
8
|
+
// One area of the Store; see index.js.
|
|
9
|
+
|
|
10
|
+
/** Adds one stored attribution snapshot (see harness/attribution.js) into a running total. */
|
|
11
|
+
function mergeAttribution(target, snapshot) {
|
|
12
|
+
if (!snapshot || snapshot.version !== 1) return target;
|
|
13
|
+
target ||= { requests: 0, input: { total: 0, reported: 0, buckets: [] }, output: { total: 0, reported: 0, buckets: [] }, cached: 0 };
|
|
14
|
+
target.requests++;
|
|
15
|
+
for (const side of ['input', 'output']) {
|
|
16
|
+
const value = snapshot[side];
|
|
17
|
+
if (!value) continue;
|
|
18
|
+
if (value.total !== null) { target[side].total += value.total; target[side].reported++; }
|
|
19
|
+
for (const b of value.buckets || []) {
|
|
20
|
+
let existing = target[side].buckets.find((x) => x.category === b.category && x.source === b.source && x.capability === b.capability);
|
|
21
|
+
if (!existing) target[side].buckets.push(existing = { ...b, tokens: 0 });
|
|
22
|
+
existing.tokens += b.tokens;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
target.cached += snapshot.cached || 0;
|
|
26
|
+
return target;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Causes of a cache miss, most actionable first. */
|
|
30
|
+
export const MISS = { PREFIX: 'prefix', SWITCH: 'switch', EXPIRED: 'expired', STUB: 'stub', PROVIDER: 'provider' };
|
|
31
|
+
|
|
32
|
+
// How long an entry outlives its last use on most providers. Longer TTLs exist
|
|
33
|
+
// (Anthropic's 1h, Gemini's explicit caches), so a gap past this is a likely
|
|
34
|
+
// expiry, not a certain one.
|
|
35
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Why a round missed the cache, given the round before it in the same chat:
|
|
39
|
+
* null for a hit or a round that can't be judged. A miss is less than half of
|
|
40
|
+
* the previous request cached -- that request is what this one should have
|
|
41
|
+
* found, and providers cache in blocks, so "less than all" is normal.
|
|
42
|
+
* `prefix` is the fingerprint streamTurn() stores (see chat/llm.js).
|
|
43
|
+
*/
|
|
44
|
+
export function cacheMiss(round, prev) {
|
|
45
|
+
const { usage, at } = round;
|
|
46
|
+
const prefix = usage.prefix;
|
|
47
|
+
if (!prev || !prefix || prefix.previous == null || !tally([usage]).reported) return null;
|
|
48
|
+
if ((cacheReads(usage) ?? 0) >= prev.input / 2) return null;
|
|
49
|
+
if (prefix.matched < prefix.previous) return prefix.stubSwap ? MISS.STUB : MISS.PREFIX;
|
|
50
|
+
if (usage.provider && prev.provider && usage.provider !== prev.provider) return MISS.SWITCH;
|
|
51
|
+
if (at != null && prev.at != null && at - prev.at > CACHE_TTL_MS) return MISS.EXPIRED;
|
|
52
|
+
return MISS.PROVIDER;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What caching saved, from costs providers already report. Within one model on
|
|
57
|
+
* one upstream, every round's input cost is fresh * full + cached * cacheRate
|
|
58
|
+
* with the same two prices, so rounds at different cache ratios pin both down
|
|
59
|
+
* (least squares). The fit is trusted only if it reproduces every round to 1%:
|
|
60
|
+
* a tiered or changed price would not, and then nothing is claimed.
|
|
61
|
+
* `rounds`: [{ fresh, cached, inputCost }]. Returns null when it can't tell.
|
|
62
|
+
*/
|
|
63
|
+
export function derivedCacheRates(rounds) {
|
|
64
|
+
if (rounds.length < 2) return null;
|
|
65
|
+
let ff = 0, fc = 0, cc = 0, fy = 0, cy = 0;
|
|
66
|
+
for (const { fresh, cached, inputCost: y } of rounds) {
|
|
67
|
+
ff += fresh * fresh; fc += fresh * cached; cc += cached * cached; fy += fresh * y; cy += cached * y;
|
|
68
|
+
}
|
|
69
|
+
const det = ff * cc - fc * fc;
|
|
70
|
+
// All rounds at one cache ratio: the two prices can't be told apart.
|
|
71
|
+
if (!(det > 1e-9 * ff * cc)) return null;
|
|
72
|
+
const full = (fy * cc - fc * cy) / det;
|
|
73
|
+
const cacheRate = (ff * cy - fc * fy) / det;
|
|
74
|
+
if (!(full > 0) || cacheRate < -1e-12 || cacheRate >= full) return null;
|
|
75
|
+
const fits = rounds.every(({ fresh, cached, inputCost }) =>
|
|
76
|
+
Math.abs(fresh * full + cached * cacheRate - inputCost) <= 0.01 * inputCost + 1e-9);
|
|
77
|
+
return fits ? { full, cache: Math.max(cacheRate, 0) } : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Which rounds a statistics view covers: `model` alone is every upstream that
|
|
82
|
+
* served it; `provider` narrows to one ('' is rounds with none on record).
|
|
83
|
+
*/
|
|
84
|
+
const matches = (filter, model, provider) =>
|
|
85
|
+
(!filter.model || model === filter.model)
|
|
86
|
+
&& (filter.provider == null || (provider ?? '') === filter.provider);
|
|
87
|
+
|
|
88
|
+
/** Every model seen, with the upstreams that served it, for the filter. */
|
|
89
|
+
function addTarget(targets, model, provider) {
|
|
90
|
+
if (!targets.has(model)) targets.set(model, new Set());
|
|
91
|
+
if (provider) targets.get(model).add(provider);
|
|
92
|
+
}
|
|
93
|
+
const listTargets = (targets) => [...targets].map(([model, providers]) => ({ model, providers: [...providers].sort() }))
|
|
94
|
+
.sort((a, b) => a.model.localeCompare(b.model));
|
|
95
|
+
|
|
96
|
+
export class UsageStore {
|
|
97
|
+
constructor(db, deps = {}) {
|
|
98
|
+
this.db = db;
|
|
99
|
+
Object.assign(this, deps);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Usage of a request made for a chat that is not one of its turns. `kind`: AUX_KIND. */
|
|
103
|
+
addAuxiliary(chatId, { kind, model, usage }) {
|
|
104
|
+
this.db.prepare('INSERT INTO aux_usage (chat_id, kind, model, usage_json, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
105
|
+
.run(chatId, kind, model ?? null, JSON.stringify(usage ?? {}), Date.now());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Usage of one chat's auxiliary requests, oldest first. */
|
|
109
|
+
auxiliaryFor(chatId) {
|
|
110
|
+
return this.db.prepare('SELECT usage_json FROM aux_usage WHERE chat_id = ? ORDER BY id').all(chatId)
|
|
111
|
+
.map((r) => JSON.parse(r.usage_json));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Rolls up token usage across every assistant round that has it, bucketed
|
|
116
|
+
* by local calendar day ('YYYY-MM-DD', via SQLite's julianday/strftime on
|
|
117
|
+
* created_at) and by model. Buckets are computed in SQL rather than in JS
|
|
118
|
+
* so a year of history doesn't have to be pulled across just to summarize
|
|
119
|
+
* it. Field-name variance across providers (prompt_tokens vs input_tokens,
|
|
120
|
+
* the several cache-token spellings) is normalized here the same way
|
|
121
|
+
* transcript.js's tally() does client-side for a single round.
|
|
122
|
+
*
|
|
123
|
+
* Rows predating model/timestamp tracking have neither column set and land
|
|
124
|
+
* in an 'unknown' day bucket instead of being dropped, so their tokens
|
|
125
|
+
* still show up here the same way their cost already does in
|
|
126
|
+
* usageStatistics() below.
|
|
127
|
+
*/
|
|
128
|
+
rollup(userId, filter = {}) {
|
|
129
|
+
const s = scope(userId, 'c.user_id');
|
|
130
|
+
const rows = this.db.prepare(`
|
|
131
|
+
SELECT CASE WHEN m.created_at IS NOT NULL
|
|
132
|
+
THEN strftime('%Y-%m-%d', m.created_at / 1000, 'unixepoch')
|
|
133
|
+
ELSE 'unknown' END AS day,
|
|
134
|
+
m.model, m.usage_json
|
|
135
|
+
FROM (SELECT chat_id, created_at, model, usage_json FROM messages
|
|
136
|
+
UNION ALL SELECT chat_id, created_at, model, usage_json FROM aux_usage) m
|
|
137
|
+
JOIN chats c ON c.id=m.chat_id
|
|
138
|
+
WHERE m.usage_json IS NOT NULL AND ${s.sql}
|
|
139
|
+
`).all(...s.params);
|
|
140
|
+
|
|
141
|
+
const byDay = new Map();
|
|
142
|
+
for (const row of rows) {
|
|
143
|
+
let u;
|
|
144
|
+
try { u = JSON.parse(row.usage_json); } catch { continue; }
|
|
145
|
+
const inTok = inputTokens(u) ?? 0;
|
|
146
|
+
const outTok = outputTokens(u) ?? 0;
|
|
147
|
+
const cached = cacheReads(u) ?? 0;
|
|
148
|
+
const model = row.model || u.model || 'unknown';
|
|
149
|
+
if (!matches(filter, model, u.provider)) continue;
|
|
150
|
+
|
|
151
|
+
const cost = u.cost != null && Number.isFinite(Number(u.cost)) ? Number(u.cost) : null;
|
|
152
|
+
if (!byDay.has(row.day)) byDay.set(row.day, { day: row.day, in: 0, out: 0, cached: 0, models: new Map() });
|
|
153
|
+
const d = byDay.get(row.day);
|
|
154
|
+
d.in += inTok; d.out += outTok; d.cached += cached;
|
|
155
|
+
|
|
156
|
+
if (!d.models.has(model)) d.models.set(model, { model, in: 0, out: 0, cached: 0 });
|
|
157
|
+
const m = d.models.get(model);
|
|
158
|
+
m.in += inTok; m.out += outTok; m.cached += cached;
|
|
159
|
+
m.attribution = mergeAttribution(m.attribution, u.attribution);
|
|
160
|
+
m.requests = (m.requests || 0) + 1;
|
|
161
|
+
if (cost !== null) {
|
|
162
|
+
d.cost = (d.cost || 0) + cost;
|
|
163
|
+
m.cost = (m.cost || 0) + cost;
|
|
164
|
+
d.pricedRounds = (d.pricedRounds || 0) + 1;
|
|
165
|
+
m.pricedRounds = (m.pricedRounds || 0) + 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return [...byDay.values()]
|
|
170
|
+
.map((d) => ({ ...d, models: [...d.models.values()] }))
|
|
171
|
+
.sort((a, b) => a.day.localeCompare(b.day));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
statistics(userId, filter = {}) {
|
|
175
|
+
const s = scope(userId, 'c.user_id');
|
|
176
|
+
const rows = this.db.prepare(`SELECT m.chat_id,m.seq,m.role,m.content,m.tool_calls_json,m.model,m.usage_json,m.created_at
|
|
177
|
+
FROM messages m JOIN chats c ON c.id=m.chat_id WHERE ${s.sql} ORDER BY m.chat_id,m.seq`).all(...s.params);
|
|
178
|
+
const models = new Map();
|
|
179
|
+
const tools = new Map();
|
|
180
|
+
const summary = { rounds: 0, pricedRounds: 0, reportedCost: 0, completedAnswers: 0,
|
|
181
|
+
pricedAnswers: 0, answerCostTotal: 0, answerTokens: 0, tokenAnswers: 0, toolCalls: 0 };
|
|
182
|
+
// Hit share counts only rounds whose provider reports cache figures: one
|
|
183
|
+
// that reports nothing would otherwise read as a 0% hit and drag it down.
|
|
184
|
+
const cache = { rounds: 0, input: 0, cached: 0, written: 0, saved: 0, savedRounds: 0, savedCost: 0,
|
|
185
|
+
judged: 0, misses: Object.fromEntries(Object.values(MISS).map((k) => [k, 0])) };
|
|
186
|
+
let prevRound = null;
|
|
187
|
+
// Per model @ upstream, for derivedCacheRates().
|
|
188
|
+
const priced = new Map();
|
|
189
|
+
const targets = new Map();
|
|
190
|
+
let chatId = null;
|
|
191
|
+
let answer = null;
|
|
192
|
+
const finishAnswer = () => {
|
|
193
|
+
if (!answer) return;
|
|
194
|
+
if (answer.complete) {
|
|
195
|
+
summary.completedAnswers++;
|
|
196
|
+
summary.answerRoundsTotal = (summary.answerRoundsTotal || 0) + answer.rounds;
|
|
197
|
+
if (answer.rounds && answer.pricedRounds === answer.rounds) {
|
|
198
|
+
summary.pricedAnswers++;
|
|
199
|
+
summary.answerCostTotal += answer.cost;
|
|
200
|
+
}
|
|
201
|
+
if (answer.rounds && answer.usageRounds === answer.rounds) { summary.tokenAnswers++; summary.answerTokens += answer.tokens; }
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
for (const row of rows) {
|
|
205
|
+
if (row.chat_id !== chatId) { finishAnswer(); chatId = row.chat_id; answer = null; prevRound = null; }
|
|
206
|
+
if (row.role === 'user') { finishAnswer(); answer = { rounds:0, usageRounds:0, pricedRounds:0, cost:0, tokens:0, complete:false }; continue; }
|
|
207
|
+
if (row.role !== 'assistant') continue;
|
|
208
|
+
let usage = null;
|
|
209
|
+
try { if (row.usage_json) usage = JSON.parse(row.usage_json); } catch { /* ignore malformed historic usage */ }
|
|
210
|
+
const model = row.model || usage?.model || 'unknown';
|
|
211
|
+
addTarget(targets, model, usage?.provider);
|
|
212
|
+
if (!matches(filter, model, usage?.provider)) {
|
|
213
|
+
// Still the round the next one is judged against: a filtered-out
|
|
214
|
+
// upstream in between is exactly what makes the next one a switch.
|
|
215
|
+
if (usage) prevRound = { input: tally([usage]).in, provider: usage.provider ?? null, at: row.created_at };
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
summary.rounds++;
|
|
219
|
+
if (answer) answer.rounds++;
|
|
220
|
+
if (!models.has(model)) models.set(model, { model, in:0, out:0, cached:0, rounds:0, pricedRounds:0, cost:0 });
|
|
221
|
+
const m = models.get(model);
|
|
222
|
+
m.rounds++;
|
|
223
|
+
if (row.tool_calls_json) {
|
|
224
|
+
let calls = null;
|
|
225
|
+
try { calls = JSON.parse(row.tool_calls_json); } catch { /* ignore malformed historic tool_calls */ }
|
|
226
|
+
for (const call of calls || []) {
|
|
227
|
+
const name = call?.function?.name;
|
|
228
|
+
if (!name) continue;
|
|
229
|
+
summary.toolCalls++;
|
|
230
|
+
tools.set(name, (tools.get(name) || 0) + 1);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (usage) {
|
|
234
|
+
const inTok = inputTokens(usage) ?? 0;
|
|
235
|
+
const outTok = outputTokens(usage) ?? 0;
|
|
236
|
+
const cached = cacheReads(usage) ?? 0;
|
|
237
|
+
const costValue = usage.cost ?? usage.total_cost;
|
|
238
|
+
const cost = costValue !== undefined && costValue !== null && Number.isFinite(Number(costValue)) ? Number(costValue) : null;
|
|
239
|
+
m.in += inTok; m.out += outTok; m.cached += cached;
|
|
240
|
+
const t = tally([usage]);
|
|
241
|
+
if (t.reported) { cache.rounds++; cache.input += t.in; cache.cached += t.cached; cache.written += t.written; }
|
|
242
|
+
if (t.reported && usage.prefix?.previous != null && prevRound) {
|
|
243
|
+
cache.judged++;
|
|
244
|
+
const miss = cacheMiss({ usage, at: row.created_at }, prevRound);
|
|
245
|
+
if (miss) cache.misses[miss]++;
|
|
246
|
+
}
|
|
247
|
+
prevRound = { input: t.in, provider: usage.provider ?? null, at: row.created_at };
|
|
248
|
+
const inputCost = Number(usage.cost_details?.upstream_inference_prompt_cost);
|
|
249
|
+
const key = `${model}\u0000${usage.provider ?? ''}`;
|
|
250
|
+
if (!priced.has(key)) priced.set(key, { model, provider: usage.provider ?? null, rounds: [], cost: 0, discount: 0, discounted: 0 });
|
|
251
|
+
const g = priced.get(key);
|
|
252
|
+
if (t.reported && Number.isFinite(inputCost) && inputCost > 0) g.rounds.push({ fresh: t.in - t.cached, cached: t.cached, inputCost, cost });
|
|
253
|
+
if (usage.cache_discount != null && Number.isFinite(Number(usage.cache_discount))) { g.discounted++; g.discount += Number(usage.cache_discount); g.cost += cost ?? 0; }
|
|
254
|
+
if (cost !== null) { m.cost += cost; m.pricedRounds++; summary.pricedRounds++; summary.reportedCost += cost; }
|
|
255
|
+
if (answer) {
|
|
256
|
+
if (inputTokens(usage) !== null && outputTokens(usage) !== null) answer.usageRounds++;
|
|
257
|
+
answer.tokens += inTok + outTok;
|
|
258
|
+
if (cost !== null) { answer.pricedRounds++; answer.cost += cost; }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!row.tool_calls_json && String(row.content || '').trim()) {
|
|
262
|
+
if (answer) answer.complete = true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
finishAnswer();
|
|
266
|
+
// A provider's own figure wins; otherwise derive it from what it charged.
|
|
267
|
+
const rates = [];
|
|
268
|
+
for (const g of priced.values()) {
|
|
269
|
+
if (g.discounted) { cache.saved += g.discount; cache.savedCost += g.cost; cache.savedRounds += g.discounted; continue; }
|
|
270
|
+
const r = derivedCacheRates(g.rounds);
|
|
271
|
+
if (!r) continue;
|
|
272
|
+
const cached = g.rounds.reduce((n, x) => n + x.cached, 0);
|
|
273
|
+
cache.saved += cached * (r.full - r.cache);
|
|
274
|
+
cache.savedCost += g.rounds.reduce((n, x) => n + (x.cost ?? 0), 0);
|
|
275
|
+
cache.savedRounds += g.rounds.length;
|
|
276
|
+
rates.push({ model: g.model, provider: g.provider, full: r.full, cache: r.cache });
|
|
277
|
+
}
|
|
278
|
+
// Kept out of rounds and answers: these are not turns, but they are spent.
|
|
279
|
+
const aux = { requests: 0, pricedRequests: 0, cost: 0 };
|
|
280
|
+
for (const row of this.db.prepare(`SELECT a.model, a.usage_json FROM aux_usage a
|
|
281
|
+
JOIN chats c ON c.id=a.chat_id WHERE ${s.sql}`).all(...s.params)) {
|
|
282
|
+
let usage = null;
|
|
283
|
+
try { usage = JSON.parse(row.usage_json); } catch { continue; }
|
|
284
|
+
const model = row.model || usage.model || 'unknown';
|
|
285
|
+
addTarget(targets, model, usage.provider);
|
|
286
|
+
if (!matches(filter, model, usage.provider)) continue;
|
|
287
|
+
aux.requests++;
|
|
288
|
+
const costValue = usage.cost ?? usage.total_cost;
|
|
289
|
+
if (costValue != null && Number.isFinite(Number(costValue))) { aux.pricedRequests++; aux.cost += Number(costValue); }
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
targets: listTargets(targets),
|
|
293
|
+
auxiliary: { requests: aux.requests, cost: aux.pricedRequests ? aux.cost : null },
|
|
294
|
+
cache: {
|
|
295
|
+
reportedRounds: cache.rounds,
|
|
296
|
+
input: cache.input,
|
|
297
|
+
cached: cache.cached,
|
|
298
|
+
written: cache.written,
|
|
299
|
+
hitShare: cache.input ? cache.cached / cache.input : null,
|
|
300
|
+
saved: cache.savedRounds ? cache.saved : null,
|
|
301
|
+
// Share of what those rounds would have cost uncached.
|
|
302
|
+
savedShare: cache.savedRounds && cache.saved + cache.savedCost > 0 ? cache.saved / (cache.saved + cache.savedCost) : null,
|
|
303
|
+
rates,
|
|
304
|
+
savedRounds: cache.savedRounds,
|
|
305
|
+
// Rounds with a previous request to compare against; the rest (a
|
|
306
|
+
// chat's first round, the first after a restart, old rows) are unknown.
|
|
307
|
+
judged: cache.judged,
|
|
308
|
+
misses: cache.misses
|
|
309
|
+
},
|
|
310
|
+
rounds: summary.rounds,
|
|
311
|
+
pricedRounds: summary.pricedRounds,
|
|
312
|
+
unpricedRounds: summary.rounds - summary.pricedRounds,
|
|
313
|
+
reportedCost: summary.pricedRounds ? summary.reportedCost : null,
|
|
314
|
+
averageRoundCost: summary.pricedRounds ? summary.reportedCost / summary.pricedRounds : null,
|
|
315
|
+
completedAnswers: summary.completedAnswers,
|
|
316
|
+
pricedAnswers: summary.pricedAnswers,
|
|
317
|
+
averageAnswerCost: summary.pricedAnswers ? summary.answerCostTotal / summary.pricedAnswers : null,
|
|
318
|
+
averageAnswerTokens: summary.tokenAnswers ? summary.answerTokens / summary.tokenAnswers : null,
|
|
319
|
+
averageRoundsPerAnswer: summary.completedAnswers ? summary.answerRoundsTotal / summary.completedAnswers : null,
|
|
320
|
+
toolCalls: summary.toolCalls,
|
|
321
|
+
models: [...models.values()].map((m) => ({ ...m, cost: m.pricedRounds ? m.cost : null }))
|
|
322
|
+
.sort((a,b) => b.in + b.out - a.in - a.out),
|
|
323
|
+
tools: [...tools.entries()].map(([name, calls]) => ({ name, calls }))
|
|
324
|
+
.sort((a, b) => b.calls - a.calls)
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// Users, their preferences, and login sessions.
|
|
4
|
+
// One area of the Store; see index.js.
|
|
5
|
+
|
|
6
|
+
export class UserStore {
|
|
7
|
+
constructor(db, deps = {}) {
|
|
8
|
+
this.db = db;
|
|
9
|
+
Object.assign(this, deps);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
getPrefs(userId) {
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const r of this.db.prepare('SELECT key, value FROM user_prefs WHERE user_id = ?').all(userId)) out[r.key] = r.value;
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
setPref(userId, key, value) {
|
|
19
|
+
if (value == null) this.db.prepare('DELETE FROM user_prefs WHERE user_id = ? AND key = ?').run(userId, key);
|
|
20
|
+
else this.db.prepare(`INSERT INTO user_prefs (user_id, key, value) VALUES (?, ?, ?)
|
|
21
|
+
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value`).run(userId, key, String(value));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get(id) {
|
|
25
|
+
return this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
list() {
|
|
29
|
+
return this.db.prepare(`
|
|
30
|
+
SELECT u.id, u.email, u.name, u.role, u.status, u.external_id, u.created_at, u.last_login_at,
|
|
31
|
+
(SELECT COUNT(*) FROM chats c WHERE c.user_id = u.id) AS chat_count
|
|
32
|
+
FROM users u ORDER BY u.created_at ASC
|
|
33
|
+
`).all();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Admin-side changes only: role in (admin,user), status in (pending,approved,disabled). */
|
|
37
|
+
update(id, { role, status } = {}) {
|
|
38
|
+
const sets = []; const vals = [];
|
|
39
|
+
if (role !== undefined) { sets.push('role = ?'); vals.push(role); }
|
|
40
|
+
if (status !== undefined) {
|
|
41
|
+
sets.push('status = ?'); vals.push(status);
|
|
42
|
+
if (status === 'approved') { sets.push('approved_at = COALESCE(approved_at, ?)'); vals.push(Date.now()); }
|
|
43
|
+
}
|
|
44
|
+
if (sets.length) this.db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
|
45
|
+
return this.get(id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
byExternalId(externalId) {
|
|
49
|
+
return this.db.prepare('SELECT * FROM users WHERE external_id = ?').get(externalId) || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether another account already holds this email (emails are unique). */
|
|
53
|
+
emailTaken(email, exceptId) {
|
|
54
|
+
return Boolean(this.db.prepare('SELECT 1 FROM users WHERE email = ? AND id != ?').get(email, exceptId));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Creates the user a gateway vouched for, unless one with that external id
|
|
59
|
+
* exists. The unique index makes this atomic: concurrent first requests race
|
|
60
|
+
* to insert, exactly one wins. Returns { user, created }.
|
|
61
|
+
*/
|
|
62
|
+
provision({ externalId, role, status, now = Date.now() }) {
|
|
63
|
+
const created = this.db.prepare(`
|
|
64
|
+
INSERT INTO users (id, external_id, role, status, created_at, approved_at, last_login_at)
|
|
65
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
66
|
+
ON CONFLICT(external_id) DO NOTHING
|
|
67
|
+
`).run(randomUUID(), externalId, role, status, now, status === 'approved' ? now : null, now).changes > 0;
|
|
68
|
+
return { user: this.byExternalId(externalId), created };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Gateway-side sync of identity metadata; only these columns can be set. */
|
|
72
|
+
sync(id, fields) {
|
|
73
|
+
const allowed = ['email', 'name', 'role', 'status', 'last_login_at'];
|
|
74
|
+
const keys = Object.keys(fields).filter((k) => allowed.includes(k));
|
|
75
|
+
if (keys.length) this.db.prepare(`UPDATE users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => fields[k]), id);
|
|
76
|
+
return this.get(id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The single approved admin account behind a 'single' password. */
|
|
80
|
+
ensureOwner(id) {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
this.db.prepare(`INSERT INTO users (id, role, status, created_at, approved_at)
|
|
83
|
+
VALUES (?, 'admin', 'approved', ?, ?) ON CONFLICT(id) DO NOTHING`).run(id, now, now);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
addSession(tokenHash, userId, expiresAt) {
|
|
87
|
+
this.db.prepare('INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)')
|
|
88
|
+
.run(tokenHash, userId, Date.now(), expiresAt);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getSession(tokenHash) {
|
|
92
|
+
return this.db.prepare('SELECT * FROM sessions WHERE token_hash = ?').get(tokenHash) || null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
deleteSession(tokenHash) {
|
|
96
|
+
this.db.prepare('DELETE FROM sessions WHERE token_hash = ?').run(tokenHash);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
deleteUserSessions(userId) {
|
|
100
|
+
this.db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* Row <-> message shapes: what the model is sent (toWire) and what the UI shows (toView). */
|
|
2
|
+
|
|
3
|
+
function base(row) {
|
|
4
|
+
const msg = { role: row.role };
|
|
5
|
+
if (row.tool_call_id) msg.tool_call_id = row.tool_call_id;
|
|
6
|
+
if (row.tool_calls_json) msg.tool_calls = JSON.parse(row.tool_calls_json);
|
|
7
|
+
if (row.reasoning_details_json) msg.reasoning_details = JSON.parse(row.reasoning_details_json);
|
|
8
|
+
if (row.images_json) msg.images = JSON.parse(row.images_json);
|
|
9
|
+
return msg;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the model sees. A demoted tool message sends its stub; everything else
|
|
14
|
+
* sends its content verbatim. Reasoning text rides along; `buildMessages` in
|
|
15
|
+
* harness/provider.js decides whether the provider gets it back.
|
|
16
|
+
*/
|
|
17
|
+
export function toWire(row) {
|
|
18
|
+
const msg = base(row);
|
|
19
|
+
if (row.reasoning) msg.reasoning = row.reasoning;
|
|
20
|
+
msg.content = row.role === 'tool' ? (row.stub_text ?? row.content) : (row.content ?? null);
|
|
21
|
+
// An epoch took these images off the wire. The note is a pure function of
|
|
22
|
+
// the row, so every rebuild sends the same bytes.
|
|
23
|
+
if (row.images_dropped && msg.images?.length) {
|
|
24
|
+
const n = msg.images.length;
|
|
25
|
+
msg.content = `${msg.content ?? ''}\n\n[${n} image${n === 1 ? '' : 's'} attached here ${n === 1 ? 'was' : 'were'} removed from context to save space. Ask the user to re-attach if you need to look again.]`;
|
|
26
|
+
delete msg.images;
|
|
27
|
+
}
|
|
28
|
+
// A model that was sent images needs the OpenAI multimodal shape -- an array
|
|
29
|
+
// of parts rather than a plain string. Built only when there are images, so
|
|
30
|
+
// every text-only turn keeps sending the plain string it always has, which
|
|
31
|
+
// is what most of the caching machinery in harness/provider.js assumes.
|
|
32
|
+
if (msg.images?.length) {
|
|
33
|
+
const parts = [];
|
|
34
|
+
if (msg.content) parts.push({ type: 'text', text: msg.content });
|
|
35
|
+
for (const img of msg.images) {
|
|
36
|
+
parts.push({ type: 'image_url', image_url: { url: `data:${img.mime};base64,${img.data}` } });
|
|
37
|
+
}
|
|
38
|
+
msg.content = parts;
|
|
39
|
+
}
|
|
40
|
+
delete msg.images;
|
|
41
|
+
return msg;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the transcript shows: always the full text, stub or not. */
|
|
45
|
+
export function toView(row) {
|
|
46
|
+
const msg = base(row);
|
|
47
|
+
// The client needs a handle on each message to be able to rewind to one.
|
|
48
|
+
msg.seq = row.seq;
|
|
49
|
+
msg.content = row.content ?? null;
|
|
50
|
+
if (row.reasoning) msg.reasoning = row.reasoning;
|
|
51
|
+
if (row.usage_json) msg.usage = JSON.parse(row.usage_json);
|
|
52
|
+
if (row.stub_text) msg.compacted = true;
|
|
53
|
+
if (row.origin_json) msg.origin = JSON.parse(row.origin_json);
|
|
54
|
+
return msg;
|
|
55
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ask_user -- lets the model put one question to the user mid-run and carry
|
|
3
|
+
* on with the answer in the same run.
|
|
4
|
+
*
|
|
5
|
+
* The waiting itself belongs to the server, which owns the run: it persists
|
|
6
|
+
* the question, draws it, runs the timeout and takes the answer. This file
|
|
7
|
+
* only validates the call and turns the outcome into the tool result. A run
|
|
8
|
+
* with nobody to ask (an automation) never waits; it gets the fallback at once.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const ASK_USER = 'ask_user';
|
|
12
|
+
|
|
13
|
+
const MAX_QUESTION = 2000;
|
|
14
|
+
const MAX_CHOICES = 10;
|
|
15
|
+
const MAX_CHOICE = 200;
|
|
16
|
+
|
|
17
|
+
export function askToolDef() {
|
|
18
|
+
return {
|
|
19
|
+
type: 'function',
|
|
20
|
+
function: {
|
|
21
|
+
name: ASK_USER,
|
|
22
|
+
description: [
|
|
23
|
+
'Ask the user one question and wait for the answer, then continue this same task',
|
|
24
|
+
'with it. The run pauses until they answer; if they do not answer in time, you are',
|
|
25
|
+
'told so and continue on your own assumptions.',
|
|
26
|
+
'',
|
|
27
|
+
'Use it only for information that materially changes the result and that you cannot',
|
|
28
|
+
'infer or safely assume: choosing between materially different files, accounts or',
|
|
29
|
+
'environments; a missing value; a decision with real downstream effects; a manual',
|
|
30
|
+
'step the user must do first.',
|
|
31
|
+
'',
|
|
32
|
+
'Do not use it to ask "should I continue?", for preferences with a sensible default,',
|
|
33
|
+
'to confirm ordinary reversible actions, or because the request is only mildly',
|
|
34
|
+
'ambiguous -- make a reasonable assumption and state it instead. One question per',
|
|
35
|
+
'call; offer `choices` when there are a few valid answers.'
|
|
36
|
+
].join('\n'),
|
|
37
|
+
parameters: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
question: { type: 'string', description: 'The question, written for the user to read.' },
|
|
41
|
+
choices: {
|
|
42
|
+
type: 'array',
|
|
43
|
+
items: { type: 'string' },
|
|
44
|
+
description: `Optional answers to pick from (up to ${MAX_CHOICES}).`
|
|
45
|
+
},
|
|
46
|
+
allow_free_text: {
|
|
47
|
+
type: 'boolean',
|
|
48
|
+
description: 'Whether the user may type their own answer instead of picking a choice. Default true.'
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
required: ['question']
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The result text for each way a question can end. JSON, so it parses and reads. */
|
|
58
|
+
export function askResult(outcome) {
|
|
59
|
+
if (outcome.answered) return JSON.stringify({ answered: true, answer: outcome.answer });
|
|
60
|
+
const message = {
|
|
61
|
+
timeout: 'The user did not respond within the configured timeout.',
|
|
62
|
+
skipped: 'The user chose to let you continue without answering.',
|
|
63
|
+
unattended: 'No interactive user is available for this run.'
|
|
64
|
+
}[outcome.reason] || 'The question could not be put to the user.';
|
|
65
|
+
return JSON.stringify({
|
|
66
|
+
answered: false,
|
|
67
|
+
reason: outcome.reason,
|
|
68
|
+
message: `${message} Continue the task using your own reasonable assumptions, and state any`
|
|
69
|
+
+ ' important assumptions in your final answer when they materially affect the result.'
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The tool handler. `ctx.askUser({ question, choices, allowFreeText })`
|
|
75
|
+
* resolves to `{ answered: true, answer }` or `{ answered: false, reason }`;
|
|
76
|
+
* without one (an unattended run) the fallback comes back at once.
|
|
77
|
+
*/
|
|
78
|
+
export async function callAskUser(args, ctx) {
|
|
79
|
+
const question = typeof args.question === 'string' ? args.question.trim() : '';
|
|
80
|
+
if (!question) return 'Error: "question" is required and must be a non-empty string.';
|
|
81
|
+
if (question.length > MAX_QUESTION) return `Error: "question" is over ${MAX_QUESTION} characters; ask something shorter.`;
|
|
82
|
+
if (args.choices !== undefined && !(Array.isArray(args.choices) && args.choices.every((c) => typeof c === 'string'))) {
|
|
83
|
+
return 'Error: "choices" must be a list of strings.';
|
|
84
|
+
}
|
|
85
|
+
const choices = [...new Set((args.choices || []).map((c) => c.trim()).filter(Boolean))];
|
|
86
|
+
if (choices.length > MAX_CHOICES) return `Error: at most ${MAX_CHOICES} choices.`;
|
|
87
|
+
if (choices.some((c) => c.length > MAX_CHOICE)) return `Error: each choice must be under ${MAX_CHOICE} characters.`;
|
|
88
|
+
// Nothing to pick and nothing to type would be a question nobody can answer.
|
|
89
|
+
const allowFreeText = args.allow_free_text !== false || choices.length === 0;
|
|
90
|
+
|
|
91
|
+
if (ctx.unattended || !ctx.askUser) return askResult({ answered: false, reason: 'unattended' });
|
|
92
|
+
return askResult(await ctx.askUser({ question, choices, allowFreeText }));
|
|
93
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { CORPUS } from '../store/index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* search_chats -- lets the model look up the user's earlier conversations:
|
|
5
|
+
* what they asked and the answer they finally got. Ranked by the same
|
|
6
|
+
* retrieval engine as read_document (retrieval.js, 'chats' corpus), so it is
|
|
7
|
+
* lexical, dense or hybrid with retrieval.mode. Only the asking chat's owner's
|
|
8
|
+
* chats are searched, and never the asking chat itself -- that one is already
|
|
9
|
+
* in context (and expand_context reads what compaction folded away).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const SEARCH_CHATS = 'search_chats';
|
|
13
|
+
|
|
14
|
+
const MAX_RESULTS = 10;
|
|
15
|
+
|
|
16
|
+
export function chatSearchToolDef() {
|
|
17
|
+
return {
|
|
18
|
+
type: 'function',
|
|
19
|
+
function: {
|
|
20
|
+
name: SEARCH_CHATS,
|
|
21
|
+
description: [
|
|
22
|
+
"Search the user's earlier conversations (not this one) for what they asked and the answers they got.",
|
|
23
|
+
'Use it when the user refers to something discussed before ("like last time", "what did we decide about..."),',
|
|
24
|
+
'or when an earlier answer would save redoing work. Returns the best-matching question/answer pairs,',
|
|
25
|
+
'each with the chat it came from. Phrase the query as the topic, not as a question to the user.'
|
|
26
|
+
].join(' '),
|
|
27
|
+
parameters: {
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: {
|
|
30
|
+
query: { type: 'string', description: 'What to look for, in plain words.' },
|
|
31
|
+
limit: { type: 'integer', description: `How many turns to return, 1-${MAX_RESULTS} (default 5).` }
|
|
32
|
+
},
|
|
33
|
+
required: ['query']
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const day = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : 'undated');
|
|
40
|
+
|
|
41
|
+
function clip(text, max) {
|
|
42
|
+
const t = String(text || '').trim();
|
|
43
|
+
return t.length > max ? `${t.slice(0, max)}… [${(t.length - max).toLocaleString('en-US')} more chars]` : t;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Runs one search. Returns a string, like every other tool result. */
|
|
47
|
+
export async function callSearchChats(args, { store, retrieval, chatId, budget = 8000 }) {
|
|
48
|
+
const query = String(args?.query || '').trim();
|
|
49
|
+
if (!query) return 'Error: query is required.';
|
|
50
|
+
const limit = Math.min(MAX_RESULTS, Math.max(1, Number.parseInt(args?.limit, 10) || 5));
|
|
51
|
+
const owner = chatId ? store.chats.byId(chatId)?.user_id ?? null : null;
|
|
52
|
+
const hits = await retrieval.search(CORPUS.CHATS, { userId: owner, excludeChatId: chatId ?? null }, query, limit);
|
|
53
|
+
if (!hits.length) return `[search_chats · no earlier conversation matches "${query}"]\nTry other words for the same topic.`;
|
|
54
|
+
|
|
55
|
+
const out = [`[search_chats · ${hits.length} turn(s) matching "${query}"]`];
|
|
56
|
+
const share = Math.max(400, Math.floor((budget - out[0].length) / hits.length) - 120);
|
|
57
|
+
for (const h of hits) {
|
|
58
|
+
const q = clip(h.question, Math.min(600, Math.floor(share / 3)));
|
|
59
|
+
const a = h.answer ? clip(h.answer, share - q.length) : '(no answer recorded)';
|
|
60
|
+
out.push(`--- "${h.chatTitle}" · ${day(h.createdAt)} · chat ${h.chatId} ---\nQ: ${q}\nA: ${a}`);
|
|
61
|
+
}
|
|
62
|
+
return out.join('\n\n');
|
|
63
|
+
}
|