@glaicer/supercode-token-usage-panel 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/usage-model.js +850 -0
- package/dist/usage-panel.js +136 -0
- package/package.json +9 -7
- package/src/usage-model.ts +0 -1031
- package/src/usage-panel.tsx +0 -82
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage Model — every number, formula and string of the Token Usage section.
|
|
3
|
+
*
|
|
4
|
+
* Reads OpenCode's authoritative session aggregate and family message history,
|
|
5
|
+
* folds completed steps/speed/TTFT, and estimates the current visible stream from
|
|
6
|
+
* deltas. Exposes only ready-to-render rows plus a state flag.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const USAGE_SECTION_TITLE = "Token Usage";
|
|
10
|
+
export const USAGE_SECTION_TITLE_WITH_SUBAGENTS = "Token Usage (including subagents)";
|
|
11
|
+
export const USAGE_LABELS = ["Input", "Output", "Reasoning", "Cache read", "Cache write", "Cache rate", "Steps", "Session cost", "Generation speed", "Time to first token"];
|
|
12
|
+
export const USAGE_STATUS_TEXT = {
|
|
13
|
+
loading: "Loading…",
|
|
14
|
+
ready: "",
|
|
15
|
+
empty: "No usage yet.",
|
|
16
|
+
unavailable: "Usage unavailable."
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Placeholder for missing values: never render NaN/Infinity as a number. */
|
|
20
|
+
export const USAGE_DASH = "–";
|
|
21
|
+
function emptyMetrics() {
|
|
22
|
+
return {
|
|
23
|
+
generated: 0,
|
|
24
|
+
decodeMs: 0,
|
|
25
|
+
ttftMs: 0,
|
|
26
|
+
ttftCount: 0,
|
|
27
|
+
steps: 0,
|
|
28
|
+
calibrations: new Map()
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function allZero(totals) {
|
|
32
|
+
return totals.input === 0 && totals.output === 0 && totals.reasoning === 0 && totals.cacheRead === 0 && totals.cacheWrite === 0 && totals.cost === 0;
|
|
33
|
+
}
|
|
34
|
+
function safe(value) {
|
|
35
|
+
return Number.isFinite(value) ? Math.max(0, value ?? 0) : 0;
|
|
36
|
+
}
|
|
37
|
+
function totalsFromSession(session) {
|
|
38
|
+
if (!session?.tokens) return undefined;
|
|
39
|
+
return {
|
|
40
|
+
input: safe(session.tokens.input),
|
|
41
|
+
output: safe(session.tokens.output),
|
|
42
|
+
reasoning: safe(session.tokens.reasoning),
|
|
43
|
+
cacheRead: safe(session.tokens.cache.read),
|
|
44
|
+
cacheWrite: safe(session.tokens.cache.write),
|
|
45
|
+
cost: safe(session.cost)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function groupDigits(value) {
|
|
49
|
+
return String(Math.trunc(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
50
|
+
}
|
|
51
|
+
export function formatTokens(value) {
|
|
52
|
+
return groupDigits(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Share of all prompt tokens that the provider served from cache. */
|
|
56
|
+
function formatCacheRate(totals) {
|
|
57
|
+
const denominator = totals.input + totals.cacheRead + totals.cacheWrite;
|
|
58
|
+
if (denominator <= 0) return USAGE_DASH;
|
|
59
|
+
return `${(totals.cacheRead / denominator * 100).toFixed(1)}%`;
|
|
60
|
+
}
|
|
61
|
+
function formatCost(value) {
|
|
62
|
+
return `$${value.toFixed(2)}`;
|
|
63
|
+
}
|
|
64
|
+
const ROW_BUILDERS = [{
|
|
65
|
+
label: USAGE_LABELS[0],
|
|
66
|
+
value: t => formatTokens(t.input)
|
|
67
|
+
}, {
|
|
68
|
+
label: USAGE_LABELS[1],
|
|
69
|
+
value: t => formatTokens(t.output)
|
|
70
|
+
}, {
|
|
71
|
+
label: USAGE_LABELS[2],
|
|
72
|
+
value: t => formatTokens(t.reasoning)
|
|
73
|
+
}, {
|
|
74
|
+
label: USAGE_LABELS[3],
|
|
75
|
+
value: t => formatTokens(t.cacheRead)
|
|
76
|
+
}, {
|
|
77
|
+
label: USAGE_LABELS[4],
|
|
78
|
+
value: t => formatTokens(t.cacheWrite)
|
|
79
|
+
}, {
|
|
80
|
+
label: USAGE_LABELS[5],
|
|
81
|
+
value: formatCacheRate
|
|
82
|
+
}];
|
|
83
|
+
function formatSteps(metrics) {
|
|
84
|
+
if (!metrics || !positive(metrics.steps)) return USAGE_DASH;
|
|
85
|
+
return formatTokens(metrics.steps);
|
|
86
|
+
}
|
|
87
|
+
function positive(value) {
|
|
88
|
+
return Number.isFinite(value) && value > 0;
|
|
89
|
+
}
|
|
90
|
+
function formatGenerationSpeed(metrics) {
|
|
91
|
+
if (!metrics || !positive(metrics.generated) || !positive(metrics.decodeMs)) return USAGE_DASH;
|
|
92
|
+
const value = metrics.generated / (metrics.decodeMs / 1_000);
|
|
93
|
+
const rounded = Math.round(value);
|
|
94
|
+
return positive(rounded) ? `${rounded} tps` : USAGE_DASH;
|
|
95
|
+
}
|
|
96
|
+
function formatTtft(metrics) {
|
|
97
|
+
if (!metrics || !positive(metrics.ttftMs) || !positive(metrics.ttftCount)) return USAGE_DASH;
|
|
98
|
+
const value = metrics.ttftMs / metrics.ttftCount / 1_000;
|
|
99
|
+
return positive(value) ? `${value.toFixed(1)}s` : USAGE_DASH;
|
|
100
|
+
}
|
|
101
|
+
function buildUsageRows(totals, metrics) {
|
|
102
|
+
const head = ROW_BUILDERS.map(({
|
|
103
|
+
label,
|
|
104
|
+
value
|
|
105
|
+
}) => ({
|
|
106
|
+
label,
|
|
107
|
+
value: value(totals)
|
|
108
|
+
}));
|
|
109
|
+
return [...head, {
|
|
110
|
+
label: USAGE_LABELS[6],
|
|
111
|
+
value: formatSteps(metrics)
|
|
112
|
+
}, {
|
|
113
|
+
label: USAGE_LABELS[7],
|
|
114
|
+
value: formatCost(totals.cost)
|
|
115
|
+
}];
|
|
116
|
+
}
|
|
117
|
+
function formatLiveSpeed(live) {
|
|
118
|
+
if (!live.hasTicked) return `~${USAGE_DASH}`;
|
|
119
|
+
const elapsed = (live.now - live.startedAt) / 1_000;
|
|
120
|
+
const value = live.displayedChars / live.charsPerToken / elapsed;
|
|
121
|
+
const rounded = Math.round(value);
|
|
122
|
+
return positive(rounded) ? `~${rounded} tps` : USAGE_DASH;
|
|
123
|
+
}
|
|
124
|
+
function formatLiveTtft(live) {
|
|
125
|
+
const value = (live.now - live.createdAt) / 1_000;
|
|
126
|
+
return positive(value) ? `>${value.toFixed(1)}s` : USAGE_DASH;
|
|
127
|
+
}
|
|
128
|
+
function buildDiagnosticRows(metrics, liveSpeed, liveTtft) {
|
|
129
|
+
return [{
|
|
130
|
+
label: liveSpeed ? "Live speed" : USAGE_LABELS[8],
|
|
131
|
+
value: liveSpeed ? formatLiveSpeed(liveSpeed) : formatGenerationSpeed(metrics)
|
|
132
|
+
}, {
|
|
133
|
+
label: USAGE_LABELS[9],
|
|
134
|
+
value: liveTtft ? formatLiveTtft(liveTtft) : formatTtft(metrics)
|
|
135
|
+
}];
|
|
136
|
+
}
|
|
137
|
+
function codePoints(value) {
|
|
138
|
+
return Array.from(value).length;
|
|
139
|
+
}
|
|
140
|
+
function modelKey(message) {
|
|
141
|
+
return JSON.stringify([message.providerID, message.modelID]);
|
|
142
|
+
}
|
|
143
|
+
function addCalibration(calibrations, key, chars, tokens) {
|
|
144
|
+
if (!positive(chars) || !positive(tokens)) return;
|
|
145
|
+
const calibration = calibrations.get(key) ?? {
|
|
146
|
+
chars: 0,
|
|
147
|
+
tokens: 0
|
|
148
|
+
};
|
|
149
|
+
calibration.chars += chars;
|
|
150
|
+
calibration.tokens += tokens;
|
|
151
|
+
calibrations.set(key, calibration);
|
|
152
|
+
}
|
|
153
|
+
function addTotals(target, source) {
|
|
154
|
+
target.input += source.input;
|
|
155
|
+
target.output += source.output;
|
|
156
|
+
target.reasoning += source.reasoning;
|
|
157
|
+
target.cacheRead += source.cacheRead;
|
|
158
|
+
target.cacheWrite += source.cacheWrite;
|
|
159
|
+
target.cost += source.cost;
|
|
160
|
+
}
|
|
161
|
+
function completedMetrics(messages) {
|
|
162
|
+
const result = emptyMetrics();
|
|
163
|
+
for (const {
|
|
164
|
+
info,
|
|
165
|
+
parts
|
|
166
|
+
} of messages) {
|
|
167
|
+
if (info.role !== "assistant") continue;
|
|
168
|
+
for (const part of parts) {
|
|
169
|
+
if (part.type === "step-finish") result.steps++;
|
|
170
|
+
}
|
|
171
|
+
const message = info;
|
|
172
|
+
if (!positive(message.time.completed ?? 0)) continue;
|
|
173
|
+
let stepChars = 0;
|
|
174
|
+
let stepHasTool = false;
|
|
175
|
+
for (const part of parts) {
|
|
176
|
+
if (part.type === "text" || part.type === "reasoning") {
|
|
177
|
+
if (positive(part.time?.end ?? 0)) stepChars += codePoints(part.text);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (part.type === "tool") {
|
|
181
|
+
stepHasTool = true;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (part.type !== "step-finish") continue;
|
|
185
|
+
const tokens = part.tokens.output + part.tokens.reasoning;
|
|
186
|
+
if (!stepHasTool) addCalibration(result.calibrations, modelKey(message), stepChars, tokens);
|
|
187
|
+
stepChars = 0;
|
|
188
|
+
stepHasTool = false;
|
|
189
|
+
}
|
|
190
|
+
const visibleStarts = parts.flatMap(part => {
|
|
191
|
+
if (part.type !== "text" && part.type !== "reasoning") return [];
|
|
192
|
+
if (!part.time || !positive(part.time.start) || !positive(part.time.end ?? 0)) return [];
|
|
193
|
+
return [part.time.start];
|
|
194
|
+
});
|
|
195
|
+
if (visibleStarts.length === 0) continue;
|
|
196
|
+
const firstVisibleAt = Math.min(...visibleStarts);
|
|
197
|
+
const ttft = firstVisibleAt - message.time.created;
|
|
198
|
+
if (!positive(ttft)) continue;
|
|
199
|
+
result.ttftMs += ttft;
|
|
200
|
+
result.ttftCount++;
|
|
201
|
+
const generated = parts.reduce((sum, part) => {
|
|
202
|
+
if (part.type !== "step-finish") return sum;
|
|
203
|
+
return sum + part.tokens.output + part.tokens.reasoning;
|
|
204
|
+
}, 0);
|
|
205
|
+
const tools = parts.reduce((sum, part) => {
|
|
206
|
+
if (part.type !== "tool" || part.state.status !== "completed") return sum;
|
|
207
|
+
const duration = part.state.time.end - part.state.time.start;
|
|
208
|
+
return positive(duration) ? sum + duration : sum;
|
|
209
|
+
}, 0);
|
|
210
|
+
const decode = message.time.completed - message.time.created - ttft - tools;
|
|
211
|
+
if (!positive(generated) || !positive(decode)) continue;
|
|
212
|
+
result.generated += generated;
|
|
213
|
+
result.decodeMs += decode;
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
function addMetrics(target, source) {
|
|
218
|
+
target.generated += source.generated;
|
|
219
|
+
target.decodeMs += source.decodeMs;
|
|
220
|
+
target.ttftMs += source.ttftMs;
|
|
221
|
+
target.ttftCount += source.ttftCount;
|
|
222
|
+
target.steps += source.steps;
|
|
223
|
+
for (const [key, sourceCalibration] of source.calibrations) {
|
|
224
|
+
addCalibration(target.calibrations, key, sourceCalibration.chars, sourceCalibration.tokens);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function aggregateContributions(contributions, incompleteBranches = new Set()) {
|
|
228
|
+
let totals;
|
|
229
|
+
const metrics = emptyMetrics();
|
|
230
|
+
for (const contribution of contributions.values()) {
|
|
231
|
+
if (contribution.totals) {
|
|
232
|
+
if (totals) addTotals(totals, contribution.totals);else totals = {
|
|
233
|
+
...contribution.totals
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
addMetrics(metrics, contribution.metrics);
|
|
237
|
+
}
|
|
238
|
+
const members = new Set(contributions.keys());
|
|
239
|
+
return {
|
|
240
|
+
totals,
|
|
241
|
+
metrics,
|
|
242
|
+
hasDescendants: members.size > 1,
|
|
243
|
+
members,
|
|
244
|
+
contributions,
|
|
245
|
+
incompleteBranches
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
async function fetchContribution(client, session) {
|
|
249
|
+
let metrics = emptyMetrics();
|
|
250
|
+
try {
|
|
251
|
+
const messages = (await client.session.messages({
|
|
252
|
+
sessionID: session.id
|
|
253
|
+
}, {
|
|
254
|
+
throwOnError: true
|
|
255
|
+
})).data;
|
|
256
|
+
metrics = completedMetrics(messages);
|
|
257
|
+
} catch {
|
|
258
|
+
// Totals stay usable when diagnostic history cannot be read.
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
totals: totalsFromSession(session),
|
|
262
|
+
metrics,
|
|
263
|
+
...(session.parentID ? {
|
|
264
|
+
parentID: session.parentID
|
|
265
|
+
} : {})
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
async function fetchBranch(client, root) {
|
|
269
|
+
const contributions = new Map();
|
|
270
|
+
const incompleteBranches = new Set();
|
|
271
|
+
const queue = [root];
|
|
272
|
+
const visited = new Set();
|
|
273
|
+
while (queue.length > 0) {
|
|
274
|
+
const session = queue.shift();
|
|
275
|
+
if (visited.has(session.id)) continue;
|
|
276
|
+
visited.add(session.id);
|
|
277
|
+
contributions.set(session.id, await fetchContribution(client, session));
|
|
278
|
+
try {
|
|
279
|
+
const children = (await client.session.children({
|
|
280
|
+
sessionID: session.id
|
|
281
|
+
}, {
|
|
282
|
+
throwOnError: true
|
|
283
|
+
})).data;
|
|
284
|
+
for (const child of children) {
|
|
285
|
+
if (child && !visited.has(child.id)) queue.push(child);
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
incompleteBranches.add(session.id);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return aggregateContributions(contributions, incompleteBranches);
|
|
292
|
+
}
|
|
293
|
+
function walkParents(sessionID, contributions, matches) {
|
|
294
|
+
const visited = new Set();
|
|
295
|
+
let current = sessionID;
|
|
296
|
+
while (current && !visited.has(current)) {
|
|
297
|
+
if (matches(current)) return true;
|
|
298
|
+
visited.add(current);
|
|
299
|
+
current = contributions.get(current)?.parentID;
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
function belongsToIncompleteBranch(sessionID, incompleteBranches, previous) {
|
|
304
|
+
return walkParents(sessionID, previous, id => incompleteBranches.has(id));
|
|
305
|
+
}
|
|
306
|
+
function belongsToBranch(sessionID, rootID, contributions) {
|
|
307
|
+
return walkParents(sessionID, contributions, id => id === rootID);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Totals cover the current session plus all of its descendants (subagents).
|
|
312
|
+
* The walk counts each session once and keeps resolved aggregates when a
|
|
313
|
+
* branch fails to resolve.
|
|
314
|
+
*/
|
|
315
|
+
async function fetchFamily(client, sessionID) {
|
|
316
|
+
let root = (await client.session.get({
|
|
317
|
+
sessionID
|
|
318
|
+
}, {
|
|
319
|
+
throwOnError: true
|
|
320
|
+
})).data;
|
|
321
|
+
if (!root) throw new Error("session unavailable");
|
|
322
|
+
const ancestors = new Set([root.id]);
|
|
323
|
+
while (root.parentID && !ancestors.has(root.parentID)) {
|
|
324
|
+
ancestors.add(root.parentID);
|
|
325
|
+
const parent = (await client.session.get({
|
|
326
|
+
sessionID: root.parentID
|
|
327
|
+
}, {
|
|
328
|
+
throwOnError: true
|
|
329
|
+
})).data;
|
|
330
|
+
if (!parent) throw new Error("parent session unavailable");
|
|
331
|
+
root = parent;
|
|
332
|
+
}
|
|
333
|
+
return fetchBranch(client, root);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Reactive primitives owned by the TUI entrypoint.
|
|
338
|
+
*
|
|
339
|
+
* The entry (usage-panel.tsx) imports these from "solid-js", where the host
|
|
340
|
+
* rewrites them to its own runtime. usage-model.ts must not value-import
|
|
341
|
+
* "solid-js" itself: as an npm-installed file under node_modules, the host's
|
|
342
|
+
* prescan can miss this sibling, leaving it bound to an isolated solid-js
|
|
343
|
+
* copy. Signals from two runtimes never notify each other's renderers, which
|
|
344
|
+
* freezes the panel at its first paint ("No usage yet.").
|
|
345
|
+
*/
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Usage Model over OpenCode's session aggregate. Totals cover the current
|
|
349
|
+
* session plus all of its descendants (subagents); request sequencing keeps
|
|
350
|
+
* slower responses from overwriting newer ones.
|
|
351
|
+
*/
|
|
352
|
+
export function createUsageModel(api, sessionId, solid) {
|
|
353
|
+
const [remote, setRemote] = solid.createSignal();
|
|
354
|
+
const [liveSpeed, setLiveSpeed] = solid.createSignal();
|
|
355
|
+
const [liveTtft, setLiveTtft] = solid.createSignal();
|
|
356
|
+
let request = 0;
|
|
357
|
+
let nextAsyncRequest = 0;
|
|
358
|
+
const memberRequests = new Map();
|
|
359
|
+
const branchRequests = new Map();
|
|
360
|
+
let appliedRevision = 0;
|
|
361
|
+
const contributionRevisions = new Map();
|
|
362
|
+
const pendingBranches = new Map();
|
|
363
|
+
const failedMembers = new Set();
|
|
364
|
+
/** Deleted session ids; a full refresh must never resurrect their totals. */
|
|
365
|
+
const tombstones = new Set();
|
|
366
|
+
let timer;
|
|
367
|
+
let ttftTurns = new Set();
|
|
368
|
+
let members = new Set();
|
|
369
|
+
const isAttachable = session => !!session.parentID && members.has(session.parentID) && !members.has(session.id);
|
|
370
|
+
const captureFreshness = () => ({
|
|
371
|
+
request,
|
|
372
|
+
sessionID: sessionId(),
|
|
373
|
+
startRevision: appliedRevision
|
|
374
|
+
});
|
|
375
|
+
const isFresh = freshness => freshness.request === request && freshness.sessionID === sessionId();
|
|
376
|
+
const isCurrent = (previous, freshness) => !!previous && isFresh(freshness);
|
|
377
|
+
const hasNewerIncremental = (id, freshness) => (contributionRevisions.get(id) ?? 0) > freshness.startRevision;
|
|
378
|
+
const markRevised = ids => {
|
|
379
|
+
const revision = ++appliedRevision;
|
|
380
|
+
for (const id of ids) contributionRevisions.set(id, revision);
|
|
381
|
+
};
|
|
382
|
+
const publishFamily = (sessionID, contributions, incompleteBranches) => {
|
|
383
|
+
const aggregate = aggregateContributions(contributions, incompleteBranches);
|
|
384
|
+
members = aggregate.members;
|
|
385
|
+
return {
|
|
386
|
+
sessionID,
|
|
387
|
+
totals: aggregate.totals,
|
|
388
|
+
metrics: aggregate.metrics,
|
|
389
|
+
hasDescendants: aggregate.hasDescendants,
|
|
390
|
+
contributions,
|
|
391
|
+
incompleteBranches: aggregate.incompleteBranches,
|
|
392
|
+
failed: !aggregate.totals
|
|
393
|
+
};
|
|
394
|
+
};
|
|
395
|
+
const stopTimerIfIdle = () => {
|
|
396
|
+
if (liveSpeed() || liveTtft() || !timer) return;
|
|
397
|
+
clearInterval(timer);
|
|
398
|
+
timer = undefined;
|
|
399
|
+
};
|
|
400
|
+
const ensureTimer = () => {
|
|
401
|
+
if (timer) return;
|
|
402
|
+
timer = setInterval(() => {
|
|
403
|
+
const now = Date.now();
|
|
404
|
+
setLiveSpeed(current => current && {
|
|
405
|
+
...current,
|
|
406
|
+
now,
|
|
407
|
+
displayedChars: current.chars,
|
|
408
|
+
hasTicked: now - current.startedAt >= 1_000
|
|
409
|
+
});
|
|
410
|
+
setLiveTtft(current => current && {
|
|
411
|
+
...current,
|
|
412
|
+
now
|
|
413
|
+
});
|
|
414
|
+
stopTimerIfIdle();
|
|
415
|
+
}, 1_000);
|
|
416
|
+
};
|
|
417
|
+
const clearLiveSpeed = () => {
|
|
418
|
+
setLiveSpeed(undefined);
|
|
419
|
+
stopTimerIfIdle();
|
|
420
|
+
};
|
|
421
|
+
const clearLiveTtft = () => {
|
|
422
|
+
setLiveTtft(undefined);
|
|
423
|
+
stopTimerIfIdle();
|
|
424
|
+
};
|
|
425
|
+
const clearProvisional = () => {
|
|
426
|
+
setLiveSpeed(undefined);
|
|
427
|
+
setLiveTtft(undefined);
|
|
428
|
+
if (timer) clearInterval(timer);
|
|
429
|
+
timer = undefined;
|
|
430
|
+
};
|
|
431
|
+
const completedTurns = sessionID => {
|
|
432
|
+
const turns = new Set();
|
|
433
|
+
try {
|
|
434
|
+
for (const message of api.state.session.messages(sessionID)) {
|
|
435
|
+
if (message.role !== "assistant") continue;
|
|
436
|
+
const hasFinishedStep = api.state.part(message.id).some(part => part.type === "step-finish");
|
|
437
|
+
if (message.time.completed !== undefined || hasFinishedStep) turns.add(message.parentID);
|
|
438
|
+
}
|
|
439
|
+
} catch {}
|
|
440
|
+
return turns;
|
|
441
|
+
};
|
|
442
|
+
const refresh = sessionID => {
|
|
443
|
+
const freshness = {
|
|
444
|
+
request: ++request,
|
|
445
|
+
sessionID,
|
|
446
|
+
startRevision: appliedRevision
|
|
447
|
+
};
|
|
448
|
+
void fetchFamily(api.client, sessionID).then(family => {
|
|
449
|
+
if (!isFresh(freshness)) return;
|
|
450
|
+
setRemote(previous => {
|
|
451
|
+
const contributions = new Map(family.contributions);
|
|
452
|
+
if (previous?.sessionID === sessionID && previous.contributions) {
|
|
453
|
+
for (const [id, revision] of contributionRevisions) {
|
|
454
|
+
if (!hasNewerIncremental(id, freshness)) continue;
|
|
455
|
+
const contribution = previous.contributions.get(id);
|
|
456
|
+
if (contribution) contributions.set(id, contribution);else contributions.delete(id);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (family.incompleteBranches.size > 0 && previous?.sessionID === sessionID && previous.contributions) {
|
|
460
|
+
for (const [id, contribution] of previous.contributions) {
|
|
461
|
+
if (!contributions.has(id) && !tombstones.has(id) && belongsToIncompleteBranch(id, family.incompleteBranches, previous.contributions)) {
|
|
462
|
+
contributions.set(id, contribution);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const next = publishFamily(sessionID, contributions, family.incompleteBranches);
|
|
467
|
+
for (const id of failedMembers) {
|
|
468
|
+
if (!members.has(id)) failedMembers.delete(id);
|
|
469
|
+
}
|
|
470
|
+
return next;
|
|
471
|
+
});
|
|
472
|
+
const deferred = [...pendingBranches.values()];
|
|
473
|
+
pendingBranches.clear();
|
|
474
|
+
let progressed = true;
|
|
475
|
+
while (deferred.length > 0 && progressed) {
|
|
476
|
+
progressed = false;
|
|
477
|
+
for (let i = deferred.length - 1; i >= 0; i--) {
|
|
478
|
+
const session = deferred[i];
|
|
479
|
+
if (tombstones.has(session.id) || members.has(session.id)) {
|
|
480
|
+
deferred.splice(i, 1);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (!isAttachable(session)) continue;
|
|
484
|
+
deferred.splice(i, 1);
|
|
485
|
+
refreshBranch(session, true);
|
|
486
|
+
progressed = true;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}).catch(() => {
|
|
490
|
+
if (!isFresh(freshness)) return;
|
|
491
|
+
setRemote(previous => previous?.sessionID === sessionID && previous.totals ? {
|
|
492
|
+
...previous,
|
|
493
|
+
failed: true
|
|
494
|
+
} : {
|
|
495
|
+
sessionID,
|
|
496
|
+
hasDescendants: false,
|
|
497
|
+
failed: true
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
};
|
|
501
|
+
solid.createEffect(() => {
|
|
502
|
+
const sessionID = sessionId();
|
|
503
|
+
clearProvisional();
|
|
504
|
+
members = new Set([sessionID]);
|
|
505
|
+
memberRequests.clear();
|
|
506
|
+
branchRequests.clear();
|
|
507
|
+
contributionRevisions.clear();
|
|
508
|
+
appliedRevision = 0;
|
|
509
|
+
pendingBranches.clear();
|
|
510
|
+
failedMembers.clear();
|
|
511
|
+
tombstones.clear();
|
|
512
|
+
setRemote(undefined);
|
|
513
|
+
solid.untrack(() => {
|
|
514
|
+
ttftTurns = completedTurns(sessionID);
|
|
515
|
+
refresh(sessionID);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
const mergeContributions = (additions, freshness) => {
|
|
519
|
+
if (!isFresh(freshness)) return;
|
|
520
|
+
setRemote(previous => {
|
|
521
|
+
if (!isCurrent(previous, freshness) || !previous.contributions) return previous;
|
|
522
|
+
const contributions = new Map(previous.contributions);
|
|
523
|
+
for (const [id, contribution] of additions) contributions.set(id, contribution);
|
|
524
|
+
markRevised(additions.keys());
|
|
525
|
+
for (const id of additions.keys()) failedMembers.delete(id);
|
|
526
|
+
return publishFamily(freshness.sessionID, contributions, previous.incompleteBranches);
|
|
527
|
+
});
|
|
528
|
+
};
|
|
529
|
+
const refreshMember = memberID => {
|
|
530
|
+
if (!members.has(memberID)) return;
|
|
531
|
+
const freshness = captureFreshness();
|
|
532
|
+
const memberRequest = ++nextAsyncRequest;
|
|
533
|
+
memberRequests.set(memberID, memberRequest);
|
|
534
|
+
void api.client.session.get({
|
|
535
|
+
sessionID: memberID
|
|
536
|
+
}, {
|
|
537
|
+
throwOnError: true
|
|
538
|
+
}).then(({
|
|
539
|
+
data
|
|
540
|
+
}) => {
|
|
541
|
+
if (!data) throw new Error("session unavailable");
|
|
542
|
+
return fetchContribution(api.client, data);
|
|
543
|
+
}).then(contribution => {
|
|
544
|
+
if (memberRequests.get(memberID) !== memberRequest) return;
|
|
545
|
+
mergeContributions(new Map([[memberID, contribution]]), freshness);
|
|
546
|
+
}).catch(() => {
|
|
547
|
+
if (memberRequests.get(memberID) !== memberRequest) return;
|
|
548
|
+
if (!isFresh(freshness)) return;
|
|
549
|
+
failedMembers.add(memberID);
|
|
550
|
+
});
|
|
551
|
+
};
|
|
552
|
+
const refreshBranch = (session, isNew) => {
|
|
553
|
+
if (isNew) members = new Set([...members, session.id]);
|
|
554
|
+
const freshness = captureFreshness();
|
|
555
|
+
const branchRequest = ++nextAsyncRequest;
|
|
556
|
+
branchRequests.set(session.id, branchRequest);
|
|
557
|
+
void fetchBranch(api.client, session).then(branch => {
|
|
558
|
+
setRemote(previous => {
|
|
559
|
+
if (!isCurrent(previous, freshness) || branchRequests.get(session.id) !== branchRequest || !previous.contributions) {
|
|
560
|
+
return previous;
|
|
561
|
+
}
|
|
562
|
+
const contributions = new Map(previous.contributions);
|
|
563
|
+
const changedIds = new Set();
|
|
564
|
+
for (const id of previous.contributions.keys()) {
|
|
565
|
+
if (belongsToBranch(id, session.id, previous.contributions) && !branch.contributions.has(id) && !belongsToIncompleteBranch(id, branch.incompleteBranches, previous.contributions) && !hasNewerIncremental(id, freshness)) {
|
|
566
|
+
contributions.delete(id);
|
|
567
|
+
changedIds.add(id);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
for (const [id, contribution] of branch.contributions) {
|
|
571
|
+
if (!hasNewerIncremental(id, freshness)) {
|
|
572
|
+
contributions.set(id, contribution);
|
|
573
|
+
changedIds.add(id);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const incompleteBranches = new Set(previous.incompleteBranches);
|
|
577
|
+
for (const id of incompleteBranches) {
|
|
578
|
+
if (belongsToBranch(id, session.id, previous.contributions)) {
|
|
579
|
+
incompleteBranches.delete(id);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
for (const id of branch.incompleteBranches) incompleteBranches.add(id);
|
|
583
|
+
markRevised(changedIds);
|
|
584
|
+
for (const id of changedIds) failedMembers.delete(id);
|
|
585
|
+
return publishFamily(freshness.sessionID, contributions, incompleteBranches);
|
|
586
|
+
});
|
|
587
|
+
}).catch(() => {
|
|
588
|
+
if (isNew) members = new Set([...members].filter(id => id !== session.id));
|
|
589
|
+
});
|
|
590
|
+
};
|
|
591
|
+
const retryIncompleteBranches = () => {
|
|
592
|
+
for (const sessionID of remote()?.incompleteBranches ?? []) {
|
|
593
|
+
void api.client.session.get({
|
|
594
|
+
sessionID
|
|
595
|
+
}, {
|
|
596
|
+
throwOnError: true
|
|
597
|
+
}).then(({
|
|
598
|
+
data
|
|
599
|
+
}) => {
|
|
600
|
+
if (data) refreshBranch(data, false);
|
|
601
|
+
}).catch(() => {});
|
|
602
|
+
}
|
|
603
|
+
for (const memberID of failedMembers) {
|
|
604
|
+
if (!members.has(memberID)) {
|
|
605
|
+
failedMembers.delete(memberID);
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
refreshMember(memberID);
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
const addBranch = session => {
|
|
612
|
+
if (members.has(session.id)) return;
|
|
613
|
+
if (!session.parentID) return;
|
|
614
|
+
if (!remote()?.contributions) {
|
|
615
|
+
members = new Set([...members, session.id]);
|
|
616
|
+
pendingBranches.set(session.id, session);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (!isAttachable(session)) return;
|
|
620
|
+
refreshBranch(session, true);
|
|
621
|
+
};
|
|
622
|
+
const offSessionCreated = api.event.on("session.created", event => {
|
|
623
|
+
addBranch(event.properties.info);
|
|
624
|
+
});
|
|
625
|
+
const offSessionDeleted = api.event.on("session.deleted", event => {
|
|
626
|
+
const deletedID = event.properties.sessionID;
|
|
627
|
+
const tracked = deletedID === sessionId() || members.has(deletedID) || pendingBranches.has(deletedID);
|
|
628
|
+
const pruned = new Set([deletedID]);
|
|
629
|
+
let expanded = true;
|
|
630
|
+
while (expanded) {
|
|
631
|
+
expanded = false;
|
|
632
|
+
for (const [id, session] of pendingBranches) {
|
|
633
|
+
if (!pruned.has(id) && session.parentID && pruned.has(session.parentID)) {
|
|
634
|
+
pruned.add(id);
|
|
635
|
+
expanded = true;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
for (const id of pruned) {
|
|
640
|
+
pendingBranches.delete(id);
|
|
641
|
+
tombstones.add(id);
|
|
642
|
+
failedMembers.delete(id);
|
|
643
|
+
if (branchRequests.has(id)) {
|
|
644
|
+
branchRequests.set(id, ++nextAsyncRequest);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const previous = remote()?.contributions;
|
|
648
|
+
if (previous) {
|
|
649
|
+
for (const id of previous.keys()) {
|
|
650
|
+
if (id === deletedID || belongsToBranch(id, deletedID, previous)) {
|
|
651
|
+
tombstones.add(id);
|
|
652
|
+
failedMembers.delete(id);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (tracked) {
|
|
657
|
+
clearProvisional();
|
|
658
|
+
refresh(sessionId());
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
const offPartUpdated = api.event.on("message.part.updated", event => {
|
|
662
|
+
const part = event.properties.part;
|
|
663
|
+
if (part.sessionID === sessionId() && (part.type === "text" || part.type === "reasoning") && part.time?.end !== undefined && liveSpeed()?.partID === part.id) {
|
|
664
|
+
clearLiveSpeed();
|
|
665
|
+
}
|
|
666
|
+
if (event.properties.part.type === "step-finish") {
|
|
667
|
+
if (event.properties.part.sessionID === sessionId() && liveTtft()?.messageID === event.properties.part.messageID) {
|
|
668
|
+
clearLiveTtft();
|
|
669
|
+
}
|
|
670
|
+
if (event.properties.part.sessionID === sessionId()) {
|
|
671
|
+
const message = api.state.session.messages(sessionId()).find(candidate => candidate.id === event.properties.part.messageID);
|
|
672
|
+
if (message?.role === "assistant") ttftTurns.add(message.parentID);
|
|
673
|
+
}
|
|
674
|
+
refreshMember(event.properties.part.sessionID);
|
|
675
|
+
retryIncompleteBranches();
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
const offPartRemoved = api.event.on("message.part.removed", event => {
|
|
679
|
+
if (event.properties.sessionID === sessionId()) clearProvisional();
|
|
680
|
+
if (members.has(event.properties.sessionID)) refresh(sessionId());
|
|
681
|
+
});
|
|
682
|
+
const offMessageRemoved = api.event.on("message.removed", event => {
|
|
683
|
+
if (event.properties.sessionID === sessionId()) clearProvisional();
|
|
684
|
+
if (members.has(event.properties.sessionID)) refresh(sessionId());
|
|
685
|
+
});
|
|
686
|
+
const offSessionUpdated = api.event.on("session.updated", event => {
|
|
687
|
+
if (pendingBranches.has(event.properties.info.id)) {
|
|
688
|
+
pendingBranches.set(event.properties.info.id, event.properties.info);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (members.has(event.properties.info.id)) {
|
|
692
|
+
refreshMember(event.properties.info.id);
|
|
693
|
+
retryIncompleteBranches();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
addBranch(event.properties.info);
|
|
697
|
+
});
|
|
698
|
+
const offMessageUpdated = api.event.on("message.updated", event => {
|
|
699
|
+
const message = event.properties.info;
|
|
700
|
+
if (!members.has(event.properties.sessionID) || message.role !== "assistant") return;
|
|
701
|
+
if (event.properties.sessionID !== sessionId()) {
|
|
702
|
+
if (message.time.completed !== undefined) {
|
|
703
|
+
refreshMember(event.properties.sessionID);
|
|
704
|
+
retryIncompleteBranches();
|
|
705
|
+
}
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (message.time.completed !== undefined) {
|
|
709
|
+
ttftTurns.add(message.parentID);
|
|
710
|
+
if (liveTtft()?.messageID === message.id) clearLiveTtft();
|
|
711
|
+
refreshMember(event.properties.sessionID);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (ttftTurns.has(message.parentID)) return;
|
|
715
|
+
ttftTurns.add(message.parentID);
|
|
716
|
+
const now = Date.now();
|
|
717
|
+
setLiveTtft({
|
|
718
|
+
messageID: message.id,
|
|
719
|
+
createdAt: message.time.created,
|
|
720
|
+
now
|
|
721
|
+
});
|
|
722
|
+
ensureTimer();
|
|
723
|
+
});
|
|
724
|
+
const offPartDelta = api.event.on("message.part.delta", event => {
|
|
725
|
+
const {
|
|
726
|
+
sessionID,
|
|
727
|
+
messageID,
|
|
728
|
+
partID,
|
|
729
|
+
field,
|
|
730
|
+
delta
|
|
731
|
+
} = event.properties;
|
|
732
|
+
if (sessionID !== sessionId() || field !== "text") return;
|
|
733
|
+
let part;
|
|
734
|
+
let message;
|
|
735
|
+
try {
|
|
736
|
+
part = api.state.part(messageID).find(candidate => candidate.id === partID);
|
|
737
|
+
message = api.state.session.messages(sessionID).find(candidate => candidate.id === messageID);
|
|
738
|
+
} catch {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (part?.type !== "text" && part?.type !== "reasoning" || part.time?.end !== undefined || message?.role !== "assistant") {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const now = Date.now();
|
|
745
|
+
const chars = codePoints(delta);
|
|
746
|
+
setLiveSpeed(current => {
|
|
747
|
+
if (current?.partID === partID && current.messageID === messageID) {
|
|
748
|
+
return {
|
|
749
|
+
...current,
|
|
750
|
+
chars: current.chars + chars
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
const calibration = remote()?.metrics?.calibrations.get(modelKey(message));
|
|
754
|
+
const charsPerToken = (400 + (calibration?.chars ?? 0)) / (100 + (calibration?.tokens ?? 0));
|
|
755
|
+
return {
|
|
756
|
+
messageID,
|
|
757
|
+
partID,
|
|
758
|
+
startedAt: now,
|
|
759
|
+
now,
|
|
760
|
+
chars,
|
|
761
|
+
displayedChars: 0,
|
|
762
|
+
charsPerToken,
|
|
763
|
+
hasTicked: false
|
|
764
|
+
};
|
|
765
|
+
});
|
|
766
|
+
if (liveTtft()?.messageID === messageID) clearLiveTtft();
|
|
767
|
+
ensureTimer();
|
|
768
|
+
});
|
|
769
|
+
const offServerConnected = api.event.on("server.connected", () => {
|
|
770
|
+
clearProvisional();
|
|
771
|
+
refresh(sessionId());
|
|
772
|
+
});
|
|
773
|
+
const offSessionError = api.event.on("session.error", event => {
|
|
774
|
+
if (!event.properties.sessionID || event.properties.sessionID === sessionId()) clearProvisional();
|
|
775
|
+
});
|
|
776
|
+
const offSessionIdle = api.event.on("session.idle", event => {
|
|
777
|
+
if (event.properties.sessionID === sessionId()) clearProvisional();
|
|
778
|
+
});
|
|
779
|
+
solid.onCleanup(() => {
|
|
780
|
+
request++;
|
|
781
|
+
clearProvisional();
|
|
782
|
+
offSessionCreated();
|
|
783
|
+
offSessionDeleted();
|
|
784
|
+
offPartUpdated();
|
|
785
|
+
offPartRemoved();
|
|
786
|
+
offMessageRemoved();
|
|
787
|
+
offSessionUpdated();
|
|
788
|
+
offMessageUpdated();
|
|
789
|
+
offPartDelta();
|
|
790
|
+
offServerConnected();
|
|
791
|
+
offSessionError();
|
|
792
|
+
offSessionIdle();
|
|
793
|
+
});
|
|
794
|
+
const snapshot = solid.createMemo(() => {
|
|
795
|
+
try {
|
|
796
|
+
const sessionID = sessionId();
|
|
797
|
+
const loaded = remote();
|
|
798
|
+
if (loaded?.sessionID === sessionID && loaded.failed && !loaded.totals) {
|
|
799
|
+
const speed = liveSpeed();
|
|
800
|
+
const ttft = liveTtft();
|
|
801
|
+
const diagnostics = speed || ttft ? buildDiagnosticRows(undefined, speed, ttft) : [];
|
|
802
|
+
return {
|
|
803
|
+
status: "unavailable",
|
|
804
|
+
rows: diagnostics,
|
|
805
|
+
hasDescendants: false
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
const totals = loaded?.sessionID === sessionID && loaded.totals ? loaded.totals : totalsFromSession(api.state.session.get(sessionID));
|
|
809
|
+
const hasDescendants = loaded?.sessionID === sessionID ? loaded.hasDescendants : false;
|
|
810
|
+
if (!totals) {
|
|
811
|
+
const speed = liveSpeed();
|
|
812
|
+
const ttft = liveTtft();
|
|
813
|
+
const diagnostics = speed || ttft ? buildDiagnosticRows(undefined, speed, ttft) : [];
|
|
814
|
+
return {
|
|
815
|
+
status: loaded?.sessionID === sessionID && loaded.failed ? "unavailable" : "loading",
|
|
816
|
+
rows: diagnostics,
|
|
817
|
+
hasDescendants: false
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
const metrics = loaded?.sessionID === sessionID ? loaded.metrics : undefined;
|
|
821
|
+
const speed = liveSpeed();
|
|
822
|
+
const ttft = liveTtft();
|
|
823
|
+
if (allZero(totals)) {
|
|
824
|
+
const diagnostics = speed || ttft ? buildDiagnosticRows(undefined, speed, ttft) : [];
|
|
825
|
+
return {
|
|
826
|
+
status: "empty",
|
|
827
|
+
rows: diagnostics,
|
|
828
|
+
hasDescendants: false
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
const diagnostics = buildDiagnosticRows(metrics, speed, ttft);
|
|
832
|
+
return {
|
|
833
|
+
status: "ready",
|
|
834
|
+
rows: [...buildUsageRows(totals, metrics), ...diagnostics],
|
|
835
|
+
hasDescendants
|
|
836
|
+
};
|
|
837
|
+
} catch {
|
|
838
|
+
return {
|
|
839
|
+
status: "unavailable",
|
|
840
|
+
rows: [],
|
|
841
|
+
hasDescendants: false
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
return {
|
|
846
|
+
status: () => snapshot().status,
|
|
847
|
+
rows: () => snapshot().rows,
|
|
848
|
+
includesSubagents: () => snapshot().hasDescendants
|
|
849
|
+
};
|
|
850
|
+
}
|