@talgent/workbench-ui 0.0.0-stage → 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 +15 -2
- package/assets/brand-icons/LICENSE.md +30 -0
- package/assets/brand-icons/README.md +5 -0
- package/assets/brand-icons/figma.svg +1 -0
- package/assets/brand-icons/github.svg +1 -0
- package/assets/brand-icons/linear.svg +1 -0
- package/assets/brand-icons/notion.svg +1 -0
- package/assets/brand-icons/sentry.svg +1 -0
- package/assets/brand-icons/slack.svg +1 -0
- package/assets/fonts/Inter-OFL.txt +93 -0
- package/assets/fonts/inter-latin.woff2 +0 -0
- package/components/agent/agent-icon.css +14 -0
- package/components/agent/agent-icon.tsx +14 -0
- package/components/analytics/chart-geometry.ts +95 -0
- package/components/analytics/dashboard-charts.tsx +136 -0
- package/components/analytics/model-usage-chart.tsx +84 -0
- package/components/analytics/token-breakdown.css +13 -0
- package/components/analytics/token-breakdown.tsx +23 -0
- package/components/analytics/token-report.tsx +57 -0
- package/components/analytics/work-usage/turn-timeline.tsx +98 -0
- package/components/analytics/work-usage/turns.ts +170 -0
- package/components/analytics/work-usage/work-status.ts +36 -0
- package/components/analytics/work-usage/work-usage-reader.tsx +122 -0
- package/components/analytics/work-usage/work-usage.css +48 -0
- package/components/auth/auth-gate.tsx +44 -0
- package/components/chat/message-input.tsx +105 -0
- package/components/composer/permission-mode-list.tsx +15 -0
- package/components/composer/prompt-bar-controls.tsx +77 -0
- package/components/composer/prompt-bar.tsx +45 -0
- package/components/composer/skill-completion.css +27 -0
- package/components/composer/skill-completion.tsx +49 -0
- package/components/composer/use-slash-completion.tsx +69 -0
- package/components/composer/work-start.css +133 -0
- package/components/feedback/toast-region.css +2 -0
- package/components/feedback/toast-region.tsx +15 -0
- package/components/files/file-facts.tsx +16 -0
- package/components/files/file-section.tsx +181 -0
- package/components/markdown.tsx +144 -0
- package/components/marketplace/connector-pages.tsx +166 -0
- package/components/marketplace/marketplace-icons.tsx +66 -0
- package/components/marketplace/marketplace-shared.tsx +43 -0
- package/components/marketplace/marketplace-shell.tsx +32 -0
- package/components/marketplace/marketplace.css +97 -0
- package/components/marketplace/parameter-fields.tsx +46 -0
- package/components/marketplace/plugin-list.tsx +84 -0
- package/components/marketplace/plugin-page.tsx +133 -0
- package/components/marketplace/provider-pages.tsx +120 -0
- package/components/preview/file-format.ts +75 -0
- package/components/preview/file-preview.tsx +153 -0
- package/components/preview/preview-verdict.ts +44 -0
- package/components/preview/preview.css +19 -0
- package/components/preview/text-preview.tsx +174 -0
- package/components/profile/provider-callback.tsx +43 -0
- package/components/profile/work-agent-appearance.tsx +109 -0
- package/components/profile/work-agent-soul.tsx +76 -0
- package/components/rail/rail-section.tsx +20 -0
- package/components/rail/rail.css +19 -0
- package/components/rail/use-expand-on-change.ts +18 -0
- package/components/session/startup-progress.css +77 -0
- package/components/session/startup-progress.tsx +103 -0
- package/components/session/work-permission-control.tsx +45 -0
- package/components/session/work-thinking-control.tsx +52 -0
- package/components/shell/account-menu.tsx +134 -0
- package/components/shell/app-shell.tsx +148 -0
- package/components/shell/core-reading-views.tsx +26 -0
- package/components/shell/core-reading.tsx +17 -0
- package/components/shell/document-title.tsx +11 -0
- package/components/shell/entry-shell.ts +39 -0
- package/components/shell/file-reading.tsx +22 -0
- package/components/shell/page-layout.tsx +7 -0
- package/components/shell/reading-context.ts +41 -0
- package/components/shell/reading-panel.css +67 -0
- package/components/shell/reading-panel.tsx +74 -0
- package/components/shell/sidebar-parts.tsx +38 -0
- package/components/work/context-rail.css +61 -0
- package/components/work/conversation-unavailable.css +6 -0
- package/components/work/conversation-unavailable.tsx +11 -0
- package/components/work/subagent-item.tsx +42 -0
- package/components/work/subagents-section.css +31 -0
- package/components/work/subagents-section.tsx +40 -0
- package/components/work/token-usage.tsx +28 -0
- package/components/work/tool-message-detail.tsx +39 -0
- package/components/work/tool-output-preview.tsx +52 -0
- package/components/work/tool-preview-reading.tsx +27 -0
- package/components/work/tool-reading-content.tsx +31 -0
- package/components/work/work-context-rail.tsx +23 -0
- package/components/work/work-conversation.tsx +207 -0
- package/components/work/work-history.tsx +115 -0
- package/components/work/work-interaction.css +38 -0
- package/components/work/work-interaction.tsx +105 -0
- package/components/work/work-message-flow.css +86 -0
- package/components/work/work-message-flow.tsx +352 -0
- package/components/work/work-progress-section.tsx +20 -0
- package/components/work/work-prompt-bar.tsx +82 -0
- package/components/work/work-question-record.tsx +72 -0
- package/components/work/work-snapshot-state.tsx +24 -0
- package/components/work/work-status-control.tsx +54 -0
- package/components/work-start/start-work-launch-state.ts +33 -0
- package/components/work-start/work-start-catalog.tsx +116 -0
- package/components/work-start/work-start-form.tsx +173 -0
- package/components/work-start/work-start-tray.css +40 -0
- package/components/work-start/work-start-tray.tsx +128 -0
- package/hooks/use-debounced.ts +11 -0
- package/hooks/use-work-conversation.ts +125 -0
- package/hooks/use-work-lifecycle-controls.ts +40 -0
- package/hooks/use-work-stream.ts +91 -0
- package/lib/account-service.ts +77 -0
- package/lib/agent-appearance-service.ts +59 -0
- package/lib/agent-icon.ts +26 -0
- package/lib/agent-soul-service.ts +48 -0
- package/lib/agent-work-service.ts +23 -0
- package/lib/client.ts +20 -0
- package/lib/computer-release.ts +30 -0
- package/lib/computer-service.ts +241 -0
- package/lib/dashboard-report.ts +38 -0
- package/lib/dashboard.ts +21 -0
- package/lib/marketplace-catalog.ts +138 -0
- package/lib/marketplace-connections.ts +98 -0
- package/lib/marketplace-plugins.ts +53 -0
- package/lib/marketplace-routes.ts +12 -0
- package/lib/model-vendors.ts +13 -0
- package/lib/name-taken-error.ts +7 -0
- package/lib/notifications.ts +37 -0
- package/lib/permission-modes.ts +25 -0
- package/lib/personal-usage-service.ts +19 -0
- package/lib/personal-work-service.ts +159 -0
- package/lib/plugin-parameters.ts +64 -0
- package/lib/preview-verdict.ts +26 -0
- package/lib/relative-time.ts +11 -0
- package/lib/session.ts +108 -0
- package/lib/share-link.ts +4 -0
- package/lib/stale-build-reload.ts +41 -0
- package/lib/start-work-request.ts +34 -0
- package/lib/startup-progress.ts +9 -0
- package/lib/subagents.ts +12 -0
- package/lib/thinking-levels.ts +2 -0
- package/lib/token-analytics.ts +168 -0
- package/lib/token-usage.ts +64 -0
- package/lib/trace-view-model.ts +292 -0
- package/lib/transport.ts +29 -0
- package/lib/use-now.ts +11 -0
- package/lib/use-work-trace.ts +175 -0
- package/lib/user-display.ts +49 -0
- package/lib/work-conversation.ts +118 -0
- package/lib/work-file-service.ts +118 -0
- package/lib/work-model.ts +38 -0
- package/lib/work-notices.ts +23 -0
- package/lib/work-progress.ts +41 -0
- package/lib/work-projection.ts +122 -0
- package/lib/work-questions.ts +18 -0
- package/lib/work-start-draft.ts +61 -0
- package/lib/work-start-loadout.ts +100 -0
- package/lib/work-start-source.ts +16 -0
- package/lib/work-state-presentation.ts +80 -0
- package/lib/work-stream-source.ts +32 -0
- package/package.json +87 -3
- package/pages/computers/computers-page.tsx +56 -0
- package/pages/computers/connect-computer-page.tsx +92 -0
- package/pages/computers/install-commands.tsx +42 -0
- package/pages/computers/remove-computer-dialog.tsx +33 -0
- package/pages/computers/rename-computer-dialog.tsx +37 -0
- package/pages/computers/room-rules-dialog.tsx +55 -0
- package/pages/login-page.css +33 -0
- package/pages/login-page.tsx +88 -0
- package/pages/login-threads.tsx +51 -0
- package/pages/marketplace-page.tsx +59 -0
- package/pages/profile-page.tsx +68 -0
- package/pages/workbench/library-page.tsx +88 -0
- package/pages/workbench/new-work-page.tsx +24 -0
- package/pages/workbench/person.tsx +13 -0
- package/pages/workbench/personal-work-context.tsx +93 -0
- package/pages/workbench/personal-work-page.tsx +100 -0
- package/pages/workbench/personal-works-page.tsx +36 -0
- package/pages/workbench/share-dialog.tsx +57 -0
- package/pages/workbench/title-dialog.tsx +35 -0
- package/pages/workbench/usage-page.tsx +52 -0
- package/public/assets/brand/talgent-lettering.png +0 -0
- package/public/assets/brand/talgent-logo-page-bg.png +0 -0
- package/public/assets/brand/talgent-logo-white-bg.png +0 -0
- package/public/assets/brand/talgent-mark-page-bg.png +0 -0
- package/public/assets/brand/talgent-mark-white-bg.png +0 -0
- package/public/assets/brand/talgent-mark.png +0 -0
- package/public/assets/product-icons/LICENSE +21 -0
- package/public/assets/product-icons/README.md +13 -0
- package/public/assets/product-icons/anthropic.svg +1 -0
- package/public/assets/product-icons/deepseek.svg +1 -0
- package/public/assets/product-icons/glm.svg +1 -0
- package/public/assets/product-icons/kimi.svg +1 -0
- package/public/assets/product-icons/linear.svg +1 -0
- package/public/assets/product-icons/minimax.svg +1 -0
- package/public/assets/product-icons/notion.svg +1 -0
- package/public/assets/product-icons/openai.svg +1 -0
- package/public/assets/product-icons/qwen.svg +1 -0
- package/styles/base.css +95 -0
- package/styles/fonts.css +14 -0
- package/styles/markdown.css +79 -0
- package/styles/uber-theme.css +96 -0
- package/styles/work-composer.css +222 -0
- package/styles/work-stream.css +343 -0
- package/styles/workbench.css +144 -0
- package/styles/workspace.css +260 -0
- package/test-support/backend.ts +67 -0
- package/test-support/plugin-summary.ts +8 -0
- package/test-support/setup.ts +59 -0
- package/vite-preset.ts +85 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|
2
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { dashboardReportPeriod, previousReportPeriod, type DashboardReportPeriod } from "./dashboard-report";
|
|
5
|
+
import { type DashboardWindow } from "./dashboard";
|
|
6
|
+
import {
|
|
7
|
+
buildTraceMetricLeaderboards,
|
|
8
|
+
buildTraceMetricsDiagnostics,
|
|
9
|
+
type TraceMetricBucketLike,
|
|
10
|
+
type TraceDiagnostics,
|
|
11
|
+
type TraceMetricLeaderboards,
|
|
12
|
+
type TraceMetricsLike,
|
|
13
|
+
type TraceTokenTrendPoint,
|
|
14
|
+
} from "./trace-view-model";
|
|
15
|
+
import { addTokenMeasurement, addTokenUsage, emptyTokenUsage, tokenMetricKinds, tokenTotal, tokenUsageFromMeasurements, type TokenUsage } from "./token-usage";
|
|
16
|
+
|
|
17
|
+
export type TokenAnalytics = {
|
|
18
|
+
window: DashboardWindow;
|
|
19
|
+
period: DashboardReportPeriod;
|
|
20
|
+
previous: TokenAnalytics["tokenValues"] | null;
|
|
21
|
+
comparisonUnavailable: string;
|
|
22
|
+
tokenValues: TokenUsage & {total: number};
|
|
23
|
+
metrics: TraceMetricsLike;
|
|
24
|
+
summary: TraceDiagnostics["summary"];
|
|
25
|
+
tokenTrend: TraceTokenTrendPoint[];
|
|
26
|
+
modelUsage: ModelUsageTrend;
|
|
27
|
+
leaderboards: TraceMetricLeaderboards;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Each window's usage per model: the window's five largest models by Total
|
|
31
|
+
* Tokens individually, the rest combined as Other. */
|
|
32
|
+
export type ModelUsageTrend = {
|
|
33
|
+
models: string[];
|
|
34
|
+
buckets: Array<{label: string; usage: Record<string, TokenUsage>}>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const otherModels = "Other";
|
|
38
|
+
const shownModelCount = 5;
|
|
39
|
+
|
|
40
|
+
const refreshIntervalMs = 10000;
|
|
41
|
+
|
|
42
|
+
/** Spend's analytics over whatever `read` measures, such as a Project's or a
|
|
43
|
+
* person's usage, with the previous period for comparison. `scope` names
|
|
44
|
+
* what is measured. */
|
|
45
|
+
export function useTokenAnalytics(scope: readonly [string, string], read: (bounds: DashboardReportPeriod) => Promise<TraceMetricsLike>, window: DashboardWindow = "24h", period?: DashboardReportPeriod, enabled = true) {
|
|
46
|
+
return useQuery({
|
|
47
|
+
queryKey: [...scope, window, period?.end.getTime()],
|
|
48
|
+
queryFn: async (): Promise<TokenAnalytics> => {
|
|
49
|
+
const metricWindow = period ?? dashboardReportPeriod(window, new Date());
|
|
50
|
+
const [current, previous] = await Promise.allSettled([
|
|
51
|
+
read(metricWindow),
|
|
52
|
+
window === "30d" ? Promise.resolve(null) : read(previousReportPeriod(metricWindow)),
|
|
53
|
+
]);
|
|
54
|
+
for (const result of [current, previous]) {
|
|
55
|
+
if (result.status === "rejected" && [Code.PermissionDenied, Code.Unauthenticated].includes(ConnectError.from(result.reason).code)) throw result.reason;
|
|
56
|
+
}
|
|
57
|
+
if (current.status === "rejected") throw current.reason;
|
|
58
|
+
return {
|
|
59
|
+
...buildTokenAnalytics(current.value, window, metricWindow),
|
|
60
|
+
previous: previous.status === "fulfilled" && previous.value ? buildTokenAnalytics(previous.value, window, previousReportPeriod(metricWindow)).tokenValues : null,
|
|
61
|
+
comparisonUnavailable: window === "30d" ? "The previous 30D period is outside the 30-day metrics retention window." : previous.status === "rejected" ? `Previous metrics unavailable: ${previous.reason instanceof Error ? previous.reason.message : "query failed"}` : "",
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
enabled,
|
|
65
|
+
// A new period keeps showing the last report of the same scope and window until it answers.
|
|
66
|
+
placeholderData: (previous, previousQuery) => previousQuery?.queryKey[0] === scope[0] && previousQuery?.queryKey[1] === scope[1] && previousQuery?.queryKey[2] === window ? previous : undefined,
|
|
67
|
+
retry: false,
|
|
68
|
+
refetchInterval: refreshIntervalMs,
|
|
69
|
+
refetchIntervalInBackground: false,
|
|
70
|
+
refetchOnWindowFocus: false,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildTokenAnalytics(traceMetrics: TraceMetricsLike, window: DashboardWindow, metricWindow = dashboardReportPeriod(window, new Date())): TokenAnalytics {
|
|
75
|
+
const diagnostics = buildTraceMetricsDiagnostics(traceMetrics);
|
|
76
|
+
const usage = tokenUsageFromMeasurements(traceMetrics.totals);
|
|
77
|
+
const trends = buildTokenTrends(traceMetrics.buckets, metricWindow);
|
|
78
|
+
return {
|
|
79
|
+
window,
|
|
80
|
+
period: metricWindow,
|
|
81
|
+
previous: null,
|
|
82
|
+
comparisonUnavailable: "Comparison not loaded",
|
|
83
|
+
tokenValues: {...usage, total: tokenTotal(usage)},
|
|
84
|
+
metrics: traceMetrics,
|
|
85
|
+
summary: diagnostics.summary,
|
|
86
|
+
tokenTrend: trends.tokenTrend,
|
|
87
|
+
modelUsage: trends.modelUsage,
|
|
88
|
+
leaderboards: buildTraceMetricLeaderboards(traceMetrics, 5),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildTokenTrends(metrics: TraceMetricBucketLike[], metricWindow: DashboardReportPeriod): Pick<TokenAnalytics, "tokenTrend" | "modelUsage"> {
|
|
93
|
+
const stepMillis = metricWindow.stepSeconds * 1000;
|
|
94
|
+
const startMillis = metricWindow.start.getTime();
|
|
95
|
+
const endMillis = metricWindow.end.getTime();
|
|
96
|
+
const buckets = Array.from({ length: metricWindow.bucketCount }, (_, index) => ({
|
|
97
|
+
label: formatTrendWindow(startMillis + index * stepMillis, startMillis + (index + 1) * stepMillis),
|
|
98
|
+
usage: emptyTokenUsage(),
|
|
99
|
+
models: new Map<string, TokenUsage>(),
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
for (const metric of metrics) {
|
|
103
|
+
if (!metric.bucketStart) continue;
|
|
104
|
+
const bucketMillis = timestampDate(metric.bucketStart).getTime();
|
|
105
|
+
if (bucketMillis >= endMillis || bucketMillis + metric.bucketSeconds * 1000 <= startMillis) continue;
|
|
106
|
+
const bucket = buckets[Math.min(Math.max(Math.floor((bucketMillis - startMillis) / stepMillis), 0), buckets.length - 1)];
|
|
107
|
+
const value = Number.isFinite(metric.value) ? metric.value : 0;
|
|
108
|
+
addTokenMeasurement(bucket.usage, metric.metricKind, value);
|
|
109
|
+
// Token measurements of a model call carry the model as their dimension.
|
|
110
|
+
const model = metric.dimension.trim();
|
|
111
|
+
if (!model || !tokenMetricKinds.includes(metric.metricKind)) continue;
|
|
112
|
+
const usage = bucket.models.get(model) ?? emptyTokenUsage();
|
|
113
|
+
addTokenMeasurement(usage, metric.metricKind, value);
|
|
114
|
+
bucket.models.set(model, usage);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const windowTotals = new Map<string, number>();
|
|
118
|
+
for (const bucket of buckets) for (const [model, usage] of bucket.models) windowTotals.set(model, (windowTotals.get(model) ?? 0) + tokenTotal(usage));
|
|
119
|
+
const ranked = [...windowTotals].filter(([, total]) => total > 0).sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])).map(([model]) => model);
|
|
120
|
+
const shown = ranked.slice(0, shownModelCount);
|
|
121
|
+
const models = ranked.length > shown.length ? [...shown, otherModels] : shown;
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
tokenTrend: buckets.map(bucket => ({ label: bucket.label, ...bucket.usage, totalTokens: tokenTotal(bucket.usage) })),
|
|
125
|
+
modelUsage: {
|
|
126
|
+
models,
|
|
127
|
+
buckets: buckets.map(bucket => {
|
|
128
|
+
const usage: Record<string, TokenUsage> = {};
|
|
129
|
+
for (const [model, value] of bucket.models) {
|
|
130
|
+
const key = shown.includes(model) ? model : otherModels;
|
|
131
|
+
usage[key] = addTokenUsage(usage[key] ?? emptyTokenUsage(), value);
|
|
132
|
+
}
|
|
133
|
+
return { label: bucket.label, usage };
|
|
134
|
+
}),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatTrendWindow(startEpochMillis: number, endEpochMillis: number): string {
|
|
140
|
+
const start = new Date(startEpochMillis);
|
|
141
|
+
const end = new Date(endEpochMillis);
|
|
142
|
+
if (sameUtcDay(start, end)) {
|
|
143
|
+
return `${formatUtcClock(start)}-${formatUtcClock(end)}`;
|
|
144
|
+
}
|
|
145
|
+
return `${formatUtcDateTime(start)}-${formatUtcDateTime(end)}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sameUtcDay(left: Date, right: Date): boolean {
|
|
149
|
+
return left.getUTCFullYear() === right.getUTCFullYear()
|
|
150
|
+
&& left.getUTCMonth() === right.getUTCMonth()
|
|
151
|
+
&& left.getUTCDate() === right.getUTCDate();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function formatUtcClock(value: Date): string {
|
|
155
|
+
return [
|
|
156
|
+
value.getUTCHours(),
|
|
157
|
+
value.getUTCMinutes(),
|
|
158
|
+
value.getUTCSeconds(),
|
|
159
|
+
].map(padTimePart).join(":");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function formatUtcDateTime(value: Date): string {
|
|
163
|
+
return `${padTimePart(value.getUTCMonth() + 1)}-${padTimePart(value.getUTCDate())} ${formatUtcClock(value)}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function padTimePart(value: number): string {
|
|
167
|
+
return String(value).padStart(2, "0");
|
|
168
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Token usage in its four categories; Total Tokens is their sum. */
|
|
2
|
+
export interface TokenUsage {
|
|
3
|
+
input: number;
|
|
4
|
+
cacheRead: number;
|
|
5
|
+
cacheWrite: number;
|
|
6
|
+
output: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const tokenCategories = [
|
|
10
|
+
{ key: "input", label: "Input", metricKind: "input_tokens" },
|
|
11
|
+
{ key: "cacheRead", label: "Cache read", metricKind: "cache_read_tokens" },
|
|
12
|
+
{ key: "cacheWrite", label: "Cache write", metricKind: "cache_creation_tokens" },
|
|
13
|
+
{ key: "output", label: "Output", metricKind: "output_tokens" },
|
|
14
|
+
] as const satisfies readonly { key: keyof TokenUsage; label: string; metricKind: string }[];
|
|
15
|
+
|
|
16
|
+
export const tokenMetricKinds: readonly string[] = tokenCategories.map(category => category.metricKind);
|
|
17
|
+
|
|
18
|
+
export const emptyTokenUsage = (): TokenUsage => ({ input: 0, cacheRead: 0, cacheWrite: 0, output: 0 });
|
|
19
|
+
|
|
20
|
+
export function tokenTotal(usage: TokenUsage): number {
|
|
21
|
+
return usage.input + usage.cacheRead + usage.cacheWrite + usage.output;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Adds one measurement to usage; other metric kinds are ignored. */
|
|
25
|
+
export function addTokenMeasurement(usage: TokenUsage, metricKind: string, value: number): void {
|
|
26
|
+
const category = tokenCategories.find(item => item.metricKind === metricKind);
|
|
27
|
+
if (category && Number.isFinite(value)) usage[category.key] += value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function addTokenUsage(target: TokenUsage, usage: TokenUsage): TokenUsage {
|
|
31
|
+
for (const { key } of tokenCategories) target[key] += usage[key];
|
|
32
|
+
return target;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function tokenUsageFromMeasurements(measurements: readonly { metricKind: string; value: number }[]): TokenUsage {
|
|
36
|
+
const usage = emptyTokenUsage();
|
|
37
|
+
for (const item of measurements) addTokenMeasurement(usage, item.metricKind, item.value);
|
|
38
|
+
return usage;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A model call's usage as its span reports it. A call without usage, such as
|
|
42
|
+
* a rejected request, counts as zero. */
|
|
43
|
+
export function tokenUsageFromAttributes(attributes: Record<string, string>): TokenUsage {
|
|
44
|
+
const usage = emptyTokenUsage();
|
|
45
|
+
for (const { key, metricKind } of tokenCategories) {
|
|
46
|
+
const value = Number(attributes[metricKind]);
|
|
47
|
+
if (attributes[metricKind]?.trim() && Number.isFinite(value) && value >= 0) usage[key] = value;
|
|
48
|
+
}
|
|
49
|
+
return usage;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Cache Hit Rate = Cache read ÷ (Input + Cache read + Cache write). Callers sum
|
|
54
|
+
* usage before asking, so an aggregate rate follows volume; null when there is
|
|
55
|
+
* no input usage at all.
|
|
56
|
+
*/
|
|
57
|
+
export function cacheHitRate(usage: TokenUsage): number | null {
|
|
58
|
+
const denominator = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
59
|
+
return denominator > 0 ? usage.cacheRead / denominator : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatHitRate(rate: number): string {
|
|
63
|
+
return `${(rate * 100).toFixed(1)}%`;
|
|
64
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import type { Timestamp } from "@bufbuild/protobuf/wkt";
|
|
2
|
+
import { tokenMetricKinds, tokenTotal, tokenUsageFromMeasurements, type TokenUsage } from "./token-usage";
|
|
3
|
+
|
|
4
|
+
export const traceMetricKinds = ["tool_call", "mcp_call", "skill_call", "agent_call", "skill_activation", "model_call", "input_tokens", "output_tokens", "cache_read_tokens", "cache_creation_tokens"] as const;
|
|
5
|
+
|
|
6
|
+
export interface TraceDiagnostics {
|
|
7
|
+
summary: TraceSummary;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TraceSummary {
|
|
11
|
+
tokenUsage: TokenUsage;
|
|
12
|
+
totalTokens: number;
|
|
13
|
+
observationGaps: string[];
|
|
14
|
+
incompleteMetricKinds: string[];
|
|
15
|
+
modelCallCount: number;
|
|
16
|
+
toolCallCount: number;
|
|
17
|
+
skillCallCount: number;
|
|
18
|
+
agentCallCount: number;
|
|
19
|
+
mcpCallCount: number;
|
|
20
|
+
skillActivationCount: number;
|
|
21
|
+
toolDistribution: TraceMetricBar[];
|
|
22
|
+
skillDistribution: TraceMetricBar[];
|
|
23
|
+
agentDistribution: TraceMetricBar[];
|
|
24
|
+
mcpDistribution: TraceMetricBar[];
|
|
25
|
+
modelDistribution: TraceMetricBar[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TraceMetricBar {
|
|
29
|
+
label: string;
|
|
30
|
+
value: number;
|
|
31
|
+
displayValue: string;
|
|
32
|
+
percent: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TraceTokenTrendPoint extends TokenUsage {
|
|
36
|
+
label: string;
|
|
37
|
+
totalTokens: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TraceMetricBucketLike {
|
|
41
|
+
bucketStart?: Timestamp;
|
|
42
|
+
bucketSeconds: number;
|
|
43
|
+
ownerContext?: string;
|
|
44
|
+
ownerId?: string;
|
|
45
|
+
runtimePurpose?: string;
|
|
46
|
+
userId?: string;
|
|
47
|
+
agentWorkId?: string;
|
|
48
|
+
projectId?: string;
|
|
49
|
+
metricKind: string;
|
|
50
|
+
dimension: string;
|
|
51
|
+
value: number;
|
|
52
|
+
unknownCount?: bigint;
|
|
53
|
+
gaps?: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TraceMetricTotalLike {
|
|
57
|
+
metricKind: string;
|
|
58
|
+
dimension: string;
|
|
59
|
+
value: number;
|
|
60
|
+
unknownCount?: bigint;
|
|
61
|
+
gaps?: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface TraceMetricsLike {
|
|
65
|
+
buckets: TraceMetricBucketLike[];
|
|
66
|
+
totals: TraceMetricTotalLike[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface TraceTokenLeaderboardRow {
|
|
70
|
+
key: string;
|
|
71
|
+
totalTokens: number;
|
|
72
|
+
inputTokens: number;
|
|
73
|
+
outputTokens: number;
|
|
74
|
+
percent: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface TraceOwnerRuntimeLeaderboardRow extends TraceTokenLeaderboardRow {
|
|
78
|
+
ownerContext: string;
|
|
79
|
+
runtimePurpose: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TraceMetricLeaderboards {
|
|
83
|
+
ownerRuntimes: TraceOwnerRuntimeLeaderboardRow[];
|
|
84
|
+
users: TraceTokenLeaderboardRow[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
export function buildTraceMetricsDiagnostics(metrics: TraceMetricsLike): TraceDiagnostics {
|
|
89
|
+
const tokenUsage = tokenUsageFromMeasurements(metrics.totals);
|
|
90
|
+
const modelCallCount = metricKindTotal(metrics.totals, "model_call");
|
|
91
|
+
const toolCallCount = metricKindTotal(metrics.totals, "tool_call");
|
|
92
|
+
const skillCallCount = metricKindTotal(metrics.totals, "skill_call");
|
|
93
|
+
const agentCallCount = metricKindTotal(metrics.totals, "agent_call");
|
|
94
|
+
const mcpCallCount = metricKindTotal(metrics.totals, "mcp_call");
|
|
95
|
+
const skillActivationCount = metricKindTotal(metrics.totals, "skill_activation");
|
|
96
|
+
const observationGaps = [...new Set(metrics.buckets.flatMap((bucket) => bucket.gaps ?? []))];
|
|
97
|
+
// Token usage is always the recorded amount, so only call kinds can be incomplete.
|
|
98
|
+
const incompleteMetricKinds = new Set(metrics.totals.filter((metric) => (metric.unknownCount ?? 0n) > 0n && !tokenKinds.includes(metric.metricKind)).map((metric) => metric.metricKind));
|
|
99
|
+
if (observationGaps.includes("projection_incomplete")) traceMetricKinds.filter((kind) => !tokenKinds.includes(kind)).forEach((kind) => incompleteMetricKinds.add(kind));
|
|
100
|
+
if (observationGaps.some((gap) => gap === "missing_call_id" || gap === "unclassified_call")) {
|
|
101
|
+
["tool_call", "mcp_call", "skill_call", "agent_call"].forEach((kind) => incompleteMetricKinds.add(kind));
|
|
102
|
+
}
|
|
103
|
+
if (observationGaps.includes("missing_event_identity")) incompleteMetricKinds.add("skill_activation");
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
summary: {
|
|
107
|
+
tokenUsage,
|
|
108
|
+
totalTokens: tokenTotal(tokenUsage),
|
|
109
|
+
observationGaps,
|
|
110
|
+
incompleteMetricKinds: [...incompleteMetricKinds],
|
|
111
|
+
modelCallCount,
|
|
112
|
+
toolCallCount,
|
|
113
|
+
skillCallCount, agentCallCount, mcpCallCount, skillActivationCount,
|
|
114
|
+
toolDistribution: metricDistribution(metrics.totals, "tool_call"),
|
|
115
|
+
skillDistribution: metricDistribution(metrics.totals, "skill_call"),
|
|
116
|
+
agentDistribution: metricDistribution(metrics.totals, "agent_call"),
|
|
117
|
+
mcpDistribution: metricDistribution(metrics.totals, "mcp_call"),
|
|
118
|
+
modelDistribution: metricDistribution(metrics.totals, "model_call"),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function buildTraceMetricLeaderboards(metrics: TraceMetricsLike, limit = 5): TraceMetricLeaderboards {
|
|
124
|
+
return {
|
|
125
|
+
ownerRuntimes: ownerRuntimeLeaderboard(metrics.buckets, limit),
|
|
126
|
+
users: tokenLeaderboard(metrics.buckets, "user", limit),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const tokenKinds = tokenMetricKinds;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What token usage is grouped by: a person, a Work, or the owner its Works
|
|
134
|
+
* name through the caller attributes ownerContext and ownerId, as the caller
|
|
135
|
+
* that started them names it.
|
|
136
|
+
*/
|
|
137
|
+
export type TokenAttribution = "user" | "work" | { ownerContext: string };
|
|
138
|
+
|
|
139
|
+
function attributionKey(metric: TraceMetricBucketLike, by: TokenAttribution): string {
|
|
140
|
+
if (by === "user") return metric.userId?.trim() ?? "";
|
|
141
|
+
if (by === "work") return metric.agentWorkId?.trim() ?? "";
|
|
142
|
+
return metric.ownerContext === by.ownerContext ? metric.ownerId?.trim() ?? "" : "";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Total Tokens per person, Work or owner, for every one with usage (no ranking cut-off). */
|
|
146
|
+
export function tokenTotals(metrics: TraceMetricBucketLike[], by: TokenAttribution): Map<string, number> {
|
|
147
|
+
return new Map(tokenLeaderboard(metrics, by, Number.POSITIVE_INFINITY).map(row => [row.key, row.totalTokens]));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function formatTraceDuration(durationNanos: bigint): string {
|
|
151
|
+
const millis = durationMillis(durationNanos);
|
|
152
|
+
|
|
153
|
+
if (millis < 1000) {
|
|
154
|
+
return `${formatCompactNumber(millis, Number.isInteger(millis) ? 0 : 1)}ms`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const seconds = millis / 1000;
|
|
158
|
+
if (seconds < 60) {
|
|
159
|
+
return `${formatCompactNumber(seconds, 1)}s`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const minutes = Math.floor(seconds / 60);
|
|
163
|
+
const remainingSeconds = Math.round(seconds % 60);
|
|
164
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function durationMillis(durationNanos: bigint): number {
|
|
168
|
+
return Number(durationNanos) / 1_000_000;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function formatCompactNumber(value: number, fractionDigits: number): string {
|
|
172
|
+
return value.toFixed(fractionDigits).replace(/\.0$/, "");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function formatCompactTokenCount(value: number): string {
|
|
176
|
+
if (value < 1000) {
|
|
177
|
+
return `${Math.round(value)}`;
|
|
178
|
+
}
|
|
179
|
+
if (value < 1_000_000) {
|
|
180
|
+
return `${formatCompactNumber(value / 1000, 1)}k`;
|
|
181
|
+
}
|
|
182
|
+
if (value < 1_000_000_000) {
|
|
183
|
+
return `${formatCompactNumber(value / 1_000_000, 1)}M`;
|
|
184
|
+
}
|
|
185
|
+
return `${formatCompactNumber(value / 1_000_000_000, 1)}B`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function metricKindTotal(metrics: TraceMetricTotalLike[], kind: string): number {
|
|
189
|
+
return metrics
|
|
190
|
+
.filter((metric) => metric.metricKind === kind)
|
|
191
|
+
.reduce((sum, metric) => sum + safeMetricValue(metric.value), 0);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function metricDistribution(metrics: TraceMetricTotalLike[], kind: string): TraceMetricBar[] {
|
|
195
|
+
const rows = metrics
|
|
196
|
+
.filter((metric) => metric.metricKind === kind && metric.dimension.trim() !== "" && safeMetricValue(metric.value) > 0)
|
|
197
|
+
.map((metric) => ({ label: metric.dimension, value: safeMetricValue(metric.value) }));
|
|
198
|
+
const total = rows.reduce((sum, row) => sum + row.value, 0);
|
|
199
|
+
|
|
200
|
+
return rows
|
|
201
|
+
.sort((left, right) => right.value - left.value || left.label.localeCompare(right.label))
|
|
202
|
+
.map((row) => ({
|
|
203
|
+
label: row.label,
|
|
204
|
+
value: row.value,
|
|
205
|
+
displayValue: formatMetricValue(row.value),
|
|
206
|
+
percent: total > 0 ? (row.value / total) * 100 : 0,
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function safeMetricValue(value: number): number {
|
|
211
|
+
return Number.isFinite(value) ? value : 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function formatMetricValue(value: number): string {
|
|
215
|
+
return Number.isInteger(value) ? String(value) : formatCompactNumber(value, 1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The limit people, Works or owners with the most Tokens, largest first. */
|
|
219
|
+
export function tokenLeaderboard(metrics: TraceMetricBucketLike[], by: TokenAttribution, limit = 5): TraceTokenLeaderboardRow[] {
|
|
220
|
+
const totals = new Map<string, { inputTokens: number; outputTokens: number; cacheTokens: number }>();
|
|
221
|
+
for (const metric of metrics) {
|
|
222
|
+
const key = attributionKey(metric, by);
|
|
223
|
+
if (!key) continue;
|
|
224
|
+
const value = safeMetricValue(metric.value);
|
|
225
|
+
if (!tokenKinds.includes(metric.metricKind)) continue;
|
|
226
|
+
|
|
227
|
+
const current = totals.get(key) ?? { inputTokens: 0, outputTokens: 0, cacheTokens: 0 };
|
|
228
|
+
if (metric.metricKind === "input_tokens") current.inputTokens += value;
|
|
229
|
+
if (metric.metricKind === "output_tokens") current.outputTokens += value;
|
|
230
|
+
if (metric.metricKind === "cache_read_tokens" || metric.metricKind === "cache_creation_tokens") current.cacheTokens += value;
|
|
231
|
+
totals.set(key, current);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const rows = Array.from(totals.entries())
|
|
235
|
+
.map(([key, value]) => ({
|
|
236
|
+
key,
|
|
237
|
+
inputTokens: value.inputTokens,
|
|
238
|
+
outputTokens: value.outputTokens,
|
|
239
|
+
totalTokens: value.inputTokens + value.outputTokens + value.cacheTokens,
|
|
240
|
+
percent: 0,
|
|
241
|
+
}))
|
|
242
|
+
.filter((row) => row.totalTokens > 0)
|
|
243
|
+
.sort((left, right) => right.totalTokens - left.totalTokens || left.key.localeCompare(right.key))
|
|
244
|
+
.slice(0, Math.max(limit, 0));
|
|
245
|
+
const maxTotal = Math.max(0, ...rows.map((row) => row.totalTokens));
|
|
246
|
+
return rows.map((row) => ({
|
|
247
|
+
...row,
|
|
248
|
+
percent: maxTotal > 0 ? Math.max((row.totalTokens / maxTotal) * 100, 4) : 0,
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function ownerRuntimeLeaderboard(metrics: TraceMetricBucketLike[], limit: number): TraceOwnerRuntimeLeaderboardRow[] {
|
|
253
|
+
const totals = new Map<string, { ownerContext: string; runtimePurpose: string; inputTokens: number; outputTokens: number; cacheTokens: number }>();
|
|
254
|
+
for (const metric of metrics) {
|
|
255
|
+
const ownerContext = metric.ownerContext?.trim() ?? "";
|
|
256
|
+
const runtimePurpose = metric.runtimePurpose?.trim() ?? "";
|
|
257
|
+
if (!ownerContext && !runtimePurpose) continue;
|
|
258
|
+
if (!tokenKinds.includes(metric.metricKind)) continue;
|
|
259
|
+
|
|
260
|
+
const key = `${ownerContext || "unknown"}:${runtimePurpose || "unknown"}`;
|
|
261
|
+
const current = totals.get(key) ?? {
|
|
262
|
+
ownerContext,
|
|
263
|
+
runtimePurpose,
|
|
264
|
+
inputTokens: 0,
|
|
265
|
+
outputTokens: 0, cacheTokens: 0,
|
|
266
|
+
};
|
|
267
|
+
const value = safeMetricValue(metric.value);
|
|
268
|
+
if (metric.metricKind === "input_tokens") current.inputTokens += value;
|
|
269
|
+
if (metric.metricKind === "output_tokens") current.outputTokens += value;
|
|
270
|
+
if (metric.metricKind === "cache_read_tokens" || metric.metricKind === "cache_creation_tokens") current.cacheTokens += value;
|
|
271
|
+
totals.set(key, current);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const rows = Array.from(totals.entries())
|
|
275
|
+
.map(([key, value]) => ({
|
|
276
|
+
key,
|
|
277
|
+
ownerContext: value.ownerContext,
|
|
278
|
+
runtimePurpose: value.runtimePurpose,
|
|
279
|
+
inputTokens: value.inputTokens,
|
|
280
|
+
outputTokens: value.outputTokens,
|
|
281
|
+
totalTokens: value.inputTokens + value.outputTokens + value.cacheTokens,
|
|
282
|
+
percent: 0,
|
|
283
|
+
}))
|
|
284
|
+
.filter((row) => row.totalTokens > 0)
|
|
285
|
+
.sort((left, right) => right.totalTokens - left.totalTokens || left.key.localeCompare(right.key))
|
|
286
|
+
.slice(0, Math.max(limit, 0));
|
|
287
|
+
const maxTotal = Math.max(0, ...rows.map((row) => row.totalTokens));
|
|
288
|
+
return rows.map((row) => ({
|
|
289
|
+
...row,
|
|
290
|
+
percent: maxTotal > 0 ? Math.max((row.totalTokens / maxTotal) * 100, 4) : 0,
|
|
291
|
+
}));
|
|
292
|
+
}
|
package/lib/transport.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Code, ConnectError, type Interceptor } from "@connectrpc/connect";
|
|
2
|
+
import { createConnectTransport } from "@connectrpc/connect-web";
|
|
3
|
+
import { refreshSession } from "./session";
|
|
4
|
+
|
|
5
|
+
// An Identity Token lives fifteen minutes. When the gateway rejects one, ask
|
|
6
|
+
// Account for a fresh token once and repeat the call. If Account rejects the
|
|
7
|
+
// session, the error reaches AuthGate, which sends the person to sign in; if
|
|
8
|
+
// Account cannot be reached, the call fails as unavailable so the page offers
|
|
9
|
+
// a retry instead. A stream cannot replay its request, so it only refreshes
|
|
10
|
+
// for its reconnect.
|
|
11
|
+
export const refreshOnUnauthenticated: Interceptor = (next) => async (request) => {
|
|
12
|
+
try {
|
|
13
|
+
return await next(request);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (!(error instanceof ConnectError) || error.code !== Code.Unauthenticated) throw error;
|
|
16
|
+
const outcome = await refreshSession();
|
|
17
|
+
if (outcome === "unavailable") throw new ConnectError("session refresh unavailable", Code.Unavailable, undefined, undefined, error);
|
|
18
|
+
if (outcome === "rejected" || request.stream) throw error;
|
|
19
|
+
return next(request);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const transport = createConnectTransport({
|
|
24
|
+
baseUrl: "/api",
|
|
25
|
+
interceptors: [refreshOnUnauthenticated],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const streamTransport = transport;
|
|
29
|
+
export const traceTransport = transport;
|
package/lib/use-now.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
// The current time, refreshed on an interval, so rendering stays pure.
|
|
4
|
+
export function useNow(intervalMs = 60_000) {
|
|
5
|
+
const [now, setNow] = useState(() => Date.now());
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const timer = setInterval(() => setNow(Date.now()), intervalMs);
|
|
8
|
+
return () => clearInterval(timer);
|
|
9
|
+
}, [intervalMs]);
|
|
10
|
+
return now;
|
|
11
|
+
}
|