From fef86e475b490744e77d266c9d1cb84b6e7a0812 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 03:33:33 +0000 Subject: [PATCH] refactor: dedupe shared helpers across ui/gateway/extensions --- extensions/feishu/src/policy.ts | 36 +-- extensions/msteams/src/policy.ts | 20 +- extensions/voice-call/src/config.ts | 82 +------ scripts/dev/gateway-smoke.ts | 106 +-------- scripts/dev/gateway-ws-client.ts | 132 ++++++++++ scripts/dev/ios-node-e2e.ts | 108 +-------- src/agents/tool-display-common.ts | 221 +++++++++++++++++ src/agents/tool-display.ts | 194 ++------------- src/channels/allowlist-match.ts | 29 +++ src/channels/plugins/allowlist-match.ts | 2 +- src/commands/model-picker.ts | 127 ++++------ src/commands/onboard-custom.ts | 65 +++-- src/gateway/server-methods/usage.ts | 39 +-- src/plugin-sdk/index.ts | 9 +- src/shared/usage-aggregates.ts | 63 +++++ ui/src/ui/tool-display.ts | 157 ++---------- ui/src/ui/types.ts | 225 +----------------- ui/src/ui/usage-types.ts | 216 +++++++++++++++++ ui/src/ui/views/agents-panels-tools-skills.ts | 41 +--- ui/src/ui/views/skills-grouping.ts | 40 ++++ ui/src/ui/views/skills.ts | 40 +--- ui/src/ui/views/usage-metrics.ts | 37 +-- ui/src/ui/views/usageTypes.ts | 207 +--------------- 23 files changed, 898 insertions(+), 1298 deletions(-) create mode 100644 scripts/dev/gateway-ws-client.ts create mode 100644 src/agents/tool-display-common.ts create mode 100644 src/shared/usage-aggregates.ts create mode 100644 ui/src/ui/usage-types.ts create mode 100644 ui/src/ui/views/skills-grouping.ts diff --git a/extensions/feishu/src/policy.ts b/extensions/feishu/src/policy.ts index cd9eb90496..89e12ba859 100644 --- a/extensions/feishu/src/policy.ts +++ b/extensions/feishu/src/policy.ts @@ -1,39 +1,19 @@ -import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk"; +import type { + AllowlistMatch, + ChannelGroupContext, + GroupToolPolicyConfig, +} from "openclaw/plugin-sdk"; +import { resolveAllowlistMatchSimple } from "openclaw/plugin-sdk"; import type { FeishuConfig, FeishuGroupConfig } from "./types.js"; -export type FeishuAllowlistMatch = { - allowed: boolean; - matchKey?: string; - matchSource?: "wildcard" | "id" | "name"; -}; +export type FeishuAllowlistMatch = AllowlistMatch<"wildcard" | "id" | "name">; export function resolveFeishuAllowlistMatch(params: { allowFrom: Array; senderId: string; senderName?: string | null; }): FeishuAllowlistMatch { - const allowFrom = params.allowFrom - .map((entry) => String(entry).trim().toLowerCase()) - .filter(Boolean); - - if (allowFrom.length === 0) { - return { allowed: false }; - } - if (allowFrom.includes("*")) { - return { allowed: true, matchKey: "*", matchSource: "wildcard" }; - } - - const senderId = params.senderId.toLowerCase(); - if (allowFrom.includes(senderId)) { - return { allowed: true, matchKey: senderId, matchSource: "id" }; - } - - const senderName = params.senderName?.toLowerCase(); - if (senderName && allowFrom.includes(senderName)) { - return { allowed: true, matchKey: senderName, matchSource: "name" }; - } - - return { allowed: false }; + return resolveAllowlistMatchSimple(params); } export function resolveFeishuGroupConfig(params: { diff --git a/extensions/msteams/src/policy.ts b/extensions/msteams/src/policy.ts index eb1e747624..6bab808ce9 100644 --- a/extensions/msteams/src/policy.ts +++ b/extensions/msteams/src/policy.ts @@ -11,6 +11,7 @@ import type { import { buildChannelKeyCandidates, normalizeChannelSlug, + resolveAllowlistMatchSimple, resolveToolsBySender, resolveChannelEntryMatchWithFallback, resolveNestedAllowlistDecision, @@ -209,24 +210,7 @@ export function resolveMSTeamsAllowlistMatch(params: { senderId: string; senderName?: string | null; }): MSTeamsAllowlistMatch { - const allowFrom = params.allowFrom - .map((entry) => String(entry).trim().toLowerCase()) - .filter(Boolean); - if (allowFrom.length === 0) { - return { allowed: false }; - } - if (allowFrom.includes("*")) { - return { allowed: true, matchKey: "*", matchSource: "wildcard" }; - } - const senderId = params.senderId.toLowerCase(); - if (allowFrom.includes(senderId)) { - return { allowed: true, matchKey: senderId, matchSource: "id" }; - } - const senderName = params.senderName?.toLowerCase(); - if (senderName && allowFrom.includes(senderName)) { - return { allowed: true, matchKey: senderName, matchSource: "name" }; - } - return { allowed: false }; + return resolveAllowlistMatchSimple(params); } export function resolveMSTeamsReplyPolicy(params: { diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index 9b63c3e817..df7cf57b61 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -1,3 +1,9 @@ +import { + TtsAutoSchema, + TtsConfigSchema, + TtsModeSchema, + TtsProviderSchema, +} from "openclaw/plugin-sdk"; import { z } from "zod"; // ----------------------------------------------------------------------------- @@ -77,81 +83,7 @@ export const SttConfigSchema = z .default({ provider: "openai", model: "whisper-1" }); export type SttConfig = z.infer; -export const TtsProviderSchema = z.enum(["openai", "elevenlabs", "edge"]); -export const TtsModeSchema = z.enum(["final", "all"]); -export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]); - -export const TtsConfigSchema = z - .object({ - auto: TtsAutoSchema.optional(), - enabled: z.boolean().optional(), - mode: TtsModeSchema.optional(), - provider: TtsProviderSchema.optional(), - summaryModel: z.string().optional(), - modelOverrides: z - .object({ - enabled: z.boolean().optional(), - allowText: z.boolean().optional(), - allowProvider: z.boolean().optional(), - allowVoice: z.boolean().optional(), - allowModelId: z.boolean().optional(), - allowVoiceSettings: z.boolean().optional(), - allowNormalization: z.boolean().optional(), - allowSeed: z.boolean().optional(), - }) - .strict() - .optional(), - elevenlabs: z - .object({ - apiKey: z.string().optional(), - baseUrl: z.string().optional(), - voiceId: z.string().optional(), - modelId: z.string().optional(), - seed: z.number().int().min(0).max(4294967295).optional(), - applyTextNormalization: z.enum(["auto", "on", "off"]).optional(), - languageCode: z.string().optional(), - voiceSettings: z - .object({ - stability: z.number().min(0).max(1).optional(), - similarityBoost: z.number().min(0).max(1).optional(), - style: z.number().min(0).max(1).optional(), - useSpeakerBoost: z.boolean().optional(), - speed: z.number().min(0.5).max(2).optional(), - }) - .strict() - .optional(), - }) - .strict() - .optional(), - openai: z - .object({ - apiKey: z.string().optional(), - model: z.string().optional(), - voice: z.string().optional(), - }) - .strict() - .optional(), - edge: z - .object({ - enabled: z.boolean().optional(), - voice: z.string().optional(), - lang: z.string().optional(), - outputFormat: z.string().optional(), - pitch: z.string().optional(), - rate: z.string().optional(), - volume: z.string().optional(), - saveSubtitles: z.boolean().optional(), - proxy: z.string().optional(), - timeoutMs: z.number().int().min(1000).max(120000).optional(), - }) - .strict() - .optional(), - prefsPath: z.string().optional(), - maxTextLength: z.number().int().min(1).optional(), - timeoutMs: z.number().int().min(1000).max(120000).optional(), - }) - .strict() - .optional(); +export { TtsAutoSchema, TtsConfigSchema, TtsModeSchema, TtsProviderSchema }; export type VoiceCallTtsConfig = z.infer; // ----------------------------------------------------------------------------- diff --git a/scripts/dev/gateway-smoke.ts b/scripts/dev/gateway-smoke.ts index e217adf5ee..63bec21a4b 100644 --- a/scripts/dev/gateway-smoke.ts +++ b/scripts/dev/gateway-smoke.ts @@ -1,20 +1,6 @@ -import { randomUUID } from "node:crypto"; -import WebSocket from "ws"; - -type GatewayReqFrame = { type: "req"; id: string; method: string; params?: unknown }; -type GatewayResFrame = { type: "res"; id: string; ok: boolean; payload?: unknown; error?: unknown }; -type GatewayEventFrame = { type: "event"; event: string; seq?: number; payload?: unknown }; -type GatewayFrame = GatewayReqFrame | GatewayResFrame | GatewayEventFrame | { type: string }; - -const args = process.argv.slice(2); -const getArg = (flag: string) => { - const idx = args.indexOf(flag); - if (idx !== -1 && idx + 1 < args.length) { - return args[idx + 1]; - } - return undefined; -}; +import { createArgReader, createGatewayWsClient, resolveGatewayUrl } from "./gateway-ws-client.ts"; +const { get: getArg } = createArgReader(); const urlRaw = getArg("--url") ?? process.env.OPENCLAW_GATEWAY_URL; const token = getArg("--token") ?? process.env.OPENCLAW_GATEWAY_TOKEN; @@ -27,90 +13,16 @@ if (!urlRaw || !token) { process.exit(1); } -const url = new URL(urlRaw.includes("://") ? urlRaw : `wss://${urlRaw}`); -if (!url.port) { - url.port = url.protocol === "wss:" ? "443" : "80"; -} - -const randomId = () => randomUUID(); - async function main() { - const ws = new WebSocket(url.toString(), { handshakeTimeout: 8000 }); - const pending = new Map< - string, - { - resolve: (res: GatewayResFrame) => void; - reject: (err: Error) => void; - timeout: ReturnType; - } - >(); - - const request = (method: string, params?: unknown, timeoutMs = 12000) => - new Promise((resolve, reject) => { - const id = randomId(); - const frame: GatewayReqFrame = { type: "req", id, method, params }; - const timeout = setTimeout(() => { - pending.delete(id); - reject(new Error(`timeout waiting for ${method}`)); - }, timeoutMs); - pending.set(id, { resolve, reject, timeout }); - ws.send(JSON.stringify(frame)); - }); - - const waitOpen = () => - new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error("ws open timeout")), 8000); - ws.once("open", () => { - clearTimeout(t); - resolve(); - }); - ws.once("error", (err) => { - clearTimeout(t); - reject(err instanceof Error ? err : new Error(String(err))); - }); - }); - - const toText = (data: WebSocket.RawData) => { - if (typeof data === "string") { - return data; - } - if (data instanceof ArrayBuffer) { - return Buffer.from(data).toString("utf8"); - } - if (Array.isArray(data)) { - return Buffer.concat(data.map((chunk) => Buffer.from(chunk))).toString("utf8"); - } - return Buffer.from(data as Buffer).toString("utf8"); - }; - - ws.on("message", (data) => { - const text = toText(data); - let frame: GatewayFrame | null = null; - try { - frame = JSON.parse(text) as GatewayFrame; - } catch { - return; - } - if (!frame || typeof frame !== "object" || !("type" in frame)) { - return; - } - if (frame.type === "res") { - const res = frame as GatewayResFrame; - const waiter = pending.get(res.id); - if (waiter) { - pending.delete(res.id); - clearTimeout(waiter.timeout); - waiter.resolve(res); - } - return; - } - if (frame.type === "event") { - const evt = frame as GatewayEventFrame; + const url = resolveGatewayUrl(urlRaw); + const { request, waitOpen, close } = createGatewayWsClient({ + url: url.toString(), + onEvent: (evt) => { + // Ignore noisy connect handshakes. if (evt.event === "connect.challenge") { return; } - return; - } + }, }); await waitOpen(); @@ -157,7 +69,7 @@ async function main() { // eslint-disable-next-line no-console console.log("ok: connected + health + chat.history"); - ws.close(); + close(); } await main(); diff --git a/scripts/dev/gateway-ws-client.ts b/scripts/dev/gateway-ws-client.ts new file mode 100644 index 0000000000..4070399d33 --- /dev/null +++ b/scripts/dev/gateway-ws-client.ts @@ -0,0 +1,132 @@ +import { randomUUID } from "node:crypto"; +import WebSocket from "ws"; + +export type GatewayReqFrame = { type: "req"; id: string; method: string; params?: unknown }; +export type GatewayResFrame = { + type: "res"; + id: string; + ok: boolean; + payload?: unknown; + error?: unknown; +}; +export type GatewayEventFrame = { type: "event"; event: string; seq?: number; payload?: unknown }; +export type GatewayFrame = + | GatewayReqFrame + | GatewayResFrame + | GatewayEventFrame + | { type: string; [key: string]: unknown }; + +export function createArgReader(argv = process.argv.slice(2)) { + const get = (flag: string) => { + const idx = argv.indexOf(flag); + if (idx !== -1 && idx + 1 < argv.length) { + return argv[idx + 1]; + } + return undefined; + }; + const has = (flag: string) => argv.includes(flag); + return { argv, get, has }; +} + +export function resolveGatewayUrl(urlRaw: string): URL { + const url = new URL(urlRaw.includes("://") ? urlRaw : `wss://${urlRaw}`); + if (!url.port) { + url.port = url.protocol === "wss:" ? "443" : "80"; + } + return url; +} + +function toText(data: WebSocket.RawData): string { + if (typeof data === "string") { + return data; + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString("utf8"); + } + if (Array.isArray(data)) { + return Buffer.concat(data.map((chunk) => Buffer.from(chunk))).toString("utf8"); + } + return Buffer.from(data as Buffer).toString("utf8"); +} + +export function createGatewayWsClient(params: { + url: string; + handshakeTimeoutMs?: number; + openTimeoutMs?: number; + onEvent?: (evt: GatewayEventFrame) => void; +}) { + const ws = new WebSocket(params.url, { handshakeTimeout: params.handshakeTimeoutMs ?? 8000 }); + const pending = new Map< + string, + { + resolve: (res: GatewayResFrame) => void; + reject: (err: Error) => void; + timeout: ReturnType; + } + >(); + + const request = (method: string, paramsObj?: unknown, timeoutMs = 12_000) => + new Promise((resolve, reject) => { + const id = randomUUID(); + const frame: GatewayReqFrame = { type: "req", id, method, params: paramsObj }; + const timeout = setTimeout(() => { + pending.delete(id); + reject(new Error(`timeout waiting for ${method}`)); + }, timeoutMs); + pending.set(id, { resolve, reject, timeout }); + ws.send(JSON.stringify(frame)); + }); + + const waitOpen = () => + new Promise((resolve, reject) => { + const t = setTimeout( + () => reject(new Error("ws open timeout")), + params.openTimeoutMs ?? 8000, + ); + ws.once("open", () => { + clearTimeout(t); + resolve(); + }); + ws.once("error", (err) => { + clearTimeout(t); + reject(err instanceof Error ? err : new Error(String(err))); + }); + }); + + ws.on("message", (data) => { + const text = toText(data); + let frame: GatewayFrame | null = null; + try { + frame = JSON.parse(text) as GatewayFrame; + } catch { + return; + } + if (!frame || typeof frame !== "object" || !("type" in frame)) { + return; + } + if (frame.type === "res") { + const res = frame as GatewayResFrame; + const waiter = pending.get(res.id); + if (waiter) { + pending.delete(res.id); + clearTimeout(waiter.timeout); + waiter.resolve(res); + } + return; + } + if (frame.type === "event") { + const evt = frame as GatewayEventFrame; + params.onEvent?.(evt); + } + }); + + const close = () => { + for (const waiter of pending.values()) { + clearTimeout(waiter.timeout); + } + pending.clear(); + ws.close(); + }; + + return { ws, request, waitOpen, close }; +} diff --git a/scripts/dev/ios-node-e2e.ts b/scripts/dev/ios-node-e2e.ts index 7b64b6e2d6..6885a32d74 100644 --- a/scripts/dev/ios-node-e2e.ts +++ b/scripts/dev/ios-node-e2e.ts @@ -1,10 +1,4 @@ -import { randomUUID } from "node:crypto"; -import WebSocket from "ws"; - -type GatewayReqFrame = { type: "req"; id: string; method: string; params?: unknown }; -type GatewayResFrame = { type: "res"; id: string; ok: boolean; payload?: unknown; error?: unknown }; -type GatewayEventFrame = { type: "event"; event: string; seq?: number; payload?: unknown }; -type GatewayFrame = GatewayReqFrame | GatewayResFrame | GatewayEventFrame | { type: string }; +import { createArgReader, createGatewayWsClient, resolveGatewayUrl } from "./gateway-ws-client.ts"; type NodeListPayload = { ts?: number; @@ -21,16 +15,7 @@ type NodeListPayload = { type NodeListNode = NonNullable[number]; -const args = process.argv.slice(2); -const getArg = (flag: string) => { - const idx = args.indexOf(flag); - if (idx !== -1 && idx + 1 < args.length) { - return args[idx + 1]; - } - return undefined; -}; - -const hasFlag = (flag: string) => args.includes(flag); +const { get: getArg, has: hasFlag } = createArgReader(); const urlRaw = getArg("--url") ?? process.env.OPENCLAW_GATEWAY_URL; const token = getArg("--token") ?? process.env.OPENCLAW_GATEWAY_TOKEN; @@ -47,12 +32,7 @@ if (!urlRaw || !token) { process.exit(1); } -const url = new URL(urlRaw.includes("://") ? urlRaw : `wss://${urlRaw}`); -if (!url.port) { - url.port = url.protocol === "wss:" ? "443" : "80"; -} - -const randomId = () => randomUUID(); +const url = resolveGatewayUrl(urlRaw); const isoNow = () => new Date().toISOString(); const isoMinusMs = (ms: number) => new Date(Date.now() - ms).toISOString(); @@ -102,81 +82,7 @@ function pickIosNode(list: NodeListPayload, hint?: string): NodeListNode | null } async function main() { - const ws = new WebSocket(url.toString(), { handshakeTimeout: 8000 }); - const pending = new Map< - string, - { - resolve: (res: GatewayResFrame) => void; - reject: (err: Error) => void; - timeout: ReturnType; - } - >(); - - const request = (method: string, params?: unknown, timeoutMs = 12_000) => - new Promise((resolve, reject) => { - const id = randomId(); - const frame: GatewayReqFrame = { type: "req", id, method, params }; - const timeout = setTimeout(() => { - pending.delete(id); - reject(new Error(`timeout waiting for ${method}`)); - }, timeoutMs); - pending.set(id, { resolve, reject, timeout }); - ws.send(JSON.stringify(frame)); - }); - - const waitOpen = () => - new Promise((resolve, reject) => { - const t = setTimeout(() => reject(new Error("ws open timeout")), 8000); - ws.once("open", () => { - clearTimeout(t); - resolve(); - }); - ws.once("error", (err) => { - clearTimeout(t); - reject(err instanceof Error ? err : new Error(String(err))); - }); - }); - - const toText = (data: WebSocket.RawData) => { - if (typeof data === "string") { - return data; - } - if (data instanceof ArrayBuffer) { - return Buffer.from(data).toString("utf8"); - } - if (Array.isArray(data)) { - return Buffer.concat(data.map((chunk) => Buffer.from(chunk))).toString("utf8"); - } - return Buffer.from(data as Buffer).toString("utf8"); - }; - - ws.on("message", (data) => { - const text = toText(data); - let frame: GatewayFrame | null = null; - try { - frame = JSON.parse(text) as GatewayFrame; - } catch { - return; - } - if (!frame || typeof frame !== "object" || !("type" in frame)) { - return; - } - if (frame.type === "res") { - const res = frame as GatewayResFrame; - const waiter = pending.get(res.id); - if (waiter) { - pending.delete(res.id); - clearTimeout(waiter.timeout); - waiter.resolve(res); - } - return; - } - if (frame.type === "event") { - // Ignore; caller can extend to watch node.pair.* etc. - return; - } - }); - + const { request, waitOpen, close } = createGatewayWsClient({ url: url.toString() }); await waitOpen(); const connectRes = await request("connect", { @@ -201,6 +107,7 @@ async function main() { if (!connectRes.ok) { // eslint-disable-next-line no-console console.error("connect failed:", connectRes.error); + close(); process.exit(2); } @@ -208,6 +115,7 @@ async function main() { if (!healthRes.ok) { // eslint-disable-next-line no-console console.error("health failed:", healthRes.error); + close(); process.exit(3); } @@ -215,6 +123,7 @@ async function main() { if (!nodesRes.ok) { // eslint-disable-next-line no-console console.error("node.list failed:", nodesRes.error); + close(); process.exit(4); } @@ -235,6 +144,7 @@ async function main() { if (!node) { // eslint-disable-next-line no-console console.error("No connected iOS nodes found. (Is the iOS app connected to the gateway?)"); + close(); process.exit(5); } @@ -363,7 +273,7 @@ async function main() { } const failed = results.filter((r) => !r.ok); - ws.close(); + close(); if (failed.length > 0) { process.exit(10); diff --git a/src/agents/tool-display-common.ts b/src/agents/tool-display-common.ts new file mode 100644 index 0000000000..a9e89cc602 --- /dev/null +++ b/src/agents/tool-display-common.ts @@ -0,0 +1,221 @@ +export type ToolDisplayActionSpec = { + label?: string; + detailKeys?: string[]; +}; + +export type ToolDisplaySpec = { + title?: string; + label?: string; + detailKeys?: string[]; + actions?: Record; +}; + +export type CoerceDisplayValueOptions = { + includeFalse?: boolean; + includeZero?: boolean; + includeNonFinite?: boolean; + maxStringChars?: number; + maxArrayEntries?: number; +}; + +export function normalizeToolName(name?: string): string { + return (name ?? "tool").trim(); +} + +export function defaultTitle(name: string): string { + const cleaned = name.replace(/_/g, " ").trim(); + if (!cleaned) { + return "Tool"; + } + return cleaned + .split(/\s+/) + .map((part) => + part.length <= 2 && part.toUpperCase() === part + ? part + : `${part.at(0)?.toUpperCase() ?? ""}${part.slice(1)}`, + ) + .join(" "); +} + +export function normalizeVerb(value?: string): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + return trimmed.replace(/_/g, " "); +} + +export function coerceDisplayValue( + value: unknown, + opts: CoerceDisplayValueOptions = {}, +): string | undefined { + const maxStringChars = opts.maxStringChars ?? 160; + const maxArrayEntries = opts.maxArrayEntries ?? 3; + + if (value === null || value === undefined) { + return undefined; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? ""; + if (!firstLine) { + return undefined; + } + if (firstLine.length > maxStringChars) { + return `${firstLine.slice(0, Math.max(0, maxStringChars - 3))}…`; + } + return firstLine; + } + if (typeof value === "boolean") { + if (!value && !opts.includeFalse) { + return undefined; + } + return value ? "true" : "false"; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return opts.includeNonFinite ? String(value) : undefined; + } + if (value === 0 && !opts.includeZero) { + return undefined; + } + return String(value); + } + if (Array.isArray(value)) { + const values = value + .map((item) => coerceDisplayValue(item, opts)) + .filter((item): item is string => Boolean(item)); + if (values.length === 0) { + return undefined; + } + const preview = values.slice(0, maxArrayEntries).join(", "); + return values.length > maxArrayEntries ? `${preview}…` : preview; + } + return undefined; +} + +export function lookupValueByPath(args: unknown, path: string): unknown { + if (!args || typeof args !== "object") { + return undefined; + } + let current: unknown = args; + for (const segment of path.split(".")) { + if (!segment) { + return undefined; + } + if (!current || typeof current !== "object") { + return undefined; + } + const record = current as Record; + current = record[segment]; + } + return current; +} + +export function formatDetailKey(raw: string, overrides: Record = {}): string { + const segments = raw.split(".").filter(Boolean); + const last = segments.at(-1) ?? raw; + const override = overrides[last]; + if (override) { + return override; + } + const cleaned = last.replace(/_/g, " ").replace(/-/g, " "); + const spaced = cleaned.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); + return spaced.trim().toLowerCase() || last.toLowerCase(); +} + +export function resolveReadDetail(args: unknown): string | undefined { + if (!args || typeof args !== "object") { + return undefined; + } + const record = args as Record; + const path = typeof record.path === "string" ? record.path : undefined; + if (!path) { + return undefined; + } + const offset = typeof record.offset === "number" ? record.offset : undefined; + const limit = typeof record.limit === "number" ? record.limit : undefined; + if (offset !== undefined && limit !== undefined) { + return `${path}:${offset}-${offset + limit}`; + } + return path; +} + +export function resolveWriteDetail(args: unknown): string | undefined { + if (!args || typeof args !== "object") { + return undefined; + } + const record = args as Record; + const path = typeof record.path === "string" ? record.path : undefined; + return path; +} + +export function resolveActionSpec( + spec: ToolDisplaySpec | undefined, + action: string | undefined, +): ToolDisplayActionSpec | undefined { + if (!spec || !action) { + return undefined; + } + return spec.actions?.[action] ?? undefined; +} + +export function resolveDetailFromKeys( + args: unknown, + keys: string[], + opts: { + mode: "first" | "summary"; + coerce?: CoerceDisplayValueOptions; + maxEntries?: number; + formatKey?: (raw: string) => string; + }, +): string | undefined { + if (opts.mode === "first") { + for (const key of keys) { + const value = lookupValueByPath(args, key); + const display = coerceDisplayValue(value, opts.coerce); + if (display) { + return display; + } + } + return undefined; + } + + const entries: Array<{ label: string; value: string }> = []; + for (const key of keys) { + const value = lookupValueByPath(args, key); + const display = coerceDisplayValue(value, opts.coerce); + if (!display) { + continue; + } + entries.push({ label: opts.formatKey ? opts.formatKey(key) : key, value: display }); + } + if (entries.length === 0) { + return undefined; + } + if (entries.length === 1) { + return entries[0].value; + } + + const seen = new Set(); + const unique: Array<{ label: string; value: string }> = []; + for (const entry of entries) { + const token = `${entry.label}:${entry.value}`; + if (seen.has(token)) { + continue; + } + seen.add(token); + unique.push(entry); + } + if (unique.length === 0) { + return undefined; + } + + return unique + .slice(0, opts.maxEntries ?? 8) + .map((entry) => `${entry.label} ${entry.value}`) + .join(" · "); +} diff --git a/src/agents/tool-display.ts b/src/agents/tool-display.ts index f3b1fae4fc..06ded51e65 100644 --- a/src/agents/tool-display.ts +++ b/src/agents/tool-display.ts @@ -1,18 +1,20 @@ import { redactToolDetail } from "../logging/redact.js"; import { shortenHomeInString } from "../utils.js"; +import { + defaultTitle, + formatDetailKey, + normalizeToolName, + normalizeVerb, + resolveActionSpec, + resolveDetailFromKeys, + resolveReadDetail, + resolveWriteDetail, + type ToolDisplaySpec as ToolDisplaySpecBase, +} from "./tool-display-common.js"; import TOOL_DISPLAY_JSON from "./tool-display.json" with { type: "json" }; -type ToolDisplayActionSpec = { - label?: string; - detailKeys?: string[]; -}; - -type ToolDisplaySpec = { +type ToolDisplaySpec = ToolDisplaySpecBase & { emoji?: string; - title?: string; - label?: string; - detailKeys?: string[]; - actions?: Record; }; type ToolDisplayConfig = { @@ -53,172 +55,6 @@ const DETAIL_LABEL_OVERRIDES: Record = { }; const MAX_DETAIL_ENTRIES = 8; -function normalizeToolName(name?: string): string { - return (name ?? "tool").trim(); -} - -function defaultTitle(name: string): string { - const cleaned = name.replace(/_/g, " ").trim(); - if (!cleaned) { - return "Tool"; - } - return cleaned - .split(/\s+/) - .map((part) => - part.length <= 2 && part.toUpperCase() === part - ? part - : `${part.at(0)?.toUpperCase() ?? ""}${part.slice(1)}`, - ) - .join(" "); -} - -function normalizeVerb(value?: string): string | undefined { - const trimmed = value?.trim(); - if (!trimmed) { - return undefined; - } - return trimmed.replace(/_/g, " "); -} - -function coerceDisplayValue(value: unknown): string | undefined { - if (value === null || value === undefined) { - return undefined; - } - if (typeof value === "string") { - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? ""; - if (!firstLine) { - return undefined; - } - return firstLine.length > 160 ? `${firstLine.slice(0, 157)}…` : firstLine; - } - if (typeof value === "boolean") { - return value ? "true" : undefined; - } - if (typeof value === "number") { - if (!Number.isFinite(value) || value === 0) { - return undefined; - } - return String(value); - } - if (Array.isArray(value)) { - const values = value - .map((item) => coerceDisplayValue(item)) - .filter((item): item is string => Boolean(item)); - if (values.length === 0) { - return undefined; - } - const preview = values.slice(0, 3).join(", "); - return values.length > 3 ? `${preview}…` : preview; - } - return undefined; -} - -function lookupValueByPath(args: unknown, path: string): unknown { - if (!args || typeof args !== "object") { - return undefined; - } - let current: unknown = args; - for (const segment of path.split(".")) { - if (!segment) { - return undefined; - } - if (!current || typeof current !== "object") { - return undefined; - } - const record = current as Record; - current = record[segment]; - } - return current; -} - -function formatDetailKey(raw: string): string { - const segments = raw.split(".").filter(Boolean); - const last = segments.at(-1) ?? raw; - const override = DETAIL_LABEL_OVERRIDES[last]; - if (override) { - return override; - } - const cleaned = last.replace(/_/g, " ").replace(/-/g, " "); - const spaced = cleaned.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); - return spaced.trim().toLowerCase() || last.toLowerCase(); -} - -function resolveDetailFromKeys(args: unknown, keys: string[]): string | undefined { - const entries: Array<{ label: string; value: string }> = []; - for (const key of keys) { - const value = lookupValueByPath(args, key); - const display = coerceDisplayValue(value); - if (!display) { - continue; - } - entries.push({ label: formatDetailKey(key), value: display }); - } - if (entries.length === 0) { - return undefined; - } - if (entries.length === 1) { - return entries[0].value; - } - - const seen = new Set(); - const unique: Array<{ label: string; value: string }> = []; - for (const entry of entries) { - const token = `${entry.label}:${entry.value}`; - if (seen.has(token)) { - continue; - } - seen.add(token); - unique.push(entry); - } - if (unique.length === 0) { - return undefined; - } - return unique - .slice(0, MAX_DETAIL_ENTRIES) - .map((entry) => `${entry.label} ${entry.value}`) - .join(" · "); -} - -function resolveReadDetail(args: unknown): string | undefined { - if (!args || typeof args !== "object") { - return undefined; - } - const record = args as Record; - const path = typeof record.path === "string" ? record.path : undefined; - if (!path) { - return undefined; - } - const offset = typeof record.offset === "number" ? record.offset : undefined; - const limit = typeof record.limit === "number" ? record.limit : undefined; - if (offset !== undefined && limit !== undefined) { - return `${path}:${offset}-${offset + limit}`; - } - return path; -} - -function resolveWriteDetail(args: unknown): string | undefined { - if (!args || typeof args !== "object") { - return undefined; - } - const record = args as Record; - const path = typeof record.path === "string" ? record.path : undefined; - return path; -} - -function resolveActionSpec( - spec: ToolDisplaySpec | undefined, - action: string | undefined, -): ToolDisplayActionSpec | undefined { - if (!spec || !action) { - return undefined; - } - return spec.actions?.[action] ?? undefined; -} - export function resolveToolDisplay(params: { name?: string; args?: unknown; @@ -248,7 +84,11 @@ export function resolveToolDisplay(params: { const detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? []; if (!detail && detailKeys.length > 0) { - detail = resolveDetailFromKeys(params.args, detailKeys); + detail = resolveDetailFromKeys(params.args, detailKeys, { + mode: "summary", + maxEntries: MAX_DETAIL_ENTRIES, + formatKey: (raw) => formatDetailKey(raw, DETAIL_LABEL_OVERRIDES), + }); } if (!detail && params.meta) { diff --git a/src/channels/allowlist-match.ts b/src/channels/allowlist-match.ts index d77fac1f98..09cc525dad 100644 --- a/src/channels/allowlist-match.ts +++ b/src/channels/allowlist-match.ts @@ -21,3 +21,32 @@ export function formatAllowlistMatchMeta( ): string { return `matchKey=${match?.matchKey ?? "none"} matchSource=${match?.matchSource ?? "none"}`; } + +export function resolveAllowlistMatchSimple(params: { + allowFrom: Array; + senderId: string; + senderName?: string | null; +}): AllowlistMatch<"wildcard" | "id" | "name"> { + const allowFrom = params.allowFrom + .map((entry) => String(entry).trim().toLowerCase()) + .filter(Boolean); + + if (allowFrom.length === 0) { + return { allowed: false }; + } + if (allowFrom.includes("*")) { + return { allowed: true, matchKey: "*", matchSource: "wildcard" }; + } + + const senderId = params.senderId.toLowerCase(); + if (allowFrom.includes(senderId)) { + return { allowed: true, matchKey: senderId, matchSource: "id" }; + } + + const senderName = params.senderName?.toLowerCase(); + if (senderName && allowFrom.includes(senderName)) { + return { allowed: true, matchKey: senderName, matchSource: "name" }; + } + + return { allowed: false }; +} diff --git a/src/channels/plugins/allowlist-match.ts b/src/channels/plugins/allowlist-match.ts index fdf4d1d7a9..3fd989abc2 100644 --- a/src/channels/plugins/allowlist-match.ts +++ b/src/channels/plugins/allowlist-match.ts @@ -1,2 +1,2 @@ export type { AllowlistMatch, AllowlistMatchSource } from "../allowlist-match.js"; -export { formatAllowlistMatchMeta } from "../allowlist-match.js"; +export { formatAllowlistMatchMeta, resolveAllowlistMatchSimple } from "../allowlist-match.js"; diff --git a/src/commands/model-picker.ts b/src/commands/model-picker.ts index 85f4851a1a..0afd7b2e6f 100644 --- a/src/commands/model-picker.ts +++ b/src/commands/model-picker.ts @@ -86,6 +86,52 @@ function normalizeModelKeys(values: string[]): string[] { return next; } +function addModelSelectOption(params: { + entry: { + provider: string; + id: string; + name?: string; + contextWindow?: number; + reasoning?: boolean; + }; + options: WizardSelectOption[]; + seen: Set; + aliasIndex: ReturnType; + hasAuth: (provider: string) => boolean; +}) { + const key = modelKey(params.entry.provider, params.entry.id); + if (params.seen.has(key)) { + return; + } + // Skip internal router models that can't be directly called via API. + if (HIDDEN_ROUTER_MODELS.has(key)) { + return; + } + const hints: string[] = []; + if (params.entry.name && params.entry.name !== params.entry.id) { + hints.push(params.entry.name); + } + if (params.entry.contextWindow) { + hints.push(`ctx ${formatTokenK(params.entry.contextWindow)}`); + } + if (params.entry.reasoning) { + hints.push("reasoning"); + } + const aliases = params.aliasIndex.byKey.get(key); + if (aliases?.length) { + hints.push(`alias: ${aliases.join(", ")}`); + } + if (!params.hasAuth(params.entry.provider)) { + hints.push("auth missing"); + } + params.options.push({ + value: key, + label: key, + hint: hints.length > 0 ? hints.join(" · ") : undefined, + }); + params.seen.add(key); +} + async function promptManualModel(params: { prompter: WizardPrompter; allowBlank: boolean; @@ -226,48 +272,9 @@ export async function promptDefaultModel( } const seen = new Set(); - const addModelOption = (entry: { - provider: string; - id: string; - name?: string; - contextWindow?: number; - reasoning?: boolean; - }) => { - const key = modelKey(entry.provider, entry.id); - if (seen.has(key)) { - return; - } - // Skip internal router models that can't be directly called via API. - if (HIDDEN_ROUTER_MODELS.has(key)) { - return; - } - const hints: string[] = []; - if (entry.name && entry.name !== entry.id) { - hints.push(entry.name); - } - if (entry.contextWindow) { - hints.push(`ctx ${formatTokenK(entry.contextWindow)}`); - } - if (entry.reasoning) { - hints.push("reasoning"); - } - const aliases = aliasIndex.byKey.get(key); - if (aliases?.length) { - hints.push(`alias: ${aliases.join(", ")}`); - } - if (!hasAuth(entry.provider)) { - hints.push("auth missing"); - } - options.push({ - value: key, - label: key, - hint: hints.length > 0 ? hints.join(" · ") : undefined, - }); - seen.add(key); - }; for (const entry of models) { - addModelOption(entry); + addModelSelectOption({ entry, options, seen, aliasIndex, hasAuth }); } if (configuredKey && !seen.has(configuredKey)) { @@ -392,51 +399,13 @@ export async function promptModelAllowlist(params: { const options: WizardSelectOption[] = []; const seen = new Set(); - const addModelOption = (entry: { - provider: string; - id: string; - name?: string; - contextWindow?: number; - reasoning?: boolean; - }) => { - const key = modelKey(entry.provider, entry.id); - if (seen.has(key)) { - return; - } - if (HIDDEN_ROUTER_MODELS.has(key)) { - return; - } - const hints: string[] = []; - if (entry.name && entry.name !== entry.id) { - hints.push(entry.name); - } - if (entry.contextWindow) { - hints.push(`ctx ${formatTokenK(entry.contextWindow)}`); - } - if (entry.reasoning) { - hints.push("reasoning"); - } - const aliases = aliasIndex.byKey.get(key); - if (aliases?.length) { - hints.push(`alias: ${aliases.join(", ")}`); - } - if (!hasAuth(entry.provider)) { - hints.push("auth missing"); - } - options.push({ - value: key, - label: key, - hint: hints.length > 0 ? hints.join(" · ") : undefined, - }); - seen.add(key); - }; const filteredCatalog = allowedKeySet ? catalog.filter((entry) => allowedKeySet.has(modelKey(entry.provider, entry.id))) : catalog; for (const entry of filteredCatalog) { - addModelOption(entry); + addModelSelectOption({ entry, options, seen, aliasIndex, hasAuth }); } const supplementalKeys = allowedKeySet ? allowedKeys : existingKeys; diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index 1beaf1c071..d3212e33cb 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -299,6 +299,29 @@ async function promptBaseUrlAndKey(params: { return { baseUrl: baseUrlInput.trim(), apiKey: apiKeyInput.trim() }; } +type CustomApiRetryChoice = "baseUrl" | "model" | "both"; + +async function promptCustomApiRetryChoice(prompter: WizardPrompter): Promise { + return await prompter.select({ + message: "What would you like to change?", + options: [ + { value: "baseUrl", label: "Change base URL" }, + { value: "model", label: "Change model" }, + { value: "both", label: "Change base URL and model" }, + ], + }); +} + +async function promptCustomApiModelId(prompter: WizardPrompter): Promise { + return ( + await prompter.text({ + message: "Model ID", + placeholder: "e.g. llama3, claude-3-7-sonnet", + validate: (val) => (val.trim() ? undefined : "Model ID is required"), + }) + ).trim(); +} + function resolveProviderApi( compatibility: CustomApiCompatibility, ): "openai-completions" | "anthropic-messages" { @@ -504,13 +527,7 @@ export async function promptCustomApiConfig(params: { })), }); - let modelId = ( - await prompter.text({ - message: "Model ID", - placeholder: "e.g. llama3, claude-3-7-sonnet", - validate: (val) => (val.trim() ? undefined : "Model ID is required"), - }) - ).trim(); + let modelId = await promptCustomApiModelId(prompter); let compatibility: CustomApiCompatibility | null = compatibilityChoice === "unknown" ? null : compatibilityChoice; @@ -536,14 +553,7 @@ export async function promptCustomApiConfig(params: { "This endpoint did not respond to OpenAI or Anthropic style requests.", "Endpoint detection", ); - const retryChoice = await prompter.select({ - message: "What would you like to change?", - options: [ - { value: "baseUrl", label: "Change base URL" }, - { value: "model", label: "Change model" }, - { value: "both", label: "Change base URL and model" }, - ], - }); + const retryChoice = await promptCustomApiRetryChoice(prompter); if (retryChoice === "baseUrl" || retryChoice === "both") { const retryInput = await promptBaseUrlAndKey({ prompter, @@ -553,13 +563,7 @@ export async function promptCustomApiConfig(params: { apiKey = retryInput.apiKey; } if (retryChoice === "model" || retryChoice === "both") { - modelId = ( - await prompter.text({ - message: "Model ID", - placeholder: "e.g. llama3, claude-3-7-sonnet", - validate: (val) => (val.trim() ? undefined : "Model ID is required"), - }) - ).trim(); + modelId = await promptCustomApiModelId(prompter); } continue; } @@ -584,14 +588,7 @@ export async function promptCustomApiConfig(params: { } else { verifySpinner.stop(`Verification failed: ${formatVerificationError(result.error)}`); } - const retryChoice = await prompter.select({ - message: "What would you like to change?", - options: [ - { value: "baseUrl", label: "Change base URL" }, - { value: "model", label: "Change model" }, - { value: "both", label: "Change base URL and model" }, - ], - }); + const retryChoice = await promptCustomApiRetryChoice(prompter); if (retryChoice === "baseUrl" || retryChoice === "both") { const retryInput = await promptBaseUrlAndKey({ prompter, @@ -601,13 +598,7 @@ export async function promptCustomApiConfig(params: { apiKey = retryInput.apiKey; } if (retryChoice === "model" || retryChoice === "both") { - modelId = ( - await prompter.text({ - message: "Model ID", - placeholder: "e.g. llama3, claude-3-7-sonnet", - validate: (val) => (val.trim() ? undefined : "Model ID is required"), - }) - ).trim(); + modelId = await promptCustomApiModelId(prompter); } if (compatibilityChoice === "unknown") { compatibility = null; diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index dab574c058..b8c5514712 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -25,6 +25,7 @@ import { type DiscoveredSession, } from "../../infra/session-cost-usage.js"; import { parseAgentSessionKey } from "../../routing/session-key.js"; +import { buildUsageAggregateTail } from "../../shared/usage-aggregates.js"; import { ErrorCodes, errorShape, @@ -692,6 +693,14 @@ export const usageHandlers: GatewayRequestHandlers = { return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`; }; + const tail = buildUsageAggregateTail({ + byChannelMap: byChannelMap, + latencyTotals, + dailyLatencyMap, + modelDailyMap, + dailyMap: dailyAggregateMap, + }); + const aggregates: SessionsUsageAggregates = { messages: aggregateMessages, tools: { @@ -718,35 +727,7 @@ export const usageHandlers: GatewayRequestHandlers = { byAgent: Array.from(byAgentMap.entries()) .map(([id, totals]) => ({ agentId: id, totals })) .toSorted((a, b) => b.totals.totalCost - a.totals.totalCost), - byChannel: Array.from(byChannelMap.entries()) - .map(([name, totals]) => ({ channel: name, totals })) - .toSorted((a, b) => b.totals.totalCost - a.totals.totalCost), - latency: - latencyTotals.count > 0 - ? { - count: latencyTotals.count, - avgMs: latencyTotals.sum / latencyTotals.count, - minMs: latencyTotals.min === Number.POSITIVE_INFINITY ? 0 : latencyTotals.min, - maxMs: latencyTotals.max, - p95Ms: latencyTotals.p95Max, - } - : undefined, - dailyLatency: Array.from(dailyLatencyMap.values()) - .map((entry) => ({ - date: entry.date, - count: entry.count, - avgMs: entry.count ? entry.sum / entry.count : 0, - minMs: entry.min === Number.POSITIVE_INFINITY ? 0 : entry.min, - maxMs: entry.max, - p95Ms: entry.p95Max, - })) - .toSorted((a, b) => a.date.localeCompare(b.date)), - modelDaily: Array.from(modelDailyMap.values()).toSorted( - (a, b) => a.date.localeCompare(b.date) || b.cost - a.cost, - ), - daily: Array.from(dailyAggregateMap.values()).toSorted((a, b) => - a.date.localeCompare(b.date), - ), + ...tail, }; const result: SessionsUsageResult = { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 7a7634b0bc..6a8d967244 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -121,6 +121,10 @@ export { MarkdownTableModeSchema, normalizeAllowFrom, requireOpenAllowFrom, + TtsAutoSchema, + TtsConfigSchema, + TtsModeSchema, + TtsProviderSchema, } from "../config/zod-schema.core.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export type { RuntimeEnv } from "../runtime.js"; @@ -227,7 +231,10 @@ export { listWhatsAppDirectoryPeersFromConfig, } from "../channels/plugins/directory-config.js"; export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js"; -export { formatAllowlistMatchMeta } from "../channels/plugins/allowlist-match.js"; +export { + formatAllowlistMatchMeta, + resolveAllowlistMatchSimple, +} from "../channels/plugins/allowlist-match.js"; export { optionalStringEnum, stringEnum } from "../agents/schema/typebox.js"; export type { PollInput } from "../polls.js"; diff --git a/src/shared/usage-aggregates.ts b/src/shared/usage-aggregates.ts new file mode 100644 index 0000000000..af2d316fc6 --- /dev/null +++ b/src/shared/usage-aggregates.ts @@ -0,0 +1,63 @@ +type LatencyTotalsLike = { + count: number; + sum: number; + min: number; + max: number; + p95Max: number; +}; + +type DailyLatencyLike = { + date: string; + count: number; + sum: number; + min: number; + max: number; + p95Max: number; +}; + +type DailyLike = { + date: string; +}; + +export function buildUsageAggregateTail< + TTotals extends { totalCost: number }, + TDaily extends DailyLike, + TModelDaily extends { date: string; cost: number }, +>(params: { + byChannelMap: Map; + latencyTotals: LatencyTotalsLike; + dailyLatencyMap: Map; + modelDailyMap: Map; + dailyMap: Map; +}) { + return { + byChannel: Array.from(params.byChannelMap.entries()) + .map(([channel, totals]) => ({ channel, totals })) + .toSorted((a, b) => b.totals.totalCost - a.totals.totalCost), + latency: + params.latencyTotals.count > 0 + ? { + count: params.latencyTotals.count, + avgMs: params.latencyTotals.sum / params.latencyTotals.count, + minMs: + params.latencyTotals.min === Number.POSITIVE_INFINITY ? 0 : params.latencyTotals.min, + maxMs: params.latencyTotals.max, + p95Ms: params.latencyTotals.p95Max, + } + : undefined, + dailyLatency: Array.from(params.dailyLatencyMap.values()) + .map((entry) => ({ + date: entry.date, + count: entry.count, + avgMs: entry.count ? entry.sum / entry.count : 0, + minMs: entry.min === Number.POSITIVE_INFINITY ? 0 : entry.min, + maxMs: entry.max, + p95Ms: entry.p95Max, + })) + .toSorted((a, b) => a.date.localeCompare(b.date)), + modelDaily: Array.from(params.modelDailyMap.values()).toSorted( + (a, b) => a.date.localeCompare(b.date) || b.cost - a.cost, + ), + daily: Array.from(params.dailyMap.values()).toSorted((a, b) => a.date.localeCompare(b.date)), + }; +} diff --git a/ui/src/ui/tool-display.ts b/ui/src/ui/tool-display.ts index 509381723a..695127df04 100644 --- a/ui/src/ui/tool-display.ts +++ b/ui/src/ui/tool-display.ts @@ -1,17 +1,19 @@ import type { IconName } from "./icons.ts"; +import { + defaultTitle, + normalizeToolName, + normalizeVerb, + resolveActionSpec, + resolveDetailFromKeys, + resolveReadDetail, + resolveWriteDetail, + type ToolDisplaySpec as ToolDisplaySpecBase, +} from "../../../src/agents/tool-display-common.js"; +import { shortenHomeInString } from "../../../src/utils.js"; import rawConfig from "./tool-display.json" with { type: "json" }; -type ToolDisplayActionSpec = { - label?: string; - detailKeys?: string[]; -}; - -type ToolDisplaySpec = { +type ToolDisplaySpec = ToolDisplaySpecBase & { icon?: string; - title?: string; - label?: string; - detailKeys?: string[]; - actions?: Record; }; type ToolDisplayConfig = { @@ -33,129 +35,6 @@ const TOOL_DISPLAY_CONFIG = rawConfig as ToolDisplayConfig; const FALLBACK = TOOL_DISPLAY_CONFIG.fallback ?? { icon: "puzzle" }; const TOOL_MAP = TOOL_DISPLAY_CONFIG.tools ?? {}; -function normalizeToolName(name?: string): string { - return (name ?? "tool").trim(); -} - -function defaultTitle(name: string): string { - const cleaned = name.replace(/_/g, " ").trim(); - if (!cleaned) { - return "Tool"; - } - return cleaned - .split(/\s+/) - .map((part) => - part.length <= 2 && part.toUpperCase() === part - ? part - : `${part.at(0)?.toUpperCase() ?? ""}${part.slice(1)}`, - ) - .join(" "); -} - -function normalizeVerb(value?: string): string | undefined { - const trimmed = value?.trim(); - if (!trimmed) { - return undefined; - } - return trimmed.replace(/_/g, " "); -} - -function coerceDisplayValue(value: unknown): string | undefined { - if (value === null || value === undefined) { - return undefined; - } - if (typeof value === "string") { - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? ""; - if (!firstLine) { - return undefined; - } - return firstLine.length > 160 ? `${firstLine.slice(0, 157)}…` : firstLine; - } - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - if (Array.isArray(value)) { - const values = value - .map((item) => coerceDisplayValue(item)) - .filter((item): item is string => Boolean(item)); - if (values.length === 0) { - return undefined; - } - const preview = values.slice(0, 3).join(", "); - return values.length > 3 ? `${preview}…` : preview; - } - return undefined; -} - -function lookupValueByPath(args: unknown, path: string): unknown { - if (!args || typeof args !== "object") { - return undefined; - } - let current: unknown = args; - for (const segment of path.split(".")) { - if (!segment) { - return undefined; - } - if (!current || typeof current !== "object") { - return undefined; - } - const record = current as Record; - current = record[segment]; - } - return current; -} - -function resolveDetailFromKeys(args: unknown, keys: string[]): string | undefined { - for (const key of keys) { - const value = lookupValueByPath(args, key); - const display = coerceDisplayValue(value); - if (display) { - return display; - } - } - return undefined; -} - -function resolveReadDetail(args: unknown): string | undefined { - if (!args || typeof args !== "object") { - return undefined; - } - const record = args as Record; - const path = typeof record.path === "string" ? record.path : undefined; - if (!path) { - return undefined; - } - const offset = typeof record.offset === "number" ? record.offset : undefined; - const limit = typeof record.limit === "number" ? record.limit : undefined; - if (offset !== undefined && limit !== undefined) { - return `${path}:${offset}-${offset + limit}`; - } - return path; -} - -function resolveWriteDetail(args: unknown): string | undefined { - if (!args || typeof args !== "object") { - return undefined; - } - const record = args as Record; - const path = typeof record.path === "string" ? record.path : undefined; - return path; -} - -function resolveActionSpec( - spec: ToolDisplaySpec | undefined, - action: string | undefined, -): ToolDisplayActionSpec | undefined { - if (!spec || !action) { - return undefined; - } - return spec.actions?.[action] ?? undefined; -} - export function resolveToolDisplay(params: { name?: string; args?: unknown; @@ -185,7 +64,10 @@ export function resolveToolDisplay(params: { const detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? []; if (!detail && detailKeys.length > 0) { - detail = resolveDetailFromKeys(params.args, detailKeys); + detail = resolveDetailFromKeys(params.args, detailKeys, { + mode: "first", + coerce: { includeFalse: true, includeZero: true }, + }); } if (!detail && params.meta) { @@ -224,10 +106,3 @@ export function formatToolSummary(display: ToolDisplay): string { const detail = formatToolDetail(display); return detail ? `${display.label}: ${detail}` : display.label; } - -function shortenHomeInString(input: string): string { - if (!input) { - return input; - } - return input.replace(/\/Users\/[^/]+/g, "~").replace(/\/home\/[^/]+/g, "~"); -} diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index b1114386c0..2d53a9ccbb 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -424,222 +424,15 @@ export type SessionsPatchResult = { }; }; -export type SessionsUsageEntry = { - key: string; - label?: string; - sessionId?: string; - updatedAt?: number; - agentId?: string; - channel?: string; - chatType?: string; - origin?: { - label?: string; - provider?: string; - surface?: string; - chatType?: string; - from?: string; - to?: string; - accountId?: string; - threadId?: string | number; - }; - modelOverride?: string; - providerOverride?: string; - modelProvider?: string; - model?: string; - usage: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - totalTokens: number; - totalCost: number; - inputCost?: number; - outputCost?: number; - cacheReadCost?: number; - cacheWriteCost?: number; - missingCostEntries: number; - firstActivity?: number; - lastActivity?: number; - durationMs?: number; - activityDates?: string[]; // YYYY-MM-DD dates when session had activity - dailyBreakdown?: Array<{ date: string; tokens: number; cost: number }>; - dailyMessageCounts?: Array<{ - date: string; - total: number; - user: number; - assistant: number; - toolCalls: number; - toolResults: number; - errors: number; - }>; - dailyLatency?: Array<{ - date: string; - count: number; - avgMs: number; - p95Ms: number; - minMs: number; - maxMs: number; - }>; - dailyModelUsage?: Array<{ - date: string; - provider?: string; - model?: string; - tokens: number; - cost: number; - count: number; - }>; - messageCounts?: { - total: number; - user: number; - assistant: number; - toolCalls: number; - toolResults: number; - errors: number; - }; - toolUsage?: { - totalCalls: number; - uniqueTools: number; - tools: Array<{ name: string; count: number }>; - }; - modelUsage?: Array<{ - provider?: string; - model?: string; - count: number; - totals: SessionsUsageTotals; - }>; - latency?: { - count: number; - avgMs: number; - p95Ms: number; - minMs: number; - maxMs: number; - }; - } | null; - contextWeight?: { - systemPrompt: { chars: number; projectContextChars: number; nonProjectContextChars: number }; - skills: { promptChars: number; entries: Array<{ name: string; blockChars: number }> }; - tools: { - listChars: number; - schemaChars: number; - entries: Array<{ name: string; summaryChars: number; schemaChars: number }>; - }; - injectedWorkspaceFiles: Array<{ - name: string; - path: string; - rawChars: number; - injectedChars: number; - truncated: boolean; - }>; - } | null; -}; - -export type SessionsUsageTotals = { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - totalTokens: number; - totalCost: number; - inputCost: number; - outputCost: number; - cacheReadCost: number; - cacheWriteCost: number; - missingCostEntries: number; -}; - -export type SessionsUsageResult = { - updatedAt: number; - startDate: string; - endDate: string; - sessions: SessionsUsageEntry[]; - totals: SessionsUsageTotals; - aggregates: { - messages: { - total: number; - user: number; - assistant: number; - toolCalls: number; - toolResults: number; - errors: number; - }; - tools: { - totalCalls: number; - uniqueTools: number; - tools: Array<{ name: string; count: number }>; - }; - byModel: Array<{ - provider?: string; - model?: string; - count: number; - totals: SessionsUsageTotals; - }>; - byProvider: Array<{ - provider?: string; - model?: string; - count: number; - totals: SessionsUsageTotals; - }>; - byAgent: Array<{ agentId: string; totals: SessionsUsageTotals }>; - byChannel: Array<{ channel: string; totals: SessionsUsageTotals }>; - latency?: { - count: number; - avgMs: number; - p95Ms: number; - minMs: number; - maxMs: number; - }; - dailyLatency?: Array<{ - date: string; - count: number; - avgMs: number; - p95Ms: number; - minMs: number; - maxMs: number; - }>; - modelDaily?: Array<{ - date: string; - provider?: string; - model?: string; - tokens: number; - cost: number; - count: number; - }>; - daily: Array<{ - date: string; - tokens: number; - cost: number; - messages: number; - toolCalls: number; - errors: number; - }>; - }; -}; - -export type CostUsageDailyEntry = SessionsUsageTotals & { date: string }; - -export type CostUsageSummary = { - updatedAt: number; - days: number; - daily: CostUsageDailyEntry[]; - totals: SessionsUsageTotals; -}; - -export type SessionUsageTimePoint = { - timestamp: number; - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - totalTokens: number; - cost: number; - cumulativeTokens: number; - cumulativeCost: number; -}; - -export type SessionUsageTimeSeries = { - sessionId?: string; - points: SessionUsageTimePoint[]; -}; +export type { + CostUsageDailyEntry, + CostUsageSummary, + SessionsUsageEntry, + SessionsUsageResult, + SessionsUsageTotals, + SessionUsageTimePoint, + SessionUsageTimeSeries, +} from "./usage-types.ts"; export type CronSchedule = | { kind: "at"; at: string } diff --git a/ui/src/ui/usage-types.ts b/ui/src/ui/usage-types.ts new file mode 100644 index 0000000000..258c684e06 --- /dev/null +++ b/ui/src/ui/usage-types.ts @@ -0,0 +1,216 @@ +export type SessionsUsageEntry = { + key: string; + label?: string; + sessionId?: string; + updatedAt?: number; + agentId?: string; + channel?: string; + chatType?: string; + origin?: { + label?: string; + provider?: string; + surface?: string; + chatType?: string; + from?: string; + to?: string; + accountId?: string; + threadId?: string | number; + }; + modelOverride?: string; + providerOverride?: string; + modelProvider?: string; + model?: string; + usage: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + totalCost: number; + inputCost?: number; + outputCost?: number; + cacheReadCost?: number; + cacheWriteCost?: number; + missingCostEntries: number; + firstActivity?: number; + lastActivity?: number; + durationMs?: number; + activityDates?: string[]; + dailyBreakdown?: Array<{ date: string; tokens: number; cost: number }>; + dailyMessageCounts?: Array<{ + date: string; + total: number; + user: number; + assistant: number; + toolCalls: number; + toolResults: number; + errors: number; + }>; + dailyLatency?: Array<{ + date: string; + count: number; + avgMs: number; + p95Ms: number; + minMs: number; + maxMs: number; + }>; + dailyModelUsage?: Array<{ + date: string; + provider?: string; + model?: string; + tokens: number; + cost: number; + count: number; + }>; + messageCounts?: { + total: number; + user: number; + assistant: number; + toolCalls: number; + toolResults: number; + errors: number; + }; + toolUsage?: { + totalCalls: number; + uniqueTools: number; + tools: Array<{ name: string; count: number }>; + }; + modelUsage?: Array<{ + provider?: string; + model?: string; + count: number; + totals: SessionsUsageTotals; + }>; + latency?: { + count: number; + avgMs: number; + p95Ms: number; + minMs: number; + maxMs: number; + }; + } | null; + contextWeight?: { + systemPrompt: { chars: number; projectContextChars: number; nonProjectContextChars: number }; + skills: { promptChars: number; entries: Array<{ name: string; blockChars: number }> }; + tools: { + listChars: number; + schemaChars: number; + entries: Array<{ name: string; summaryChars: number; schemaChars: number }>; + }; + injectedWorkspaceFiles: Array<{ + name: string; + path: string; + rawChars: number; + injectedChars: number; + truncated: boolean; + }>; + } | null; +}; + +export type SessionsUsageTotals = { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + totalCost: number; + inputCost: number; + outputCost: number; + cacheReadCost: number; + cacheWriteCost: number; + missingCostEntries: number; +}; + +export type SessionsUsageResult = { + updatedAt: number; + startDate: string; + endDate: string; + sessions: SessionsUsageEntry[]; + totals: SessionsUsageTotals; + aggregates: { + messages: { + total: number; + user: number; + assistant: number; + toolCalls: number; + toolResults: number; + errors: number; + }; + tools: { + totalCalls: number; + uniqueTools: number; + tools: Array<{ name: string; count: number }>; + }; + byModel: Array<{ + provider?: string; + model?: string; + count: number; + totals: SessionsUsageTotals; + }>; + byProvider: Array<{ + provider?: string; + model?: string; + count: number; + totals: SessionsUsageTotals; + }>; + byAgent: Array<{ agentId: string; totals: SessionsUsageTotals }>; + byChannel: Array<{ channel: string; totals: SessionsUsageTotals }>; + latency?: { + count: number; + avgMs: number; + p95Ms: number; + minMs: number; + maxMs: number; + }; + dailyLatency?: Array<{ + date: string; + count: number; + avgMs: number; + p95Ms: number; + minMs: number; + maxMs: number; + }>; + modelDaily?: Array<{ + date: string; + provider?: string; + model?: string; + tokens: number; + cost: number; + count: number; + }>; + daily: Array<{ + date: string; + tokens: number; + cost: number; + messages: number; + toolCalls: number; + errors: number; + }>; + }; +}; + +export type CostUsageDailyEntry = SessionsUsageTotals & { date: string }; + +export type CostUsageSummary = { + updatedAt: number; + days: number; + daily: CostUsageDailyEntry[]; + totals: SessionsUsageTotals; +}; + +export type SessionUsageTimePoint = { + timestamp: number; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + cost: number; + cumulativeTokens: number; + cumulativeCost: number; +}; + +export type SessionUsageTimeSeries = { + sessionId?: string; + points: SessionUsageTimePoint[]; +}; diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/ui/views/agents-panels-tools-skills.ts index 8017ad73a5..ac1884f17c 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; import type { SkillStatusEntry, SkillStatusReport } from "../types.ts"; +import type { SkillGroup } from "./skills-grouping.ts"; import { normalizeToolName } from "../../../../src/agents/tool-policy.js"; import { isAllowedByPolicy, @@ -9,6 +10,7 @@ import { resolveToolProfile, TOOL_SECTIONS, } from "./agents-utils.ts"; +import { groupSkills } from "./skills-grouping.ts"; export function renderAgentTools(params: { agentId: string; @@ -242,45 +244,6 @@ export function renderAgentTools(params: { `; } -type SkillGroup = { - id: string; - label: string; - skills: SkillStatusEntry[]; -}; - -const SKILL_SOURCE_GROUPS: Array<{ id: string; label: string; sources: string[] }> = [ - { id: "workspace", label: "Workspace Skills", sources: ["openclaw-workspace"] }, - { id: "built-in", label: "Built-in Skills", sources: ["openclaw-bundled"] }, - { id: "installed", label: "Installed Skills", sources: ["openclaw-managed"] }, - { id: "extra", label: "Extra Skills", sources: ["openclaw-extra"] }, -]; - -function groupSkills(skills: SkillStatusEntry[]): SkillGroup[] { - const groups = new Map(); - for (const def of SKILL_SOURCE_GROUPS) { - groups.set(def.id, { id: def.id, label: def.label, skills: [] }); - } - const builtInGroup = SKILL_SOURCE_GROUPS.find((group) => group.id === "built-in"); - const other: SkillGroup = { id: "other", label: "Other Skills", skills: [] }; - for (const skill of skills) { - const match = skill.bundled - ? builtInGroup - : SKILL_SOURCE_GROUPS.find((group) => group.sources.includes(skill.source)); - if (match) { - groups.get(match.id)?.skills.push(skill); - } else { - other.skills.push(skill); - } - } - const ordered = SKILL_SOURCE_GROUPS.map((group) => groups.get(group.id)).filter( - (group): group is SkillGroup => Boolean(group && group.skills.length > 0), - ); - if (other.skills.length > 0) { - ordered.push(other); - } - return ordered; -} - export function renderAgentSkills(params: { agentId: string; report: SkillStatusReport | null; diff --git a/ui/src/ui/views/skills-grouping.ts b/ui/src/ui/views/skills-grouping.ts new file mode 100644 index 0000000000..1316454d59 --- /dev/null +++ b/ui/src/ui/views/skills-grouping.ts @@ -0,0 +1,40 @@ +import type { SkillStatusEntry } from "../types.ts"; + +export type SkillGroup = { + id: string; + label: string; + skills: SkillStatusEntry[]; +}; + +const SKILL_SOURCE_GROUPS: Array<{ id: string; label: string; sources: string[] }> = [ + { id: "workspace", label: "Workspace Skills", sources: ["openclaw-workspace"] }, + { id: "built-in", label: "Built-in Skills", sources: ["openclaw-bundled"] }, + { id: "installed", label: "Installed Skills", sources: ["openclaw-managed"] }, + { id: "extra", label: "Extra Skills", sources: ["openclaw-extra"] }, +]; + +export function groupSkills(skills: SkillStatusEntry[]): SkillGroup[] { + const groups = new Map(); + for (const def of SKILL_SOURCE_GROUPS) { + groups.set(def.id, { id: def.id, label: def.label, skills: [] }); + } + const builtInGroup = SKILL_SOURCE_GROUPS.find((group) => group.id === "built-in"); + const other: SkillGroup = { id: "other", label: "Other Skills", skills: [] }; + for (const skill of skills) { + const match = skill.bundled + ? builtInGroup + : SKILL_SOURCE_GROUPS.find((group) => group.sources.includes(skill.source)); + if (match) { + groups.get(match.id)?.skills.push(skill); + } else { + other.skills.push(skill); + } + } + const ordered = SKILL_SOURCE_GROUPS.map((group) => groups.get(group.id)).filter( + (group): group is SkillGroup => Boolean(group && group.skills.length > 0), + ); + if (other.skills.length > 0) { + ordered.push(other); + } + return ordered; +} diff --git a/ui/src/ui/views/skills.ts b/ui/src/ui/views/skills.ts index 40c04692c8..4a82228896 100644 --- a/ui/src/ui/views/skills.ts +++ b/ui/src/ui/views/skills.ts @@ -2,45 +2,7 @@ import { html, nothing } from "lit"; import type { SkillMessageMap } from "../controllers/skills.ts"; import type { SkillStatusEntry, SkillStatusReport } from "../types.ts"; import { clampText } from "../format.ts"; - -type SkillGroup = { - id: string; - label: string; - skills: SkillStatusEntry[]; -}; - -const SKILL_SOURCE_GROUPS: Array<{ id: string; label: string; sources: string[] }> = [ - { id: "workspace", label: "Workspace Skills", sources: ["openclaw-workspace"] }, - { id: "built-in", label: "Built-in Skills", sources: ["openclaw-bundled"] }, - { id: "installed", label: "Installed Skills", sources: ["openclaw-managed"] }, - { id: "extra", label: "Extra Skills", sources: ["openclaw-extra"] }, -]; - -function groupSkills(skills: SkillStatusEntry[]): SkillGroup[] { - const groups = new Map(); - for (const def of SKILL_SOURCE_GROUPS) { - groups.set(def.id, { id: def.id, label: def.label, skills: [] }); - } - const builtInGroup = SKILL_SOURCE_GROUPS.find((group) => group.id === "built-in"); - const other: SkillGroup = { id: "other", label: "Other Skills", skills: [] }; - for (const skill of skills) { - const match = skill.bundled - ? builtInGroup - : SKILL_SOURCE_GROUPS.find((group) => group.sources.includes(skill.source)); - if (match) { - groups.get(match.id)?.skills.push(skill); - } else { - other.skills.push(skill); - } - } - const ordered = SKILL_SOURCE_GROUPS.map((group) => groups.get(group.id)).filter( - (group): group is SkillGroup => Boolean(group && group.skills.length > 0), - ); - if (other.skills.length > 0) { - ordered.push(other); - } - return ordered; -} +import { groupSkills } from "./skills-grouping.ts"; export type SkillsProps = { loading: boolean; diff --git a/ui/src/ui/views/usage-metrics.ts b/ui/src/ui/views/usage-metrics.ts index 32dd457f5e..70ae497de2 100644 --- a/ui/src/ui/views/usage-metrics.ts +++ b/ui/src/ui/views/usage-metrics.ts @@ -1,4 +1,5 @@ import { html } from "lit"; +import { buildUsageAggregateTail } from "../../../../src/shared/usage-aggregates.js"; import { UsageSessionEntry, UsageTotals, UsageAggregates } from "./usageTypes.ts"; const CHARS_PER_TOKEN = 4; @@ -494,6 +495,14 @@ const buildAggregatesFromSessions = ( } } + const tail = buildUsageAggregateTail({ + byChannelMap: channelMap, + latencyTotals, + dailyLatencyMap, + modelDailyMap, + dailyMap, + }); + return { messages, tools: { @@ -512,33 +521,7 @@ const buildAggregatesFromSessions = ( byAgent: Array.from(agentMap.entries()) .map(([agentId, totals]) => ({ agentId, totals })) .toSorted((a, b) => b.totals.totalCost - a.totals.totalCost), - byChannel: Array.from(channelMap.entries()) - .map(([channel, totals]) => ({ channel, totals })) - .toSorted((a, b) => b.totals.totalCost - a.totals.totalCost), - latency: - latencyTotals.count > 0 - ? { - count: latencyTotals.count, - avgMs: latencyTotals.sum / latencyTotals.count, - minMs: latencyTotals.min === Number.POSITIVE_INFINITY ? 0 : latencyTotals.min, - maxMs: latencyTotals.max, - p95Ms: latencyTotals.p95Max, - } - : undefined, - dailyLatency: Array.from(dailyLatencyMap.values()) - .map((entry) => ({ - date: entry.date, - count: entry.count, - avgMs: entry.count ? entry.sum / entry.count : 0, - minMs: entry.min === Number.POSITIVE_INFINITY ? 0 : entry.min, - maxMs: entry.max, - p95Ms: entry.p95Max, - })) - .toSorted((a, b) => a.date.localeCompare(b.date)), - modelDaily: Array.from(modelDailyMap.values()).toSorted( - (a, b) => a.date.localeCompare(b.date) || b.cost - a.cost, - ), - daily: Array.from(dailyMap.values()).toSorted((a, b) => a.date.localeCompare(b.date)), + ...tail, }; }; diff --git a/ui/src/ui/views/usageTypes.ts b/ui/src/ui/views/usageTypes.ts index 7b73ea902c..d25ae16fba 100644 --- a/ui/src/ui/views/usageTypes.ts +++ b/ui/src/ui/views/usageTypes.ts @@ -1,188 +1,15 @@ -export type UsageSessionEntry = { - key: string; - label?: string; - sessionId?: string; - updatedAt?: number; - agentId?: string; - channel?: string; - chatType?: string; - origin?: { - label?: string; - provider?: string; - surface?: string; - chatType?: string; - from?: string; - to?: string; - accountId?: string; - threadId?: string | number; - }; - modelOverride?: string; - providerOverride?: string; - modelProvider?: string; - model?: string; - usage: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - totalTokens: number; - totalCost: number; - inputCost?: number; - outputCost?: number; - cacheReadCost?: number; - cacheWriteCost?: number; - missingCostEntries: number; - firstActivity?: number; - lastActivity?: number; - durationMs?: number; - activityDates?: string[]; // YYYY-MM-DD dates when session had activity - dailyBreakdown?: Array<{ date: string; tokens: number; cost: number }>; // Per-day breakdown - dailyMessageCounts?: Array<{ - date: string; - total: number; - user: number; - assistant: number; - toolCalls: number; - toolResults: number; - errors: number; - }>; - dailyLatency?: Array<{ - date: string; - count: number; - avgMs: number; - p95Ms: number; - minMs: number; - maxMs: number; - }>; - dailyModelUsage?: Array<{ - date: string; - provider?: string; - model?: string; - tokens: number; - cost: number; - count: number; - }>; - messageCounts?: { - total: number; - user: number; - assistant: number; - toolCalls: number; - toolResults: number; - errors: number; - }; - toolUsage?: { - totalCalls: number; - uniqueTools: number; - tools: Array<{ name: string; count: number }>; - }; - modelUsage?: Array<{ - provider?: string; - model?: string; - count: number; - totals: UsageTotals; - }>; - latency?: { - count: number; - avgMs: number; - p95Ms: number; - minMs: number; - maxMs: number; - }; - } | null; - contextWeight?: { - systemPrompt: { chars: number; projectContextChars: number; nonProjectContextChars: number }; - skills: { promptChars: number; entries: Array<{ name: string; blockChars: number }> }; - tools: { - listChars: number; - schemaChars: number; - entries: Array<{ name: string; summaryChars: number; schemaChars: number }>; - }; - injectedWorkspaceFiles: Array<{ - name: string; - path: string; - rawChars: number; - injectedChars: number; - truncated: boolean; - }>; - } | null; -}; +import type { + CostUsageDailyEntry, + SessionsUsageEntry, + SessionsUsageResult, + SessionsUsageTotals, + SessionUsageTimePoint, +} from "../usage-types.ts"; -export type UsageTotals = { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - totalTokens: number; - totalCost: number; - inputCost: number; - outputCost: number; - cacheReadCost: number; - cacheWriteCost: number; - missingCostEntries: number; -}; - -export type CostDailyEntry = UsageTotals & { date: string }; - -export type UsageAggregates = { - messages: { - total: number; - user: number; - assistant: number; - toolCalls: number; - toolResults: number; - errors: number; - }; - tools: { - totalCalls: number; - uniqueTools: number; - tools: Array<{ name: string; count: number }>; - }; - byModel: Array<{ - provider?: string; - model?: string; - count: number; - totals: UsageTotals; - }>; - byProvider: Array<{ - provider?: string; - model?: string; - count: number; - totals: UsageTotals; - }>; - byAgent: Array<{ agentId: string; totals: UsageTotals }>; - byChannel: Array<{ channel: string; totals: UsageTotals }>; - latency?: { - count: number; - avgMs: number; - p95Ms: number; - minMs: number; - maxMs: number; - }; - dailyLatency?: Array<{ - date: string; - count: number; - avgMs: number; - p95Ms: number; - minMs: number; - maxMs: number; - }>; - modelDaily?: Array<{ - date: string; - provider?: string; - model?: string; - tokens: number; - cost: number; - count: number; - }>; - daily: Array<{ - date: string; - tokens: number; - cost: number; - messages: number; - toolCalls: number; - errors: number; - }>; -}; +export type UsageSessionEntry = SessionsUsageEntry; +export type UsageTotals = SessionsUsageTotals; +export type CostDailyEntry = CostUsageDailyEntry; +export type UsageAggregates = SessionsUsageResult["aggregates"]; export type UsageColumnId = | "channel" @@ -194,17 +21,7 @@ export type UsageColumnId = | "errors" | "duration"; -export type TimeSeriesPoint = { - timestamp: number; - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - totalTokens: number; - cost: number; - cumulativeTokens: number; - cumulativeCost: number; -}; +export type TimeSeriesPoint = SessionUsageTimePoint; export type UsageProps = { loading: boolean;