improved usage dashboard and spending caps

This commit is contained in:
Davit
2026-05-02 15:29:49 +04:00
parent 6ebd495154
commit 9ff4393bf4
17 changed files with 1292 additions and 157 deletions
+19 -4
View File
@@ -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 };
+2 -33
View File
@@ -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;
}
+1 -1
View File
@@ -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';
+698
View File
@@ -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
View File
@@ -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>
);
+10
View File
@@ -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,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} />;
+10
View File
@@ -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)} />;
}
+10
View File
@@ -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)} />;
}
+10
View File
@@ -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} />;
}
+5 -1
View File
@@ -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 -8
View File
@@ -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>
);
}
+34 -18
View File
@@ -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
View File
@@ -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",