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 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.
|
- 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.
|
- 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: 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: 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.
|
- 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: resolve header logo path when `gateway.controlUi.basePath` is set. (#7178) Thanks @Yeom-JinHo.
|
||||||
- Web UI: apply button styling to the new-messages indicator.
|
- Web UI: apply button styling to the new-messages indicator.
|
||||||
- Onboarding: infer auth choice from non-interactive API key flags. (#8484) Thanks @f-trycua.
|
- 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: 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.
|
- 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.
|
- 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 {
|
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 {
|
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 distDir = path.join(rootDir, "dist");
|
||||||
const cliDir = path.join(distDir, "cli");
|
const cliDir = path.join(distDir, "cli");
|
||||||
|
|
||||||
const candidates = fs
|
const findCandidates = () =>
|
||||||
.readdirSync(distDir)
|
fs
|
||||||
.filter((entry) => entry.startsWith("daemon-cli-") && entry.endsWith(".js"));
|
.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) {
|
if (candidates.length === 0) {
|
||||||
throw new Error("No daemon-cli bundle found in dist; cannot write legacy CLI shim.");
|
throw new Error("No daemon-cli bundle found in dist; cannot write legacy CLI shim.");
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ import {
|
|||||||
SessionsResetParamsSchema,
|
SessionsResetParamsSchema,
|
||||||
type SessionsResolveParams,
|
type SessionsResolveParams,
|
||||||
SessionsResolveParamsSchema,
|
SessionsResolveParamsSchema,
|
||||||
|
type SessionsUsageParams,
|
||||||
|
SessionsUsageParamsSchema,
|
||||||
type ShutdownEvent,
|
type ShutdownEvent,
|
||||||
ShutdownEventSchema,
|
ShutdownEventSchema,
|
||||||
type SkillsBinsParams,
|
type SkillsBinsParams,
|
||||||
@@ -271,6 +273,8 @@ export const validateSessionsDeleteParams = ajv.compile<SessionsDeleteParams>(
|
|||||||
export const validateSessionsCompactParams = ajv.compile<SessionsCompactParams>(
|
export const validateSessionsCompactParams = ajv.compile<SessionsCompactParams>(
|
||||||
SessionsCompactParamsSchema,
|
SessionsCompactParamsSchema,
|
||||||
);
|
);
|
||||||
|
export const validateSessionsUsageParams =
|
||||||
|
ajv.compile<SessionsUsageParams>(SessionsUsageParamsSchema);
|
||||||
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(ConfigGetParamsSchema);
|
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(ConfigGetParamsSchema);
|
||||||
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(ConfigSetParamsSchema);
|
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(ConfigSetParamsSchema);
|
||||||
export const validateConfigApplyParams = ajv.compile<ConfigApplyParams>(ConfigApplyParamsSchema);
|
export const validateConfigApplyParams = ajv.compile<ConfigApplyParams>(ConfigApplyParamsSchema);
|
||||||
@@ -412,6 +416,7 @@ export {
|
|||||||
SessionsResetParamsSchema,
|
SessionsResetParamsSchema,
|
||||||
SessionsDeleteParamsSchema,
|
SessionsDeleteParamsSchema,
|
||||||
SessionsCompactParamsSchema,
|
SessionsCompactParamsSchema,
|
||||||
|
SessionsUsageParamsSchema,
|
||||||
ConfigGetParamsSchema,
|
ConfigGetParamsSchema,
|
||||||
ConfigSetParamsSchema,
|
ConfigSetParamsSchema,
|
||||||
ConfigApplyParamsSchema,
|
ConfigApplyParamsSchema,
|
||||||
@@ -541,6 +546,7 @@ export type {
|
|||||||
SessionsResetParams,
|
SessionsResetParams,
|
||||||
SessionsDeleteParams,
|
SessionsDeleteParams,
|
||||||
SessionsCompactParams,
|
SessionsCompactParams,
|
||||||
|
SessionsUsageParams,
|
||||||
CronJob,
|
CronJob,
|
||||||
CronListParams,
|
CronListParams,
|
||||||
CronStatusParams,
|
CronStatusParams,
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ import {
|
|||||||
SessionsPreviewParamsSchema,
|
SessionsPreviewParamsSchema,
|
||||||
SessionsResetParamsSchema,
|
SessionsResetParamsSchema,
|
||||||
SessionsResolveParamsSchema,
|
SessionsResolveParamsSchema,
|
||||||
|
SessionsUsageParamsSchema,
|
||||||
} from "./sessions.js";
|
} from "./sessions.js";
|
||||||
import { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
|
import { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
|
||||||
import {
|
import {
|
||||||
@@ -168,6 +169,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
SessionsResetParams: SessionsResetParamsSchema,
|
SessionsResetParams: SessionsResetParamsSchema,
|
||||||
SessionsDeleteParams: SessionsDeleteParamsSchema,
|
SessionsDeleteParams: SessionsDeleteParamsSchema,
|
||||||
SessionsCompactParams: SessionsCompactParamsSchema,
|
SessionsCompactParams: SessionsCompactParamsSchema,
|
||||||
|
SessionsUsageParams: SessionsUsageParamsSchema,
|
||||||
ConfigGetParams: ConfigGetParamsSchema,
|
ConfigGetParams: ConfigGetParamsSchema,
|
||||||
ConfigSetParams: ConfigSetParamsSchema,
|
ConfigSetParams: ConfigSetParamsSchema,
|
||||||
ConfigApplyParams: ConfigApplyParamsSchema,
|
ConfigApplyParams: ConfigApplyParamsSchema,
|
||||||
|
|||||||
@@ -101,3 +101,19 @@ export const SessionsCompactParamsSchema = Type.Object(
|
|||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ 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,
|
SessionsPreviewParamsSchema,
|
||||||
SessionsResetParamsSchema,
|
SessionsResetParamsSchema,
|
||||||
SessionsResolveParamsSchema,
|
SessionsResolveParamsSchema,
|
||||||
|
SessionsUsageParamsSchema,
|
||||||
} from "./sessions.js";
|
} from "./sessions.js";
|
||||||
import type { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
|
import type { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -157,6 +158,7 @@ export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
|||||||
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
|
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
|
||||||
export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
|
export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
|
||||||
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
|
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
|
||||||
|
export type SessionsUsageParams = Static<typeof SessionsUsageParamsSchema>;
|
||||||
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
||||||
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
|
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
|
||||||
export type ConfigApplyParams = Static<typeof ConfigApplyParamsSchema>;
|
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 type { GatewayRequestHandlers } from "./types.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
|
import { resolveSessionFilePath } from "../../config/sessions/paths.js";
|
||||||
import { loadProviderUsageSummary } from "../../infra/provider-usage.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;
|
const COST_USAGE_CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
type DateRange = { startMs: number; endMs: number };
|
||||||
|
|
||||||
type CostUsageCacheEntry = {
|
type CostUsageCacheEntry = {
|
||||||
summary?: CostUsageSummary;
|
summary?: CostUsageSummary;
|
||||||
updatedAt?: number;
|
updatedAt?: number;
|
||||||
inFlight?: Promise<CostUsageSummary>;
|
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)) {
|
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||||
return Math.floor(raw);
|
return Math.floor(raw);
|
||||||
}
|
}
|
||||||
@@ -24,16 +72,51 @@ const parseDays = (raw: unknown): number => {
|
|||||||
return Math.floor(parsed);
|
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: {
|
async function loadCostUsageSummaryCached(params: {
|
||||||
days: number;
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
config: ReturnType<typeof loadConfig>;
|
config: ReturnType<typeof loadConfig>;
|
||||||
}): Promise<CostUsageSummary> {
|
}): Promise<CostUsageSummary> {
|
||||||
const days = Math.max(1, params.days);
|
const cacheKey = `${params.startMs}-${params.endMs}`;
|
||||||
const now = Date.now();
|
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) {
|
if (cached?.summary && cached.updatedAt && now - cached.updatedAt < COST_USAGE_CACHE_TTL_MS) {
|
||||||
return cached.summary;
|
return cached.summary;
|
||||||
}
|
}
|
||||||
@@ -46,9 +129,13 @@ async function loadCostUsageSummaryCached(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entry: CostUsageCacheEntry = cached ?? {};
|
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) => {
|
.then((summary) => {
|
||||||
costUsageCache.set(days, { summary, updatedAt: Date.now() });
|
costUsageCache.set(cacheKey, { summary, updatedAt: Date.now() });
|
||||||
return summary;
|
return summary;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -58,15 +145,15 @@ async function loadCostUsageSummaryCached(params: {
|
|||||||
throw err;
|
throw err;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
const current = costUsageCache.get(days);
|
const current = costUsageCache.get(cacheKey);
|
||||||
if (current?.inFlight === inFlight) {
|
if (current?.inFlight === inFlight) {
|
||||||
current.inFlight = undefined;
|
current.inFlight = undefined;
|
||||||
costUsageCache.set(days, current);
|
costUsageCache.set(cacheKey, current);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
entry.inFlight = inFlight;
|
entry.inFlight = inFlight;
|
||||||
costUsageCache.set(days, entry);
|
costUsageCache.set(cacheKey, entry);
|
||||||
|
|
||||||
if (entry.summary) {
|
if (entry.summary) {
|
||||||
return entry.summary;
|
return entry.summary;
|
||||||
@@ -74,6 +161,70 @@ async function loadCostUsageSummaryCached(params: {
|
|||||||
return await inFlight;
|
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 = {
|
export const usageHandlers: GatewayRequestHandlers = {
|
||||||
"usage.status": async ({ respond }) => {
|
"usage.status": async ({ respond }) => {
|
||||||
const summary = await loadProviderUsageSummary();
|
const summary = await loadProviderUsageSummary();
|
||||||
@@ -81,8 +232,535 @@ export const usageHandlers: GatewayRequestHandlers = {
|
|||||||
},
|
},
|
||||||
"usage.cost": async ({ respond, params }) => {
|
"usage.cost": async ({ respond, params }) => {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const days = parseDays(params?.days);
|
const { startMs, endMs } = parseDateRange({
|
||||||
const summary = await loadCostUsageSummaryCached({ days, config });
|
startDate: params?.startDate,
|
||||||
|
endDate: params?.endDate,
|
||||||
|
days: params?.days,
|
||||||
|
});
|
||||||
|
const summary = await loadCostUsageSummaryCached({ startMs, endMs, config });
|
||||||
respond(true, summary, undefined);
|
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");
|
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", () => {
|
test("truncates preview text to max chars", () => {
|
||||||
const sessionId = "preview-truncate";
|
const sessionId = "preview-truncate";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { SessionPreviewItem } from "./session-utils.types.js";
|
import type { SessionPreviewItem } from "./session-utils.types.js";
|
||||||
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||||
|
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
|
||||||
import { stripEnvelope } from "./chat-sanitize.js";
|
import { stripEnvelope } from "./chat-sanitize.js";
|
||||||
|
|
||||||
export function readSessionMessages(
|
export function readSessionMessages(
|
||||||
@@ -292,35 +293,11 @@ function extractPreviewText(message: TranscriptPreviewMessage): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isToolCall(message: TranscriptPreviewMessage): boolean {
|
function isToolCall(message: TranscriptPreviewMessage): boolean {
|
||||||
if (message.toolName || message.tool_name) {
|
return hasToolCall(message as Record<string, unknown>);
|
||||||
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";
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractToolNames(message: TranscriptPreviewMessage): string[] {
|
function extractToolNames(message: TranscriptPreviewMessage): string[] {
|
||||||
const names: string[] = [];
|
return extractToolCallNames(message as Record<string, unknown>);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractMediaSummary(message: TranscriptPreviewMessage): string | null {
|
function extractMediaSummary(message: TranscriptPreviewMessage): string | null {
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
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", () => {
|
describe("session cost usage", () => {
|
||||||
it("aggregates daily totals with log cost and pricing fallback", async () => {
|
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?.totalTokens).toBe(30);
|
||||||
expect(summary?.lastActivity).toBeGreaterThan(0);
|
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,
|
lastActiveSessionKey: next,
|
||||||
});
|
});
|
||||||
void state.loadAssistantIdentity();
|
void state.loadAssistantIdentity();
|
||||||
syncUrlWithSessionKey(next, true);
|
syncUrlWithSessionKey(
|
||||||
|
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
|
||||||
|
next,
|
||||||
|
true,
|
||||||
|
);
|
||||||
void loadChatHistory(state as unknown as ChatState);
|
void loadChatHistory(state as unknown as ChatState);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import type { AppViewState } from "./app-view-state.ts";
|
import type { AppViewState } from "./app-view-state.ts";
|
||||||
|
import type { UsageState } from "./controllers/usage.ts";
|
||||||
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
|
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 { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers.ts";
|
||||||
import { OpenClawApp } from "./app.ts";
|
|
||||||
import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts";
|
import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts";
|
||||||
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
|
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
|
||||||
import { loadAgentSkills } from "./controllers/agent-skills.ts";
|
import { loadAgentSkills } from "./controllers/agent-skills.ts";
|
||||||
import { loadAgents } from "./controllers/agents.ts";
|
import { loadAgents } from "./controllers/agents.ts";
|
||||||
import { loadChannels } from "./controllers/channels.ts";
|
import { loadChannels } from "./controllers/channels.ts";
|
||||||
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
|
import { loadChatHistory } from "./controllers/chat.ts";
|
||||||
import {
|
import {
|
||||||
applyConfig,
|
applyConfig,
|
||||||
ConfigState,
|
|
||||||
loadConfig,
|
loadConfig,
|
||||||
runUpdate,
|
runUpdate,
|
||||||
saveConfig,
|
saveConfig,
|
||||||
@@ -40,7 +39,7 @@ import {
|
|||||||
saveExecApprovals,
|
saveExecApprovals,
|
||||||
updateExecApprovalsFormValue,
|
updateExecApprovalsFormValue,
|
||||||
} from "./controllers/exec-approvals.ts";
|
} 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 { loadNodes } from "./controllers/nodes.ts";
|
||||||
import { loadPresence } from "./controllers/presence.ts";
|
import { loadPresence } from "./controllers/presence.ts";
|
||||||
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions.ts";
|
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions.ts";
|
||||||
@@ -51,9 +50,18 @@ import {
|
|||||||
updateSkillEdit,
|
updateSkillEdit,
|
||||||
updateSkillEnabled,
|
updateSkillEnabled,
|
||||||
} from "./controllers/skills.ts";
|
} from "./controllers/skills.ts";
|
||||||
|
import { loadUsage, loadSessionTimeSeries, loadSessionLogs } from "./controllers/usage.ts";
|
||||||
import { icons } from "./icons.ts";
|
import { icons } from "./icons.ts";
|
||||||
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.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 { renderAgents } from "./views/agents.ts";
|
||||||
import { renderChannels } from "./views/channels.ts";
|
import { renderChannels } from "./views/channels.ts";
|
||||||
import { renderChat } from "./views/chat.ts";
|
import { renderChat } from "./views/chat.ts";
|
||||||
@@ -68,6 +76,7 @@ import { renderNodes } from "./views/nodes.ts";
|
|||||||
import { renderOverview } from "./views/overview.ts";
|
import { renderOverview } from "./views/overview.ts";
|
||||||
import { renderSessions } from "./views/sessions.ts";
|
import { renderSessions } from "./views/sessions.ts";
|
||||||
import { renderSkills } from "./views/skills.ts";
|
import { renderSkills } from "./views/skills.ts";
|
||||||
|
import { renderUsage } from "./views/usage.ts";
|
||||||
|
|
||||||
const AVATAR_DATA_RE = /^data:/i;
|
const AVATAR_DATA_RE = /^data:/i;
|
||||||
const AVATAR_HTTP_RE = /^https?:\/\//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 showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||||
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
|
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
|
||||||
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
|
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
|
||||||
const logoBase = normalizeBasePath(state.basePath);
|
|
||||||
const logoHref = logoBase ? `${logoBase}/favicon.svg` : "/favicon.svg";
|
|
||||||
const configValue =
|
const configValue =
|
||||||
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
|
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
|
||||||
|
const basePath = normalizeBasePath(state.basePath ?? "");
|
||||||
const resolvedAgentId =
|
const resolvedAgentId =
|
||||||
state.agentsSelectedId ??
|
state.agentsSelectedId ??
|
||||||
state.agentsList?.defaultId ??
|
state.agentsList?.defaultId ??
|
||||||
state.agentsList?.agents?.[0]?.id ??
|
state.agentsList?.agents?.[0]?.id ??
|
||||||
null;
|
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`
|
return html`
|
||||||
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
|
<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>
|
</button>
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-logo">
|
<div class="brand-logo">
|
||||||
<img src="${logoHref}" alt="OpenClaw" />
|
<img src=${basePath ? `${basePath}/favicon.svg` : "/favicon.svg"} alt="OpenClaw" />
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
<div class="brand-title">OPENCLAW</div>
|
<div class="brand-title">OPENCLAW</div>
|
||||||
@@ -212,8 +199,8 @@ export function renderApp(state: AppViewState) {
|
|||||||
<main class="content ${isChat ? "content--chat" : ""}">
|
<main class="content ${isChat ? "content--chat" : ""}">
|
||||||
<section class="content-header">
|
<section class="content-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="page-title">${titleForTab(state.tab)}</div>
|
${state.tab === "usage" ? nothing : html`<div class="page-title">${titleForTab(state.tab)}</div>`}
|
||||||
<div class="page-sub">${subtitleForTab(state.tab)}</div>
|
${state.tab === "usage" ? nothing : html`<div class="page-sub">${subtitleForTab(state.tab)}</div>`}
|
||||||
</div>
|
</div>
|
||||||
<div class="page-meta">
|
<div class="page-meta">
|
||||||
${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing}
|
${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing}
|
||||||
@@ -239,7 +226,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
onSessionKeyChange: (next) => {
|
onSessionKeyChange: (next) => {
|
||||||
state.sessionKey = next;
|
state.sessionKey = next;
|
||||||
state.chatMessage = "";
|
state.chatMessage = "";
|
||||||
(state as unknown as OpenClawApp).resetToolStream();
|
state.resetToolStream();
|
||||||
state.applySettings({
|
state.applySettings({
|
||||||
...state.settings,
|
...state.settings,
|
||||||
sessionKey: next,
|
sessionKey: next,
|
||||||
@@ -268,7 +255,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
configSchema: state.configSchema,
|
configSchema: state.configSchema,
|
||||||
configSchemaLoading: state.configSchemaLoading,
|
configSchemaLoading: state.configSchemaLoading,
|
||||||
configForm: state.configForm,
|
configForm: state.configForm,
|
||||||
configUiHints: state.configUiHints as ConfigUiHints,
|
configUiHints: state.configUiHints,
|
||||||
configSaving: state.configSaving,
|
configSaving: state.configSaving,
|
||||||
configFormDirty: state.configFormDirty,
|
configFormDirty: state.configFormDirty,
|
||||||
nostrProfileFormState: state.nostrProfileFormState,
|
nostrProfileFormState: state.nostrProfileFormState,
|
||||||
@@ -277,8 +264,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
|
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
|
||||||
onWhatsAppWait: () => state.handleWhatsAppWait(),
|
onWhatsAppWait: () => state.handleWhatsAppWait(),
|
||||||
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
|
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
|
||||||
onConfigPatch: (path, value) =>
|
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||||
updateConfigFormValue(state as unknown as ConfigState, path, value),
|
|
||||||
onConfigSave: () => state.handleChannelConfigSave(),
|
onConfigSave: () => state.handleChannelConfigSave(),
|
||||||
onConfigReload: () => state.handleChannelConfigReload(),
|
onConfigReload: () => state.handleChannelConfigReload(),
|
||||||
onNostrProfileEdit: (accountId, profile) =>
|
onNostrProfileEdit: (accountId, profile) =>
|
||||||
@@ -329,6 +315,269 @@ export function renderApp(state: AppViewState) {
|
|||||||
: nothing
|
: 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"
|
state.tab === "cron"
|
||||||
? renderCron({
|
? renderCron({
|
||||||
@@ -444,17 +693,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
void state.loadCron();
|
void state.loadCron();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLoadFiles: (agentId) => {
|
onLoadFiles: (agentId) => loadAgentFiles(state, agentId),
|
||||||
void (async () => {
|
|
||||||
await loadAgentFiles(state, agentId);
|
|
||||||
if (state.agentFileActive) {
|
|
||||||
await loadAgentFileContent(state, agentId, state.agentFileActive, {
|
|
||||||
force: true,
|
|
||||||
preserveDraft: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
onSelectFile: (name) => {
|
onSelectFile: (name) => {
|
||||||
state.agentFileActive = name;
|
state.agentFileActive = name;
|
||||||
if (!resolvedAgentId) {
|
if (!resolvedAgentId) {
|
||||||
@@ -497,19 +736,12 @@ export function renderApp(state: AppViewState) {
|
|||||||
}
|
}
|
||||||
const basePath = ["agents", "list", index, "tools"];
|
const basePath = ["agents", "list", index, "tools"];
|
||||||
if (profile) {
|
if (profile) {
|
||||||
updateConfigFormValue(
|
updateConfigFormValue(state, [...basePath, "profile"], profile);
|
||||||
state as unknown as ConfigState,
|
|
||||||
[...basePath, "profile"],
|
|
||||||
profile,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
removeConfigFormValue(state as unknown as ConfigState, [
|
removeConfigFormValue(state, [...basePath, "profile"]);
|
||||||
...basePath,
|
|
||||||
"profile",
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
if (clearAllow) {
|
if (clearAllow) {
|
||||||
removeConfigFormValue(state as unknown as ConfigState, [...basePath, "allow"]);
|
removeConfigFormValue(state, [...basePath, "allow"]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onToolsOverridesChange: (agentId, alsoAllow, deny) => {
|
onToolsOverridesChange: (agentId, alsoAllow, deny) => {
|
||||||
@@ -532,29 +764,18 @@ export function renderApp(state: AppViewState) {
|
|||||||
}
|
}
|
||||||
const basePath = ["agents", "list", index, "tools"];
|
const basePath = ["agents", "list", index, "tools"];
|
||||||
if (alsoAllow.length > 0) {
|
if (alsoAllow.length > 0) {
|
||||||
updateConfigFormValue(
|
updateConfigFormValue(state, [...basePath, "alsoAllow"], alsoAllow);
|
||||||
state as unknown as ConfigState,
|
|
||||||
[...basePath, "alsoAllow"],
|
|
||||||
alsoAllow,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
removeConfigFormValue(state as unknown as ConfigState, [
|
removeConfigFormValue(state, [...basePath, "alsoAllow"]);
|
||||||
...basePath,
|
|
||||||
"alsoAllow",
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
if (deny.length > 0) {
|
if (deny.length > 0) {
|
||||||
updateConfigFormValue(
|
updateConfigFormValue(state, [...basePath, "deny"], deny);
|
||||||
state as unknown as ConfigState,
|
|
||||||
[...basePath, "deny"],
|
|
||||||
deny,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
removeConfigFormValue(state as unknown as ConfigState, [...basePath, "deny"]);
|
removeConfigFormValue(state, [...basePath, "deny"]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onConfigReload: () => loadConfig(state as unknown as ConfigState),
|
onConfigReload: () => loadConfig(state),
|
||||||
onConfigSave: () => saveConfig(state as unknown as ConfigState),
|
onConfigSave: () => saveConfig(state),
|
||||||
onChannelsRefresh: () => loadChannels(state, false),
|
onChannelsRefresh: () => loadChannels(state, false),
|
||||||
onCronRefresh: () => state.loadCron(),
|
onCronRefresh: () => state.loadCron(),
|
||||||
onSkillsFilterChange: (next) => (state.skillsFilter = next),
|
onSkillsFilterChange: (next) => (state.skillsFilter = next),
|
||||||
@@ -599,11 +820,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
} else {
|
} else {
|
||||||
next.delete(normalizedSkill);
|
next.delete(normalizedSkill);
|
||||||
}
|
}
|
||||||
updateConfigFormValue(
|
updateConfigFormValue(state, ["agents", "list", index, "skills"], [...next]);
|
||||||
state as unknown as ConfigState,
|
|
||||||
["agents", "list", index, "skills"],
|
|
||||||
[...next],
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onAgentSkillsClear: (agentId) => {
|
onAgentSkillsClear: (agentId) => {
|
||||||
if (!configValue) {
|
if (!configValue) {
|
||||||
@@ -623,12 +840,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
removeConfigFormValue(state as unknown as ConfigState, [
|
removeConfigFormValue(state, ["agents", "list", index, "skills"]);
|
||||||
"agents",
|
|
||||||
"list",
|
|
||||||
index,
|
|
||||||
"skills",
|
|
||||||
]);
|
|
||||||
},
|
},
|
||||||
onAgentSkillsDisableAll: (agentId) => {
|
onAgentSkillsDisableAll: (agentId) => {
|
||||||
if (!configValue) {
|
if (!configValue) {
|
||||||
@@ -648,58 +860,32 @@ export function renderApp(state: AppViewState) {
|
|||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateConfigFormValue(
|
updateConfigFormValue(state, ["agents", "list", index, "skills"], []);
|
||||||
state as unknown as ConfigState,
|
|
||||||
["agents", "list", index, "skills"],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onModelChange: (agentId, modelId) => {
|
onModelChange: (agentId, modelId) => {
|
||||||
if (!configValue) {
|
if (!configValue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const defaultId = state.agentsList?.defaultId ?? null;
|
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
|
||||||
if (defaultId && agentId === defaultId) {
|
if (!Array.isArray(list)) {
|
||||||
const basePath = ["agents", "defaults", "model"];
|
return;
|
||||||
const defaults =
|
}
|
||||||
(configValue as { agents?: { defaults?: { model?: unknown } } }).agents
|
const index = list.findIndex(
|
||||||
?.defaults ?? {};
|
(entry) =>
|
||||||
const existing = defaults.model;
|
entry &&
|
||||||
if (!modelId) {
|
typeof entry === "object" &&
|
||||||
removeConfigFormValue(state as unknown as ConfigState, basePath);
|
"id" in entry &&
|
||||||
return;
|
(entry as { id?: string }).id === agentId,
|
||||||
}
|
);
|
||||||
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
if (index < 0) {
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = ensureAgentListEntry(agentId);
|
|
||||||
const basePath = ["agents", "list", index, "model"];
|
const basePath = ["agents", "list", index, "model"];
|
||||||
if (!modelId) {
|
if (!modelId) {
|
||||||
removeConfigFormValue(state as unknown as ConfigState, basePath);
|
removeConfigFormValue(state, basePath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const list = (
|
const entry = list[index] as { model?: unknown };
|
||||||
(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;
|
const existing = entry?.model;
|
||||||
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
||||||
const fallbacks = (existing as { fallbacks?: unknown }).fallbacks;
|
const fallbacks = (existing as { fallbacks?: unknown }).fallbacks;
|
||||||
@@ -707,70 +893,33 @@ export function renderApp(state: AppViewState) {
|
|||||||
primary: modelId,
|
primary: modelId,
|
||||||
...(Array.isArray(fallbacks) ? { fallbacks } : {}),
|
...(Array.isArray(fallbacks) ? { fallbacks } : {}),
|
||||||
};
|
};
|
||||||
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
|
updateConfigFormValue(state, basePath, next);
|
||||||
} else {
|
} else {
|
||||||
updateConfigFormValue(state as unknown as ConfigState, basePath, modelId);
|
updateConfigFormValue(state, basePath, modelId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onModelFallbacksChange: (agentId, fallbacks) => {
|
onModelFallbacksChange: (agentId, fallbacks) => {
|
||||||
if (!configValue) {
|
if (!configValue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalized = fallbacks.map((name) => name.trim()).filter(Boolean);
|
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
|
||||||
const defaultId = state.agentsList?.defaultId ?? null;
|
if (!Array.isArray(list)) {
|
||||||
if (defaultId && agentId === defaultId) {
|
return;
|
||||||
const basePath = ["agents", "defaults", "model"];
|
}
|
||||||
const defaults =
|
const index = list.findIndex(
|
||||||
(configValue as { agents?: { defaults?: { model?: unknown } } }).agents
|
(entry) =>
|
||||||
?.defaults ?? {};
|
entry &&
|
||||||
const existing = defaults.model;
|
typeof entry === "object" &&
|
||||||
const resolvePrimary = () => {
|
"id" in entry &&
|
||||||
if (typeof existing === "string") {
|
(entry as { id?: string }).id === agentId,
|
||||||
return existing.trim() || null;
|
);
|
||||||
}
|
if (index < 0) {
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = ensureAgentListEntry(agentId);
|
|
||||||
const basePath = ["agents", "list", index, "model"];
|
const basePath = ["agents", "list", index, "model"];
|
||||||
const list = (
|
const entry = list[index] as { model?: unknown };
|
||||||
(state.configForm ??
|
const normalized = fallbacks.map((name) => name.trim()).filter(Boolean);
|
||||||
(state.configSnapshot?.config as Record<string, unknown> | null)) as {
|
const existing = entry.model;
|
||||||
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 resolvePrimary = () => {
|
const resolvePrimary = () => {
|
||||||
if (typeof existing === "string") {
|
if (typeof existing === "string") {
|
||||||
return existing.trim() || null;
|
return existing.trim() || null;
|
||||||
@@ -787,16 +936,16 @@ export function renderApp(state: AppViewState) {
|
|||||||
const primary = resolvePrimary();
|
const primary = resolvePrimary();
|
||||||
if (normalized.length === 0) {
|
if (normalized.length === 0) {
|
||||||
if (primary) {
|
if (primary) {
|
||||||
updateConfigFormValue(state as unknown as ConfigState, basePath, primary);
|
updateConfigFormValue(state, basePath, primary);
|
||||||
} else {
|
} else {
|
||||||
removeConfigFormValue(state as unknown as ConfigState, basePath);
|
removeConfigFormValue(state, basePath);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = primary
|
const next = primary
|
||||||
? { primary, fallbacks: normalized }
|
? { primary, fallbacks: normalized }
|
||||||
: { fallbacks: normalized };
|
: { fallbacks: normalized };
|
||||||
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
|
updateConfigFormValue(state, basePath, next);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: nothing
|
: nothing
|
||||||
@@ -853,7 +1002,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
onDeviceRotate: (deviceId, role, scopes) =>
|
onDeviceRotate: (deviceId, role, scopes) =>
|
||||||
rotateDeviceToken(state, { deviceId, role, scopes }),
|
rotateDeviceToken(state, { deviceId, role, scopes }),
|
||||||
onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }),
|
onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }),
|
||||||
onLoadConfig: () => loadConfig(state as unknown as ConfigState),
|
onLoadConfig: () => loadConfig(state),
|
||||||
onLoadExecApprovals: () => {
|
onLoadExecApprovals: () => {
|
||||||
const target =
|
const target =
|
||||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||||
@@ -863,28 +1012,20 @@ export function renderApp(state: AppViewState) {
|
|||||||
},
|
},
|
||||||
onBindDefault: (nodeId) => {
|
onBindDefault: (nodeId) => {
|
||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
updateConfigFormValue(
|
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
||||||
state as unknown as ConfigState,
|
|
||||||
["tools", "exec", "node"],
|
|
||||||
nodeId,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
removeConfigFormValue(state as unknown as ConfigState, [
|
removeConfigFormValue(state, ["tools", "exec", "node"]);
|
||||||
"tools",
|
|
||||||
"exec",
|
|
||||||
"node",
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onBindAgent: (agentIndex, nodeId) => {
|
onBindAgent: (agentIndex, nodeId) => {
|
||||||
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
|
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
|
||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
updateConfigFormValue(state as unknown as ConfigState, basePath, nodeId);
|
updateConfigFormValue(state, basePath, nodeId);
|
||||||
} else {
|
} else {
|
||||||
removeConfigFormValue(state as unknown as ConfigState, basePath);
|
removeConfigFormValue(state, basePath);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSaveBindings: () => saveConfig(state as unknown as ConfigState),
|
onSaveBindings: () => saveConfig(state),
|
||||||
onExecApprovalsTargetChange: (kind, nodeId) => {
|
onExecApprovalsTargetChange: (kind, nodeId) => {
|
||||||
state.execApprovalsTarget = kind;
|
state.execApprovalsTarget = kind;
|
||||||
state.execApprovalsTargetNodeId = nodeId;
|
state.execApprovalsTargetNodeId = nodeId;
|
||||||
@@ -919,29 +1060,30 @@ export function renderApp(state: AppViewState) {
|
|||||||
state.chatMessage = "";
|
state.chatMessage = "";
|
||||||
state.chatAttachments = [];
|
state.chatAttachments = [];
|
||||||
state.chatStream = null;
|
state.chatStream = null;
|
||||||
|
state.chatStreamStartedAt = null;
|
||||||
state.chatRunId = null;
|
state.chatRunId = null;
|
||||||
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
|
|
||||||
state.chatQueue = [];
|
state.chatQueue = [];
|
||||||
(state as unknown as OpenClawApp).resetToolStream();
|
state.resetToolStream();
|
||||||
(state as unknown as OpenClawApp).resetChatScroll();
|
state.resetChatScroll();
|
||||||
state.applySettings({
|
state.applySettings({
|
||||||
...state.settings,
|
...state.settings,
|
||||||
sessionKey: next,
|
sessionKey: next,
|
||||||
lastActiveSessionKey: next,
|
lastActiveSessionKey: next,
|
||||||
});
|
});
|
||||||
void state.loadAssistantIdentity();
|
void state.loadAssistantIdentity();
|
||||||
void loadChatHistory(state as unknown as ChatState);
|
void loadChatHistory(state);
|
||||||
void refreshChatAvatar(state as unknown as ChatHost);
|
void refreshChatAvatar(state);
|
||||||
},
|
},
|
||||||
thinkingLevel: state.chatThinkingLevel,
|
thinkingLevel: state.chatThinkingLevel,
|
||||||
showThinking,
|
showThinking,
|
||||||
loading: state.chatLoading,
|
loading: state.chatLoading,
|
||||||
sending: state.chatSending,
|
sending: state.chatSending,
|
||||||
|
compactionStatus: state.compactionStatus,
|
||||||
assistantAvatarUrl: chatAvatarUrl,
|
assistantAvatarUrl: chatAvatarUrl,
|
||||||
messages: state.chatMessages,
|
messages: state.chatMessages,
|
||||||
toolMessages: state.chatToolMessages,
|
toolMessages: state.chatToolMessages,
|
||||||
stream: state.chatStream,
|
stream: state.chatStream,
|
||||||
streamStartedAt: null,
|
streamStartedAt: state.chatStreamStartedAt,
|
||||||
draft: state.chatMessage,
|
draft: state.chatMessage,
|
||||||
queue: state.chatQueue,
|
queue: state.chatQueue,
|
||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
@@ -951,10 +1093,8 @@ export function renderApp(state: AppViewState) {
|
|||||||
sessions: state.sessionsResult,
|
sessions: state.sessionsResult,
|
||||||
focusMode: chatFocus,
|
focusMode: chatFocus,
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
return Promise.all([
|
state.resetToolStream();
|
||||||
loadChatHistory(state as unknown as ChatState),
|
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
||||||
refreshChatAvatar(state as unknown as ChatHost),
|
|
||||||
]);
|
|
||||||
},
|
},
|
||||||
onToggleFocusMode: () => {
|
onToggleFocusMode: () => {
|
||||||
if (state.onboarding) {
|
if (state.onboarding) {
|
||||||
@@ -965,28 +1105,25 @@ export function renderApp(state: AppViewState) {
|
|||||||
chatFocusMode: !state.settings.chatFocusMode,
|
chatFocusMode: !state.settings.chatFocusMode,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onChatScroll: (event) => (state as unknown as OpenClawApp).handleChatScroll(event),
|
onChatScroll: (event) => state.handleChatScroll(event),
|
||||||
onDraftChange: (next) => (state.chatMessage = next),
|
onDraftChange: (next) => (state.chatMessage = next),
|
||||||
attachments: state.chatAttachments,
|
attachments: state.chatAttachments,
|
||||||
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||||
onSend: () => (state as unknown as OpenClawApp).handleSendChat(),
|
onSend: () => state.handleSendChat(),
|
||||||
canAbort: Boolean(state.chatRunId),
|
canAbort: Boolean(state.chatRunId),
|
||||||
onAbort: () => void (state as unknown as OpenClawApp).handleAbortChat(),
|
onAbort: () => void state.handleAbortChat(),
|
||||||
onQueueRemove: (id) => (state as unknown as OpenClawApp).removeQueuedMessage(id),
|
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||||
onNewSession: () =>
|
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
|
||||||
(state as unknown as OpenClawApp).handleSendChat("/new", { restoreDraft: true }),
|
|
||||||
showNewMessages: state.chatNewMessagesBelow,
|
showNewMessages: state.chatNewMessagesBelow,
|
||||||
onScrollToBottom: () => state.scrollToBottom(),
|
onScrollToBottom: () => state.scrollToBottom(),
|
||||||
// Sidebar props for tool output viewing
|
// Sidebar props for tool output viewing
|
||||||
sidebarOpen: (state as unknown as OpenClawApp).sidebarOpen,
|
sidebarOpen: state.sidebarOpen,
|
||||||
sidebarContent: (state as unknown as OpenClawApp).sidebarContent,
|
sidebarContent: state.sidebarContent,
|
||||||
sidebarError: (state as unknown as OpenClawApp).sidebarError,
|
sidebarError: state.sidebarError,
|
||||||
splitRatio: (state as unknown as OpenClawApp).splitRatio,
|
splitRatio: state.splitRatio,
|
||||||
onOpenSidebar: (content: string) =>
|
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
||||||
(state as unknown as OpenClawApp).handleOpenSidebar(content),
|
onCloseSidebar: () => state.handleCloseSidebar(),
|
||||||
onCloseSidebar: () => (state as unknown as OpenClawApp).handleCloseSidebar(),
|
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||||
onSplitRatioChange: (ratio: number) =>
|
|
||||||
(state as unknown as OpenClawApp).handleSplitRatioChange(ratio),
|
|
||||||
assistantName: state.assistantName,
|
assistantName: state.assistantName,
|
||||||
assistantAvatar: state.assistantAvatar,
|
assistantAvatar: state.assistantAvatar,
|
||||||
})
|
})
|
||||||
@@ -1007,31 +1144,28 @@ export function renderApp(state: AppViewState) {
|
|||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
schema: state.configSchema,
|
schema: state.configSchema,
|
||||||
schemaLoading: state.configSchemaLoading,
|
schemaLoading: state.configSchemaLoading,
|
||||||
uiHints: state.configUiHints as ConfigUiHints,
|
uiHints: state.configUiHints,
|
||||||
formMode: state.configFormMode,
|
formMode: state.configFormMode,
|
||||||
formValue: state.configForm,
|
formValue: state.configForm,
|
||||||
originalValue: state.configFormOriginal,
|
originalValue: state.configFormOriginal,
|
||||||
searchQuery: (state as unknown as OpenClawApp).configSearchQuery,
|
searchQuery: state.configSearchQuery,
|
||||||
activeSection: (state as unknown as OpenClawApp).configActiveSection,
|
activeSection: state.configActiveSection,
|
||||||
activeSubsection: (state as unknown as OpenClawApp).configActiveSubsection,
|
activeSubsection: state.configActiveSubsection,
|
||||||
onRawChange: (next) => {
|
onRawChange: (next) => {
|
||||||
state.configRaw = next;
|
state.configRaw = next;
|
||||||
},
|
},
|
||||||
onFormModeChange: (mode) => (state.configFormMode = mode),
|
onFormModeChange: (mode) => (state.configFormMode = mode),
|
||||||
onFormPatch: (path, value) =>
|
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||||
updateConfigFormValue(state as unknown as OpenClawApp, path, value),
|
onSearchChange: (query) => (state.configSearchQuery = query),
|
||||||
onSearchChange: (query) =>
|
|
||||||
((state as unknown as OpenClawApp).configSearchQuery = query),
|
|
||||||
onSectionChange: (section) => {
|
onSectionChange: (section) => {
|
||||||
(state as unknown as OpenClawApp).configActiveSection = section;
|
state.configActiveSection = section;
|
||||||
(state as unknown as OpenClawApp).configActiveSubsection = null;
|
state.configActiveSubsection = null;
|
||||||
},
|
},
|
||||||
onSubsectionChange: (section) =>
|
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
||||||
((state as unknown as OpenClawApp).configActiveSubsection = section),
|
onReload: () => loadConfig(state),
|
||||||
onReload: () => loadConfig(state as unknown as OpenClawApp),
|
onSave: () => saveConfig(state),
|
||||||
onSave: () => saveConfig(state as unknown as OpenClawApp),
|
onApply: () => applyConfig(state),
|
||||||
onApply: () => applyConfig(state as unknown as OpenClawApp),
|
onUpdate: () => runUpdate(state),
|
||||||
onUpdate: () => runUpdate(state as unknown as OpenClawApp),
|
|
||||||
})
|
})
|
||||||
: nothing
|
: nothing
|
||||||
}
|
}
|
||||||
@@ -1073,10 +1207,9 @@ export function renderApp(state: AppViewState) {
|
|||||||
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
||||||
},
|
},
|
||||||
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
||||||
onRefresh: () => loadLogs(state as unknown as LogsState, { reset: true }),
|
onRefresh: () => loadLogs(state, { reset: true }),
|
||||||
onExport: (lines, label) =>
|
onExport: (lines, label) => state.exportLogs(lines, label),
|
||||||
(state as unknown as OpenClawApp).exportLogs(lines, label),
|
onScroll: (event) => state.handleLogsScroll(event),
|
||||||
onScroll: (event) => (state as unknown as OpenClawApp).handleLogsScroll(event),
|
|
||||||
})
|
})
|
||||||
: nothing
|
: nothing
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { OpenClawApp } from "./app.ts";
|
import type { OpenClawApp } from "./app.ts";
|
||||||
|
import type { AgentsListResult } from "./types.ts";
|
||||||
import { refreshChat } from "./app-chat.ts";
|
import { refreshChat } from "./app-chat.ts";
|
||||||
import {
|
import {
|
||||||
startLogsPolling,
|
startLogsPolling,
|
||||||
@@ -35,6 +36,7 @@ import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme.ts";
|
|||||||
|
|
||||||
type SettingsHost = {
|
type SettingsHost = {
|
||||||
settings: UiSettings;
|
settings: UiSettings;
|
||||||
|
password?: string;
|
||||||
theme: ThemeMode;
|
theme: ThemeMode;
|
||||||
themeResolved: ResolvedTheme;
|
themeResolved: ResolvedTheme;
|
||||||
applySessionKey: string;
|
applySessionKey: string;
|
||||||
@@ -46,35 +48,14 @@ type SettingsHost = {
|
|||||||
eventLog: unknown[];
|
eventLog: unknown[];
|
||||||
eventLogBuffer: unknown[];
|
eventLogBuffer: unknown[];
|
||||||
basePath: string;
|
basePath: string;
|
||||||
|
agentsList?: AgentsListResult | null;
|
||||||
|
agentsSelectedId?: string | null;
|
||||||
|
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
|
||||||
themeMedia: MediaQueryList | null;
|
themeMedia: MediaQueryList | null;
|
||||||
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
|
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
|
||||||
pendingGatewayUrl?: string | 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) {
|
export function applySettings(host: SettingsHost, next: UiSettings) {
|
||||||
const normalized = {
|
const normalized = {
|
||||||
...next,
|
...next,
|
||||||
@@ -117,6 +98,10 @@ export function applySettingsFromUrl(host: SettingsHost) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (passwordRaw != null) {
|
if (passwordRaw != null) {
|
||||||
|
const password = passwordRaw.trim();
|
||||||
|
if (password) {
|
||||||
|
(host as { password: string }).password = password;
|
||||||
|
}
|
||||||
params.delete("password");
|
params.delete("password");
|
||||||
shouldCleanUrl = true;
|
shouldCleanUrl = true;
|
||||||
}
|
}
|
||||||
@@ -134,8 +119,8 @@ export function applySettingsFromUrl(host: SettingsHost) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (gatewayUrlRaw != null) {
|
if (gatewayUrlRaw != null) {
|
||||||
const gatewayUrl = normalizeGatewayUrl(gatewayUrlRaw);
|
const gatewayUrl = gatewayUrlRaw.trim();
|
||||||
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl && isTopLevelWindow()) {
|
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
|
||||||
host.pendingGatewayUrl = gatewayUrl;
|
host.pendingGatewayUrl = gatewayUrl;
|
||||||
}
|
}
|
||||||
params.delete("gatewayUrl");
|
params.delete("gatewayUrl");
|
||||||
@@ -205,24 +190,23 @@ export async function refreshActiveTab(host: SettingsHost) {
|
|||||||
await loadSkills(host as unknown as OpenClawApp);
|
await loadSkills(host as unknown as OpenClawApp);
|
||||||
}
|
}
|
||||||
if (host.tab === "agents") {
|
if (host.tab === "agents") {
|
||||||
const app = host as unknown as OpenClawApp;
|
await loadAgents(host as unknown as OpenClawApp);
|
||||||
await loadAgents(app);
|
await loadConfig(host as unknown as OpenClawApp);
|
||||||
await loadConfig(app);
|
const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
||||||
const agentIds = app.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
|
||||||
if (agentIds.length > 0) {
|
if (agentIds.length > 0) {
|
||||||
void loadAgentIdentities(app, agentIds);
|
void loadAgentIdentities(host as unknown as OpenClawApp, agentIds);
|
||||||
}
|
}
|
||||||
const agentId =
|
const agentId =
|
||||||
app.agentsSelectedId ?? app.agentsList?.defaultId ?? app.agentsList?.agents?.[0]?.id;
|
host.agentsSelectedId ?? host.agentsList?.defaultId ?? host.agentsList?.agents?.[0]?.id;
|
||||||
if (agentId) {
|
if (agentId) {
|
||||||
void loadAgentIdentity(app, agentId);
|
void loadAgentIdentity(host as unknown as OpenClawApp, agentId);
|
||||||
if (app.agentsPanel === "skills") {
|
if (host.agentsPanel === "skills") {
|
||||||
void loadAgentSkills(app, agentId);
|
void loadAgentSkills(host as unknown as OpenClawApp, agentId);
|
||||||
}
|
}
|
||||||
if (app.agentsPanel === "channels") {
|
if (host.agentsPanel === "channels") {
|
||||||
void loadChannels(app, false);
|
void loadChannels(host as unknown as OpenClawApp, false);
|
||||||
}
|
}
|
||||||
if (app.agentsPanel === "cron") {
|
if (host.agentsPanel === "cron") {
|
||||||
void loadCron(host);
|
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") {
|
if (typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { EventLogEntry } from "./app-events.ts";
|
import type { EventLogEntry } from "./app-events.ts";
|
||||||
|
import type { CompactionStatus } from "./app-tool-stream.ts";
|
||||||
import type { DevicePairingList } from "./controllers/devices.ts";
|
import type { DevicePairingList } from "./controllers/devices.ts";
|
||||||
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
||||||
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
|
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
|
||||||
@@ -14,6 +15,7 @@ import type {
|
|||||||
AgentIdentityResult,
|
AgentIdentityResult,
|
||||||
ChannelsStatusSnapshot,
|
ChannelsStatusSnapshot,
|
||||||
ConfigSnapshot,
|
ConfigSnapshot,
|
||||||
|
ConfigUiHints,
|
||||||
CronJob,
|
CronJob,
|
||||||
CronRunLogEntry,
|
CronRunLogEntry,
|
||||||
CronStatus,
|
CronStatus,
|
||||||
@@ -22,12 +24,16 @@ import type {
|
|||||||
LogLevel,
|
LogLevel,
|
||||||
NostrProfile,
|
NostrProfile,
|
||||||
PresenceEntry,
|
PresenceEntry,
|
||||||
|
SessionsUsageResult,
|
||||||
|
CostUsageSummary,
|
||||||
|
SessionUsageTimeSeries,
|
||||||
SessionsListResult,
|
SessionsListResult,
|
||||||
SkillStatusReport,
|
SkillStatusReport,
|
||||||
StatusSummary,
|
StatusSummary,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types.ts";
|
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types.ts";
|
||||||
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
|
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
|
||||||
|
import type { SessionLogEntry } from "./views/usage.ts";
|
||||||
|
|
||||||
export type AppViewState = {
|
export type AppViewState = {
|
||||||
settings: UiSettings;
|
settings: UiSettings;
|
||||||
@@ -52,13 +58,19 @@ export type AppViewState = {
|
|||||||
chatMessages: unknown[];
|
chatMessages: unknown[];
|
||||||
chatToolMessages: unknown[];
|
chatToolMessages: unknown[];
|
||||||
chatStream: string | null;
|
chatStream: string | null;
|
||||||
|
chatStreamStartedAt: number | null;
|
||||||
chatRunId: string | null;
|
chatRunId: string | null;
|
||||||
|
compactionStatus: CompactionStatus | null;
|
||||||
chatAvatarUrl: string | null;
|
chatAvatarUrl: string | null;
|
||||||
chatThinkingLevel: string | null;
|
chatThinkingLevel: string | null;
|
||||||
chatQueue: ChatQueueItem[];
|
chatQueue: ChatQueueItem[];
|
||||||
nodesLoading: boolean;
|
nodesLoading: boolean;
|
||||||
nodes: Array<Record<string, unknown>>;
|
nodes: Array<Record<string, unknown>>;
|
||||||
chatNewMessagesBelow: boolean;
|
chatNewMessagesBelow: boolean;
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
sidebarContent: string | null;
|
||||||
|
sidebarError: string | null;
|
||||||
|
splitRatio: number;
|
||||||
scrollToBottom: () => void;
|
scrollToBottom: () => void;
|
||||||
devicesLoading: boolean;
|
devicesLoading: boolean;
|
||||||
devicesError: string | null;
|
devicesError: string | null;
|
||||||
@@ -83,13 +95,18 @@ export type AppViewState = {
|
|||||||
configSaving: boolean;
|
configSaving: boolean;
|
||||||
configApplying: boolean;
|
configApplying: boolean;
|
||||||
updateRunning: boolean;
|
updateRunning: boolean;
|
||||||
|
applySessionKey: string;
|
||||||
configSnapshot: ConfigSnapshot | null;
|
configSnapshot: ConfigSnapshot | null;
|
||||||
configSchema: unknown;
|
configSchema: unknown;
|
||||||
|
configSchemaVersion: string | null;
|
||||||
configSchemaLoading: boolean;
|
configSchemaLoading: boolean;
|
||||||
configUiHints: Record<string, unknown>;
|
configUiHints: ConfigUiHints;
|
||||||
configForm: Record<string, unknown> | null;
|
configForm: Record<string, unknown> | null;
|
||||||
configFormOriginal: Record<string, unknown> | null;
|
configFormOriginal: Record<string, unknown> | null;
|
||||||
configFormMode: "form" | "raw";
|
configFormMode: "form" | "raw";
|
||||||
|
configSearchQuery: string;
|
||||||
|
configActiveSection: string | null;
|
||||||
|
configActiveSubsection: string | null;
|
||||||
channelsLoading: boolean;
|
channelsLoading: boolean;
|
||||||
channelsSnapshot: ChannelsStatusSnapshot | null;
|
channelsSnapshot: ChannelsStatusSnapshot | null;
|
||||||
channelsError: string | null;
|
channelsError: string | null;
|
||||||
@@ -131,6 +148,39 @@ export type AppViewState = {
|
|||||||
sessionsFilterLimit: string;
|
sessionsFilterLimit: string;
|
||||||
sessionsIncludeGlobal: boolean;
|
sessionsIncludeGlobal: boolean;
|
||||||
sessionsIncludeUnknown: 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;
|
cronLoading: boolean;
|
||||||
cronJobs: CronJob[];
|
cronJobs: CronJob[];
|
||||||
cronStatus: CronStatus | null;
|
cronStatus: CronStatus | null;
|
||||||
@@ -163,7 +213,13 @@ export type AppViewState = {
|
|||||||
logsLevelFilters: Record<LogLevel, boolean>;
|
logsLevelFilters: Record<LogLevel, boolean>;
|
||||||
logsAutoFollow: boolean;
|
logsAutoFollow: boolean;
|
||||||
logsTruncated: boolean;
|
logsTruncated: boolean;
|
||||||
|
logsCursor: number | null;
|
||||||
|
logsLastFetchAt: number | null;
|
||||||
|
logsLimit: number;
|
||||||
|
logsMaxBytes: number;
|
||||||
|
logsAtBottom: boolean;
|
||||||
client: GatewayBrowserClient | null;
|
client: GatewayBrowserClient | null;
|
||||||
|
refreshSessionsAfterChat: Set<string>;
|
||||||
connect: () => void;
|
connect: () => void;
|
||||||
setTab: (tab: Tab) => void;
|
setTab: (tab: Tab) => void;
|
||||||
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
|
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||||
@@ -214,13 +270,15 @@ export type AppViewState = {
|
|||||||
setPassword: (next: string) => void;
|
setPassword: (next: string) => void;
|
||||||
setSessionKey: (next: string) => void;
|
setSessionKey: (next: string) => void;
|
||||||
setChatMessage: (next: string) => void;
|
setChatMessage: (next: string) => void;
|
||||||
handleChatSend: () => Promise<void>;
|
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
|
||||||
handleChatAbort: () => Promise<void>;
|
handleAbortChat: () => Promise<void>;
|
||||||
handleChatSelectQueueItem: (id: string) => void;
|
removeQueuedMessage: (id: string) => void;
|
||||||
handleChatDropQueueItem: (id: string) => void;
|
handleChatScroll: (event: Event) => void;
|
||||||
handleChatClearQueue: () => void;
|
resetToolStream: () => void;
|
||||||
handleLogsFilterChange: (next: string) => void;
|
resetChatScroll: () => void;
|
||||||
handleLogsLevelFilterToggle: (level: LogLevel) => void;
|
exportLogs: (lines: string[], label: string) => void;
|
||||||
handleLogsAutoFollowToggle: (next: boolean) => void;
|
handleLogsScroll: (event: Event) => void;
|
||||||
handleCallDebugMethod: (method: string, params: string) => Promise<void>;
|
handleOpenSidebar: (content: string) => void;
|
||||||
|
handleCloseSidebar: () => void;
|
||||||
|
handleSplitRatioChange: (ratio: number) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
resetToolStream as resetToolStreamInternal,
|
resetToolStream as resetToolStreamInternal,
|
||||||
type ToolStreamEntry,
|
type ToolStreamEntry,
|
||||||
|
type CompactionStatus,
|
||||||
} from "./app-tool-stream.ts";
|
} from "./app-tool-stream.ts";
|
||||||
import { resolveInjectedAssistantIdentity } from "./assistant-identity.ts";
|
import { resolveInjectedAssistantIdentity } from "./assistant-identity.ts";
|
||||||
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/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() chatStream: string | null = null;
|
||||||
@state() chatStreamStartedAt: number | null = null;
|
@state() chatStreamStartedAt: number | null = null;
|
||||||
@state() chatRunId: string | 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() chatAvatarUrl: string | null = null;
|
||||||
@state() chatThinkingLevel: string | null = null;
|
@state() chatThinkingLevel: string | null = null;
|
||||||
@state() chatQueue: ChatQueueItem[] = [];
|
@state() chatQueue: ChatQueueItem[] = [];
|
||||||
@@ -226,6 +227,59 @@ export class OpenClawApp extends LitElement {
|
|||||||
@state() sessionsIncludeGlobal = true;
|
@state() sessionsIncludeGlobal = true;
|
||||||
@state() sessionsIncludeUnknown = false;
|
@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() cronLoading = false;
|
||||||
@state() cronJobs: CronJob[] = [];
|
@state() cronJobs: CronJob[] = [];
|
||||||
@state() cronStatus: CronStatus | null = null;
|
@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: "Chat", tabs: ["chat"] },
|
||||||
{
|
{
|
||||||
label: "Control",
|
label: "Control",
|
||||||
tabs: ["overview", "channels", "instances", "sessions", "cron"],
|
tabs: ["overview", "channels", "instances", "sessions", "usage", "cron"],
|
||||||
},
|
},
|
||||||
{ label: "Agent", tabs: ["agents", "skills", "nodes"] },
|
{ label: "Agent", tabs: ["agents", "skills", "nodes"] },
|
||||||
{ label: "Settings", tabs: ["config", "debug", "logs"] },
|
{ label: "Settings", tabs: ["config", "debug", "logs"] },
|
||||||
@@ -16,6 +16,7 @@ export type Tab =
|
|||||||
| "channels"
|
| "channels"
|
||||||
| "instances"
|
| "instances"
|
||||||
| "sessions"
|
| "sessions"
|
||||||
|
| "usage"
|
||||||
| "cron"
|
| "cron"
|
||||||
| "skills"
|
| "skills"
|
||||||
| "nodes"
|
| "nodes"
|
||||||
@@ -30,6 +31,7 @@ const TAB_PATHS: Record<Tab, string> = {
|
|||||||
channels: "/channels",
|
channels: "/channels",
|
||||||
instances: "/instances",
|
instances: "/instances",
|
||||||
sessions: "/sessions",
|
sessions: "/sessions",
|
||||||
|
usage: "/usage",
|
||||||
cron: "/cron",
|
cron: "/cron",
|
||||||
skills: "/skills",
|
skills: "/skills",
|
||||||
nodes: "/nodes",
|
nodes: "/nodes",
|
||||||
@@ -134,6 +136,8 @@ export function iconForTab(tab: Tab): IconName {
|
|||||||
return "radio";
|
return "radio";
|
||||||
case "sessions":
|
case "sessions":
|
||||||
return "fileText";
|
return "fileText";
|
||||||
|
case "usage":
|
||||||
|
return "barChart";
|
||||||
case "cron":
|
case "cron":
|
||||||
return "loader";
|
return "loader";
|
||||||
case "skills":
|
case "skills":
|
||||||
@@ -163,6 +167,8 @@ export function titleForTab(tab: Tab) {
|
|||||||
return "Instances";
|
return "Instances";
|
||||||
case "sessions":
|
case "sessions":
|
||||||
return "Sessions";
|
return "Sessions";
|
||||||
|
case "usage":
|
||||||
|
return "Usage";
|
||||||
case "cron":
|
case "cron":
|
||||||
return "Cron Jobs";
|
return "Cron Jobs";
|
||||||
case "skills":
|
case "skills":
|
||||||
@@ -194,6 +200,8 @@ export function subtitleForTab(tab: Tab) {
|
|||||||
return "Presence beacons from connected clients and nodes.";
|
return "Presence beacons from connected clients and nodes.";
|
||||||
case "sessions":
|
case "sessions":
|
||||||
return "Inspect active sessions and adjust per-session defaults.";
|
return "Inspect active sessions and adjust per-session defaults.";
|
||||||
|
case "usage":
|
||||||
|
return "";
|
||||||
case "cron":
|
case "cron":
|
||||||
return "Schedule wakeups and recurring agent runs.";
|
return "Schedule wakeups and recurring agent runs.";
|
||||||
case "skills":
|
case "skills":
|
||||||
|
|||||||
@@ -302,20 +302,20 @@ export type ConfigSchemaResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type PresenceEntry = {
|
export type PresenceEntry = {
|
||||||
deviceFamily?: string | null;
|
|
||||||
host?: string | null;
|
|
||||||
instanceId?: string | null;
|
instanceId?: string | null;
|
||||||
|
host?: string | null;
|
||||||
ip?: string | null;
|
ip?: string | null;
|
||||||
lastInputSeconds?: number | null;
|
version?: string | null;
|
||||||
mode?: string | null;
|
|
||||||
modelIdentifier?: string | null;
|
|
||||||
platform?: 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;
|
reason?: string | null;
|
||||||
roles?: Array<string | null> | null;
|
|
||||||
scopes?: Array<string | null> | null;
|
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
ts?: number | null;
|
ts?: number | null;
|
||||||
version?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewaySessionsDefaults = {
|
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 =
|
export type CronSchedule =
|
||||||
| { kind: "at"; at: string }
|
| { kind: "at"; at: string }
|
||||||
| { kind: "every"; everyMs: number; anchorMs?: number }
|
| { kind: "every"; everyMs: number; anchorMs?: number }
|
||||||
@@ -506,10 +723,10 @@ export type SkillStatusEntry = {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
source: string;
|
source: string;
|
||||||
bundled?: boolean;
|
|
||||||
filePath: string;
|
filePath: string;
|
||||||
baseDir: string;
|
baseDir: string;
|
||||||
skillKey: string;
|
skillKey: string;
|
||||||
|
bundled?: boolean;
|
||||||
primaryEnv?: string;
|
primaryEnv?: string;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
homepage?: 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