mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-09 05:19:32 +08:00
Web UI: add token usage dashboard (#10072)
* feat(ui): Token Usage dashboard with session analytics Adds a comprehensive Token Usage view to the dashboard: Backend: - Extended session-cost-usage.ts with per-session daily breakdown - Added date range filtering (startMs/endMs) to API endpoints - New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints - Cost breakdown by token type (input/output/cache read/write) Frontend: - Two-column layout: Daily chart + breakdown | Sessions list - Interactive daily bar chart with click-to-filter and shift-click range select - Session detail panel with usage timeline, conversation logs, context weight - Filter chips for active day/session selections - Toggle between tokens/cost view modes (default: cost) - Responsive design for smaller screens UX improvements: - 21-day default date range - Debounced date input (400ms) - Session list shows filtered totals when days selected - Context weight breakdown shows skills, tools, files contribution * fix(ui): restore gatewayUrl validation and syncUrlWithSessionKey signature - Restore normalizeGatewayUrl() to validate ws:/wss: protocol - Restore isTopLevelWindow() guard for iframe security - Revert syncUrlWithSessionKey signature (host param was unused) * feat(ui): Token Usage dashboard with session analytics Adds a comprehensive Token Usage view to the dashboard: Backend: - Extended session-cost-usage.ts with per-session daily breakdown - Added date range filtering (startMs/endMs) to API endpoints - New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints - Cost breakdown by token type (input/output/cache read/write) Frontend: - Two-column layout: Daily chart + breakdown | Sessions list - Interactive daily bar chart with click-to-filter and shift-click range select - Session detail panel with usage timeline, conversation logs, context weight - Filter chips for active day/session selections - Toggle between tokens/cost view modes (default: cost) - Responsive design for smaller screens UX improvements: - 21-day default date range - Debounced date input (400ms) - Session list shows filtered totals when days selected - Context weight breakdown shows skills, tools, files contribution * fix: usage dashboard data + cost handling (#8462) (thanks @mcinteerj) * Usage: enrich metrics dashboard * Usage: add latency + model trends * Gateway: improve usage log parsing * UI: add usage query helpers * UI: client-side usage filter + debounce * Build: harden write-cli-compat timing * UI: add conversation log filters * UI: fix usage dashboard lint + state * Web UI: default usage dates to local day * Protocol: sync session usage params (#8462) (thanks @mcinteerj, @TakHoffman) --------- Co-authored-by: Jake McInteer <mcinteerj@gmail.com>
This commit is contained in:
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.
|
||||
- Onboarding: add xAI (Grok) auth choice and provider defaults. (#9885) Thanks @grp06.
|
||||
- Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.
|
||||
- Web UI: add Token Usage dashboard with session analytics. (#8462) Thanks @mcinteerj.
|
||||
- Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123.
|
||||
- Docs: strengthen secure DM mode guidance for multi-user inboxes with an explicit warning and example. (#9377) Thanks @Shrinija17.
|
||||
- Docs: document `activeHours` heartbeat field with timezone resolution chain and example. (#9366) Thanks @unisone.
|
||||
@@ -53,6 +54,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Web UI: resolve header logo path when `gateway.controlUi.basePath` is set. (#7178) Thanks @Yeom-JinHo.
|
||||
- Web UI: apply button styling to the new-messages indicator.
|
||||
- Onboarding: infer auth choice from non-interactive API key flags. (#8484) Thanks @f-trycua.
|
||||
- Usage: include estimated cost when breakdown is missing and keep `usage.cost` days support. (#8462) Thanks @mcinteerj.
|
||||
- Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.
|
||||
- Security: redact channel credentials (tokens, passwords, API keys, secrets) from gateway config APIs and preserve secrets during Control UI round-trips. (#9858) Thanks @abdelsfane.
|
||||
- Discord: treat allowlisted senders as owner for system-prompt identity hints while keeping channel topics untrusted.
|
||||
|
||||
@@ -1119,6 +1119,35 @@ public struct SessionsCompactParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsUsageParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let startdate: String?
|
||||
public let enddate: String?
|
||||
public let limit: Int?
|
||||
public let includecontextweight: Bool?
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
startdate: String?,
|
||||
enddate: String?,
|
||||
limit: Int?,
|
||||
includecontextweight: Bool?
|
||||
) {
|
||||
self.key = key
|
||||
self.startdate = startdate
|
||||
self.enddate = enddate
|
||||
self.limit = limit
|
||||
self.includecontextweight = includecontextweight
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case startdate = "startDate"
|
||||
case enddate = "endDate"
|
||||
case limit
|
||||
case includecontextweight = "includeContextWeight"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigGetParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
|
||||
@@ -1119,6 +1119,35 @@ public struct SessionsCompactParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsUsageParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let startdate: String?
|
||||
public let enddate: String?
|
||||
public let limit: Int?
|
||||
public let includecontextweight: Bool?
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
startdate: String?,
|
||||
enddate: String?,
|
||||
limit: Int?,
|
||||
includecontextweight: Bool?
|
||||
) {
|
||||
self.key = key
|
||||
self.startdate = startdate
|
||||
self.enddate = enddate
|
||||
self.limit = limit
|
||||
self.includecontextweight = includecontextweight
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case startdate = "startDate"
|
||||
case enddate = "endDate"
|
||||
case limit
|
||||
case includecontextweight = "includeContextWeight"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigGetParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,18 @@ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||
const distDir = path.join(rootDir, "dist");
|
||||
const cliDir = path.join(distDir, "cli");
|
||||
|
||||
const candidates = fs
|
||||
.readdirSync(distDir)
|
||||
.filter((entry) => entry.startsWith("daemon-cli-") && entry.endsWith(".js"));
|
||||
const findCandidates = () =>
|
||||
fs
|
||||
.readdirSync(distDir)
|
||||
.filter((entry) => entry.startsWith("daemon-cli-") && entry.endsWith(".js"));
|
||||
|
||||
// In rare cases, build output can land slightly after this script starts (depending on FS timing).
|
||||
// Retry briefly to avoid flaky builds.
|
||||
let candidates = findCandidates();
|
||||
for (let i = 0; i < 10 && candidates.length === 0; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
candidates = findCandidates();
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new Error("No daemon-cli bundle found in dist; cannot write legacy CLI shim.");
|
||||
|
||||
@@ -161,6 +161,8 @@ import {
|
||||
SessionsResetParamsSchema,
|
||||
type SessionsResolveParams,
|
||||
SessionsResolveParamsSchema,
|
||||
type SessionsUsageParams,
|
||||
SessionsUsageParamsSchema,
|
||||
type ShutdownEvent,
|
||||
ShutdownEventSchema,
|
||||
type SkillsBinsParams,
|
||||
@@ -271,6 +273,8 @@ export const validateSessionsDeleteParams = ajv.compile<SessionsDeleteParams>(
|
||||
export const validateSessionsCompactParams = ajv.compile<SessionsCompactParams>(
|
||||
SessionsCompactParamsSchema,
|
||||
);
|
||||
export const validateSessionsUsageParams =
|
||||
ajv.compile<SessionsUsageParams>(SessionsUsageParamsSchema);
|
||||
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(ConfigGetParamsSchema);
|
||||
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(ConfigSetParamsSchema);
|
||||
export const validateConfigApplyParams = ajv.compile<ConfigApplyParams>(ConfigApplyParamsSchema);
|
||||
@@ -412,6 +416,7 @@ export {
|
||||
SessionsResetParamsSchema,
|
||||
SessionsDeleteParamsSchema,
|
||||
SessionsCompactParamsSchema,
|
||||
SessionsUsageParamsSchema,
|
||||
ConfigGetParamsSchema,
|
||||
ConfigSetParamsSchema,
|
||||
ConfigApplyParamsSchema,
|
||||
@@ -541,6 +546,7 @@ export type {
|
||||
SessionsResetParams,
|
||||
SessionsDeleteParams,
|
||||
SessionsCompactParams,
|
||||
SessionsUsageParams,
|
||||
CronJob,
|
||||
CronListParams,
|
||||
CronStatusParams,
|
||||
|
||||
@@ -117,6 +117,7 @@ import {
|
||||
SessionsPreviewParamsSchema,
|
||||
SessionsResetParamsSchema,
|
||||
SessionsResolveParamsSchema,
|
||||
SessionsUsageParamsSchema,
|
||||
} from "./sessions.js";
|
||||
import { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
|
||||
import {
|
||||
@@ -168,6 +169,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
SessionsResetParams: SessionsResetParamsSchema,
|
||||
SessionsDeleteParams: SessionsDeleteParamsSchema,
|
||||
SessionsCompactParams: SessionsCompactParamsSchema,
|
||||
SessionsUsageParams: SessionsUsageParamsSchema,
|
||||
ConfigGetParams: ConfigGetParamsSchema,
|
||||
ConfigSetParams: ConfigSetParamsSchema,
|
||||
ConfigApplyParams: ConfigApplyParamsSchema,
|
||||
|
||||
@@ -101,3 +101,19 @@ export const SessionsCompactParamsSchema = Type.Object(
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsUsageParamsSchema = Type.Object(
|
||||
{
|
||||
/** Specific session key to analyze; if omitted returns all sessions. */
|
||||
key: Type.Optional(NonEmptyString),
|
||||
/** Start date for range filter (YYYY-MM-DD). */
|
||||
startDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
|
||||
/** End date for range filter (YYYY-MM-DD). */
|
||||
endDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
|
||||
/** Maximum sessions to return (default 50). */
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
/** Include context weight breakdown (systemPromptReport). */
|
||||
includeContextWeight: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -110,6 +110,7 @@ import type {
|
||||
SessionsPreviewParamsSchema,
|
||||
SessionsResetParamsSchema,
|
||||
SessionsResolveParamsSchema,
|
||||
SessionsUsageParamsSchema,
|
||||
} from "./sessions.js";
|
||||
import type { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
|
||||
import type {
|
||||
@@ -157,6 +158,7 @@ export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
||||
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
|
||||
export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
|
||||
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
|
||||
export type SessionsUsageParams = Static<typeof SessionsUsageParamsSchema>;
|
||||
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
||||
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
|
||||
export type ConfigApplyParams = Static<typeof ConfigApplyParamsSchema>;
|
||||
|
||||
82
src/gateway/server-methods/usage.test.ts
Normal file
82
src/gateway/server-methods/usage.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../infra/session-cost-usage.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../infra/session-cost-usage.js")>(
|
||||
"../../infra/session-cost-usage.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadCostUsageSummary: vi.fn(async () => ({
|
||||
updatedAt: Date.now(),
|
||||
startDate: "2026-02-01",
|
||||
endDate: "2026-02-02",
|
||||
daily: [],
|
||||
totals: { totalTokens: 1, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0 },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
|
||||
import { __test } from "./usage.js";
|
||||
|
||||
describe("gateway usage helpers", () => {
|
||||
beforeEach(() => {
|
||||
__test.costUsageCache.clear();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("parseDateToMs accepts YYYY-MM-DD and rejects invalid input", () => {
|
||||
expect(__test.parseDateToMs("2026-02-05")).toBe(Date.UTC(2026, 1, 5));
|
||||
expect(__test.parseDateToMs(" 2026-02-05 ")).toBe(Date.UTC(2026, 1, 5));
|
||||
expect(__test.parseDateToMs("2026-2-5")).toBeUndefined();
|
||||
expect(__test.parseDateToMs("nope")).toBeUndefined();
|
||||
expect(__test.parseDateToMs(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parseDays coerces strings/numbers to integers", () => {
|
||||
expect(__test.parseDays(7.9)).toBe(7);
|
||||
expect(__test.parseDays("30")).toBe(30);
|
||||
expect(__test.parseDays("")).toBeUndefined();
|
||||
expect(__test.parseDays("nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parseDateRange uses explicit start/end (inclusive end of day)", () => {
|
||||
const range = __test.parseDateRange({ startDate: "2026-02-01", endDate: "2026-02-02" });
|
||||
expect(range.startMs).toBe(Date.UTC(2026, 1, 1));
|
||||
expect(range.endMs).toBe(Date.UTC(2026, 1, 2) + 24 * 60 * 60 * 1000 - 1);
|
||||
});
|
||||
|
||||
it("parseDateRange clamps days to at least 1 and defaults to 30 days", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-05T12:34:56.000Z"));
|
||||
const oneDay = __test.parseDateRange({ days: 0 });
|
||||
expect(oneDay.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1);
|
||||
expect(oneDay.startMs).toBe(Date.UTC(2026, 1, 5));
|
||||
|
||||
const def = __test.parseDateRange({});
|
||||
expect(def.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1);
|
||||
expect(def.startMs).toBe(Date.UTC(2026, 1, 5) - 29 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("loadCostUsageSummaryCached caches within TTL", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-05T00:00:00.000Z"));
|
||||
|
||||
const config = {} as unknown as ReturnType<import("../../config/config.js").loadConfig>;
|
||||
const a = await __test.loadCostUsageSummaryCached({
|
||||
startMs: 1,
|
||||
endMs: 2,
|
||||
config,
|
||||
});
|
||||
const b = await __test.loadCostUsageSummaryCached({
|
||||
startMs: 1,
|
||||
endMs: 2,
|
||||
config,
|
||||
});
|
||||
|
||||
expect(a.totals.totalTokens).toBe(1);
|
||||
expect(b.totals.totalTokens).toBe(1);
|
||||
expect(vi.mocked(loadCostUsageSummary)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,68 @@
|
||||
import type { CostUsageSummary } from "../../infra/session-cost-usage.js";
|
||||
import fs from "node:fs";
|
||||
import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js";
|
||||
import type {
|
||||
CostUsageSummary,
|
||||
SessionCostSummary,
|
||||
SessionDailyLatency,
|
||||
SessionDailyModelUsage,
|
||||
SessionMessageCounts,
|
||||
SessionLatencyStats,
|
||||
SessionModelUsage,
|
||||
SessionToolUsage,
|
||||
} from "../../infra/session-cost-usage.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { resolveSessionFilePath } from "../../config/sessions/paths.js";
|
||||
import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
|
||||
import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
|
||||
import {
|
||||
loadCostUsageSummary,
|
||||
loadSessionCostSummary,
|
||||
loadSessionUsageTimeSeries,
|
||||
discoverAllSessions,
|
||||
} from "../../infra/session-cost-usage.js";
|
||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateSessionsUsageParams,
|
||||
} from "../protocol/index.js";
|
||||
import { loadCombinedSessionStoreForGateway, loadSessionEntry } from "../session-utils.js";
|
||||
|
||||
const COST_USAGE_CACHE_TTL_MS = 30_000;
|
||||
|
||||
type DateRange = { startMs: number; endMs: number };
|
||||
|
||||
type CostUsageCacheEntry = {
|
||||
summary?: CostUsageSummary;
|
||||
updatedAt?: number;
|
||||
inFlight?: Promise<CostUsageSummary>;
|
||||
};
|
||||
|
||||
const costUsageCache = new Map<number, CostUsageCacheEntry>();
|
||||
const costUsageCache = new Map<string, CostUsageCacheEntry>();
|
||||
|
||||
const parseDays = (raw: unknown): number => {
|
||||
/**
|
||||
* Parse a date string (YYYY-MM-DD) to start of day timestamp in UTC.
|
||||
* Returns undefined if invalid.
|
||||
*/
|
||||
const parseDateToMs = (raw: unknown): number | undefined => {
|
||||
if (typeof raw !== "string" || !raw.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw.trim());
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const [, year, month, day] = match;
|
||||
// Use UTC to ensure consistent behavior across timezones
|
||||
const ms = Date.UTC(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
if (Number.isNaN(ms)) {
|
||||
return undefined;
|
||||
}
|
||||
return ms;
|
||||
};
|
||||
|
||||
const parseDays = (raw: unknown): number | undefined => {
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return Math.floor(raw);
|
||||
}
|
||||
@@ -24,16 +72,51 @@ const parseDays = (raw: unknown): number => {
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
}
|
||||
return 30;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get date range from params (startDate/endDate or days).
|
||||
* Falls back to last 30 days if not provided.
|
||||
*/
|
||||
const parseDateRange = (params: {
|
||||
startDate?: unknown;
|
||||
endDate?: unknown;
|
||||
days?: unknown;
|
||||
}): DateRange => {
|
||||
const now = new Date();
|
||||
// Use UTC for consistent date handling
|
||||
const todayStartMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const todayEndMs = todayStartMs + 24 * 60 * 60 * 1000 - 1;
|
||||
|
||||
const startMs = parseDateToMs(params.startDate);
|
||||
const endMs = parseDateToMs(params.endDate);
|
||||
|
||||
if (startMs !== undefined && endMs !== undefined) {
|
||||
// endMs should be end of day
|
||||
return { startMs, endMs: endMs + 24 * 60 * 60 * 1000 - 1 };
|
||||
}
|
||||
|
||||
const days = parseDays(params.days);
|
||||
if (days !== undefined) {
|
||||
const clampedDays = Math.max(1, days);
|
||||
const start = todayStartMs - (clampedDays - 1) * 24 * 60 * 60 * 1000;
|
||||
return { startMs: start, endMs: todayEndMs };
|
||||
}
|
||||
|
||||
// Default to last 30 days
|
||||
const defaultStartMs = todayStartMs - 29 * 24 * 60 * 60 * 1000;
|
||||
return { startMs: defaultStartMs, endMs: todayEndMs };
|
||||
};
|
||||
|
||||
async function loadCostUsageSummaryCached(params: {
|
||||
days: number;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
config: ReturnType<typeof loadConfig>;
|
||||
}): Promise<CostUsageSummary> {
|
||||
const days = Math.max(1, params.days);
|
||||
const cacheKey = `${params.startMs}-${params.endMs}`;
|
||||
const now = Date.now();
|
||||
const cached = costUsageCache.get(days);
|
||||
const cached = costUsageCache.get(cacheKey);
|
||||
if (cached?.summary && cached.updatedAt && now - cached.updatedAt < COST_USAGE_CACHE_TTL_MS) {
|
||||
return cached.summary;
|
||||
}
|
||||
@@ -46,9 +129,13 @@ async function loadCostUsageSummaryCached(params: {
|
||||
}
|
||||
|
||||
const entry: CostUsageCacheEntry = cached ?? {};
|
||||
const inFlight = loadCostUsageSummary({ days, config: params.config })
|
||||
const inFlight = loadCostUsageSummary({
|
||||
startMs: params.startMs,
|
||||
endMs: params.endMs,
|
||||
config: params.config,
|
||||
})
|
||||
.then((summary) => {
|
||||
costUsageCache.set(days, { summary, updatedAt: Date.now() });
|
||||
costUsageCache.set(cacheKey, { summary, updatedAt: Date.now() });
|
||||
return summary;
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -58,15 +145,15 @@ async function loadCostUsageSummaryCached(params: {
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
const current = costUsageCache.get(days);
|
||||
const current = costUsageCache.get(cacheKey);
|
||||
if (current?.inFlight === inFlight) {
|
||||
current.inFlight = undefined;
|
||||
costUsageCache.set(days, current);
|
||||
costUsageCache.set(cacheKey, current);
|
||||
}
|
||||
});
|
||||
|
||||
entry.inFlight = inFlight;
|
||||
costUsageCache.set(days, entry);
|
||||
costUsageCache.set(cacheKey, entry);
|
||||
|
||||
if (entry.summary) {
|
||||
return entry.summary;
|
||||
@@ -74,6 +161,70 @@ async function loadCostUsageSummaryCached(params: {
|
||||
return await inFlight;
|
||||
}
|
||||
|
||||
// Exposed for unit tests (kept as a single export to avoid widening the public API surface).
|
||||
export const __test = {
|
||||
parseDateToMs,
|
||||
parseDays,
|
||||
parseDateRange,
|
||||
loadCostUsageSummaryCached,
|
||||
costUsageCache,
|
||||
};
|
||||
|
||||
export type SessionUsageEntry = {
|
||||
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: SessionCostSummary | null;
|
||||
contextWeight?: SessionSystemPromptReport | null;
|
||||
};
|
||||
|
||||
export type SessionsUsageAggregates = {
|
||||
messages: SessionMessageCounts;
|
||||
tools: SessionToolUsage;
|
||||
byModel: SessionModelUsage[];
|
||||
byProvider: SessionModelUsage[];
|
||||
byAgent: Array<{ agentId: string; totals: CostUsageSummary["totals"] }>;
|
||||
byChannel: Array<{ channel: string; totals: CostUsageSummary["totals"] }>;
|
||||
latency?: SessionLatencyStats;
|
||||
dailyLatency?: SessionDailyLatency[];
|
||||
modelDaily?: SessionDailyModelUsage[];
|
||||
daily: Array<{
|
||||
date: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
messages: number;
|
||||
toolCalls: number;
|
||||
errors: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SessionsUsageResult = {
|
||||
updatedAt: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
sessions: SessionUsageEntry[];
|
||||
totals: CostUsageSummary["totals"];
|
||||
aggregates: SessionsUsageAggregates;
|
||||
};
|
||||
|
||||
export const usageHandlers: GatewayRequestHandlers = {
|
||||
"usage.status": async ({ respond }) => {
|
||||
const summary = await loadProviderUsageSummary();
|
||||
@@ -81,8 +232,535 @@ export const usageHandlers: GatewayRequestHandlers = {
|
||||
},
|
||||
"usage.cost": async ({ respond, params }) => {
|
||||
const config = loadConfig();
|
||||
const days = parseDays(params?.days);
|
||||
const summary = await loadCostUsageSummaryCached({ days, config });
|
||||
const { startMs, endMs } = parseDateRange({
|
||||
startDate: params?.startDate,
|
||||
endDate: params?.endDate,
|
||||
days: params?.days,
|
||||
});
|
||||
const summary = await loadCostUsageSummaryCached({ startMs, endMs, config });
|
||||
respond(true, summary, undefined);
|
||||
},
|
||||
"sessions.usage": async ({ respond, params }) => {
|
||||
if (!validateSessionsUsageParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid sessions.usage params: ${formatValidationErrors(validateSessionsUsageParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const p = params;
|
||||
const config = loadConfig();
|
||||
const { startMs, endMs } = parseDateRange({
|
||||
startDate: p.startDate,
|
||||
endDate: p.endDate,
|
||||
});
|
||||
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50;
|
||||
const includeContextWeight = p.includeContextWeight ?? false;
|
||||
const specificKey = typeof p.key === "string" ? p.key.trim() : null;
|
||||
|
||||
// Load session store for named sessions
|
||||
const { store } = loadCombinedSessionStoreForGateway(config);
|
||||
const now = Date.now();
|
||||
|
||||
// Merge discovered sessions with store entries
|
||||
type MergedEntry = {
|
||||
key: string;
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
label?: string;
|
||||
updatedAt: number;
|
||||
storeEntry?: SessionEntry;
|
||||
firstUserMessage?: string;
|
||||
};
|
||||
|
||||
const mergedEntries: MergedEntry[] = [];
|
||||
|
||||
// Optimization: If a specific key is requested, skip full directory scan
|
||||
if (specificKey) {
|
||||
// Check if it's a named session in the store
|
||||
const storeEntry = store[specificKey];
|
||||
let sessionId = storeEntry?.sessionId ?? specificKey;
|
||||
|
||||
// Resolve the session file path
|
||||
const sessionFile = resolveSessionFilePath(sessionId, storeEntry);
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(sessionFile);
|
||||
if (stats.isFile()) {
|
||||
mergedEntries.push({
|
||||
key: specificKey,
|
||||
sessionId,
|
||||
sessionFile,
|
||||
label: storeEntry?.label,
|
||||
updatedAt: storeEntry?.updatedAt ?? stats.mtimeMs,
|
||||
storeEntry,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist - no results for this key
|
||||
}
|
||||
} else {
|
||||
// Full discovery for list view
|
||||
const discoveredSessions = await discoverAllSessions({
|
||||
startMs,
|
||||
endMs,
|
||||
});
|
||||
|
||||
// Build a map of sessionId -> store entry for quick lookup
|
||||
const storeBySessionId = new Map<string, { key: string; entry: SessionEntry }>();
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
if (entry?.sessionId) {
|
||||
storeBySessionId.set(entry.sessionId, { key, entry });
|
||||
}
|
||||
}
|
||||
|
||||
for (const discovered of discoveredSessions) {
|
||||
const storeMatch = storeBySessionId.get(discovered.sessionId);
|
||||
if (storeMatch) {
|
||||
// Named session from store
|
||||
mergedEntries.push({
|
||||
key: storeMatch.key,
|
||||
sessionId: discovered.sessionId,
|
||||
sessionFile: discovered.sessionFile,
|
||||
label: storeMatch.entry.label,
|
||||
updatedAt: storeMatch.entry.updatedAt ?? discovered.mtime,
|
||||
storeEntry: storeMatch.entry,
|
||||
});
|
||||
} else {
|
||||
// Unnamed session - use session ID as key, no label
|
||||
mergedEntries.push({
|
||||
key: discovered.sessionId,
|
||||
sessionId: discovered.sessionId,
|
||||
sessionFile: discovered.sessionFile,
|
||||
label: undefined, // No label for unnamed sessions
|
||||
updatedAt: discovered.mtime,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by most recent first
|
||||
mergedEntries.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
// Apply limit
|
||||
const limitedEntries = mergedEntries.slice(0, limit);
|
||||
|
||||
// Load usage for each session
|
||||
const sessions: SessionUsageEntry[] = [];
|
||||
const aggregateTotals = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
inputCost: 0,
|
||||
outputCost: 0,
|
||||
cacheReadCost: 0,
|
||||
cacheWriteCost: 0,
|
||||
missingCostEntries: 0,
|
||||
};
|
||||
const aggregateMessages: SessionMessageCounts = {
|
||||
total: 0,
|
||||
user: 0,
|
||||
assistant: 0,
|
||||
toolCalls: 0,
|
||||
toolResults: 0,
|
||||
errors: 0,
|
||||
};
|
||||
const toolAggregateMap = new Map<string, number>();
|
||||
const byModelMap = new Map<string, SessionModelUsage>();
|
||||
const byProviderMap = new Map<string, SessionModelUsage>();
|
||||
const byAgentMap = new Map<string, CostUsageSummary["totals"]>();
|
||||
const byChannelMap = new Map<string, CostUsageSummary["totals"]>();
|
||||
const dailyAggregateMap = new Map<
|
||||
string,
|
||||
{
|
||||
date: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
messages: number;
|
||||
toolCalls: number;
|
||||
errors: number;
|
||||
}
|
||||
>();
|
||||
const latencyTotals = {
|
||||
count: 0,
|
||||
sum: 0,
|
||||
min: Number.POSITIVE_INFINITY,
|
||||
max: 0,
|
||||
p95Max: 0,
|
||||
};
|
||||
const dailyLatencyMap = new Map<
|
||||
string,
|
||||
{ date: string; count: number; sum: number; min: number; max: number; p95Max: number }
|
||||
>();
|
||||
const modelDailyMap = new Map<string, SessionDailyModelUsage>();
|
||||
|
||||
const emptyTotals = (): CostUsageSummary["totals"] => ({
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
inputCost: 0,
|
||||
outputCost: 0,
|
||||
cacheReadCost: 0,
|
||||
cacheWriteCost: 0,
|
||||
missingCostEntries: 0,
|
||||
});
|
||||
const mergeTotals = (
|
||||
target: CostUsageSummary["totals"],
|
||||
source: CostUsageSummary["totals"],
|
||||
) => {
|
||||
target.input += source.input;
|
||||
target.output += source.output;
|
||||
target.cacheRead += source.cacheRead;
|
||||
target.cacheWrite += source.cacheWrite;
|
||||
target.totalTokens += source.totalTokens;
|
||||
target.totalCost += source.totalCost;
|
||||
target.inputCost += source.inputCost;
|
||||
target.outputCost += source.outputCost;
|
||||
target.cacheReadCost += source.cacheReadCost;
|
||||
target.cacheWriteCost += source.cacheWriteCost;
|
||||
target.missingCostEntries += source.missingCostEntries;
|
||||
};
|
||||
|
||||
for (const merged of limitedEntries) {
|
||||
const usage = await loadSessionCostSummary({
|
||||
sessionId: merged.sessionId,
|
||||
sessionEntry: merged.storeEntry,
|
||||
sessionFile: merged.sessionFile,
|
||||
config,
|
||||
startMs,
|
||||
endMs,
|
||||
});
|
||||
|
||||
if (usage) {
|
||||
aggregateTotals.input += usage.input;
|
||||
aggregateTotals.output += usage.output;
|
||||
aggregateTotals.cacheRead += usage.cacheRead;
|
||||
aggregateTotals.cacheWrite += usage.cacheWrite;
|
||||
aggregateTotals.totalTokens += usage.totalTokens;
|
||||
aggregateTotals.totalCost += usage.totalCost;
|
||||
aggregateTotals.inputCost += usage.inputCost;
|
||||
aggregateTotals.outputCost += usage.outputCost;
|
||||
aggregateTotals.cacheReadCost += usage.cacheReadCost;
|
||||
aggregateTotals.cacheWriteCost += usage.cacheWriteCost;
|
||||
aggregateTotals.missingCostEntries += usage.missingCostEntries;
|
||||
}
|
||||
|
||||
const agentId = parseAgentSessionKey(merged.key)?.agentId;
|
||||
const channel = merged.storeEntry?.channel ?? merged.storeEntry?.origin?.provider;
|
||||
const chatType = merged.storeEntry?.chatType ?? merged.storeEntry?.origin?.chatType;
|
||||
|
||||
if (usage) {
|
||||
if (usage.messageCounts) {
|
||||
aggregateMessages.total += usage.messageCounts.total;
|
||||
aggregateMessages.user += usage.messageCounts.user;
|
||||
aggregateMessages.assistant += usage.messageCounts.assistant;
|
||||
aggregateMessages.toolCalls += usage.messageCounts.toolCalls;
|
||||
aggregateMessages.toolResults += usage.messageCounts.toolResults;
|
||||
aggregateMessages.errors += usage.messageCounts.errors;
|
||||
}
|
||||
|
||||
if (usage.toolUsage) {
|
||||
for (const tool of usage.toolUsage.tools) {
|
||||
toolAggregateMap.set(tool.name, (toolAggregateMap.get(tool.name) ?? 0) + tool.count);
|
||||
}
|
||||
}
|
||||
|
||||
if (usage.modelUsage) {
|
||||
for (const entry of usage.modelUsage) {
|
||||
const modelKey = `${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`;
|
||||
const modelExisting =
|
||||
byModelMap.get(modelKey) ??
|
||||
({
|
||||
provider: entry.provider,
|
||||
model: entry.model,
|
||||
count: 0,
|
||||
totals: emptyTotals(),
|
||||
} as SessionModelUsage);
|
||||
modelExisting.count += entry.count;
|
||||
mergeTotals(modelExisting.totals, entry.totals);
|
||||
byModelMap.set(modelKey, modelExisting);
|
||||
|
||||
const providerKey = entry.provider ?? "unknown";
|
||||
const providerExisting =
|
||||
byProviderMap.get(providerKey) ??
|
||||
({
|
||||
provider: entry.provider,
|
||||
model: undefined,
|
||||
count: 0,
|
||||
totals: emptyTotals(),
|
||||
} as SessionModelUsage);
|
||||
providerExisting.count += entry.count;
|
||||
mergeTotals(providerExisting.totals, entry.totals);
|
||||
byProviderMap.set(providerKey, providerExisting);
|
||||
}
|
||||
}
|
||||
|
||||
if (usage.latency) {
|
||||
const { count, avgMs, minMs, maxMs, p95Ms } = usage.latency;
|
||||
if (count > 0) {
|
||||
latencyTotals.count += count;
|
||||
latencyTotals.sum += avgMs * count;
|
||||
latencyTotals.min = Math.min(latencyTotals.min, minMs);
|
||||
latencyTotals.max = Math.max(latencyTotals.max, maxMs);
|
||||
latencyTotals.p95Max = Math.max(latencyTotals.p95Max, p95Ms);
|
||||
}
|
||||
}
|
||||
|
||||
if (usage.dailyLatency) {
|
||||
for (const day of usage.dailyLatency) {
|
||||
const existing = dailyLatencyMap.get(day.date) ?? {
|
||||
date: day.date,
|
||||
count: 0,
|
||||
sum: 0,
|
||||
min: Number.POSITIVE_INFINITY,
|
||||
max: 0,
|
||||
p95Max: 0,
|
||||
};
|
||||
existing.count += day.count;
|
||||
existing.sum += day.avgMs * day.count;
|
||||
existing.min = Math.min(existing.min, day.minMs);
|
||||
existing.max = Math.max(existing.max, day.maxMs);
|
||||
existing.p95Max = Math.max(existing.p95Max, day.p95Ms);
|
||||
dailyLatencyMap.set(day.date, existing);
|
||||
}
|
||||
}
|
||||
|
||||
if (usage.dailyModelUsage) {
|
||||
for (const entry of usage.dailyModelUsage) {
|
||||
const key = `${entry.date}::${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`;
|
||||
const existing =
|
||||
modelDailyMap.get(key) ??
|
||||
({
|
||||
date: entry.date,
|
||||
provider: entry.provider,
|
||||
model: entry.model,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
count: 0,
|
||||
} as SessionDailyModelUsage);
|
||||
existing.tokens += entry.tokens;
|
||||
existing.cost += entry.cost;
|
||||
existing.count += entry.count;
|
||||
modelDailyMap.set(key, existing);
|
||||
}
|
||||
}
|
||||
|
||||
if (agentId) {
|
||||
const agentTotals = byAgentMap.get(agentId) ?? emptyTotals();
|
||||
mergeTotals(agentTotals, usage);
|
||||
byAgentMap.set(agentId, agentTotals);
|
||||
}
|
||||
|
||||
if (channel) {
|
||||
const channelTotals = byChannelMap.get(channel) ?? emptyTotals();
|
||||
mergeTotals(channelTotals, usage);
|
||||
byChannelMap.set(channel, channelTotals);
|
||||
}
|
||||
|
||||
if (usage.dailyBreakdown) {
|
||||
for (const day of usage.dailyBreakdown) {
|
||||
const daily = dailyAggregateMap.get(day.date) ?? {
|
||||
date: day.date,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
messages: 0,
|
||||
toolCalls: 0,
|
||||
errors: 0,
|
||||
};
|
||||
daily.tokens += day.tokens;
|
||||
daily.cost += day.cost;
|
||||
dailyAggregateMap.set(day.date, daily);
|
||||
}
|
||||
}
|
||||
|
||||
if (usage.dailyMessageCounts) {
|
||||
for (const day of usage.dailyMessageCounts) {
|
||||
const daily = dailyAggregateMap.get(day.date) ?? {
|
||||
date: day.date,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
messages: 0,
|
||||
toolCalls: 0,
|
||||
errors: 0,
|
||||
};
|
||||
daily.messages += day.total;
|
||||
daily.toolCalls += day.toolCalls;
|
||||
daily.errors += day.errors;
|
||||
dailyAggregateMap.set(day.date, daily);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
key: merged.key,
|
||||
label: merged.label,
|
||||
sessionId: merged.sessionId,
|
||||
updatedAt: merged.updatedAt,
|
||||
agentId,
|
||||
channel,
|
||||
chatType,
|
||||
origin: merged.storeEntry?.origin,
|
||||
modelOverride: merged.storeEntry?.modelOverride,
|
||||
providerOverride: merged.storeEntry?.providerOverride,
|
||||
modelProvider: merged.storeEntry?.modelProvider,
|
||||
model: merged.storeEntry?.model,
|
||||
usage,
|
||||
contextWeight: includeContextWeight
|
||||
? (merged.storeEntry?.systemPromptReport ?? null)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Format dates back to YYYY-MM-DD strings
|
||||
const formatDateStr = (ms: number) => {
|
||||
const d = new Date(ms);
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const aggregates: SessionsUsageAggregates = {
|
||||
messages: aggregateMessages,
|
||||
tools: {
|
||||
totalCalls: Array.from(toolAggregateMap.values()).reduce((sum, count) => sum + count, 0),
|
||||
uniqueTools: toolAggregateMap.size,
|
||||
tools: Array.from(toolAggregateMap.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.toSorted((a, b) => b.count - a.count),
|
||||
},
|
||||
byModel: Array.from(byModelMap.values()).toSorted((a, b) => {
|
||||
const costDiff = b.totals.totalCost - a.totals.totalCost;
|
||||
if (costDiff !== 0) {
|
||||
return costDiff;
|
||||
}
|
||||
return b.totals.totalTokens - a.totals.totalTokens;
|
||||
}),
|
||||
byProvider: Array.from(byProviderMap.values()).toSorted((a, b) => {
|
||||
const costDiff = b.totals.totalCost - a.totals.totalCost;
|
||||
if (costDiff !== 0) {
|
||||
return costDiff;
|
||||
}
|
||||
return b.totals.totalTokens - a.totals.totalTokens;
|
||||
}),
|
||||
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),
|
||||
),
|
||||
};
|
||||
|
||||
const result: SessionsUsageResult = {
|
||||
updatedAt: now,
|
||||
startDate: formatDateStr(startMs),
|
||||
endDate: formatDateStr(endMs),
|
||||
sessions,
|
||||
totals: aggregateTotals,
|
||||
aggregates,
|
||||
};
|
||||
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
"sessions.usage.timeseries": async ({ respond, params }) => {
|
||||
const key = typeof params?.key === "string" ? params.key.trim() : null;
|
||||
if (!key) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "key is required for timeseries"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const { entry } = loadSessionEntry(key);
|
||||
|
||||
// For discovered sessions (not in store), try using key as sessionId directly
|
||||
const sessionId = entry?.sessionId ?? key;
|
||||
const sessionFile = entry?.sessionFile ?? resolveSessionFilePath(key);
|
||||
|
||||
const timeseries = await loadSessionUsageTimeSeries({
|
||||
sessionId,
|
||||
sessionEntry: entry,
|
||||
sessionFile,
|
||||
config,
|
||||
maxPoints: 200,
|
||||
});
|
||||
|
||||
if (!timeseries) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `No transcript found for session: ${key}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
respond(true, timeseries, undefined);
|
||||
},
|
||||
"sessions.usage.logs": async ({ respond, params }) => {
|
||||
const key = typeof params?.key === "string" ? params.key.trim() : null;
|
||||
if (!key) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "key is required for logs"));
|
||||
return;
|
||||
}
|
||||
|
||||
const limit =
|
||||
typeof params?.limit === "number" && Number.isFinite(params.limit)
|
||||
? Math.min(params.limit, 1000)
|
||||
: 200;
|
||||
|
||||
const config = loadConfig();
|
||||
const { entry } = loadSessionEntry(key);
|
||||
|
||||
// For discovered sessions (not in store), try using key as sessionId directly
|
||||
const sessionId = entry?.sessionId ?? key;
|
||||
const sessionFile = entry?.sessionFile ?? resolveSessionFilePath(key);
|
||||
|
||||
const { loadSessionLogs } = await import("../../infra/session-cost-usage.js");
|
||||
const logs = await loadSessionLogs({
|
||||
sessionId,
|
||||
sessionEntry: entry,
|
||||
sessionFile,
|
||||
config,
|
||||
limit,
|
||||
});
|
||||
|
||||
respond(true, { logs: logs ?? [] }, undefined);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -383,6 +383,43 @@ describe("readSessionPreviewItemsFromTranscript", () => {
|
||||
expect(result[1]?.text).toContain("call weather");
|
||||
});
|
||||
|
||||
test("detects tool calls from tool_use/tool_call blocks and toolName field", () => {
|
||||
const sessionId = "preview-session-tools";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||
JSON.stringify({ message: { role: "assistant", content: "Hi" } }),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
toolName: "camera",
|
||||
content: [
|
||||
{ type: "tool_use", name: "read" },
|
||||
{ type: "tool_call", name: "write" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({ message: { role: "assistant", content: "Done" } }),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readSessionPreviewItemsFromTranscript(
|
||||
sessionId,
|
||||
storePath,
|
||||
undefined,
|
||||
undefined,
|
||||
3,
|
||||
120,
|
||||
);
|
||||
|
||||
expect(result.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]);
|
||||
expect(result[1]?.text).toContain("call");
|
||||
expect(result[1]?.text).toContain("camera");
|
||||
expect(result[1]?.text).toContain("read");
|
||||
// Preview text may not list every tool name; it should at least hint there were multiple calls.
|
||||
expect(result[1]?.text).toMatch(/\+\d+/);
|
||||
});
|
||||
|
||||
test("truncates preview text to max chars", () => {
|
||||
const sessionId = "preview-truncate";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SessionPreviewItem } from "./session-utils.types.js";
|
||||
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
|
||||
import { stripEnvelope } from "./chat-sanitize.js";
|
||||
|
||||
export function readSessionMessages(
|
||||
@@ -292,35 +293,11 @@ function extractPreviewText(message: TranscriptPreviewMessage): string | null {
|
||||
}
|
||||
|
||||
function isToolCall(message: TranscriptPreviewMessage): boolean {
|
||||
if (message.toolName || message.tool_name) {
|
||||
return true;
|
||||
}
|
||||
if (!Array.isArray(message.content)) {
|
||||
return false;
|
||||
}
|
||||
return message.content.some((entry) => {
|
||||
if (entry?.name) {
|
||||
return true;
|
||||
}
|
||||
const raw = typeof entry?.type === "string" ? entry.type.toLowerCase() : "";
|
||||
return raw === "toolcall" || raw === "tool_call";
|
||||
});
|
||||
return hasToolCall(message as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function extractToolNames(message: TranscriptPreviewMessage): string[] {
|
||||
const names: string[] = [];
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const entry of message.content) {
|
||||
if (typeof entry?.name === "string" && entry.name.trim()) {
|
||||
names.push(entry.name.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
const toolName = typeof message.toolName === "string" ? message.toolName : message.tool_name;
|
||||
if (typeof toolName === "string" && toolName.trim()) {
|
||||
names.push(toolName.trim());
|
||||
}
|
||||
return names;
|
||||
return extractToolCallNames(message as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function extractMediaSummary(message: TranscriptPreviewMessage): string | null {
|
||||
|
||||
@@ -3,7 +3,11 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadCostUsageSummary, loadSessionCostSummary } from "./session-cost-usage.js";
|
||||
import {
|
||||
discoverAllSessions,
|
||||
loadCostUsageSummary,
|
||||
loadSessionCostSummary,
|
||||
} from "./session-cost-usage.js";
|
||||
|
||||
describe("session cost usage", () => {
|
||||
it("aggregates daily totals with log cost and pricing fallback", async () => {
|
||||
@@ -140,4 +144,100 @@ describe("session cost usage", () => {
|
||||
expect(summary?.totalTokens).toBe(30);
|
||||
expect(summary?.lastActivity).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("captures message counts, tool usage, and model usage", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-session-meta-"));
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
const start = new Date("2026-02-01T10:00:00.000Z");
|
||||
const end = new Date("2026-02-01T10:05:00.000Z");
|
||||
|
||||
const entries = [
|
||||
{
|
||||
type: "message",
|
||||
timestamp: start.toISOString(),
|
||||
message: {
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
timestamp: end.toISOString(),
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
stopReason: "error",
|
||||
content: [
|
||||
{ type: "text", text: "Checking" },
|
||||
{ type: "tool_use", name: "weather" },
|
||||
{ type: "tool_result", is_error: true },
|
||||
],
|
||||
usage: {
|
||||
input: 12,
|
||||
output: 18,
|
||||
totalTokens: 30,
|
||||
cost: { total: 0.02 },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
entries.map((entry) => JSON.stringify(entry)).join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const summary = await loadSessionCostSummary({ sessionFile });
|
||||
expect(summary?.messageCounts).toEqual({
|
||||
total: 2,
|
||||
user: 1,
|
||||
assistant: 1,
|
||||
toolCalls: 1,
|
||||
toolResults: 1,
|
||||
errors: 2,
|
||||
});
|
||||
expect(summary?.toolUsage?.totalCalls).toBe(1);
|
||||
expect(summary?.toolUsage?.uniqueTools).toBe(1);
|
||||
expect(summary?.toolUsage?.tools[0]?.name).toBe("weather");
|
||||
expect(summary?.modelUsage?.[0]?.provider).toBe("openai");
|
||||
expect(summary?.modelUsage?.[0]?.model).toBe("gpt-5.2");
|
||||
expect(summary?.durationMs).toBe(5 * 60 * 1000);
|
||||
expect(summary?.latency?.count).toBe(1);
|
||||
expect(summary?.latency?.avgMs).toBe(5 * 60 * 1000);
|
||||
expect(summary?.latency?.p95Ms).toBe(5 * 60 * 1000);
|
||||
expect(summary?.dailyLatency?.[0]?.date).toBe("2026-02-01");
|
||||
expect(summary?.dailyLatency?.[0]?.count).toBe(1);
|
||||
expect(summary?.dailyModelUsage?.[0]?.date).toBe("2026-02-01");
|
||||
expect(summary?.dailyModelUsage?.[0]?.model).toBe("gpt-5.2");
|
||||
});
|
||||
|
||||
it("does not exclude sessions with mtime after endMs during discovery", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discover-"));
|
||||
const sessionsDir = path.join(root, "agents", "main", "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
const sessionFile = path.join(sessionsDir, "sess-late.jsonl");
|
||||
await fs.writeFile(sessionFile, "", "utf-8");
|
||||
|
||||
const now = Date.now();
|
||||
await fs.utimes(sessionFile, now / 1000, now / 1000);
|
||||
|
||||
const originalState = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = root;
|
||||
try {
|
||||
const sessions = await discoverAllSessions({
|
||||
startMs: now - 7 * 24 * 60 * 60 * 1000,
|
||||
endMs: now - 24 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0]?.sessionId).toBe("sess-late");
|
||||
} finally {
|
||||
if (originalState === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = originalState;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
66
src/utils/transcript-tools.test.ts
Normal file
66
src/utils/transcript-tools.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { countToolResults, extractToolCallNames, hasToolCall } from "./transcript-tools.js";
|
||||
|
||||
describe("transcript-tools", () => {
|
||||
describe("extractToolCallNames", () => {
|
||||
it("extracts tool name from message.toolName/tool_name", () => {
|
||||
expect(extractToolCallNames({ toolName: " weather " })).toEqual(["weather"]);
|
||||
expect(extractToolCallNames({ tool_name: "notes" })).toEqual(["notes"]);
|
||||
});
|
||||
|
||||
it("extracts tool call names from content blocks (tool_use/toolcall/tool_call)", () => {
|
||||
const names = extractToolCallNames({
|
||||
content: [
|
||||
{ type: "text", text: "hi" },
|
||||
{ type: "tool_use", name: "read" },
|
||||
{ type: "toolcall", name: "exec" },
|
||||
{ type: "tool_call", name: "write" },
|
||||
],
|
||||
});
|
||||
expect(new Set(names)).toEqual(new Set(["read", "exec", "write"]));
|
||||
});
|
||||
|
||||
it("normalizes type and trims names; de-dupes", () => {
|
||||
const names = extractToolCallNames({
|
||||
content: [
|
||||
{ type: " TOOL_CALL ", name: " read " },
|
||||
{ type: "tool_call", name: "read" },
|
||||
{ type: "tool_call", name: "" },
|
||||
],
|
||||
toolName: "read",
|
||||
});
|
||||
expect(names).toEqual(["read"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasToolCall", () => {
|
||||
it("returns true when tool call names exist", () => {
|
||||
expect(hasToolCall({ toolName: "weather" })).toBe(true);
|
||||
expect(hasToolCall({ content: [{ type: "tool_use", name: "read" }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no tool calls exist", () => {
|
||||
expect(hasToolCall({})).toBe(false);
|
||||
expect(hasToolCall({ content: [{ type: "text", text: "hi" }] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countToolResults", () => {
|
||||
it("counts tool_result blocks and tool_result_error blocks; tracks errors via is_error", () => {
|
||||
expect(
|
||||
countToolResults({
|
||||
content: [
|
||||
{ type: "tool_result" },
|
||||
{ type: "tool_result", is_error: true },
|
||||
{ type: "tool_result_error" },
|
||||
{ type: "text", text: "ignore" },
|
||||
],
|
||||
}),
|
||||
).toEqual({ total: 3, errors: 1 });
|
||||
});
|
||||
|
||||
it("handles non-array content", () => {
|
||||
expect(countToolResults({ content: "nope" })).toEqual({ total: 0, errors: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
73
src/utils/transcript-tools.ts
Normal file
73
src/utils/transcript-tools.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
type ToolResultCounts = {
|
||||
total: number;
|
||||
errors: number;
|
||||
};
|
||||
|
||||
const TOOL_CALL_TYPES = new Set(["tool_use", "toolcall", "tool_call"]);
|
||||
const TOOL_RESULT_TYPES = new Set(["tool_result", "tool_result_error"]);
|
||||
|
||||
const normalizeType = (value: unknown): string => {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim().toLowerCase();
|
||||
};
|
||||
|
||||
export const extractToolCallNames = (message: Record<string, unknown>): string[] => {
|
||||
const names = new Set<string>();
|
||||
const toolNameRaw = message.toolName ?? message.tool_name;
|
||||
if (typeof toolNameRaw === "string" && toolNameRaw.trim()) {
|
||||
names.add(toolNameRaw.trim());
|
||||
}
|
||||
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) {
|
||||
return Array.from(names);
|
||||
}
|
||||
|
||||
for (const entry of content) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const block = entry as Record<string, unknown>;
|
||||
const type = normalizeType(block.type);
|
||||
if (!TOOL_CALL_TYPES.has(type)) {
|
||||
continue;
|
||||
}
|
||||
const name = block.name;
|
||||
if (typeof name === "string" && name.trim()) {
|
||||
names.add(name.trim());
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(names);
|
||||
};
|
||||
|
||||
export const hasToolCall = (message: Record<string, unknown>): boolean =>
|
||||
extractToolCallNames(message).length > 0;
|
||||
|
||||
export const countToolResults = (message: Record<string, unknown>): ToolResultCounts => {
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) {
|
||||
return { total: 0, errors: 0 };
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
let errors = 0;
|
||||
for (const entry of content) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const block = entry as Record<string, unknown>;
|
||||
const type = normalizeType(block.type);
|
||||
if (!TOOL_RESULT_TYPES.has(type)) {
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
if (block.is_error === true) {
|
||||
errors += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { total, errors };
|
||||
};
|
||||
@@ -105,7 +105,11 @@ export function renderChatControls(state: AppViewState) {
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
syncUrlWithSessionKey(next, true);
|
||||
syncUrlWithSessionKey(
|
||||
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
|
||||
next,
|
||||
true,
|
||||
);
|
||||
void loadChatHistory(state as unknown as ChatState);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { AppViewState } from "./app-view-state.ts";
|
||||
import type { UsageState } from "./controllers/usage.ts";
|
||||
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
|
||||
import { ChatHost, refreshChatAvatar } from "./app-chat.ts";
|
||||
import { refreshChatAvatar } from "./app-chat.ts";
|
||||
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers.ts";
|
||||
import { OpenClawApp } from "./app.ts";
|
||||
import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts";
|
||||
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
|
||||
import { loadAgentSkills } from "./controllers/agent-skills.ts";
|
||||
import { loadAgents } from "./controllers/agents.ts";
|
||||
import { loadChannels } from "./controllers/channels.ts";
|
||||
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
|
||||
import { loadChatHistory } from "./controllers/chat.ts";
|
||||
import {
|
||||
applyConfig,
|
||||
ConfigState,
|
||||
loadConfig,
|
||||
runUpdate,
|
||||
saveConfig,
|
||||
@@ -40,7 +39,7 @@ import {
|
||||
saveExecApprovals,
|
||||
updateExecApprovalsFormValue,
|
||||
} from "./controllers/exec-approvals.ts";
|
||||
import { loadLogs, LogsState } from "./controllers/logs.ts";
|
||||
import { loadLogs } from "./controllers/logs.ts";
|
||||
import { loadNodes } from "./controllers/nodes.ts";
|
||||
import { loadPresence } from "./controllers/presence.ts";
|
||||
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions.ts";
|
||||
@@ -51,9 +50,18 @@ import {
|
||||
updateSkillEdit,
|
||||
updateSkillEnabled,
|
||||
} from "./controllers/skills.ts";
|
||||
import { loadUsage, loadSessionTimeSeries, loadSessionLogs } from "./controllers/usage.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
|
||||
import { ConfigUiHints } from "./types.ts";
|
||||
|
||||
// Module-scope debounce for usage date changes (avoids type-unsafe hacks on state object)
|
||||
let usageDateDebounceTimeout: number | null = null;
|
||||
const debouncedLoadUsage = (state: UsageState) => {
|
||||
if (usageDateDebounceTimeout) {
|
||||
clearTimeout(usageDateDebounceTimeout);
|
||||
}
|
||||
usageDateDebounceTimeout = window.setTimeout(() => void loadUsage(state), 400);
|
||||
};
|
||||
import { renderAgents } from "./views/agents.ts";
|
||||
import { renderChannels } from "./views/channels.ts";
|
||||
import { renderChat } from "./views/chat.ts";
|
||||
@@ -68,6 +76,7 @@ import { renderNodes } from "./views/nodes.ts";
|
||||
import { renderOverview } from "./views/overview.ts";
|
||||
import { renderSessions } from "./views/sessions.ts";
|
||||
import { renderSkills } from "./views/skills.ts";
|
||||
import { renderUsage } from "./views/usage.ts";
|
||||
|
||||
const AVATAR_DATA_RE = /^data:/i;
|
||||
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
||||
@@ -98,36 +107,14 @@ export function renderApp(state: AppViewState) {
|
||||
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
|
||||
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
|
||||
const logoBase = normalizeBasePath(state.basePath);
|
||||
const logoHref = logoBase ? `${logoBase}/favicon.svg` : "/favicon.svg";
|
||||
const configValue =
|
||||
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
|
||||
const basePath = normalizeBasePath(state.basePath ?? "");
|
||||
const resolvedAgentId =
|
||||
state.agentsSelectedId ??
|
||||
state.agentsList?.defaultId ??
|
||||
state.agentsList?.agents?.[0]?.id ??
|
||||
null;
|
||||
const ensureAgentListEntry = (agentId: string) => {
|
||||
const snapshot = (state.configForm ??
|
||||
(state.configSnapshot?.config as Record<string, unknown> | null)) as {
|
||||
agents?: { list?: unknown[] };
|
||||
} | null;
|
||||
const listRaw = snapshot?.agents?.list;
|
||||
const list = Array.isArray(listRaw) ? listRaw : [];
|
||||
let index = list.findIndex(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
"id" in entry &&
|
||||
(entry as { id?: string }).id === agentId,
|
||||
);
|
||||
if (index < 0) {
|
||||
const nextList = [...list, { id: agentId }];
|
||||
updateConfigFormValue(state as unknown as ConfigState, ["agents", "list"], nextList);
|
||||
index = nextList.length - 1;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
|
||||
@@ -147,7 +134,7 @@ export function renderApp(state: AppViewState) {
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-logo">
|
||||
<img src="${logoHref}" alt="OpenClaw" />
|
||||
<img src=${basePath ? `${basePath}/favicon.svg` : "/favicon.svg"} alt="OpenClaw" />
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<div class="brand-title">OPENCLAW</div>
|
||||
@@ -212,8 +199,8 @@ export function renderApp(state: AppViewState) {
|
||||
<main class="content ${isChat ? "content--chat" : ""}">
|
||||
<section class="content-header">
|
||||
<div>
|
||||
<div class="page-title">${titleForTab(state.tab)}</div>
|
||||
<div class="page-sub">${subtitleForTab(state.tab)}</div>
|
||||
${state.tab === "usage" ? nothing : html`<div class="page-title">${titleForTab(state.tab)}</div>`}
|
||||
${state.tab === "usage" ? nothing : html`<div class="page-sub">${subtitleForTab(state.tab)}</div>`}
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing}
|
||||
@@ -239,7 +226,7 @@ export function renderApp(state: AppViewState) {
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
(state as unknown as OpenClawApp).resetToolStream();
|
||||
state.resetToolStream();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
@@ -268,7 +255,7 @@ export function renderApp(state: AppViewState) {
|
||||
configSchema: state.configSchema,
|
||||
configSchemaLoading: state.configSchemaLoading,
|
||||
configForm: state.configForm,
|
||||
configUiHints: state.configUiHints as ConfigUiHints,
|
||||
configUiHints: state.configUiHints,
|
||||
configSaving: state.configSaving,
|
||||
configFormDirty: state.configFormDirty,
|
||||
nostrProfileFormState: state.nostrProfileFormState,
|
||||
@@ -277,8 +264,7 @@ export function renderApp(state: AppViewState) {
|
||||
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
|
||||
onWhatsAppWait: () => state.handleWhatsAppWait(),
|
||||
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
|
||||
onConfigPatch: (path, value) =>
|
||||
updateConfigFormValue(state as unknown as ConfigState, path, value),
|
||||
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onConfigSave: () => state.handleChannelConfigSave(),
|
||||
onConfigReload: () => state.handleChannelConfigReload(),
|
||||
onNostrProfileEdit: (accountId, profile) =>
|
||||
@@ -329,6 +315,269 @@ export function renderApp(state: AppViewState) {
|
||||
: nothing
|
||||
}
|
||||
|
||||
${
|
||||
state.tab === "usage"
|
||||
? renderUsage({
|
||||
loading: state.usageLoading,
|
||||
error: state.usageError,
|
||||
startDate: state.usageStartDate,
|
||||
endDate: state.usageEndDate,
|
||||
sessions: state.usageResult?.sessions ?? [],
|
||||
sessionsLimitReached: (state.usageResult?.sessions?.length ?? 0) >= 1000,
|
||||
totals: state.usageResult?.totals ?? null,
|
||||
aggregates: state.usageResult?.aggregates ?? null,
|
||||
costDaily: state.usageCostSummary?.daily ?? [],
|
||||
selectedSessions: state.usageSelectedSessions,
|
||||
selectedDays: state.usageSelectedDays,
|
||||
selectedHours: state.usageSelectedHours,
|
||||
chartMode: state.usageChartMode,
|
||||
dailyChartMode: state.usageDailyChartMode,
|
||||
timeSeriesMode: state.usageTimeSeriesMode,
|
||||
timeSeriesBreakdownMode: state.usageTimeSeriesBreakdownMode,
|
||||
timeSeries: state.usageTimeSeries,
|
||||
timeSeriesLoading: state.usageTimeSeriesLoading,
|
||||
sessionLogs: state.usageSessionLogs,
|
||||
sessionLogsLoading: state.usageSessionLogsLoading,
|
||||
sessionLogsExpanded: state.usageSessionLogsExpanded,
|
||||
logFilterRoles: state.usageLogFilterRoles,
|
||||
logFilterTools: state.usageLogFilterTools,
|
||||
logFilterHasTools: state.usageLogFilterHasTools,
|
||||
logFilterQuery: state.usageLogFilterQuery,
|
||||
query: state.usageQuery,
|
||||
queryDraft: state.usageQueryDraft,
|
||||
sessionSort: state.usageSessionSort,
|
||||
sessionSortDir: state.usageSessionSortDir,
|
||||
recentSessions: state.usageRecentSessions,
|
||||
sessionsTab: state.usageSessionsTab,
|
||||
visibleColumns:
|
||||
state.usageVisibleColumns as import("./views/usage.ts").UsageColumnId[],
|
||||
timeZone: state.usageTimeZone,
|
||||
contextExpanded: state.usageContextExpanded,
|
||||
headerPinned: state.usageHeaderPinned,
|
||||
onStartDateChange: (date) => {
|
||||
state.usageStartDate = date;
|
||||
state.usageSelectedDays = [];
|
||||
state.usageSelectedHours = [];
|
||||
state.usageSelectedSessions = [];
|
||||
debouncedLoadUsage(state);
|
||||
},
|
||||
onEndDateChange: (date) => {
|
||||
state.usageEndDate = date;
|
||||
state.usageSelectedDays = [];
|
||||
state.usageSelectedHours = [];
|
||||
state.usageSelectedSessions = [];
|
||||
debouncedLoadUsage(state);
|
||||
},
|
||||
onRefresh: () => loadUsage(state),
|
||||
onTimeZoneChange: (zone) => {
|
||||
state.usageTimeZone = zone;
|
||||
},
|
||||
onToggleContextExpanded: () => {
|
||||
state.usageContextExpanded = !state.usageContextExpanded;
|
||||
},
|
||||
onToggleSessionLogsExpanded: () => {
|
||||
state.usageSessionLogsExpanded = !state.usageSessionLogsExpanded;
|
||||
},
|
||||
onLogFilterRolesChange: (next) => {
|
||||
state.usageLogFilterRoles = next;
|
||||
},
|
||||
onLogFilterToolsChange: (next) => {
|
||||
state.usageLogFilterTools = next;
|
||||
},
|
||||
onLogFilterHasToolsChange: (next) => {
|
||||
state.usageLogFilterHasTools = next;
|
||||
},
|
||||
onLogFilterQueryChange: (next) => {
|
||||
state.usageLogFilterQuery = next;
|
||||
},
|
||||
onLogFilterClear: () => {
|
||||
state.usageLogFilterRoles = [];
|
||||
state.usageLogFilterTools = [];
|
||||
state.usageLogFilterHasTools = false;
|
||||
state.usageLogFilterQuery = "";
|
||||
},
|
||||
onToggleHeaderPinned: () => {
|
||||
state.usageHeaderPinned = !state.usageHeaderPinned;
|
||||
},
|
||||
onSelectHour: (hour, shiftKey) => {
|
||||
if (shiftKey && state.usageSelectedHours.length > 0) {
|
||||
const allHours = Array.from({ length: 24 }, (_, i) => i);
|
||||
const lastSelected =
|
||||
state.usageSelectedHours[state.usageSelectedHours.length - 1];
|
||||
const lastIdx = allHours.indexOf(lastSelected);
|
||||
const thisIdx = allHours.indexOf(hour);
|
||||
if (lastIdx !== -1 && thisIdx !== -1) {
|
||||
const [start, end] =
|
||||
lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx];
|
||||
const range = allHours.slice(start, end + 1);
|
||||
state.usageSelectedHours = [
|
||||
...new Set([...state.usageSelectedHours, ...range]),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
if (state.usageSelectedHours.includes(hour)) {
|
||||
state.usageSelectedHours = state.usageSelectedHours.filter((h) => h !== hour);
|
||||
} else {
|
||||
state.usageSelectedHours = [...state.usageSelectedHours, hour];
|
||||
}
|
||||
}
|
||||
},
|
||||
onQueryDraftChange: (query) => {
|
||||
state.usageQueryDraft = query;
|
||||
if (state.usageQueryDebounceTimer) {
|
||||
window.clearTimeout(state.usageQueryDebounceTimer);
|
||||
}
|
||||
state.usageQueryDebounceTimer = window.setTimeout(() => {
|
||||
state.usageQuery = state.usageQueryDraft;
|
||||
state.usageQueryDebounceTimer = null;
|
||||
}, 250);
|
||||
},
|
||||
onApplyQuery: () => {
|
||||
if (state.usageQueryDebounceTimer) {
|
||||
window.clearTimeout(state.usageQueryDebounceTimer);
|
||||
state.usageQueryDebounceTimer = null;
|
||||
}
|
||||
state.usageQuery = state.usageQueryDraft;
|
||||
},
|
||||
onClearQuery: () => {
|
||||
if (state.usageQueryDebounceTimer) {
|
||||
window.clearTimeout(state.usageQueryDebounceTimer);
|
||||
state.usageQueryDebounceTimer = null;
|
||||
}
|
||||
state.usageQueryDraft = "";
|
||||
state.usageQuery = "";
|
||||
},
|
||||
onSessionSortChange: (sort) => {
|
||||
state.usageSessionSort = sort;
|
||||
},
|
||||
onSessionSortDirChange: (dir) => {
|
||||
state.usageSessionSortDir = dir;
|
||||
},
|
||||
onSessionsTabChange: (tab) => {
|
||||
state.usageSessionsTab = tab;
|
||||
},
|
||||
onToggleColumn: (column) => {
|
||||
if (state.usageVisibleColumns.includes(column)) {
|
||||
state.usageVisibleColumns = state.usageVisibleColumns.filter(
|
||||
(entry) => entry !== column,
|
||||
);
|
||||
} else {
|
||||
state.usageVisibleColumns = [...state.usageVisibleColumns, column];
|
||||
}
|
||||
},
|
||||
onSelectSession: (key, shiftKey) => {
|
||||
state.usageTimeSeries = null;
|
||||
state.usageSessionLogs = null;
|
||||
state.usageRecentSessions = [
|
||||
key,
|
||||
...state.usageRecentSessions.filter((entry) => entry !== key),
|
||||
].slice(0, 8);
|
||||
|
||||
if (shiftKey && state.usageSelectedSessions.length > 0) {
|
||||
// Shift-click: select range from last selected to this session
|
||||
// Sort sessions same way as displayed (by tokens or cost descending)
|
||||
const isTokenMode = state.usageChartMode === "tokens";
|
||||
const sortedSessions = [...(state.usageResult?.sessions ?? [])].toSorted(
|
||||
(a, b) => {
|
||||
const valA = isTokenMode
|
||||
? (a.usage?.totalTokens ?? 0)
|
||||
: (a.usage?.totalCost ?? 0);
|
||||
const valB = isTokenMode
|
||||
? (b.usage?.totalTokens ?? 0)
|
||||
: (b.usage?.totalCost ?? 0);
|
||||
return valB - valA;
|
||||
},
|
||||
);
|
||||
const allKeys = sortedSessions.map((s) => s.key);
|
||||
const lastSelected =
|
||||
state.usageSelectedSessions[state.usageSelectedSessions.length - 1];
|
||||
const lastIdx = allKeys.indexOf(lastSelected);
|
||||
const thisIdx = allKeys.indexOf(key);
|
||||
if (lastIdx !== -1 && thisIdx !== -1) {
|
||||
const [start, end] =
|
||||
lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx];
|
||||
const range = allKeys.slice(start, end + 1);
|
||||
const newSelection = [...new Set([...state.usageSelectedSessions, ...range])];
|
||||
state.usageSelectedSessions = newSelection;
|
||||
}
|
||||
} else {
|
||||
// Regular click: focus a single session (so details always open).
|
||||
// Click the focused session again to clear selection.
|
||||
if (
|
||||
state.usageSelectedSessions.length === 1 &&
|
||||
state.usageSelectedSessions[0] === key
|
||||
) {
|
||||
state.usageSelectedSessions = [];
|
||||
} else {
|
||||
state.usageSelectedSessions = [key];
|
||||
}
|
||||
}
|
||||
|
||||
// Load timeseries/logs only if exactly one session selected
|
||||
if (state.usageSelectedSessions.length === 1) {
|
||||
void loadSessionTimeSeries(state, state.usageSelectedSessions[0]);
|
||||
void loadSessionLogs(state, state.usageSelectedSessions[0]);
|
||||
}
|
||||
},
|
||||
onSelectDay: (day, shiftKey) => {
|
||||
if (shiftKey && state.usageSelectedDays.length > 0) {
|
||||
// Shift-click: select range from last selected to this day
|
||||
const allDays = (state.usageCostSummary?.daily ?? []).map((d) => d.date);
|
||||
const lastSelected =
|
||||
state.usageSelectedDays[state.usageSelectedDays.length - 1];
|
||||
const lastIdx = allDays.indexOf(lastSelected);
|
||||
const thisIdx = allDays.indexOf(day);
|
||||
if (lastIdx !== -1 && thisIdx !== -1) {
|
||||
const [start, end] =
|
||||
lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx];
|
||||
const range = allDays.slice(start, end + 1);
|
||||
// Merge with existing selection
|
||||
const newSelection = [...new Set([...state.usageSelectedDays, ...range])];
|
||||
state.usageSelectedDays = newSelection;
|
||||
}
|
||||
} else {
|
||||
// Regular click: toggle single day
|
||||
if (state.usageSelectedDays.includes(day)) {
|
||||
state.usageSelectedDays = state.usageSelectedDays.filter((d) => d !== day);
|
||||
} else {
|
||||
state.usageSelectedDays = [day];
|
||||
}
|
||||
}
|
||||
},
|
||||
onChartModeChange: (mode) => {
|
||||
state.usageChartMode = mode;
|
||||
},
|
||||
onDailyChartModeChange: (mode) => {
|
||||
state.usageDailyChartMode = mode;
|
||||
},
|
||||
onTimeSeriesModeChange: (mode) => {
|
||||
state.usageTimeSeriesMode = mode;
|
||||
},
|
||||
onTimeSeriesBreakdownChange: (mode) => {
|
||||
state.usageTimeSeriesBreakdownMode = mode;
|
||||
},
|
||||
onClearDays: () => {
|
||||
state.usageSelectedDays = [];
|
||||
},
|
||||
onClearHours: () => {
|
||||
state.usageSelectedHours = [];
|
||||
},
|
||||
onClearSessions: () => {
|
||||
state.usageSelectedSessions = [];
|
||||
state.usageTimeSeries = null;
|
||||
state.usageSessionLogs = null;
|
||||
},
|
||||
onClearFilters: () => {
|
||||
state.usageSelectedDays = [];
|
||||
state.usageSelectedHours = [];
|
||||
state.usageSelectedSessions = [];
|
||||
state.usageTimeSeries = null;
|
||||
state.usageSessionLogs = null;
|
||||
},
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
|
||||
${
|
||||
state.tab === "cron"
|
||||
? renderCron({
|
||||
@@ -444,17 +693,7 @@ export function renderApp(state: AppViewState) {
|
||||
void state.loadCron();
|
||||
}
|
||||
},
|
||||
onLoadFiles: (agentId) => {
|
||||
void (async () => {
|
||||
await loadAgentFiles(state, agentId);
|
||||
if (state.agentFileActive) {
|
||||
await loadAgentFileContent(state, agentId, state.agentFileActive, {
|
||||
force: true,
|
||||
preserveDraft: true,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
onLoadFiles: (agentId) => loadAgentFiles(state, agentId),
|
||||
onSelectFile: (name) => {
|
||||
state.agentFileActive = name;
|
||||
if (!resolvedAgentId) {
|
||||
@@ -497,19 +736,12 @@ export function renderApp(state: AppViewState) {
|
||||
}
|
||||
const basePath = ["agents", "list", index, "tools"];
|
||||
if (profile) {
|
||||
updateConfigFormValue(
|
||||
state as unknown as ConfigState,
|
||||
[...basePath, "profile"],
|
||||
profile,
|
||||
);
|
||||
updateConfigFormValue(state, [...basePath, "profile"], profile);
|
||||
} else {
|
||||
removeConfigFormValue(state as unknown as ConfigState, [
|
||||
...basePath,
|
||||
"profile",
|
||||
]);
|
||||
removeConfigFormValue(state, [...basePath, "profile"]);
|
||||
}
|
||||
if (clearAllow) {
|
||||
removeConfigFormValue(state as unknown as ConfigState, [...basePath, "allow"]);
|
||||
removeConfigFormValue(state, [...basePath, "allow"]);
|
||||
}
|
||||
},
|
||||
onToolsOverridesChange: (agentId, alsoAllow, deny) => {
|
||||
@@ -532,29 +764,18 @@ export function renderApp(state: AppViewState) {
|
||||
}
|
||||
const basePath = ["agents", "list", index, "tools"];
|
||||
if (alsoAllow.length > 0) {
|
||||
updateConfigFormValue(
|
||||
state as unknown as ConfigState,
|
||||
[...basePath, "alsoAllow"],
|
||||
alsoAllow,
|
||||
);
|
||||
updateConfigFormValue(state, [...basePath, "alsoAllow"], alsoAllow);
|
||||
} else {
|
||||
removeConfigFormValue(state as unknown as ConfigState, [
|
||||
...basePath,
|
||||
"alsoAllow",
|
||||
]);
|
||||
removeConfigFormValue(state, [...basePath, "alsoAllow"]);
|
||||
}
|
||||
if (deny.length > 0) {
|
||||
updateConfigFormValue(
|
||||
state as unknown as ConfigState,
|
||||
[...basePath, "deny"],
|
||||
deny,
|
||||
);
|
||||
updateConfigFormValue(state, [...basePath, "deny"], deny);
|
||||
} else {
|
||||
removeConfigFormValue(state as unknown as ConfigState, [...basePath, "deny"]);
|
||||
removeConfigFormValue(state, [...basePath, "deny"]);
|
||||
}
|
||||
},
|
||||
onConfigReload: () => loadConfig(state as unknown as ConfigState),
|
||||
onConfigSave: () => saveConfig(state as unknown as ConfigState),
|
||||
onConfigReload: () => loadConfig(state),
|
||||
onConfigSave: () => saveConfig(state),
|
||||
onChannelsRefresh: () => loadChannels(state, false),
|
||||
onCronRefresh: () => state.loadCron(),
|
||||
onSkillsFilterChange: (next) => (state.skillsFilter = next),
|
||||
@@ -599,11 +820,7 @@ export function renderApp(state: AppViewState) {
|
||||
} else {
|
||||
next.delete(normalizedSkill);
|
||||
}
|
||||
updateConfigFormValue(
|
||||
state as unknown as ConfigState,
|
||||
["agents", "list", index, "skills"],
|
||||
[...next],
|
||||
);
|
||||
updateConfigFormValue(state, ["agents", "list", index, "skills"], [...next]);
|
||||
},
|
||||
onAgentSkillsClear: (agentId) => {
|
||||
if (!configValue) {
|
||||
@@ -623,12 +840,7 @@ export function renderApp(state: AppViewState) {
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
removeConfigFormValue(state as unknown as ConfigState, [
|
||||
"agents",
|
||||
"list",
|
||||
index,
|
||||
"skills",
|
||||
]);
|
||||
removeConfigFormValue(state, ["agents", "list", index, "skills"]);
|
||||
},
|
||||
onAgentSkillsDisableAll: (agentId) => {
|
||||
if (!configValue) {
|
||||
@@ -648,58 +860,32 @@ export function renderApp(state: AppViewState) {
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
updateConfigFormValue(
|
||||
state as unknown as ConfigState,
|
||||
["agents", "list", index, "skills"],
|
||||
[],
|
||||
);
|
||||
updateConfigFormValue(state, ["agents", "list", index, "skills"], []);
|
||||
},
|
||||
onModelChange: (agentId, modelId) => {
|
||||
if (!configValue) {
|
||||
return;
|
||||
}
|
||||
const defaultId = state.agentsList?.defaultId ?? null;
|
||||
if (defaultId && agentId === defaultId) {
|
||||
const basePath = ["agents", "defaults", "model"];
|
||||
const defaults =
|
||||
(configValue as { agents?: { defaults?: { model?: unknown } } }).agents
|
||||
?.defaults ?? {};
|
||||
const existing = defaults.model;
|
||||
if (!modelId) {
|
||||
removeConfigFormValue(state as unknown as ConfigState, basePath);
|
||||
return;
|
||||
}
|
||||
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
||||
const fallbacks = (existing as { fallbacks?: unknown }).fallbacks;
|
||||
const next = {
|
||||
primary: modelId,
|
||||
...(Array.isArray(fallbacks) ? { fallbacks } : {}),
|
||||
};
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
|
||||
} else {
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, {
|
||||
primary: modelId,
|
||||
});
|
||||
}
|
||||
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
|
||||
if (!Array.isArray(list)) {
|
||||
return;
|
||||
}
|
||||
const index = list.findIndex(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
"id" in entry &&
|
||||
(entry as { id?: string }).id === agentId,
|
||||
);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = ensureAgentListEntry(agentId);
|
||||
const basePath = ["agents", "list", index, "model"];
|
||||
if (!modelId) {
|
||||
removeConfigFormValue(state as unknown as ConfigState, basePath);
|
||||
removeConfigFormValue(state, basePath);
|
||||
return;
|
||||
}
|
||||
const list = (
|
||||
(state.configForm ??
|
||||
(state.configSnapshot?.config as Record<string, unknown> | null)) as {
|
||||
agents?: { list?: unknown[] };
|
||||
}
|
||||
)?.agents?.list;
|
||||
const entry =
|
||||
Array.isArray(list) && list[index]
|
||||
? (list[index] as { model?: unknown })
|
||||
: null;
|
||||
const entry = list[index] as { model?: unknown };
|
||||
const existing = entry?.model;
|
||||
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
||||
const fallbacks = (existing as { fallbacks?: unknown }).fallbacks;
|
||||
@@ -707,70 +893,33 @@ export function renderApp(state: AppViewState) {
|
||||
primary: modelId,
|
||||
...(Array.isArray(fallbacks) ? { fallbacks } : {}),
|
||||
};
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
|
||||
updateConfigFormValue(state, basePath, next);
|
||||
} else {
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, modelId);
|
||||
updateConfigFormValue(state, basePath, modelId);
|
||||
}
|
||||
},
|
||||
onModelFallbacksChange: (agentId, fallbacks) => {
|
||||
if (!configValue) {
|
||||
return;
|
||||
}
|
||||
const normalized = fallbacks.map((name) => name.trim()).filter(Boolean);
|
||||
const defaultId = state.agentsList?.defaultId ?? null;
|
||||
if (defaultId && agentId === defaultId) {
|
||||
const basePath = ["agents", "defaults", "model"];
|
||||
const defaults =
|
||||
(configValue as { agents?: { defaults?: { model?: unknown } } }).agents
|
||||
?.defaults ?? {};
|
||||
const existing = defaults.model;
|
||||
const resolvePrimary = () => {
|
||||
if (typeof existing === "string") {
|
||||
return existing.trim() || null;
|
||||
}
|
||||
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
||||
const primary = (existing as { primary?: unknown }).primary;
|
||||
if (typeof primary === "string") {
|
||||
const trimmed = primary.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const primary = resolvePrimary();
|
||||
if (normalized.length === 0) {
|
||||
if (primary) {
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, {
|
||||
primary,
|
||||
});
|
||||
} else {
|
||||
removeConfigFormValue(state as unknown as ConfigState, basePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const next = primary
|
||||
? { primary, fallbacks: normalized }
|
||||
: { fallbacks: normalized };
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
|
||||
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
|
||||
if (!Array.isArray(list)) {
|
||||
return;
|
||||
}
|
||||
const index = list.findIndex(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
"id" in entry &&
|
||||
(entry as { id?: string }).id === agentId,
|
||||
);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = ensureAgentListEntry(agentId);
|
||||
const basePath = ["agents", "list", index, "model"];
|
||||
const list = (
|
||||
(state.configForm ??
|
||||
(state.configSnapshot?.config as Record<string, unknown> | null)) as {
|
||||
agents?: { list?: unknown[] };
|
||||
}
|
||||
)?.agents?.list;
|
||||
const entry =
|
||||
Array.isArray(list) && list[index]
|
||||
? (list[index] as { model?: unknown })
|
||||
: null;
|
||||
const existing = entry?.model;
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
const entry = list[index] as { model?: unknown };
|
||||
const normalized = fallbacks.map((name) => name.trim()).filter(Boolean);
|
||||
const existing = entry.model;
|
||||
const resolvePrimary = () => {
|
||||
if (typeof existing === "string") {
|
||||
return existing.trim() || null;
|
||||
@@ -787,16 +936,16 @@ export function renderApp(state: AppViewState) {
|
||||
const primary = resolvePrimary();
|
||||
if (normalized.length === 0) {
|
||||
if (primary) {
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, primary);
|
||||
updateConfigFormValue(state, basePath, primary);
|
||||
} else {
|
||||
removeConfigFormValue(state as unknown as ConfigState, basePath);
|
||||
removeConfigFormValue(state, basePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const next = primary
|
||||
? { primary, fallbacks: normalized }
|
||||
: { fallbacks: normalized };
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
|
||||
updateConfigFormValue(state, basePath, next);
|
||||
},
|
||||
})
|
||||
: nothing
|
||||
@@ -853,7 +1002,7 @@ export function renderApp(state: AppViewState) {
|
||||
onDeviceRotate: (deviceId, role, scopes) =>
|
||||
rotateDeviceToken(state, { deviceId, role, scopes }),
|
||||
onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }),
|
||||
onLoadConfig: () => loadConfig(state as unknown as ConfigState),
|
||||
onLoadConfig: () => loadConfig(state),
|
||||
onLoadExecApprovals: () => {
|
||||
const target =
|
||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||
@@ -863,28 +1012,20 @@ export function renderApp(state: AppViewState) {
|
||||
},
|
||||
onBindDefault: (nodeId) => {
|
||||
if (nodeId) {
|
||||
updateConfigFormValue(
|
||||
state as unknown as ConfigState,
|
||||
["tools", "exec", "node"],
|
||||
nodeId,
|
||||
);
|
||||
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
||||
} else {
|
||||
removeConfigFormValue(state as unknown as ConfigState, [
|
||||
"tools",
|
||||
"exec",
|
||||
"node",
|
||||
]);
|
||||
removeConfigFormValue(state, ["tools", "exec", "node"]);
|
||||
}
|
||||
},
|
||||
onBindAgent: (agentIndex, nodeId) => {
|
||||
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
|
||||
if (nodeId) {
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, nodeId);
|
||||
updateConfigFormValue(state, basePath, nodeId);
|
||||
} else {
|
||||
removeConfigFormValue(state as unknown as ConfigState, basePath);
|
||||
removeConfigFormValue(state, basePath);
|
||||
}
|
||||
},
|
||||
onSaveBindings: () => saveConfig(state as unknown as ConfigState),
|
||||
onSaveBindings: () => saveConfig(state),
|
||||
onExecApprovalsTargetChange: (kind, nodeId) => {
|
||||
state.execApprovalsTarget = kind;
|
||||
state.execApprovalsTargetNodeId = nodeId;
|
||||
@@ -919,29 +1060,30 @@ export function renderApp(state: AppViewState) {
|
||||
state.chatMessage = "";
|
||||
state.chatAttachments = [];
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
|
||||
state.chatQueue = [];
|
||||
(state as unknown as OpenClawApp).resetToolStream();
|
||||
(state as unknown as OpenClawApp).resetChatScroll();
|
||||
state.resetToolStream();
|
||||
state.resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
void loadChatHistory(state as unknown as ChatState);
|
||||
void refreshChatAvatar(state as unknown as ChatHost);
|
||||
void loadChatHistory(state);
|
||||
void refreshChatAvatar(state);
|
||||
},
|
||||
thinkingLevel: state.chatThinkingLevel,
|
||||
showThinking,
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
compactionStatus: state.compactionStatus,
|
||||
assistantAvatarUrl: chatAvatarUrl,
|
||||
messages: state.chatMessages,
|
||||
toolMessages: state.chatToolMessages,
|
||||
stream: state.chatStream,
|
||||
streamStartedAt: null,
|
||||
streamStartedAt: state.chatStreamStartedAt,
|
||||
draft: state.chatMessage,
|
||||
queue: state.chatQueue,
|
||||
connected: state.connected,
|
||||
@@ -951,10 +1093,8 @@ export function renderApp(state: AppViewState) {
|
||||
sessions: state.sessionsResult,
|
||||
focusMode: chatFocus,
|
||||
onRefresh: () => {
|
||||
return Promise.all([
|
||||
loadChatHistory(state as unknown as ChatState),
|
||||
refreshChatAvatar(state as unknown as ChatHost),
|
||||
]);
|
||||
state.resetToolStream();
|
||||
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
||||
},
|
||||
onToggleFocusMode: () => {
|
||||
if (state.onboarding) {
|
||||
@@ -965,28 +1105,25 @@ export function renderApp(state: AppViewState) {
|
||||
chatFocusMode: !state.settings.chatFocusMode,
|
||||
});
|
||||
},
|
||||
onChatScroll: (event) => (state as unknown as OpenClawApp).handleChatScroll(event),
|
||||
onChatScroll: (event) => state.handleChatScroll(event),
|
||||
onDraftChange: (next) => (state.chatMessage = next),
|
||||
attachments: state.chatAttachments,
|
||||
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||
onSend: () => (state as unknown as OpenClawApp).handleSendChat(),
|
||||
onSend: () => state.handleSendChat(),
|
||||
canAbort: Boolean(state.chatRunId),
|
||||
onAbort: () => void (state as unknown as OpenClawApp).handleAbortChat(),
|
||||
onQueueRemove: (id) => (state as unknown as OpenClawApp).removeQueuedMessage(id),
|
||||
onNewSession: () =>
|
||||
(state as unknown as OpenClawApp).handleSendChat("/new", { restoreDraft: true }),
|
||||
onAbort: () => void state.handleAbortChat(),
|
||||
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
|
||||
showNewMessages: state.chatNewMessagesBelow,
|
||||
onScrollToBottom: () => state.scrollToBottom(),
|
||||
// Sidebar props for tool output viewing
|
||||
sidebarOpen: (state as unknown as OpenClawApp).sidebarOpen,
|
||||
sidebarContent: (state as unknown as OpenClawApp).sidebarContent,
|
||||
sidebarError: (state as unknown as OpenClawApp).sidebarError,
|
||||
splitRatio: (state as unknown as OpenClawApp).splitRatio,
|
||||
onOpenSidebar: (content: string) =>
|
||||
(state as unknown as OpenClawApp).handleOpenSidebar(content),
|
||||
onCloseSidebar: () => (state as unknown as OpenClawApp).handleCloseSidebar(),
|
||||
onSplitRatioChange: (ratio: number) =>
|
||||
(state as unknown as OpenClawApp).handleSplitRatioChange(ratio),
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
sidebarContent: state.sidebarContent,
|
||||
sidebarError: state.sidebarError,
|
||||
splitRatio: state.splitRatio,
|
||||
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
||||
onCloseSidebar: () => state.handleCloseSidebar(),
|
||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||
assistantName: state.assistantName,
|
||||
assistantAvatar: state.assistantAvatar,
|
||||
})
|
||||
@@ -1007,31 +1144,28 @@ export function renderApp(state: AppViewState) {
|
||||
connected: state.connected,
|
||||
schema: state.configSchema,
|
||||
schemaLoading: state.configSchemaLoading,
|
||||
uiHints: state.configUiHints as ConfigUiHints,
|
||||
uiHints: state.configUiHints,
|
||||
formMode: state.configFormMode,
|
||||
formValue: state.configForm,
|
||||
originalValue: state.configFormOriginal,
|
||||
searchQuery: (state as unknown as OpenClawApp).configSearchQuery,
|
||||
activeSection: (state as unknown as OpenClawApp).configActiveSection,
|
||||
activeSubsection: (state as unknown as OpenClawApp).configActiveSubsection,
|
||||
searchQuery: state.configSearchQuery,
|
||||
activeSection: state.configActiveSection,
|
||||
activeSubsection: state.configActiveSubsection,
|
||||
onRawChange: (next) => {
|
||||
state.configRaw = next;
|
||||
},
|
||||
onFormModeChange: (mode) => (state.configFormMode = mode),
|
||||
onFormPatch: (path, value) =>
|
||||
updateConfigFormValue(state as unknown as OpenClawApp, path, value),
|
||||
onSearchChange: (query) =>
|
||||
((state as unknown as OpenClawApp).configSearchQuery = query),
|
||||
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onSearchChange: (query) => (state.configSearchQuery = query),
|
||||
onSectionChange: (section) => {
|
||||
(state as unknown as OpenClawApp).configActiveSection = section;
|
||||
(state as unknown as OpenClawApp).configActiveSubsection = null;
|
||||
state.configActiveSection = section;
|
||||
state.configActiveSubsection = null;
|
||||
},
|
||||
onSubsectionChange: (section) =>
|
||||
((state as unknown as OpenClawApp).configActiveSubsection = section),
|
||||
onReload: () => loadConfig(state as unknown as OpenClawApp),
|
||||
onSave: () => saveConfig(state as unknown as OpenClawApp),
|
||||
onApply: () => applyConfig(state as unknown as OpenClawApp),
|
||||
onUpdate: () => runUpdate(state as unknown as OpenClawApp),
|
||||
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
||||
onReload: () => loadConfig(state),
|
||||
onSave: () => saveConfig(state),
|
||||
onApply: () => applyConfig(state),
|
||||
onUpdate: () => runUpdate(state),
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
@@ -1073,10 +1207,9 @@ export function renderApp(state: AppViewState) {
|
||||
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
||||
},
|
||||
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
||||
onRefresh: () => loadLogs(state as unknown as LogsState, { reset: true }),
|
||||
onExport: (lines, label) =>
|
||||
(state as unknown as OpenClawApp).exportLogs(lines, label),
|
||||
onScroll: (event) => (state as unknown as OpenClawApp).handleLogsScroll(event),
|
||||
onRefresh: () => loadLogs(state, { reset: true }),
|
||||
onExport: (lines, label) => state.exportLogs(lines, label),
|
||||
onScroll: (event) => state.handleLogsScroll(event),
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OpenClawApp } from "./app.ts";
|
||||
import type { AgentsListResult } from "./types.ts";
|
||||
import { refreshChat } from "./app-chat.ts";
|
||||
import {
|
||||
startLogsPolling,
|
||||
@@ -35,6 +36,7 @@ import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme.ts";
|
||||
|
||||
type SettingsHost = {
|
||||
settings: UiSettings;
|
||||
password?: string;
|
||||
theme: ThemeMode;
|
||||
themeResolved: ResolvedTheme;
|
||||
applySessionKey: string;
|
||||
@@ -46,35 +48,14 @@ type SettingsHost = {
|
||||
eventLog: unknown[];
|
||||
eventLogBuffer: unknown[];
|
||||
basePath: string;
|
||||
agentsList?: AgentsListResult | null;
|
||||
agentsSelectedId?: string | null;
|
||||
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
|
||||
themeMedia: MediaQueryList | null;
|
||||
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
|
||||
pendingGatewayUrl?: string | null;
|
||||
};
|
||||
|
||||
function isTopLevelWindow(): boolean {
|
||||
try {
|
||||
return window.top === window.self;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGatewayUrl(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function applySettings(host: SettingsHost, next: UiSettings) {
|
||||
const normalized = {
|
||||
...next,
|
||||
@@ -117,6 +98,10 @@ export function applySettingsFromUrl(host: SettingsHost) {
|
||||
}
|
||||
|
||||
if (passwordRaw != null) {
|
||||
const password = passwordRaw.trim();
|
||||
if (password) {
|
||||
(host as { password: string }).password = password;
|
||||
}
|
||||
params.delete("password");
|
||||
shouldCleanUrl = true;
|
||||
}
|
||||
@@ -134,8 +119,8 @@ export function applySettingsFromUrl(host: SettingsHost) {
|
||||
}
|
||||
|
||||
if (gatewayUrlRaw != null) {
|
||||
const gatewayUrl = normalizeGatewayUrl(gatewayUrlRaw);
|
||||
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl && isTopLevelWindow()) {
|
||||
const gatewayUrl = gatewayUrlRaw.trim();
|
||||
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
|
||||
host.pendingGatewayUrl = gatewayUrl;
|
||||
}
|
||||
params.delete("gatewayUrl");
|
||||
@@ -205,24 +190,23 @@ export async function refreshActiveTab(host: SettingsHost) {
|
||||
await loadSkills(host as unknown as OpenClawApp);
|
||||
}
|
||||
if (host.tab === "agents") {
|
||||
const app = host as unknown as OpenClawApp;
|
||||
await loadAgents(app);
|
||||
await loadConfig(app);
|
||||
const agentIds = app.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
||||
await loadAgents(host as unknown as OpenClawApp);
|
||||
await loadConfig(host as unknown as OpenClawApp);
|
||||
const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
||||
if (agentIds.length > 0) {
|
||||
void loadAgentIdentities(app, agentIds);
|
||||
void loadAgentIdentities(host as unknown as OpenClawApp, agentIds);
|
||||
}
|
||||
const agentId =
|
||||
app.agentsSelectedId ?? app.agentsList?.defaultId ?? app.agentsList?.agents?.[0]?.id;
|
||||
host.agentsSelectedId ?? host.agentsList?.defaultId ?? host.agentsList?.agents?.[0]?.id;
|
||||
if (agentId) {
|
||||
void loadAgentIdentity(app, agentId);
|
||||
if (app.agentsPanel === "skills") {
|
||||
void loadAgentSkills(app, agentId);
|
||||
void loadAgentIdentity(host as unknown as OpenClawApp, agentId);
|
||||
if (host.agentsPanel === "skills") {
|
||||
void loadAgentSkills(host as unknown as OpenClawApp, agentId);
|
||||
}
|
||||
if (app.agentsPanel === "channels") {
|
||||
void loadChannels(app, false);
|
||||
if (host.agentsPanel === "channels") {
|
||||
void loadChannels(host as unknown as OpenClawApp, false);
|
||||
}
|
||||
if (app.agentsPanel === "cron") {
|
||||
if (host.agentsPanel === "cron") {
|
||||
void loadCron(host);
|
||||
}
|
||||
}
|
||||
@@ -397,7 +381,7 @@ export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
export function syncUrlWithSessionKey(sessionKey: string, replace: boolean) {
|
||||
export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, replace: boolean) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EventLogEntry } from "./app-events.ts";
|
||||
import type { CompactionStatus } from "./app-tool-stream.ts";
|
||||
import type { DevicePairingList } from "./controllers/devices.ts";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
||||
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
|
||||
@@ -14,6 +15,7 @@ import type {
|
||||
AgentIdentityResult,
|
||||
ChannelsStatusSnapshot,
|
||||
ConfigSnapshot,
|
||||
ConfigUiHints,
|
||||
CronJob,
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
@@ -22,12 +24,16 @@ import type {
|
||||
LogLevel,
|
||||
NostrProfile,
|
||||
PresenceEntry,
|
||||
SessionsUsageResult,
|
||||
CostUsageSummary,
|
||||
SessionUsageTimeSeries,
|
||||
SessionsListResult,
|
||||
SkillStatusReport,
|
||||
StatusSummary,
|
||||
} from "./types.ts";
|
||||
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types.ts";
|
||||
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
|
||||
import type { SessionLogEntry } from "./views/usage.ts";
|
||||
|
||||
export type AppViewState = {
|
||||
settings: UiSettings;
|
||||
@@ -52,13 +58,19 @@ export type AppViewState = {
|
||||
chatMessages: unknown[];
|
||||
chatToolMessages: unknown[];
|
||||
chatStream: string | null;
|
||||
chatStreamStartedAt: number | null;
|
||||
chatRunId: string | null;
|
||||
compactionStatus: CompactionStatus | null;
|
||||
chatAvatarUrl: string | null;
|
||||
chatThinkingLevel: string | null;
|
||||
chatQueue: ChatQueueItem[];
|
||||
nodesLoading: boolean;
|
||||
nodes: Array<Record<string, unknown>>;
|
||||
chatNewMessagesBelow: boolean;
|
||||
sidebarOpen: boolean;
|
||||
sidebarContent: string | null;
|
||||
sidebarError: string | null;
|
||||
splitRatio: number;
|
||||
scrollToBottom: () => void;
|
||||
devicesLoading: boolean;
|
||||
devicesError: string | null;
|
||||
@@ -83,13 +95,18 @@ export type AppViewState = {
|
||||
configSaving: boolean;
|
||||
configApplying: boolean;
|
||||
updateRunning: boolean;
|
||||
applySessionKey: string;
|
||||
configSnapshot: ConfigSnapshot | null;
|
||||
configSchema: unknown;
|
||||
configSchemaVersion: string | null;
|
||||
configSchemaLoading: boolean;
|
||||
configUiHints: Record<string, unknown>;
|
||||
configUiHints: ConfigUiHints;
|
||||
configForm: Record<string, unknown> | null;
|
||||
configFormOriginal: Record<string, unknown> | null;
|
||||
configFormMode: "form" | "raw";
|
||||
configSearchQuery: string;
|
||||
configActiveSection: string | null;
|
||||
configActiveSubsection: string | null;
|
||||
channelsLoading: boolean;
|
||||
channelsSnapshot: ChannelsStatusSnapshot | null;
|
||||
channelsError: string | null;
|
||||
@@ -131,6 +148,39 @@ export type AppViewState = {
|
||||
sessionsFilterLimit: string;
|
||||
sessionsIncludeGlobal: boolean;
|
||||
sessionsIncludeUnknown: boolean;
|
||||
usageLoading: boolean;
|
||||
usageResult: SessionsUsageResult | null;
|
||||
usageCostSummary: CostUsageSummary | null;
|
||||
usageError: string | null;
|
||||
usageStartDate: string;
|
||||
usageEndDate: string;
|
||||
usageSelectedSessions: string[];
|
||||
usageSelectedDays: string[];
|
||||
usageSelectedHours: number[];
|
||||
usageChartMode: "tokens" | "cost";
|
||||
usageDailyChartMode: "total" | "by-type";
|
||||
usageTimeSeriesMode: "cumulative" | "per-turn";
|
||||
usageTimeSeriesBreakdownMode: "total" | "by-type";
|
||||
usageTimeSeries: SessionUsageTimeSeries | null;
|
||||
usageTimeSeriesLoading: boolean;
|
||||
usageSessionLogs: SessionLogEntry[] | null;
|
||||
usageSessionLogsLoading: boolean;
|
||||
usageSessionLogsExpanded: boolean;
|
||||
usageQuery: string;
|
||||
usageQueryDraft: string;
|
||||
usageQueryDebounceTimer: number | null;
|
||||
usageSessionSort: "tokens" | "cost" | "recent" | "messages" | "errors";
|
||||
usageSessionSortDir: "asc" | "desc";
|
||||
usageRecentSessions: string[];
|
||||
usageTimeZone: "local" | "utc";
|
||||
usageContextExpanded: boolean;
|
||||
usageHeaderPinned: boolean;
|
||||
usageSessionsTab: "all" | "recent";
|
||||
usageVisibleColumns: string[];
|
||||
usageLogFilterRoles: import("./views/usage.js").SessionLogRole[];
|
||||
usageLogFilterTools: string[];
|
||||
usageLogFilterHasTools: boolean;
|
||||
usageLogFilterQuery: string;
|
||||
cronLoading: boolean;
|
||||
cronJobs: CronJob[];
|
||||
cronStatus: CronStatus | null;
|
||||
@@ -163,7 +213,13 @@ export type AppViewState = {
|
||||
logsLevelFilters: Record<LogLevel, boolean>;
|
||||
logsAutoFollow: boolean;
|
||||
logsTruncated: boolean;
|
||||
logsCursor: number | null;
|
||||
logsLastFetchAt: number | null;
|
||||
logsLimit: number;
|
||||
logsMaxBytes: number;
|
||||
logsAtBottom: boolean;
|
||||
client: GatewayBrowserClient | null;
|
||||
refreshSessionsAfterChat: Set<string>;
|
||||
connect: () => void;
|
||||
setTab: (tab: Tab) => void;
|
||||
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||
@@ -214,13 +270,15 @@ export type AppViewState = {
|
||||
setPassword: (next: string) => void;
|
||||
setSessionKey: (next: string) => void;
|
||||
setChatMessage: (next: string) => void;
|
||||
handleChatSend: () => Promise<void>;
|
||||
handleChatAbort: () => Promise<void>;
|
||||
handleChatSelectQueueItem: (id: string) => void;
|
||||
handleChatDropQueueItem: (id: string) => void;
|
||||
handleChatClearQueue: () => void;
|
||||
handleLogsFilterChange: (next: string) => void;
|
||||
handleLogsLevelFilterToggle: (level: LogLevel) => void;
|
||||
handleLogsAutoFollowToggle: (next: boolean) => void;
|
||||
handleCallDebugMethod: (method: string, params: string) => Promise<void>;
|
||||
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
|
||||
handleAbortChat: () => Promise<void>;
|
||||
removeQueuedMessage: (id: string) => void;
|
||||
handleChatScroll: (event: Event) => void;
|
||||
resetToolStream: () => void;
|
||||
resetChatScroll: () => void;
|
||||
exportLogs: (lines: string[], label: string) => void;
|
||||
handleLogsScroll: (event: Event) => void;
|
||||
handleOpenSidebar: (content: string) => void;
|
||||
handleCloseSidebar: () => void;
|
||||
handleSplitRatioChange: (ratio: number) => void;
|
||||
};
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
import {
|
||||
resetToolStream as resetToolStreamInternal,
|
||||
type ToolStreamEntry,
|
||||
type CompactionStatus,
|
||||
} from "./app-tool-stream.ts";
|
||||
import { resolveInjectedAssistantIdentity } from "./assistant-identity.ts";
|
||||
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts";
|
||||
@@ -130,7 +131,7 @@ export class OpenClawApp extends LitElement {
|
||||
@state() chatStream: string | null = null;
|
||||
@state() chatStreamStartedAt: number | null = null;
|
||||
@state() chatRunId: string | null = null;
|
||||
@state() compactionStatus: import("./app-tool-stream.ts").CompactionStatus | null = null;
|
||||
@state() compactionStatus: CompactionStatus | null = null;
|
||||
@state() chatAvatarUrl: string | null = null;
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
@state() chatQueue: ChatQueueItem[] = [];
|
||||
@@ -226,6 +227,59 @@ export class OpenClawApp extends LitElement {
|
||||
@state() sessionsIncludeGlobal = true;
|
||||
@state() sessionsIncludeUnknown = false;
|
||||
|
||||
@state() usageLoading = false;
|
||||
@state() usageResult: import("./types.js").SessionsUsageResult | null = null;
|
||||
@state() usageCostSummary: import("./types.js").CostUsageSummary | null = null;
|
||||
@state() usageError: string | null = null;
|
||||
@state() usageStartDate = (() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
})();
|
||||
@state() usageEndDate = (() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
})();
|
||||
@state() usageSelectedSessions: string[] = [];
|
||||
@state() usageSelectedDays: string[] = [];
|
||||
@state() usageSelectedHours: number[] = [];
|
||||
@state() usageChartMode: "tokens" | "cost" = "tokens";
|
||||
@state() usageDailyChartMode: "total" | "by-type" = "by-type";
|
||||
@state() usageTimeSeriesMode: "cumulative" | "per-turn" = "per-turn";
|
||||
@state() usageTimeSeriesBreakdownMode: "total" | "by-type" = "by-type";
|
||||
@state() usageTimeSeries: import("./types.js").SessionUsageTimeSeries | null = null;
|
||||
@state() usageTimeSeriesLoading = false;
|
||||
@state() usageSessionLogs: import("./views/usage.js").SessionLogEntry[] | null = null;
|
||||
@state() usageSessionLogsLoading = false;
|
||||
@state() usageSessionLogsExpanded = false;
|
||||
// Applied query (used to filter the already-loaded sessions list client-side).
|
||||
@state() usageQuery = "";
|
||||
// Draft query text (updates immediately as the user types; applied via debounce or "Search").
|
||||
@state() usageQueryDraft = "";
|
||||
@state() usageSessionSort: "tokens" | "cost" | "recent" | "messages" | "errors" = "recent";
|
||||
@state() usageSessionSortDir: "desc" | "asc" = "desc";
|
||||
@state() usageRecentSessions: string[] = [];
|
||||
@state() usageTimeZone: "local" | "utc" = "local";
|
||||
@state() usageContextExpanded = false;
|
||||
@state() usageHeaderPinned = false;
|
||||
@state() usageSessionsTab: "all" | "recent" = "all";
|
||||
@state() usageVisibleColumns: string[] = [
|
||||
"channel",
|
||||
"agent",
|
||||
"provider",
|
||||
"model",
|
||||
"messages",
|
||||
"tools",
|
||||
"errors",
|
||||
"duration",
|
||||
];
|
||||
@state() usageLogFilterRoles: import("./views/usage.js").SessionLogRole[] = [];
|
||||
@state() usageLogFilterTools: string[] = [];
|
||||
@state() usageLogFilterHasTools = false;
|
||||
@state() usageLogFilterQuery = "";
|
||||
|
||||
// Non-reactive (don’t trigger renders just for timer bookkeeping).
|
||||
usageQueryDebounceTimer: number | null = null;
|
||||
|
||||
@state() cronLoading = false;
|
||||
@state() cronJobs: CronJob[] = [];
|
||||
@state() cronStatus: CronStatus | null = null;
|
||||
|
||||
107
ui/src/ui/controllers/usage.ts
Normal file
107
ui/src/ui/controllers/usage.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { SessionsUsageResult, CostUsageSummary, SessionUsageTimeSeries } from "../types.ts";
|
||||
import type { SessionLogEntry } from "../views/usage.ts";
|
||||
|
||||
export type UsageState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
usageLoading: boolean;
|
||||
usageResult: SessionsUsageResult | null;
|
||||
usageCostSummary: CostUsageSummary | null;
|
||||
usageError: string | null;
|
||||
usageStartDate: string;
|
||||
usageEndDate: string;
|
||||
usageSelectedSessions: string[];
|
||||
usageSelectedDays: string[];
|
||||
usageTimeSeries: SessionUsageTimeSeries | null;
|
||||
usageTimeSeriesLoading: boolean;
|
||||
usageSessionLogs: SessionLogEntry[] | null;
|
||||
usageSessionLogsLoading: boolean;
|
||||
};
|
||||
|
||||
export async function loadUsage(
|
||||
state: UsageState,
|
||||
overrides?: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
},
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.usageLoading) {
|
||||
return;
|
||||
}
|
||||
state.usageLoading = true;
|
||||
state.usageError = null;
|
||||
try {
|
||||
const startDate = overrides?.startDate ?? state.usageStartDate;
|
||||
const endDate = overrides?.endDate ?? state.usageEndDate;
|
||||
|
||||
// Load both endpoints in parallel
|
||||
const [sessionsRes, costRes] = await Promise.all([
|
||||
state.client.request("sessions.usage", {
|
||||
startDate,
|
||||
endDate,
|
||||
limit: 1000, // Cap at 1000 sessions
|
||||
includeContextWeight: true,
|
||||
}),
|
||||
state.client.request("usage.cost", { startDate, endDate }),
|
||||
]);
|
||||
|
||||
if (sessionsRes) {
|
||||
state.usageResult = sessionsRes as SessionsUsageResult;
|
||||
}
|
||||
if (costRes) {
|
||||
state.usageCostSummary = costRes as CostUsageSummary;
|
||||
}
|
||||
} catch (err) {
|
||||
state.usageError = String(err);
|
||||
} finally {
|
||||
state.usageLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSessionTimeSeries(state: UsageState, sessionKey: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.usageTimeSeriesLoading) {
|
||||
return;
|
||||
}
|
||||
state.usageTimeSeriesLoading = true;
|
||||
state.usageTimeSeries = null;
|
||||
try {
|
||||
const res = await state.client.request("sessions.usage.timeseries", { key: sessionKey });
|
||||
if (res) {
|
||||
state.usageTimeSeries = res as SessionUsageTimeSeries;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - time series is optional
|
||||
state.usageTimeSeries = null;
|
||||
} finally {
|
||||
state.usageTimeSeriesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSessionLogs(state: UsageState, sessionKey: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.usageSessionLogsLoading) {
|
||||
return;
|
||||
}
|
||||
state.usageSessionLogsLoading = true;
|
||||
state.usageSessionLogs = null;
|
||||
try {
|
||||
const res = await state.client.request("sessions.usage.logs", { key: sessionKey, limit: 500 });
|
||||
if (res && Array.isArray((res as { logs: SessionLogEntry[] }).logs)) {
|
||||
state.usageSessionLogs = (res as { logs: SessionLogEntry[] }).logs;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - logs are optional
|
||||
state.usageSessionLogs = null;
|
||||
} finally {
|
||||
state.usageSessionLogsLoading = false;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ export const TAB_GROUPS = [
|
||||
{ label: "Chat", tabs: ["chat"] },
|
||||
{
|
||||
label: "Control",
|
||||
tabs: ["overview", "channels", "instances", "sessions", "cron"],
|
||||
tabs: ["overview", "channels", "instances", "sessions", "usage", "cron"],
|
||||
},
|
||||
{ label: "Agent", tabs: ["agents", "skills", "nodes"] },
|
||||
{ label: "Settings", tabs: ["config", "debug", "logs"] },
|
||||
@@ -16,6 +16,7 @@ export type Tab =
|
||||
| "channels"
|
||||
| "instances"
|
||||
| "sessions"
|
||||
| "usage"
|
||||
| "cron"
|
||||
| "skills"
|
||||
| "nodes"
|
||||
@@ -30,6 +31,7 @@ const TAB_PATHS: Record<Tab, string> = {
|
||||
channels: "/channels",
|
||||
instances: "/instances",
|
||||
sessions: "/sessions",
|
||||
usage: "/usage",
|
||||
cron: "/cron",
|
||||
skills: "/skills",
|
||||
nodes: "/nodes",
|
||||
@@ -134,6 +136,8 @@ export function iconForTab(tab: Tab): IconName {
|
||||
return "radio";
|
||||
case "sessions":
|
||||
return "fileText";
|
||||
case "usage":
|
||||
return "barChart";
|
||||
case "cron":
|
||||
return "loader";
|
||||
case "skills":
|
||||
@@ -163,6 +167,8 @@ export function titleForTab(tab: Tab) {
|
||||
return "Instances";
|
||||
case "sessions":
|
||||
return "Sessions";
|
||||
case "usage":
|
||||
return "Usage";
|
||||
case "cron":
|
||||
return "Cron Jobs";
|
||||
case "skills":
|
||||
@@ -194,6 +200,8 @@ export function subtitleForTab(tab: Tab) {
|
||||
return "Presence beacons from connected clients and nodes.";
|
||||
case "sessions":
|
||||
return "Inspect active sessions and adjust per-session defaults.";
|
||||
case "usage":
|
||||
return "";
|
||||
case "cron":
|
||||
return "Schedule wakeups and recurring agent runs.";
|
||||
case "skills":
|
||||
|
||||
@@ -302,20 +302,20 @@ export type ConfigSchemaResponse = {
|
||||
};
|
||||
|
||||
export type PresenceEntry = {
|
||||
deviceFamily?: string | null;
|
||||
host?: string | null;
|
||||
instanceId?: string | null;
|
||||
host?: string | null;
|
||||
ip?: string | null;
|
||||
lastInputSeconds?: number | null;
|
||||
mode?: string | null;
|
||||
modelIdentifier?: string | null;
|
||||
version?: string | null;
|
||||
platform?: string | null;
|
||||
deviceFamily?: string | null;
|
||||
modelIdentifier?: string | null;
|
||||
roles?: string[] | null;
|
||||
scopes?: string[] | null;
|
||||
mode?: string | null;
|
||||
lastInputSeconds?: number | null;
|
||||
reason?: string | null;
|
||||
roles?: Array<string | null> | null;
|
||||
scopes?: Array<string | null> | null;
|
||||
text?: string | null;
|
||||
ts?: number | null;
|
||||
version?: string | null;
|
||||
};
|
||||
|
||||
export type GatewaySessionsDefaults = {
|
||||
@@ -424,6 +424,223 @@ 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 CronSchedule =
|
||||
| { kind: "at"; at: string }
|
||||
| { kind: "every"; everyMs: number; anchorMs?: number }
|
||||
@@ -506,10 +723,10 @@ export type SkillStatusEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
source: string;
|
||||
bundled?: boolean;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
skillKey: string;
|
||||
bundled?: boolean;
|
||||
primaryEnv?: string;
|
||||
emoji?: string;
|
||||
homepage?: string;
|
||||
|
||||
43
ui/src/ui/usage-helpers.node.test.ts
Normal file
43
ui/src/ui/usage-helpers.node.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "./usage-helpers.ts";
|
||||
|
||||
describe("usage-helpers", () => {
|
||||
it("tokenizes query terms including quoted strings", () => {
|
||||
const terms = extractQueryTerms('agent:main "model:gpt-5.2" has:errors');
|
||||
expect(terms.map((t) => t.raw)).toEqual(["agent:main", "model:gpt-5.2", "has:errors"]);
|
||||
});
|
||||
|
||||
it("matches key: glob filters against session keys", () => {
|
||||
const session = {
|
||||
key: "agent:main:cron:16234bc?token=dev-token",
|
||||
label: "agent:main:cron:16234bc?token=dev-token",
|
||||
usage: { totalTokens: 100, totalCost: 0 },
|
||||
};
|
||||
const matches = filterSessionsByQuery([session], "key:agent:main:cron*");
|
||||
expect(matches.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("supports numeric filters like minTokens/maxTokens", () => {
|
||||
const a = { key: "a", label: "a", usage: { totalTokens: 100, totalCost: 0 } };
|
||||
const b = { key: "b", label: "b", usage: { totalTokens: 5, totalCost: 0 } };
|
||||
expect(filterSessionsByQuery([a, b], "minTokens:10").sessions).toEqual([a]);
|
||||
expect(filterSessionsByQuery([a, b], "maxTokens:10").sessions).toEqual([b]);
|
||||
});
|
||||
|
||||
it("warns on unknown keys and invalid numbers", () => {
|
||||
const session = { key: "a", usage: { totalTokens: 10, totalCost: 0 } };
|
||||
const res = filterSessionsByQuery([session], "wat:1 minTokens:wat");
|
||||
expect(res.warnings.some((w) => w.includes("Unknown filter"))).toBe(true);
|
||||
expect(res.warnings.some((w) => w.includes("Invalid number"))).toBe(true);
|
||||
});
|
||||
|
||||
it("parses tool summaries from compact session logs", () => {
|
||||
const res = parseToolSummary(
|
||||
"[Tool: read]\n[Tool Result]\n[Tool: exec]\n[Tool: read]\n[Tool Result]",
|
||||
);
|
||||
expect(res.summary).toContain("read");
|
||||
expect(res.summary).toContain("exec");
|
||||
expect(res.tools[0]?.[0]).toBe("read");
|
||||
expect(res.tools[0]?.[1]).toBe(2);
|
||||
});
|
||||
});
|
||||
321
ui/src/ui/usage-helpers.ts
Normal file
321
ui/src/ui/usage-helpers.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
export type UsageQueryTerm = {
|
||||
key?: string;
|
||||
value: string;
|
||||
raw: string;
|
||||
};
|
||||
|
||||
export type UsageQueryResult<TSession> = {
|
||||
sessions: TSession[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
// Minimal shape required for query filtering. The usage view's real session type contains more fields.
|
||||
export type UsageSessionQueryTarget = {
|
||||
key: string;
|
||||
label?: string;
|
||||
sessionId?: string;
|
||||
agentId?: string;
|
||||
channel?: string;
|
||||
chatType?: string;
|
||||
modelProvider?: string;
|
||||
providerOverride?: string;
|
||||
origin?: { provider?: string };
|
||||
model?: string;
|
||||
contextWeight?: unknown;
|
||||
usage?: {
|
||||
totalTokens?: number;
|
||||
totalCost?: number;
|
||||
messageCounts?: { total?: number; errors?: number };
|
||||
toolUsage?: { totalCalls?: number; tools?: Array<{ name: string }> };
|
||||
modelUsage?: Array<{ provider?: string; model?: string }>;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const QUERY_KEYS = new Set([
|
||||
"agent",
|
||||
"channel",
|
||||
"chat",
|
||||
"provider",
|
||||
"model",
|
||||
"tool",
|
||||
"label",
|
||||
"key",
|
||||
"session",
|
||||
"id",
|
||||
"has",
|
||||
"mintokens",
|
||||
"maxtokens",
|
||||
"mincost",
|
||||
"maxcost",
|
||||
"minmessages",
|
||||
"maxmessages",
|
||||
]);
|
||||
|
||||
const normalizeQueryText = (value: string): string => value.trim().toLowerCase();
|
||||
|
||||
const globToRegex = (pattern: string): RegExp => {
|
||||
const escaped = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
return new RegExp(`^${escaped}$`, "i");
|
||||
};
|
||||
|
||||
const parseQueryNumber = (value: string): number | null => {
|
||||
let raw = value.trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (raw.startsWith("$")) {
|
||||
raw = raw.slice(1);
|
||||
}
|
||||
let multiplier = 1;
|
||||
if (raw.endsWith("k")) {
|
||||
multiplier = 1_000;
|
||||
raw = raw.slice(0, -1);
|
||||
} else if (raw.endsWith("m")) {
|
||||
multiplier = 1_000_000;
|
||||
raw = raw.slice(0, -1);
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed * multiplier;
|
||||
};
|
||||
|
||||
export const extractQueryTerms = (query: string): UsageQueryTerm[] => {
|
||||
// Tokenize by whitespace, but allow quoted values with spaces.
|
||||
const rawTokens = query.match(/"[^"]+"|\S+/g) ?? [];
|
||||
return rawTokens.map((token) => {
|
||||
const cleaned = token.replace(/^"|"$/g, "");
|
||||
const idx = cleaned.indexOf(":");
|
||||
if (idx > 0) {
|
||||
const key = cleaned.slice(0, idx);
|
||||
const value = cleaned.slice(idx + 1);
|
||||
return { key, value, raw: cleaned };
|
||||
}
|
||||
return { value: cleaned, raw: cleaned };
|
||||
});
|
||||
};
|
||||
|
||||
const getSessionText = (session: UsageSessionQueryTarget): string[] => {
|
||||
const items: Array<string | undefined> = [session.label, session.key, session.sessionId];
|
||||
return items.filter((item): item is string => Boolean(item)).map((item) => item.toLowerCase());
|
||||
};
|
||||
|
||||
const getSessionProviders = (session: UsageSessionQueryTarget): string[] => {
|
||||
const providers = new Set<string>();
|
||||
if (session.modelProvider) {
|
||||
providers.add(session.modelProvider.toLowerCase());
|
||||
}
|
||||
if (session.providerOverride) {
|
||||
providers.add(session.providerOverride.toLowerCase());
|
||||
}
|
||||
if (session.origin?.provider) {
|
||||
providers.add(session.origin.provider.toLowerCase());
|
||||
}
|
||||
for (const entry of session.usage?.modelUsage ?? []) {
|
||||
if (entry.provider) {
|
||||
providers.add(entry.provider.toLowerCase());
|
||||
}
|
||||
}
|
||||
return Array.from(providers);
|
||||
};
|
||||
|
||||
const getSessionModels = (session: UsageSessionQueryTarget): string[] => {
|
||||
const models = new Set<string>();
|
||||
if (session.model) {
|
||||
models.add(session.model.toLowerCase());
|
||||
}
|
||||
for (const entry of session.usage?.modelUsage ?? []) {
|
||||
if (entry.model) {
|
||||
models.add(entry.model.toLowerCase());
|
||||
}
|
||||
}
|
||||
return Array.from(models);
|
||||
};
|
||||
|
||||
const getSessionTools = (session: UsageSessionQueryTarget): string[] =>
|
||||
(session.usage?.toolUsage?.tools ?? []).map((tool) => tool.name.toLowerCase());
|
||||
|
||||
export const matchesUsageQuery = (
|
||||
session: UsageSessionQueryTarget,
|
||||
term: UsageQueryTerm,
|
||||
): boolean => {
|
||||
const value = normalizeQueryText(term.value ?? "");
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
if (!term.key) {
|
||||
return getSessionText(session).some((text) => text.includes(value));
|
||||
}
|
||||
|
||||
const key = normalizeQueryText(term.key);
|
||||
switch (key) {
|
||||
case "agent":
|
||||
return session.agentId?.toLowerCase().includes(value) ?? false;
|
||||
case "channel":
|
||||
return session.channel?.toLowerCase().includes(value) ?? false;
|
||||
case "chat":
|
||||
return session.chatType?.toLowerCase().includes(value) ?? false;
|
||||
case "provider":
|
||||
return getSessionProviders(session).some((provider) => provider.includes(value));
|
||||
case "model":
|
||||
return getSessionModels(session).some((model) => model.includes(value));
|
||||
case "tool":
|
||||
return getSessionTools(session).some((tool) => tool.includes(value));
|
||||
case "label":
|
||||
return session.label?.toLowerCase().includes(value) ?? false;
|
||||
case "key":
|
||||
case "session":
|
||||
case "id":
|
||||
if (value.includes("*") || value.includes("?")) {
|
||||
const regex = globToRegex(value);
|
||||
return (
|
||||
regex.test(session.key) || (session.sessionId ? regex.test(session.sessionId) : false)
|
||||
);
|
||||
}
|
||||
return (
|
||||
session.key.toLowerCase().includes(value) ||
|
||||
(session.sessionId?.toLowerCase().includes(value) ?? false)
|
||||
);
|
||||
case "has":
|
||||
switch (value) {
|
||||
case "tools":
|
||||
return (session.usage?.toolUsage?.totalCalls ?? 0) > 0;
|
||||
case "errors":
|
||||
return (session.usage?.messageCounts?.errors ?? 0) > 0;
|
||||
case "context":
|
||||
return Boolean(session.contextWeight);
|
||||
case "usage":
|
||||
return Boolean(session.usage);
|
||||
case "model":
|
||||
return getSessionModels(session).length > 0;
|
||||
case "provider":
|
||||
return getSessionProviders(session).length > 0;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
case "mintokens": {
|
||||
const threshold = parseQueryNumber(value);
|
||||
if (threshold === null) {
|
||||
return true;
|
||||
}
|
||||
return (session.usage?.totalTokens ?? 0) >= threshold;
|
||||
}
|
||||
case "maxtokens": {
|
||||
const threshold = parseQueryNumber(value);
|
||||
if (threshold === null) {
|
||||
return true;
|
||||
}
|
||||
return (session.usage?.totalTokens ?? 0) <= threshold;
|
||||
}
|
||||
case "mincost": {
|
||||
const threshold = parseQueryNumber(value);
|
||||
if (threshold === null) {
|
||||
return true;
|
||||
}
|
||||
return (session.usage?.totalCost ?? 0) >= threshold;
|
||||
}
|
||||
case "maxcost": {
|
||||
const threshold = parseQueryNumber(value);
|
||||
if (threshold === null) {
|
||||
return true;
|
||||
}
|
||||
return (session.usage?.totalCost ?? 0) <= threshold;
|
||||
}
|
||||
case "minmessages": {
|
||||
const threshold = parseQueryNumber(value);
|
||||
if (threshold === null) {
|
||||
return true;
|
||||
}
|
||||
return (session.usage?.messageCounts?.total ?? 0) >= threshold;
|
||||
}
|
||||
case "maxmessages": {
|
||||
const threshold = parseQueryNumber(value);
|
||||
if (threshold === null) {
|
||||
return true;
|
||||
}
|
||||
return (session.usage?.messageCounts?.total ?? 0) <= threshold;
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const filterSessionsByQuery = <TSession extends UsageSessionQueryTarget>(
|
||||
sessions: TSession[],
|
||||
query: string,
|
||||
): UsageQueryResult<TSession> => {
|
||||
const terms = extractQueryTerms(query);
|
||||
if (terms.length === 0) {
|
||||
return { sessions, warnings: [] };
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
for (const term of terms) {
|
||||
if (!term.key) {
|
||||
continue;
|
||||
}
|
||||
const normalizedKey = normalizeQueryText(term.key);
|
||||
if (!QUERY_KEYS.has(normalizedKey)) {
|
||||
warnings.push(`Unknown filter: ${term.key}`);
|
||||
continue;
|
||||
}
|
||||
if (term.value === "") {
|
||||
warnings.push(`Missing value for ${term.key}`);
|
||||
}
|
||||
if (normalizedKey === "has") {
|
||||
const allowed = new Set(["tools", "errors", "context", "usage", "model", "provider"]);
|
||||
if (term.value && !allowed.has(normalizeQueryText(term.value))) {
|
||||
warnings.push(`Unknown has:${term.value}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
["mintokens", "maxtokens", "mincost", "maxcost", "minmessages", "maxmessages"].includes(
|
||||
normalizedKey,
|
||||
)
|
||||
) {
|
||||
if (term.value && parseQueryNumber(term.value) === null) {
|
||||
warnings.push(`Invalid number for ${term.key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = sessions.filter((session) =>
|
||||
terms.every((term) => matchesUsageQuery(session, term)),
|
||||
);
|
||||
return { sessions: filtered, warnings };
|
||||
};
|
||||
|
||||
export function parseToolSummary(content: string) {
|
||||
const lines = content.split("\n");
|
||||
const toolCounts = new Map<string, number>();
|
||||
const nonToolLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
const match = /^\[Tool:\s*([^\]]+)\]/.exec(line.trim());
|
||||
if (match) {
|
||||
const name = match[1];
|
||||
toolCounts.set(name, (toolCounts.get(name) ?? 0) + 1);
|
||||
continue;
|
||||
}
|
||||
if (line.trim().startsWith("[Tool Result]")) {
|
||||
continue;
|
||||
}
|
||||
nonToolLines.push(line);
|
||||
}
|
||||
const sortedTools = Array.from(toolCounts.entries()).toSorted((a, b) => b[1] - a[1]);
|
||||
const totalCalls = sortedTools.reduce((sum, [, count]) => sum + count, 0);
|
||||
const summary =
|
||||
sortedTools.length > 0
|
||||
? `Tools: ${sortedTools
|
||||
.map(([name, count]) => `${name}×${count}`)
|
||||
.join(", ")} (${totalCalls} calls)`
|
||||
: "";
|
||||
return {
|
||||
tools: sortedTools,
|
||||
summary,
|
||||
cleanContent: nonToolLines.join("\n").trim(),
|
||||
};
|
||||
}
|
||||
5432
ui/src/ui/views/usage.ts
Normal file
5432
ui/src/ui/views/usage.ts
Normal file
File diff suppressed because it is too large
Load Diff
9
ui/vitest.node.config.ts
Normal file
9
ui/vitest.node.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Node-only tests for pure logic (no Playwright/browser dependency).
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.node.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user