mirror of
https://github.com/lotsoftick/openclaw_client.git
synced 2026-08-14 08:52:46 +00:00
improved usage dashboard and spending caps
This commit is contained in:
@@ -7,7 +7,11 @@ import {
|
||||
} from '../../@types/openclaw';
|
||||
import AppDataSource from '../../data-source';
|
||||
import { Agent } from '../../entities';
|
||||
import { fetchUsagePayload, invalidateAgentUsageCache, type RawSessionUsage } from './agentUsage';
|
||||
import { type RawSessionUsage } from './agentUsage';
|
||||
import { getAgentRawUsageFromDisk, invalidateLocalUsageCache } from './localUsage';
|
||||
|
||||
const RESPONSE_TTL_MS = 3000;
|
||||
const responseCache = new Map<string, { at: number; data: AgentLimitsResponse }>();
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
@@ -70,7 +74,15 @@ function buildWindow(limit: number | null, spent: number): AgentLimitWindowState
|
||||
}
|
||||
|
||||
export async function getAgentLimits(agent: Agent): Promise<AgentLimitsResponse> {
|
||||
const payload = await fetchUsagePayload({ force: true });
|
||||
const cacheKey = `${agent._id}|${agent.costLimitDaily ?? 'x'}|${agent.costLimitMonthly ?? 'x'}|${
|
||||
agent.costLimitTotal ?? 'x'
|
||||
}`;
|
||||
const cached = responseCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < RESPONSE_TTL_MS) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const payload = await getAgentRawUsageFromDisk(agent.openclawAgentId);
|
||||
const sessions = (payload?.sessions ?? []).filter((s) => s?.agentId === agent.openclawAgentId);
|
||||
const usages = sessions
|
||||
.map((s) => s.usage)
|
||||
@@ -78,7 +90,7 @@ export async function getAgentLimits(agent: Agent): Promise<AgentLimitsResponse>
|
||||
|
||||
const spend = spendForAgent(usages);
|
||||
|
||||
return {
|
||||
const response: AgentLimitsResponse = {
|
||||
agentId: agent.openclawAgentId,
|
||||
today: spend.today,
|
||||
thisMonth: spend.thisMonth,
|
||||
@@ -88,6 +100,8 @@ export async function getAgentLimits(agent: Agent): Promise<AgentLimitsResponse>
|
||||
total: buildWindow(agent.costLimitTotal, spend.total),
|
||||
},
|
||||
};
|
||||
responseCache.set(cacheKey, { at: Date.now(), data: response });
|
||||
return response;
|
||||
}
|
||||
|
||||
const COLUMN_BY_WINDOW: Record<
|
||||
@@ -138,7 +152,8 @@ export async function setAgentLimits(
|
||||
await repo.update({ _id: agent._id }, { ...update, updatedAt: new Date() });
|
||||
}
|
||||
|
||||
invalidateAgentUsageCache();
|
||||
invalidateLocalUsageCache();
|
||||
responseCache.clear();
|
||||
const fresh = await repo.findOneByOrFail({ _id: agent._id });
|
||||
const config = await getAgentLimits(fresh);
|
||||
return { ok: true, config };
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable no-console */
|
||||
import {
|
||||
AgentUsageDailyPoint,
|
||||
AgentUsageLatency,
|
||||
@@ -9,8 +8,7 @@ import {
|
||||
AgentUsageToolRow,
|
||||
AgentUsageTotals,
|
||||
} from '../../@types/openclaw';
|
||||
import { gateway } from '../openclawGateway';
|
||||
import { errMsg } from '../../utils/errors';
|
||||
import { getAgentRawUsageFromDisk } from './localUsage';
|
||||
|
||||
interface RawDailyBreakdown {
|
||||
date?: string;
|
||||
@@ -94,31 +92,6 @@ export interface RawUsagePayload {
|
||||
|
||||
export type { RawUsageSession, RawSessionUsage, RawDailyBreakdown };
|
||||
|
||||
const CACHE_TTL_MS = 30 * 1000;
|
||||
let cachedPayload: { at: number; data: RawUsagePayload } | null = null;
|
||||
|
||||
export interface FetchUsageOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export async function fetchUsagePayload(
|
||||
options: FetchUsageOptions = {}
|
||||
): Promise<RawUsagePayload | null> {
|
||||
if (!options.force && cachedPayload && Date.now() - cachedPayload.at < CACHE_TTL_MS) {
|
||||
return cachedPayload.data;
|
||||
}
|
||||
const ok = await gateway.ensureConnected();
|
||||
if (!ok) return cachedPayload?.data ?? null;
|
||||
try {
|
||||
const data = await gateway.request<RawUsagePayload>('sessions.usage', {}, { timeoutMs: 15000 });
|
||||
cachedPayload = { at: Date.now(), data };
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.warn('[agent-usage] sessions.usage failed:', errMsg(err));
|
||||
return cachedPayload?.data ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function emptyResponse(openclawAgentId: string, known: boolean): AgentUsageResponse {
|
||||
return {
|
||||
agentId: openclawAgentId,
|
||||
@@ -243,7 +216,7 @@ function aggregateLatency(rows: RawDailyLatency[]): AgentUsageLatency {
|
||||
}
|
||||
|
||||
export async function getAgentUsage(openclawAgentId: string): Promise<AgentUsageResponse> {
|
||||
const payload = await fetchUsagePayload();
|
||||
const payload = await getAgentRawUsageFromDisk(openclawAgentId);
|
||||
if (!payload || !Array.isArray(payload.sessions)) {
|
||||
return emptyResponse(openclawAgentId, false);
|
||||
}
|
||||
@@ -322,7 +295,3 @@ export async function getAgentUsage(openclawAgentId: string): Promise<AgentUsage
|
||||
sessions: sessionRows,
|
||||
};
|
||||
}
|
||||
|
||||
export function invalidateAgentUsageCache(): void {
|
||||
cachedPayload = null;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export { getAgentProviderModels, setAgentProviderModel } from './agentProviderMo
|
||||
export { getAgentBudget, setAgentBudget, BUDGET_FIELDS } from './budget';
|
||||
export { getAgentSkillsConfig, setAgentSkills } from './agentSkills';
|
||||
export { getAgentSubagentsConfig, setAgentSubagents } from './agentSubagents';
|
||||
export { getAgentUsage, invalidateAgentUsageCache } from './agentUsage';
|
||||
export { getAgentUsage } from './agentUsage';
|
||||
export { getAgentLimits, setAgentLimits } from './agentLimits';
|
||||
export { listPlugins, togglePlugin } from './plugins';
|
||||
export { listSkills } from './skills';
|
||||
|
||||
@@ -0,0 +1,698 @@
|
||||
/* eslint-disable no-console */
|
||||
/**
|
||||
* Disk-based replacement for the `sessions.usage` gateway RPC.
|
||||
*
|
||||
* Each session writes a message JSONL at
|
||||
* `~/.openclaw/agents/<agentId>/sessions/<sessionId>.jsonl`. Every assistant
|
||||
* entry already carries the **authoritative** usage block OpenClaw uses for
|
||||
* billing (including pre-computed `cost` fields) so we don't need a local
|
||||
* price table.
|
||||
*
|
||||
* IMPORTANT: this module uses **async** file I/O exclusively. Synchronous
|
||||
* reads would block Node's event loop for tens of ms while the largest agents
|
||||
* are parsed cold, freezing every other in-flight HTTP request. With async
|
||||
* I/O the event loop yields at every `await` boundary, so the spend rings
|
||||
* (which fan out across all agents) load concurrently and don't gum up the
|
||||
* rest of the server.
|
||||
*
|
||||
* BAK MERGING: OpenClaw rotates a session's history by renaming the live
|
||||
* `<id>.jsonl` to `<id>.jsonl.bak-<pid>-<ts>` and starting fresh whenever it
|
||||
* detects a stuck session or simply runs out of context. In the common case
|
||||
* the bak is a strict prefix of the live file, but during a stuck-session
|
||||
* recovery the bak can carry entries the live no longer has. We parse every
|
||||
* `.bak-*` alongside the live file and **deduplicate by `entry.id`** so we
|
||||
* neither double-count the common rotation nor lose history when a recovery
|
||||
* truncates the live file.
|
||||
*
|
||||
* Caching is layered:
|
||||
* - per-file fact lists are memoised on `(path, mtime, size)`
|
||||
* - per-session aggregates are memoised on the fingerprint of all related
|
||||
* files (live + every bak), so adding/removing a bak invalidates correctly
|
||||
*/
|
||||
import fs from 'fs/promises';
|
||||
import { createReadStream } from 'fs';
|
||||
import readline from 'readline';
|
||||
import path from 'path';
|
||||
import { agentDir } from './paths';
|
||||
import type { RawSessionUsage, RawUsageSession, RawUsagePayload } from './agentUsage';
|
||||
|
||||
interface JsonlUsageCost {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
interface JsonlUsage {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
totalTokens?: number;
|
||||
cost?: JsonlUsageCost;
|
||||
}
|
||||
|
||||
interface JsonlContentPart {
|
||||
type?: string;
|
||||
/** `toolCall` parts carry the tool name we count for the tools breakdown. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface JsonlMessage {
|
||||
role?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
usage?: JsonlUsage;
|
||||
timestamp?: number;
|
||||
content?: JsonlContentPart[] | string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface JsonlEntry {
|
||||
type?: string;
|
||||
id?: string;
|
||||
timestamp?: string;
|
||||
message?: JsonlMessage;
|
||||
}
|
||||
|
||||
/** Compact per-entry record extracted from one JSONL line.
|
||||
*
|
||||
* Storing facts (rather than a running sum) lets us merge multiple files
|
||||
* for the same session and dedupe by `id` before aggregating. The shape is
|
||||
* intentionally narrow — only what the aggregator needs. */
|
||||
interface EntryFact {
|
||||
/** Stable per-message id from OpenClaw. Used as the dedup key when merging
|
||||
* the live file with its `.bak-*` siblings. May be absent on legacy meta
|
||||
* entries; those are kept (not deduped) to preserve their timestamps. */
|
||||
id?: string;
|
||||
ts: number | null;
|
||||
role?: string;
|
||||
errored: boolean;
|
||||
/** Tool names harvested from assistant `toolCall` content parts. */
|
||||
toolNames: string[];
|
||||
/** Set only for assistant turns that carry a `usage` block. */
|
||||
usage?: {
|
||||
provider: string;
|
||||
model: string;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens: number;
|
||||
cost: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-session aggregate built by merging fact lists from one or more files. */
|
||||
interface SessionAggregate {
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
channel: string | null;
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
firstTs: number | null;
|
||||
lastTs: number | null;
|
||||
/** Number of assistant turns observed (i.e. entries that carry `usage`). */
|
||||
turns: number;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
daily: Map<string, { tokens: number; cost: number }>;
|
||||
modelByKey: Map<
|
||||
string,
|
||||
{
|
||||
provider: string;
|
||||
model: string;
|
||||
count: number;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
}
|
||||
>;
|
||||
/** Per-role message tallies for the dashboard's "Messages" stat card. */
|
||||
messages: { total: number; user: number; assistant: number; toolCalls: number; errors: number };
|
||||
/** Tool name → invocation count, harvested from assistant `toolCall` parts. */
|
||||
toolByName: Map<string, number>;
|
||||
/** Latency samples in ms — one per assistant turn, computed as the gap
|
||||
* between the assistant entry and the previous non-assistant entry. */
|
||||
latencySamples: number[];
|
||||
}
|
||||
|
||||
interface FileFactsCache {
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
facts: EntryFact[];
|
||||
}
|
||||
|
||||
/** Per-file fact-list cache, keyed by absolute path. */
|
||||
const fileCache = new Map<string, FileFactsCache>();
|
||||
|
||||
/** In-flight parse de-dup, keyed by absolute path. */
|
||||
const fileInflight = new Map<string, Promise<EntryFact[] | null>>();
|
||||
|
||||
interface SessionAggCache {
|
||||
/** Fingerprint of every file that fed this aggregate. Invalidates when any
|
||||
* file's mtime/size changes or a new bak is added/removed. */
|
||||
fingerprint: string;
|
||||
data: SessionAggregate;
|
||||
}
|
||||
|
||||
const sessionCache = new Map<string, SessionAggCache>();
|
||||
|
||||
function isoDay(tsMs: number): string {
|
||||
const d = new Date(tsMs);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
d.getDate()
|
||||
).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function parseTimestamp(ts: string | number | undefined): number | null {
|
||||
if (ts === undefined || ts === null) return null;
|
||||
const ms = typeof ts === 'number' ? ts : new Date(ts).getTime();
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
function num(v: unknown): number {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
interface SessionsJsonValue {
|
||||
sessionId?: string;
|
||||
sessionFile?: string;
|
||||
updatedAt?: number;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
async function readSessionsJson(
|
||||
openclawAgentId: string
|
||||
): Promise<Record<string, SessionsJsonValue> | null> {
|
||||
const file = path.join(agentDir(openclawAgentId), 'sessions', 'sessions.json');
|
||||
try {
|
||||
const raw = await fs.readFile(file, 'utf-8');
|
||||
return JSON.parse(raw) as Record<string, SessionsJsonValue>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one JSONL file (live OR `.bak-*`) into a fact list. Append-only
|
||||
* during a session and immutable once rotated, so we memoise on
|
||||
* `(path, mtime, size)`. Reads stream line-by-line so multi-MB files yield
|
||||
* to the event loop and don't freeze concurrent requests.
|
||||
*/
|
||||
async function parseFileFacts(filePath: string): Promise<EntryFact[] | null> {
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = fileCache.get(filePath);
|
||||
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
||||
return cached.facts;
|
||||
}
|
||||
|
||||
const pending = fileInflight.get(filePath);
|
||||
if (pending) return pending;
|
||||
|
||||
const parsePromise = (async (): Promise<EntryFact[] | null> => {
|
||||
const facts: EntryFact[] = [];
|
||||
|
||||
const ingestLine = (line: string): void => {
|
||||
if (!line || line.length < 2) return;
|
||||
let entry: JsonlEntry;
|
||||
try {
|
||||
entry = JSON.parse(line) as JsonlEntry;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const ts = parseTimestamp(entry.timestamp ?? entry.message?.timestamp);
|
||||
const msg = entry.message;
|
||||
if (!msg) return;
|
||||
|
||||
const fact: EntryFact = {
|
||||
id: entry.id,
|
||||
ts,
|
||||
role: msg.role,
|
||||
errored: Boolean(msg.errorMessage),
|
||||
toolNames: [],
|
||||
};
|
||||
|
||||
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
||||
msg.content.forEach((part) => {
|
||||
if (part?.type === 'toolCall' && part.name) fact.toolNames.push(part.name);
|
||||
});
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant' && msg.usage) {
|
||||
const inputTok = num(msg.usage.input);
|
||||
const outputTok = num(msg.usage.output);
|
||||
const cacheReadTok = num(msg.usage.cacheRead);
|
||||
const cacheWriteTok = num(msg.usage.cacheWrite);
|
||||
const totalTok =
|
||||
num(msg.usage.totalTokens) || inputTok + outputTok + cacheReadTok + cacheWriteTok;
|
||||
fact.usage = {
|
||||
provider: msg.provider ?? '',
|
||||
model: msg.model ?? '',
|
||||
input: inputTok,
|
||||
output: outputTok,
|
||||
cacheRead: cacheReadTok,
|
||||
cacheWrite: cacheWriteTok,
|
||||
totalTokens: totalTok,
|
||||
cost: num(msg.usage.cost?.total),
|
||||
};
|
||||
}
|
||||
|
||||
facts.push(fact);
|
||||
};
|
||||
|
||||
const ok = await new Promise<boolean>((resolve) => {
|
||||
const stream = createReadStream(filePath, { encoding: 'utf-8', highWaterMark: 64 * 1024 });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
stream.once('error', () => resolve(false));
|
||||
rl.on('line', ingestLine);
|
||||
rl.once('close', () => resolve(true));
|
||||
});
|
||||
if (!ok) return null;
|
||||
|
||||
fileCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, facts });
|
||||
return facts;
|
||||
})();
|
||||
|
||||
fileInflight.set(filePath, parsePromise);
|
||||
try {
|
||||
return await parsePromise;
|
||||
} finally {
|
||||
fileInflight.delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge facts from every file belonging to a session, deduplicate by
|
||||
* `entry.id`, and aggregate into a `SessionAggregate`.
|
||||
*
|
||||
* Live file is preferred when an id collides with a bak — in practice the
|
||||
* shape is identical, but the live copy is by definition the canonical one.
|
||||
* Entries without an `id` (rare meta lines) are passed through; they don't
|
||||
* collide so dedup is moot.
|
||||
*/
|
||||
function aggregateSession(
|
||||
sessionId: string,
|
||||
agentId: string,
|
||||
files: { filePath: string; isLive: boolean; facts: EntryFact[] }[]
|
||||
): SessionAggregate {
|
||||
const agg: SessionAggregate = {
|
||||
sessionId,
|
||||
agentId,
|
||||
channel: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
firstTs: null,
|
||||
lastTs: null,
|
||||
turns: 0,
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
daily: new Map(),
|
||||
modelByKey: new Map(),
|
||||
messages: { total: 0, user: 0, assistant: 0, toolCalls: 0, errors: 0 },
|
||||
toolByName: new Map(),
|
||||
latencySamples: [],
|
||||
};
|
||||
|
||||
/* Process live first so live entries claim ids before any bak does. */
|
||||
const ordered = [...files].sort((a, b) => Number(b.isLive) - Number(a.isLive));
|
||||
const seen = new Set<string>();
|
||||
const merged: EntryFact[] = [];
|
||||
ordered.forEach(({ facts }) => {
|
||||
facts.forEach((f) => {
|
||||
if (f.id) {
|
||||
if (seen.has(f.id)) return;
|
||||
seen.add(f.id);
|
||||
}
|
||||
merged.push(f);
|
||||
});
|
||||
});
|
||||
|
||||
/* Sort by timestamp so latency calc walks the conversation chronologically
|
||||
* even when bak entries pre-date some live entries. Entries without a ts
|
||||
* sink to the end where they don't disturb latency pairing. */
|
||||
merged.sort((a, b) => {
|
||||
if (a.ts === null && b.ts === null) return 0;
|
||||
if (a.ts === null) return 1;
|
||||
if (b.ts === null) return -1;
|
||||
return a.ts - b.ts;
|
||||
});
|
||||
|
||||
let priorNonAssistantTs: number | null = null;
|
||||
|
||||
merged.forEach((f) => {
|
||||
if (f.ts !== null) {
|
||||
if (agg.firstTs === null || f.ts < agg.firstTs) agg.firstTs = f.ts;
|
||||
if (agg.lastTs === null || f.ts > agg.lastTs) agg.lastTs = f.ts;
|
||||
}
|
||||
|
||||
if (f.role === 'user') {
|
||||
agg.messages.total += 1;
|
||||
agg.messages.user += 1;
|
||||
if (f.ts !== null) priorNonAssistantTs = f.ts;
|
||||
} else if (f.role === 'toolResult') {
|
||||
agg.messages.total += 1;
|
||||
if (f.ts !== null) priorNonAssistantTs = f.ts;
|
||||
} else if (f.role === 'assistant') {
|
||||
agg.messages.total += 1;
|
||||
agg.messages.assistant += 1;
|
||||
if (f.errored) agg.messages.errors += 1;
|
||||
|
||||
f.toolNames.forEach((name) => {
|
||||
agg.messages.toolCalls += 1;
|
||||
agg.toolByName.set(name, (agg.toolByName.get(name) ?? 0) + 1);
|
||||
});
|
||||
|
||||
if (f.ts !== null && priorNonAssistantTs !== null) {
|
||||
const delta = f.ts - priorNonAssistantTs;
|
||||
if (delta >= 0 && delta < 10 * 60 * 1000) {
|
||||
agg.latencySamples.push(delta);
|
||||
}
|
||||
priorNonAssistantTs = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!f.usage) return;
|
||||
|
||||
if (!agg.provider && f.usage.provider) agg.provider = f.usage.provider;
|
||||
if (!agg.model && f.usage.model) agg.model = f.usage.model;
|
||||
|
||||
agg.turns += 1;
|
||||
agg.input += f.usage.input;
|
||||
agg.output += f.usage.output;
|
||||
agg.cacheRead += f.usage.cacheRead;
|
||||
agg.cacheWrite += f.usage.cacheWrite;
|
||||
agg.totalTokens += f.usage.totalTokens;
|
||||
agg.totalCost += f.usage.cost;
|
||||
|
||||
if (f.ts !== null) {
|
||||
const day = isoDay(f.ts);
|
||||
const dayAcc = agg.daily.get(day) ?? { tokens: 0, cost: 0 };
|
||||
dayAcc.tokens += f.usage.totalTokens;
|
||||
dayAcc.cost += f.usage.cost;
|
||||
agg.daily.set(day, dayAcc);
|
||||
}
|
||||
|
||||
const mkey = `${f.usage.provider}/${f.usage.model}`;
|
||||
const m =
|
||||
agg.modelByKey.get(mkey) ??
|
||||
{
|
||||
provider: f.usage.provider,
|
||||
model: f.usage.model,
|
||||
count: 0,
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
};
|
||||
m.count += 1;
|
||||
m.input += f.usage.input;
|
||||
m.output += f.usage.output;
|
||||
m.cacheRead += f.usage.cacheRead;
|
||||
m.cacheWrite += f.usage.cacheWrite;
|
||||
m.totalTokens += f.usage.totalTokens;
|
||||
m.totalCost += f.usage.cost;
|
||||
agg.modelByKey.set(mkey, m);
|
||||
});
|
||||
|
||||
return agg;
|
||||
}
|
||||
|
||||
/** Discover & group every JSONL related to one session: the live `<id>.jsonl`
|
||||
* plus every `<id>.jsonl.bak-*` sibling. The session id is the basename
|
||||
* before the first `.jsonl`. */
|
||||
interface SessionGroup {
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
label: string | null;
|
||||
updatedAt: number | null;
|
||||
liveFile: string | null;
|
||||
bakFiles: string[];
|
||||
}
|
||||
|
||||
async function listSessionGroups(openclawAgentId: string): Promise<SessionGroup[]> {
|
||||
const dir = path.join(agentDir(openclawAgentId), 'sessions');
|
||||
|
||||
let dirEntries;
|
||||
try {
|
||||
dirEntries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
/* Bucket every file by sessionId. We accept three forms:
|
||||
* - <id>.jsonl (live)
|
||||
* - <id>.jsonl.bak-<pid>-<ts> (rotation snapshot)
|
||||
* trajectory + deleted files are ignored. */
|
||||
const buckets = new Map<string, { liveFile: string | null; bakFiles: string[] }>();
|
||||
dirEntries.forEach((e) => {
|
||||
if (!e.isFile()) return;
|
||||
const { name } = e;
|
||||
if (name.endsWith('.trajectory.jsonl')) return;
|
||||
const fullPath = path.join(dir, name);
|
||||
|
||||
const bakIdx = name.indexOf('.jsonl.bak-');
|
||||
if (bakIdx > 0) {
|
||||
const sessionId = name.slice(0, bakIdx);
|
||||
const b = buckets.get(sessionId) ?? { liveFile: null, bakFiles: [] };
|
||||
b.bakFiles.push(fullPath);
|
||||
buckets.set(sessionId, b);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.endsWith('.jsonl')) {
|
||||
const sessionId = name.slice(0, -'.jsonl'.length);
|
||||
const b = buckets.get(sessionId) ?? { liveFile: null, bakFiles: [] };
|
||||
b.liveFile = fullPath;
|
||||
buckets.set(sessionId, b);
|
||||
}
|
||||
});
|
||||
|
||||
/* Marry bucket data with sessions.json metadata (label, updatedAt, key). */
|
||||
const sessions = await readSessionsJson(openclawAgentId);
|
||||
const metaByFile = new Map<string, { sessionKey: string; updatedAt: number | null; label: string | null }>();
|
||||
if (sessions) {
|
||||
const prefix = `agent:${openclawAgentId}:`;
|
||||
Object.entries(sessions).forEach(([key, val]) => {
|
||||
if (!key.startsWith(prefix)) return;
|
||||
const filePath = val?.sessionFile ?? path.join(dir, `${val?.sessionId ?? ''}.jsonl`);
|
||||
if (!filePath) return;
|
||||
metaByFile.set(filePath, {
|
||||
sessionKey: key.slice(prefix.length),
|
||||
updatedAt: typeof val?.updatedAt === 'number' ? val.updatedAt : null,
|
||||
label: val?.label ?? null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const groups: SessionGroup[] = [];
|
||||
buckets.forEach((b, sessionId) => {
|
||||
const meta = b.liveFile ? metaByFile.get(b.liveFile) : undefined;
|
||||
groups.push({
|
||||
sessionId,
|
||||
sessionKey: meta?.sessionKey ?? sessionId,
|
||||
label: meta?.label ?? null,
|
||||
updatedAt: meta?.updatedAt ?? null,
|
||||
liveFile: b.liveFile,
|
||||
bakFiles: b.bakFiles,
|
||||
});
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function p95(samples: number[]): number {
|
||||
if (samples.length === 0) return 0;
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95));
|
||||
return sorted[idx];
|
||||
}
|
||||
|
||||
function avg(samples: number[]): number {
|
||||
if (samples.length === 0) return 0;
|
||||
return samples.reduce((a, b) => a + b, 0) / samples.length;
|
||||
}
|
||||
|
||||
function toRawSession(
|
||||
agg: SessionAggregate,
|
||||
meta: { sessionKey: string; updatedAt: number | null; label: string | null }
|
||||
): RawUsageSession {
|
||||
const usage: RawSessionUsage = {
|
||||
firstActivity: agg.firstTs ?? undefined,
|
||||
lastActivity: agg.lastTs ?? undefined,
|
||||
dailyBreakdown: [...agg.daily.entries()]
|
||||
.map(([date, v]) => ({ date, tokens: v.tokens, cost: v.cost }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date)),
|
||||
modelUsage: [...agg.modelByKey.values()].map((m) => ({
|
||||
provider: m.provider,
|
||||
model: m.model,
|
||||
count: m.count,
|
||||
totals: {
|
||||
input: m.input,
|
||||
output: m.output,
|
||||
cacheRead: m.cacheRead,
|
||||
cacheWrite: m.cacheWrite,
|
||||
totalTokens: m.totalTokens,
|
||||
totalCost: m.totalCost,
|
||||
},
|
||||
})),
|
||||
input: agg.input,
|
||||
output: agg.output,
|
||||
cacheRead: agg.cacheRead,
|
||||
cacheWrite: agg.cacheWrite,
|
||||
totalTokens: agg.totalTokens,
|
||||
totalCost: agg.totalCost,
|
||||
messageCounts: {
|
||||
total: agg.messages.total,
|
||||
user: agg.messages.user,
|
||||
assistant: agg.messages.assistant,
|
||||
toolCalls: agg.messages.toolCalls,
|
||||
errors: agg.messages.errors,
|
||||
},
|
||||
toolUsage: {
|
||||
totalCalls: agg.messages.toolCalls,
|
||||
uniqueTools: agg.toolByName.size,
|
||||
tools: [...agg.toolByName.entries()]
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
},
|
||||
latency: {
|
||||
count: agg.latencySamples.length,
|
||||
avgMs: avg(agg.latencySamples),
|
||||
p95Ms: p95(agg.latencySamples),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
key: `agent:${agg.agentId}:${meta.sessionKey}`,
|
||||
label: meta.label,
|
||||
channel: agg.channel,
|
||||
agentId: agg.agentId,
|
||||
modelProvider: agg.provider,
|
||||
model: agg.model,
|
||||
updatedAt: meta.updatedAt ?? agg.lastTs,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve & aggregate one session: parse live + every bak in parallel, then
|
||||
* dedupe-by-id and aggregate. Cached on the fingerprint of every contributing
|
||||
* file so warm hits are O(map lookup).
|
||||
*/
|
||||
async function resolveSessionAggregate(
|
||||
group: SessionGroup,
|
||||
openclawAgentId: string
|
||||
): Promise<SessionAggregate | null> {
|
||||
const allPaths = [
|
||||
...(group.liveFile ? [{ filePath: group.liveFile, isLive: true }] : []),
|
||||
...group.bakFiles.map((p) => ({ filePath: p, isLive: false })),
|
||||
];
|
||||
if (allPaths.length === 0) return null;
|
||||
|
||||
/* Fingerprint = stat of every related file, sorted for stability. */
|
||||
const stats = await Promise.all(
|
||||
allPaths.map(async (f) => {
|
||||
try {
|
||||
const s = await fs.stat(f.filePath);
|
||||
return { ...f, mtimeMs: s.mtimeMs, size: s.size };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
const liveFiles = stats.filter((s): s is NonNullable<typeof s> => s !== null);
|
||||
if (liveFiles.length === 0) return null;
|
||||
|
||||
const fingerprint = [...liveFiles]
|
||||
.map((s) => `${s.filePath}:${s.mtimeMs}:${s.size}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
|
||||
const cached = sessionCache.get(group.sessionId);
|
||||
if (cached && cached.fingerprint === fingerprint) return cached.data;
|
||||
|
||||
const parsed = await Promise.all(
|
||||
liveFiles.map(async (f) => {
|
||||
const facts = await parseFileFacts(f.filePath);
|
||||
return facts ? { filePath: f.filePath, isLive: f.isLive, facts } : null;
|
||||
})
|
||||
);
|
||||
const usable = parsed.filter((p): p is NonNullable<typeof p> => p !== null);
|
||||
if (usable.length === 0) return null;
|
||||
|
||||
const agg = aggregateSession(group.sessionId, openclawAgentId, usable);
|
||||
sessionCache.set(group.sessionId, { fingerprint, data: agg });
|
||||
return agg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `RawUsagePayload` for one agent by walking that agent's session
|
||||
* groups (live JSONL + every bak rotation). Returns `null` when the agent
|
||||
* has no sessions yet.
|
||||
*
|
||||
* Files are parsed in parallel; each parse uses async I/O and yields to the
|
||||
* event loop while reading large files, so concurrent requests don't queue.
|
||||
*/
|
||||
export async function getAgentRawUsageFromDisk(
|
||||
openclawAgentId: string
|
||||
): Promise<RawUsagePayload | null> {
|
||||
const groups = await listSessionGroups(openclawAgentId);
|
||||
if (groups.length === 0) return null;
|
||||
|
||||
const sessions = (
|
||||
await Promise.all(
|
||||
groups.map(async (g) => {
|
||||
const agg = await resolveSessionAggregate(g, openclawAgentId);
|
||||
if (!agg) return null;
|
||||
return toRawSession(agg, {
|
||||
sessionKey: g.sessionKey,
|
||||
updatedAt: g.updatedAt,
|
||||
label: g.label,
|
||||
});
|
||||
})
|
||||
)
|
||||
).filter((s): s is RawUsageSession => s !== null);
|
||||
|
||||
const firstSeen = sessions.reduce<number>((min, s) => {
|
||||
const v = s.usage?.firstActivity;
|
||||
return typeof v === 'number' && v < min ? v : min;
|
||||
}, Number.POSITIVE_INFINITY);
|
||||
const lastSeen = sessions.reduce<number>((max, s) => {
|
||||
const v = s.usage?.lastActivity;
|
||||
return typeof v === 'number' && v > max ? v : max;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
startDate: Number.isFinite(firstSeen) ? new Date(firstSeen).toISOString() : null,
|
||||
endDate: lastSeen > 0 ? new Date(lastSeen).toISOString() : null,
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
|
||||
/** Drop the in-memory parse caches — used after writes that may invalidate them. */
|
||||
export function invalidateLocalUsageCache(): void {
|
||||
fileCache.clear();
|
||||
sessionCache.clear();
|
||||
}
|
||||
+52
-5
@@ -1,12 +1,17 @@
|
||||
import { Suspense, lazy } from 'react';
|
||||
import { Routes, Route } from 'react-router';
|
||||
import { Routes, Route, Navigate } from 'react-router';
|
||||
import { CircularProgress, Box } from '@mui/material';
|
||||
|
||||
const Login = lazy(() => import('../pages/login'));
|
||||
const PrivateRoute = lazy(() => import('../features/auth/PrivateRoute'));
|
||||
const Users = lazy(() => import('../pages/user'));
|
||||
const AgentChat = lazy(() => import('../pages/agent'));
|
||||
const AgentWorkspace = lazy(() => import('../pages/agent/WorkspacePage'));
|
||||
const AgentSettingsLayout = lazy(() => import('../pages/agent/SettingsLayoutPage'));
|
||||
const AgentWorkspaceFiles = lazy(() => import('../pages/agent/WorkspaceFilesPage'));
|
||||
const AgentUsage = lazy(() => import('../pages/agent/UsagePage'));
|
||||
const AgentBudgets = lazy(() => import('../pages/agent/BudgetsPage'));
|
||||
const AgentSkills = lazy(() => import('../pages/agent/SkillsPage'));
|
||||
const AgentSubagents = lazy(() => import('../pages/agent/SubagentsPage'));
|
||||
const Plugins = lazy(() => import('../pages/plugins'));
|
||||
const Skills = lazy(() => import('../pages/skills'));
|
||||
const Channels = lazy(() => import('../pages/channels'));
|
||||
@@ -88,13 +93,55 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="agent/:agentId/workspace"
|
||||
path="agent/:agentId"
|
||||
element={
|
||||
<Suspense fallback={<Loading />}>
|
||||
<AgentWorkspace />
|
||||
<AgentSettingsLayout />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<Route index element={<Navigate to="workspace" replace />} />
|
||||
<Route
|
||||
path="workspace"
|
||||
element={
|
||||
<Suspense fallback={<Loading />}>
|
||||
<AgentWorkspaceFiles />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="usage"
|
||||
element={
|
||||
<Suspense fallback={<Loading />}>
|
||||
<AgentUsage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="budgets"
|
||||
element={
|
||||
<Suspense fallback={<Loading />}>
|
||||
<AgentBudgets />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="skills"
|
||||
element={
|
||||
<Suspense fallback={<Loading />}>
|
||||
<AgentSkills />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="subagents"
|
||||
element={
|
||||
<Suspense fallback={<Loading />}>
|
||||
<AgentSubagents />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element="404" />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Box, CircularProgress, Stack, Tooltip, Typography } from '@mui/material';
|
||||
import { SmartToy } from '@mui/icons-material';
|
||||
import { Link } from 'react-router';
|
||||
import {
|
||||
useGetAgentLimitsQuery,
|
||||
type AgentLimitWindow,
|
||||
type AgentLimitWindowState,
|
||||
} from '../../../../entities/agent';
|
||||
import { ProviderLogo } from '../../../../shared/ui';
|
||||
|
||||
interface AgentUsageRingProps {
|
||||
agentId: string;
|
||||
conversationId?: string | number | null;
|
||||
model?: string | null;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
@@ -30,7 +34,6 @@ function ratioColour(state: AgentLimitWindowState): 'secondary' | 'warning' | 'e
|
||||
return state.limit == null ? 'primary' : 'secondary';
|
||||
}
|
||||
|
||||
/** Pick the configured window with the highest fill ratio. */
|
||||
function pickHotWindow(rows: WindowRow[]): WindowRow {
|
||||
const configured = rows.filter((r) => r.state.limit != null);
|
||||
if (configured.length === 0) return rows[0];
|
||||
@@ -40,6 +43,7 @@ function pickHotWindow(rows: WindowRow[]): WindowRow {
|
||||
export default function AgentUsageRing({
|
||||
agentId,
|
||||
conversationId,
|
||||
model,
|
||||
size = 32,
|
||||
}: AgentUsageRingProps) {
|
||||
const { data, refetch, isFetching } = useGetAgentLimitsQuery(agentId, {
|
||||
@@ -61,7 +65,13 @@ export default function AgentUsageRing({
|
||||
];
|
||||
}, [data]);
|
||||
|
||||
if (isFetching) {
|
||||
const ringThickness = Math.max(2.5, size / 11);
|
||||
const innerLogoSize = Math.round(size * 0.62);
|
||||
const usageHref = conversationId
|
||||
? `/agent/${agentId}/usage?return=${conversationId}`
|
||||
: `/agent/${agentId}/usage`;
|
||||
|
||||
if (isFetching && !data) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -78,17 +88,13 @@ export default function AgentUsageRing({
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || rows.length === 0) return null;
|
||||
|
||||
const anyConfigured = rows.some((r) => r.state.limit != null);
|
||||
if (!anyConfigured) return null;
|
||||
|
||||
const hot = pickHotWindow(rows);
|
||||
const colour = ratioColour(hot.state);
|
||||
const pct = hot.state.ratio == null ? 0 : Math.min(100, hot.state.ratio * 100);
|
||||
const labelPct = hot.state.exceeded
|
||||
const hot = rows.length > 0 ? pickHotWindow(rows) : null;
|
||||
const hasCap = !!hot && hot.state.limit != null;
|
||||
const colour = hot ? ratioColour(hot.state) : 'primary';
|
||||
const pct = !hot || hot.state.ratio == null ? 0 : Math.min(100, hot.state.ratio * 100);
|
||||
const labelPct = hot?.state.exceeded
|
||||
? '100+'
|
||||
: `${Math.round(hot.state.ratio == null ? 0 : hot.state.ratio * 100)}`;
|
||||
: `${Math.round(hot?.state.ratio == null ? 0 : (hot.state.ratio ?? 0) * 100)}`;
|
||||
|
||||
const popoverTitle = (
|
||||
<Box sx={{ minWidth: 220, py: 0.25, px: 0.5 }}>
|
||||
@@ -104,48 +110,74 @@ export default function AgentUsageRing({
|
||||
>
|
||||
Spend vs. cap
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{rows.map((r) => {
|
||||
const overLimit = r.state.exceeded;
|
||||
const nearLimit = r.state.nearLimit;
|
||||
return (
|
||||
<Box
|
||||
key={r.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
{rows.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ fontSize: '0.72rem', color: 'text.secondary' }}>
|
||||
No usage data yet.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={0.5}>
|
||||
{rows.map((r) => {
|
||||
const overLimit = r.state.exceeded;
|
||||
const nearLimit = r.state.nearLimit;
|
||||
const noCap = r.state.limit == null;
|
||||
return (
|
||||
<Box
|
||||
key={r.id}
|
||||
sx={{
|
||||
fontSize: '0.72rem',
|
||||
color: 'text.secondary',
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{r.label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: overLimit || nearLimit ? 700 : 500,
|
||||
color: overLimit ? 'error.main' : nearLimit ? 'warning.main' : 'text.primary',
|
||||
}}
|
||||
>
|
||||
{fmtUsd(r.state.spent)}
|
||||
{r.state.limit != null
|
||||
? ` / ${fmtUsd(r.state.limit)} (${((r.state.ratio ?? 0) * 100).toFixed(0)}%)`
|
||||
: ' / no cap'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: '0.72rem',
|
||||
color: 'text.secondary',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{r.label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: overLimit || nearLimit ? 700 : 500,
|
||||
color: overLimit
|
||||
? 'error.main'
|
||||
: nearLimit
|
||||
? 'warning.main'
|
||||
: noCap
|
||||
? 'text.secondary'
|
||||
: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{noCap
|
||||
? `${fmtUsd(r.state.spent)} / no cap`
|
||||
: `${fmtUsd(r.state.spent)} / ${fmtUsd(r.state.limit ?? 0)} (${(
|
||||
(r.state.ratio ?? 0) * 100
|
||||
).toFixed(0)}%)`}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
mt: 0.75,
|
||||
fontSize: '0.68rem',
|
||||
color: 'text.disabled',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
Click to open usage details.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -180,6 +212,9 @@ export default function AgentUsageRing({
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component={Link}
|
||||
to={usageHref}
|
||||
aria-label="Open agent usage details"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: size,
|
||||
@@ -189,52 +224,84 @@ export default function AgentUsageRing({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
userSelect: 'none',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '50%',
|
||||
transition: 'transform 0.15s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.06)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={100}
|
||||
size={size}
|
||||
thickness={4}
|
||||
thickness={ringThickness}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
color: 'action.hover',
|
||||
}}
|
||||
/>
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={pct}
|
||||
color={colour}
|
||||
size={size}
|
||||
thickness={4}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
transition: 'all 0.3s',
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: size <= 28 ? '0.6rem' : '0.65rem',
|
||||
fontWeight: 700,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
lineHeight: 1,
|
||||
color: hot.state.exceeded
|
||||
? 'error.main'
|
||||
: hot.state.nearLimit
|
||||
? 'warning.main'
|
||||
: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{labelPct}
|
||||
{!hot.state.exceeded && (
|
||||
<Box component="span" sx={{ fontSize: '0.55em', ml: 0.1 }}>
|
||||
%
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
{hasCap && (
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={pct}
|
||||
color={colour}
|
||||
size={size}
|
||||
thickness={ringThickness}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
transition: 'all 0.3s',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hasCap ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
fontSize: size <= 28 ? '0.6rem' : '0.65rem',
|
||||
fontWeight: 700,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
lineHeight: 1,
|
||||
color: hot?.state.exceeded
|
||||
? 'error.main'
|
||||
: hot?.state.nearLimit
|
||||
? 'warning.main'
|
||||
: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{labelPct}
|
||||
{!hot?.state.exceeded && (
|
||||
<Box component="span" sx={{ fontSize: '0.55em', ml: 0.1 }}>
|
||||
%
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: innerLogoSize,
|
||||
height: innerLogoSize,
|
||||
}}
|
||||
>
|
||||
<ProviderLogo
|
||||
modelId={model ?? null}
|
||||
size={innerLogoSize}
|
||||
fallback={<SmartToy sx={{ fontSize: innerLogoSize }} />}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { useGetAgentQuery } from '../../entities/agent';
|
||||
import { AgentBudgets } from '../../features/agent/budgets';
|
||||
|
||||
export default function AgentBudgetsPage() {
|
||||
const { agentId } = useParams<{ agentId: string }>();
|
||||
const { data: agent } = useGetAgentQuery(agentId ?? '', { skip: !agentId });
|
||||
if (!agent?._id) return null;
|
||||
return <AgentBudgets agentId={String(agent._id)} />;
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { Workspace } from '../../widgets/workspace';
|
||||
|
||||
export default function AgentWorkspacePage() {
|
||||
export default function AgentSettingsLayoutPage() {
|
||||
const { agentId } = useParams<{ agentId: string }>();
|
||||
if (!agentId) return null;
|
||||
return <Workspace agentId={agentId} />;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { useGetAgentQuery } from '../../entities/agent';
|
||||
import { AgentSkills } from '../../features/agent/skills';
|
||||
|
||||
export default function AgentSkillsPage() {
|
||||
const { agentId } = useParams<{ agentId: string }>();
|
||||
const { data: agent } = useGetAgentQuery(agentId ?? '', { skip: !agentId });
|
||||
if (!agent?._id) return null;
|
||||
return <AgentSkills agentId={String(agent._id)} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { useGetAgentQuery } from '../../entities/agent';
|
||||
import { AgentSubagents } from '../../features/agent/subagents';
|
||||
|
||||
export default function AgentSubagentsPage() {
|
||||
const { agentId } = useParams<{ agentId: string }>();
|
||||
const { data: agent } = useGetAgentQuery(agentId ?? '', { skip: !agentId });
|
||||
if (!agent?._id) return null;
|
||||
return <AgentSubagents agentId={String(agent._id)} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { useGetAgentQuery } from '../../entities/agent';
|
||||
import { AgentUsage } from '../../features/agent/usage';
|
||||
|
||||
export default function AgentUsagePage() {
|
||||
const { agentId } = useParams<{ agentId: string }>();
|
||||
const { data: agent } = useGetAgentQuery(agentId ?? '', { skip: !agentId });
|
||||
if (!agent?._id) return null;
|
||||
return <AgentUsage agentId={String(agent._id)} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { WorkspaceFileTabs } from '../../widgets/workspace';
|
||||
|
||||
export default function AgentWorkspaceFilesPage() {
|
||||
const { agentId } = useParams<{ agentId: string }>();
|
||||
if (!agentId) return null;
|
||||
return <WorkspaceFileTabs agentId={agentId} />;
|
||||
}
|
||||
@@ -105,7 +105,11 @@ export default function ChatHeader({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AgentUsageRing agentId={agentId} conversationId={conversationId} />
|
||||
<AgentUsageRing
|
||||
agentId={agentId}
|
||||
conversationId={conversationId}
|
||||
model={agent.model ?? null}
|
||||
/>
|
||||
<Stack sx={{ flex: 1, minWidth: 0 }} spacing={0.25}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
|
||||
@@ -10,14 +10,15 @@ import {
|
||||
IconButton,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { Add, ExpandMore, ExpandLess, SmartToy, DeleteOutline } from '@mui/icons-material';
|
||||
import { Add, ExpandMore, ExpandLess, DeleteOutline } from '@mui/icons-material';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { useDeleteAgentMutation } from '../../../entities/agent';
|
||||
import { useCreateConversationMutation, ConversationItem } from '../../../entities/conversation';
|
||||
import { DeleteButton, ProviderLogo } from '../../../shared/ui';
|
||||
import { DeleteButton } from '../../../shared/ui';
|
||||
import AgentSpendRing from './AgentSpendRing';
|
||||
|
||||
interface AgentSectionProps {
|
||||
agent: { _id: string; name: string; model?: string | null };
|
||||
agent: { _id: string; name: string; model?: string | null; openclawAgentId?: string };
|
||||
conversations: { _id: string; title: string | null; createdAt: string }[];
|
||||
searchQuery?: string;
|
||||
collapseKey?: number;
|
||||
@@ -104,12 +105,13 @@ export default function AgentSection({
|
||||
}}
|
||||
>
|
||||
<ListItemIcon
|
||||
sx={{ minWidth: 24, color: isAgentActive ? sidebar.selectedBorder : sidebar.text }}
|
||||
sx={{ minWidth: 34, color: isAgentActive ? sidebar.selectedBorder : sidebar.text }}
|
||||
>
|
||||
<ProviderLogo
|
||||
modelId={modelId}
|
||||
size={16}
|
||||
fallback={<SmartToy sx={{ fontSize: 16 }} />}
|
||||
<AgentSpendRing
|
||||
agentId={agent._id}
|
||||
openclawAgentId={agent.openclawAgentId}
|
||||
model={modelId}
|
||||
size={26}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Box, CircularProgress, Divider, Stack, Tooltip, Typography } from '@mui/material';
|
||||
import { SmartToy } from '@mui/icons-material';
|
||||
import {
|
||||
useGetAgentLimitsQuery,
|
||||
type AgentLimitWindow,
|
||||
type AgentLimitWindowState,
|
||||
} from '../../../entities/agent';
|
||||
import { ProviderLogo } from '../../../shared/ui';
|
||||
|
||||
interface AgentSpendRingProps {
|
||||
agentId: string;
|
||||
openclawAgentId?: string | null;
|
||||
model?: string | null;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface WindowRow {
|
||||
id: AgentLimitWindow;
|
||||
label: string;
|
||||
state: AgentLimitWindowState;
|
||||
}
|
||||
|
||||
function fmtUsd(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return '$0.00';
|
||||
if (n < 0.01) return '<$0.01';
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function ratioColour(state: AgentLimitWindowState): 'secondary' | 'warning' | 'error' | 'primary' {
|
||||
if (state.exceeded) return 'error';
|
||||
if (state.nearLimit) return 'warning';
|
||||
return state.limit == null ? 'primary' : 'secondary';
|
||||
}
|
||||
|
||||
function pickHotWindow(rows: WindowRow[]): WindowRow {
|
||||
const configured = rows.filter((r) => r.state.limit != null);
|
||||
if (configured.length === 0) return rows[0];
|
||||
return configured.reduce((acc, r) => ((r.state.ratio ?? 0) > (acc.state.ratio ?? 0) ? r : acc));
|
||||
}
|
||||
|
||||
function prettyProvider(provider: string): string {
|
||||
if (!provider) return '';
|
||||
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
}
|
||||
|
||||
function splitModel(model: string | null | undefined): { provider: string; name: string } {
|
||||
if (!model) return { provider: '', name: '' };
|
||||
const slash = model.indexOf('/');
|
||||
if (slash <= 0) return { provider: '', name: model };
|
||||
return { provider: model.slice(0, slash), name: model.slice(slash + 1) };
|
||||
}
|
||||
|
||||
export default function AgentSpendRing({
|
||||
agentId,
|
||||
openclawAgentId,
|
||||
model,
|
||||
size = 22,
|
||||
}: AgentSpendRingProps) {
|
||||
const { data, isFetching } = useGetAgentLimitsQuery(agentId, {
|
||||
skip: !agentId,
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
|
||||
const rows = useMemo<WindowRow[]>(() => {
|
||||
if (!data) return [];
|
||||
return [
|
||||
{ id: 'daily', label: 'Daily', state: data.windows.daily },
|
||||
{ id: 'monthly', label: 'Monthly', state: data.windows.monthly },
|
||||
{ id: 'total', label: 'All-time', state: data.windows.total },
|
||||
];
|
||||
}, [data]);
|
||||
|
||||
const { provider, name: modelName } = splitModel(model);
|
||||
const innerLogoSize = Math.min(size - 8, 14);
|
||||
const ringThickness = Math.max(3, size / 9);
|
||||
|
||||
const hot = rows.length > 0 ? pickHotWindow(rows) : null;
|
||||
const hasCap = hot?.state.limit != null;
|
||||
const colour = hot ? ratioColour(hot.state) : 'primary';
|
||||
const pct = !hot || hot.state.ratio == null ? 0 : Math.min(100, hot.state.ratio * 100);
|
||||
|
||||
const popoverTitle = (
|
||||
<Box sx={{ minWidth: 220, py: 0.25, px: 0.5 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
display: 'block',
|
||||
mb: 0.75,
|
||||
color: 'text.primary',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
Spend vs. cap
|
||||
</Typography>
|
||||
{rows.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ fontSize: '0.72rem', color: 'text.secondary' }}>
|
||||
{isFetching ? 'Loading…' : 'No usage data yet.'}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={0.5}>
|
||||
{rows.map((r) => {
|
||||
const overLimit = r.state.exceeded;
|
||||
const nearLimit = r.state.nearLimit;
|
||||
const noCap = r.state.limit == null;
|
||||
return (
|
||||
<Box
|
||||
key={r.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontSize: '0.72rem', color: 'text.secondary', fontWeight: 500 }}
|
||||
>
|
||||
{r.label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: overLimit || nearLimit ? 700 : 500,
|
||||
color: overLimit
|
||||
? 'error.main'
|
||||
: nearLimit
|
||||
? 'warning.main'
|
||||
: noCap
|
||||
? 'text.secondary'
|
||||
: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{noCap
|
||||
? `${fmtUsd(r.state.spent)} / no cap`
|
||||
: `${fmtUsd(r.state.spent)} / ${fmtUsd(r.state.limit ?? 0)} (${(
|
||||
(r.state.ratio ?? 0) * 100
|
||||
).toFixed(0)}%)`}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{(provider || modelName || openclawAgentId) && (
|
||||
<>
|
||||
<Divider sx={{ my: 0.75, opacity: 0.6 }} />
|
||||
<Stack spacing={0.25}>
|
||||
{(provider || modelName) && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
color: 'text.secondary',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
}}
|
||||
>
|
||||
{provider ? prettyProvider(provider) : 'Model'}
|
||||
{modelName ? ` · ${modelName}` : ''}
|
||||
</Typography>
|
||||
)}
|
||||
{openclawAgentId && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
color: 'text.disabled',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
}}
|
||||
>
|
||||
agent · {openclawAgentId}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
placement="bottom-start"
|
||||
arrow
|
||||
title={popoverTitle}
|
||||
slotProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
bgcolor: 'background.paper',
|
||||
color: 'text.primary',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.15)',
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
maxWidth: 320,
|
||||
},
|
||||
},
|
||||
arrow: {
|
||||
sx: {
|
||||
color: 'background.paper',
|
||||
'&::before': { border: '1px solid', borderColor: 'divider' },
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: size,
|
||||
height: size,
|
||||
flexShrink: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={100}
|
||||
size={size}
|
||||
thickness={ringThickness}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
color: 'action.hover',
|
||||
}}
|
||||
/>
|
||||
{hasCap && (
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={pct}
|
||||
color={colour}
|
||||
size={size}
|
||||
thickness={ringThickness}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
transition: 'all 0.3s',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: innerLogoSize,
|
||||
height: innerLogoSize,
|
||||
}}
|
||||
>
|
||||
<ProviderLogo
|
||||
modelId={model ?? null}
|
||||
size={innerLogoSize}
|
||||
fallback={<SmartToy sx={{ fontSize: innerLogoSize }} />}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,25 @@
|
||||
import { useState, type ReactElement } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Link, Outlet, useLocation, useSearchParams } from 'react-router';
|
||||
import { Box, IconButton, Typography, CircularProgress, Tab, Tabs } from '@mui/material';
|
||||
import { ArrowBack, Extension, FolderOpen, Group, Insights, Tune } from '@mui/icons-material';
|
||||
import { useGetAgentQuery } from '../../../entities/agent';
|
||||
import { AgentBudgets } from '../../../features/agent/budgets';
|
||||
import { AgentSkills } from '../../../features/agent/skills';
|
||||
import { AgentSubagents } from '../../../features/agent/subagents';
|
||||
import { AgentUsage } from '../../../features/agent/usage';
|
||||
import WorkspaceFileTabs from './WorkspaceFileTabs';
|
||||
|
||||
interface WorkspaceProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
type SectionId = 'files' | 'usage' | 'budgets' | 'skills' | 'subagents';
|
||||
type SectionId = 'workspace' | 'usage' | 'budgets' | 'skills' | 'subagents';
|
||||
|
||||
const SECTIONS: { id: SectionId; label: string; icon: ReactElement; caption: string }[] = [
|
||||
interface SectionDef {
|
||||
id: SectionId;
|
||||
label: string;
|
||||
icon: ReactElement;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
const SECTIONS: SectionDef[] = [
|
||||
{
|
||||
id: 'files',
|
||||
id: 'workspace',
|
||||
label: 'Workspace',
|
||||
icon: <FolderOpen sx={{ fontSize: 18 }} />,
|
||||
caption: 'Workspace files',
|
||||
@@ -48,15 +50,26 @@ const SECTIONS: { id: SectionId; label: string; icon: ReactElement; caption: str
|
||||
},
|
||||
];
|
||||
|
||||
function activeSectionFromPath(pathname: string): SectionId {
|
||||
const last = pathname.split('/').filter(Boolean).pop() ?? '';
|
||||
return SECTIONS.some((s) => s.id === last) ? (last as SectionId) : 'workspace';
|
||||
}
|
||||
|
||||
export default function Workspace({ agentId }: WorkspaceProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const returnConv = searchParams.get('return');
|
||||
const { data: agent, isLoading } = useGetAgentQuery(agentId, { skip: !agentId });
|
||||
const [section, setSection] = useState<SectionId>('files');
|
||||
const { pathname } = useLocation();
|
||||
const section = activeSectionFromPath(pathname);
|
||||
|
||||
const backHref = returnConv ? `/agent/${agentId}/chat/${returnConv}` : '/';
|
||||
const activeCaption = SECTIONS.find((s) => s.id === section)?.caption ?? '';
|
||||
|
||||
const tabHref = (id: SectionId): string => {
|
||||
const base = `/agent/${agentId}/${id}`;
|
||||
return returnConv ? `${base}?return=${returnConv}` : base;
|
||||
};
|
||||
|
||||
if (isLoading && !agent) {
|
||||
return (
|
||||
<Box
|
||||
@@ -121,7 +134,6 @@ export default function Workspace({ agentId }: WorkspaceProps) {
|
||||
>
|
||||
<Tabs
|
||||
value={section}
|
||||
onChange={(_, v: SectionId) => setSection(v)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
allowScrollButtonsMobile
|
||||
@@ -137,7 +149,15 @@ export default function Workspace({ agentId }: WorkspaceProps) {
|
||||
}}
|
||||
>
|
||||
{SECTIONS.map((s) => (
|
||||
<Tab key={s.id} value={s.id} iconPosition="start" icon={s.icon} label={s.label} />
|
||||
<Tab
|
||||
key={s.id}
|
||||
value={s.id}
|
||||
component={Link}
|
||||
to={tabHref(s.id)}
|
||||
iconPosition="start"
|
||||
icon={s.icon}
|
||||
label={s.label}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
@@ -151,11 +171,7 @@ export default function Workspace({ agentId }: WorkspaceProps) {
|
||||
py: 2,
|
||||
}}
|
||||
>
|
||||
{section === 'files' && <WorkspaceFileTabs agentId={agentId} />}
|
||||
{section === 'usage' && agent?._id && <AgentUsage agentId={String(agent._id)} />}
|
||||
{section === 'budgets' && agent?._id && <AgentBudgets agentId={String(agent._id)} />}
|
||||
{section === 'skills' && agent?._id && <AgentSkills agentId={String(agent._id)} />}
|
||||
{section === 'subagents' && agent?._id && <AgentSubagents agentId={String(agent._id)} />}
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw-client",
|
||||
"version": "2.4.9",
|
||||
"version": "2.4.8",
|
||||
"description": "Web-based chat interface for OpenClaw AI agents",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
Reference in New Issue
Block a user