mirror of
https://github.com/lotsoftick/openclaw_client.git
synced 2026-08-14 00:48:07 +00:00
usage dashboard and spending cap
This commit is contained in:
@@ -235,6 +235,106 @@ export interface AgentSubagentsPatch {
|
||||
requireAgentId?: boolean | null;
|
||||
}
|
||||
|
||||
// ── Agent usage (token / cost / activity stats) ──
|
||||
|
||||
export interface AgentUsageDailyPoint {
|
||||
date: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface AgentUsageModelRow {
|
||||
provider: string;
|
||||
model: string;
|
||||
count: number;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
}
|
||||
|
||||
export interface AgentUsageToolRow {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AgentUsageSessionRow {
|
||||
key: string;
|
||||
label: string | null;
|
||||
channel: string | null;
|
||||
updatedAt: number | null;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
modelProvider: string | null;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface AgentUsageTotals {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
}
|
||||
|
||||
export interface AgentUsageMessageCounts {
|
||||
total: number;
|
||||
user: number;
|
||||
assistant: number;
|
||||
toolCalls: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface AgentUsageLatency {
|
||||
count: number;
|
||||
avgMs: number;
|
||||
p95Ms: number;
|
||||
}
|
||||
|
||||
export interface AgentUsageResponse {
|
||||
agentId: string;
|
||||
known: boolean;
|
||||
range: { startDate: string | null; endDate: string | null };
|
||||
sessionCount: number;
|
||||
firstActivity: number | null;
|
||||
lastActivity: number | null;
|
||||
totals: AgentUsageTotals;
|
||||
messageCounts: AgentUsageMessageCounts;
|
||||
latency: AgentUsageLatency;
|
||||
daily: AgentUsageDailyPoint[];
|
||||
models: AgentUsageModelRow[];
|
||||
tools: AgentUsageToolRow[];
|
||||
sessions: AgentUsageSessionRow[];
|
||||
}
|
||||
|
||||
// ── Agent cost limits (per-agent USD spend caps) ──
|
||||
|
||||
export type AgentLimitWindow = 'daily' | 'monthly' | 'total';
|
||||
|
||||
export interface AgentLimitWindowState {
|
||||
limit: number | null;
|
||||
spent: number;
|
||||
ratio: number | null;
|
||||
exceeded: boolean;
|
||||
nearLimit: boolean;
|
||||
}
|
||||
|
||||
export interface AgentLimitsResponse {
|
||||
agentId: string;
|
||||
today: string;
|
||||
thisMonth: string;
|
||||
windows: Record<AgentLimitWindow, AgentLimitWindowState>;
|
||||
}
|
||||
|
||||
export interface AgentLimitsPatch {
|
||||
costLimitDaily?: number | null;
|
||||
costLimitMonthly?: number | null;
|
||||
costLimitTotal?: number | null;
|
||||
}
|
||||
|
||||
export interface OpenclawConfig {
|
||||
agents?: OpenclawAgentsSection;
|
||||
gateway?: { port?: number };
|
||||
|
||||
@@ -22,4 +22,13 @@ export default class Agent {
|
||||
|
||||
@DeleteDateColumn({ type: 'datetime', nullable: true, default: null })
|
||||
deletedAt: Date | null;
|
||||
|
||||
@Column({ type: 'real', nullable: true, default: null })
|
||||
costLimitDaily: number | null;
|
||||
|
||||
@Column({ type: 'real', nullable: true, default: null })
|
||||
costLimitMonthly: number | null;
|
||||
|
||||
@Column({ type: 'real', nullable: true, default: null })
|
||||
costLimitTotal: number | null;
|
||||
}
|
||||
|
||||
@@ -462,6 +462,20 @@ const updateSubagentsConfig: RequestHandler = async (req, res, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getUsage: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const agentRepo = AppDataSource.getRepository(Agent);
|
||||
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
|
||||
if (!agent?.openclawAgentId) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
const usage = await ocService.getAgentUsage(agent.openclawAgentId);
|
||||
return res.json(usage);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderModels: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const agentRepo = AppDataSource.getRepository(Agent);
|
||||
@@ -506,6 +520,50 @@ const updateProviderModel: RequestHandler = async (req, res, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getLimits: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const agentRepo = AppDataSource.getRepository(Agent);
|
||||
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
|
||||
if (!agent?.openclawAgentId) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
const limits = await ocService.getAgentLimits(agent);
|
||||
return res.json({
|
||||
...limits,
|
||||
stored: {
|
||||
costLimitDaily: agent.costLimitDaily,
|
||||
costLimitMonthly: agent.costLimitMonthly,
|
||||
costLimitTotal: agent.costLimitTotal,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateLimits: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const agentRepo = AppDataSource.getRepository(Agent);
|
||||
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
|
||||
if (!agent?.openclawAgentId) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
const result = await ocService.setAgentLimits(agent, req.body ?? {});
|
||||
if (!result.ok) return res.status(400).json(result);
|
||||
const fresh = await agentRepo.findOneByOrFail({ _id: agent._id });
|
||||
return res.json({
|
||||
...result.config,
|
||||
stored: {
|
||||
costLimitDaily: fresh.costLimitDaily,
|
||||
costLimitMonthly: fresh.costLimitMonthly,
|
||||
costLimitTotal: fresh.costLimitTotal,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const serveWorkspaceUpload: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const agentRepo = AppDataSource.getRepository(Agent);
|
||||
@@ -543,4 +601,7 @@ export {
|
||||
updateSubagentsConfig,
|
||||
getProviderModels,
|
||||
updateProviderModel,
|
||||
getUsage,
|
||||
getLimits,
|
||||
updateLimits,
|
||||
};
|
||||
|
||||
@@ -465,6 +465,99 @@ paths:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
/agent/{id}/usage:
|
||||
get:
|
||||
tags:
|
||||
- agent
|
||||
security:
|
||||
- bearerAuth: []
|
||||
operationId: getAgentUsage
|
||||
summary: Get aggregated token usage and activity stats for an agent
|
||||
description: >
|
||||
Aggregates the gateway's per-session `sessions.usage` data for this
|
||||
agent into a single response: total tokens (input/output/cache),
|
||||
estimated cost, message and tool counts, daily breakdown, per-model
|
||||
rollups, and a list of the agent's sessions sorted by recency. The
|
||||
response is served from a short in-memory cache to keep the UI snappy.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/id'
|
||||
responses:
|
||||
200:
|
||||
description: Aggregated usage for this agent
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/agentUsageResponse'
|
||||
404:
|
||||
description: Agent not found
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
/agent/{id}/limits:
|
||||
get:
|
||||
tags:
|
||||
- agent
|
||||
security:
|
||||
- bearerAuth: []
|
||||
operationId: getAgentLimits
|
||||
summary: Get per-agent USD spend caps + current spend per window
|
||||
description: >
|
||||
Returns the cost caps stored for this agent (daily / monthly / all-time)
|
||||
alongside the live spend bucketed into those same windows. The current
|
||||
spend is derived from the gateway's `sessions.usage` payload (cached for
|
||||
~30 seconds). All caps are in USD; `null` means no limit.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/id'
|
||||
responses:
|
||||
200:
|
||||
description: Limits + current spend per window
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/agentLimitsResponse'
|
||||
404:
|
||||
description: Agent not found
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
patch:
|
||||
tags:
|
||||
- agent
|
||||
security:
|
||||
- bearerAuth: []
|
||||
operationId: updateAgentLimits
|
||||
summary: Update per-agent USD spend caps
|
||||
description: >
|
||||
Sets or clears any subset of `costLimitDaily`, `costLimitMonthly`,
|
||||
`costLimitTotal`. Pass `null` to clear a cap, a non-negative number to
|
||||
set it. Returns the same shape as GET.
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/id'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/agentLimitsPatch'
|
||||
responses:
|
||||
200:
|
||||
description: Updated limits + live spend
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/agentLimitsResponse'
|
||||
400:
|
||||
description: Invalid limit value
|
||||
404:
|
||||
description: Agent not found
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
/agent/{id}/skills:
|
||||
get:
|
||||
tags:
|
||||
@@ -849,6 +942,232 @@ components:
|
||||
config:
|
||||
$ref: '#/components/schemas/agentProviderModelsResponse'
|
||||
#
|
||||
agentUsageTotals:
|
||||
type: object
|
||||
properties:
|
||||
input:
|
||||
type: integer
|
||||
output:
|
||||
type: integer
|
||||
cacheRead:
|
||||
type: integer
|
||||
cacheWrite:
|
||||
type: integer
|
||||
totalTokens:
|
||||
type: integer
|
||||
totalCost:
|
||||
type: number
|
||||
format: double
|
||||
description: Estimated cost in USD.
|
||||
#
|
||||
agentUsageDailyPoint:
|
||||
type: object
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
description: ISO date (YYYY-MM-DD).
|
||||
tokens:
|
||||
type: integer
|
||||
cost:
|
||||
type: number
|
||||
format: double
|
||||
#
|
||||
agentUsageModelRow:
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
model:
|
||||
type: string
|
||||
count:
|
||||
type: integer
|
||||
description: Number of turns served by this model.
|
||||
input:
|
||||
type: integer
|
||||
output:
|
||||
type: integer
|
||||
cacheRead:
|
||||
type: integer
|
||||
cacheWrite:
|
||||
type: integer
|
||||
totalTokens:
|
||||
type: integer
|
||||
totalCost:
|
||||
type: number
|
||||
format: double
|
||||
#
|
||||
agentUsageToolRow:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
count:
|
||||
type: integer
|
||||
#
|
||||
agentUsageSessionRow:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
nullable: true
|
||||
channel:
|
||||
type: string
|
||||
nullable: true
|
||||
updatedAt:
|
||||
type: integer
|
||||
nullable: true
|
||||
totalTokens:
|
||||
type: integer
|
||||
totalCost:
|
||||
type: number
|
||||
format: double
|
||||
modelProvider:
|
||||
type: string
|
||||
nullable: true
|
||||
model:
|
||||
type: string
|
||||
nullable: true
|
||||
#
|
||||
agentUsageResponse:
|
||||
type: object
|
||||
properties:
|
||||
agentId:
|
||||
type: string
|
||||
known:
|
||||
type: boolean
|
||||
description: True when the gateway responded with usage data.
|
||||
range:
|
||||
type: object
|
||||
properties:
|
||||
startDate:
|
||||
type: string
|
||||
nullable: true
|
||||
endDate:
|
||||
type: string
|
||||
nullable: true
|
||||
sessionCount:
|
||||
type: integer
|
||||
firstActivity:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Earliest activity timestamp across this agent's sessions (ms).
|
||||
lastActivity:
|
||||
type: integer
|
||||
nullable: true
|
||||
totals:
|
||||
$ref: '#/components/schemas/agentUsageTotals'
|
||||
messageCounts:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
user:
|
||||
type: integer
|
||||
assistant:
|
||||
type: integer
|
||||
toolCalls:
|
||||
type: integer
|
||||
errors:
|
||||
type: integer
|
||||
latency:
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
avgMs:
|
||||
type: number
|
||||
format: double
|
||||
p95Ms:
|
||||
type: number
|
||||
format: double
|
||||
daily:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/agentUsageDailyPoint'
|
||||
models:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/agentUsageModelRow'
|
||||
tools:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/agentUsageToolRow'
|
||||
sessions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/agentUsageSessionRow'
|
||||
#
|
||||
agentLimitWindow:
|
||||
type: object
|
||||
properties:
|
||||
limit:
|
||||
type: number
|
||||
nullable: true
|
||||
description: Cap in USD, or null when no cap is set.
|
||||
spent:
|
||||
type: number
|
||||
description: Spend (USD) accumulated in this window so far.
|
||||
ratio:
|
||||
type: number
|
||||
nullable: true
|
||||
description: spent / limit. Null when no cap is set.
|
||||
exceeded:
|
||||
type: boolean
|
||||
nearLimit:
|
||||
type: boolean
|
||||
description: True when ratio >= 0.8 and not yet exceeded.
|
||||
#
|
||||
agentLimitsResponse:
|
||||
type: object
|
||||
properties:
|
||||
agentId:
|
||||
type: string
|
||||
today:
|
||||
type: string
|
||||
description: ISO date used to bucket the daily window (host TZ).
|
||||
thisMonth:
|
||||
type: string
|
||||
description: ISO YYYY-MM bucket for the monthly window.
|
||||
windows:
|
||||
type: object
|
||||
properties:
|
||||
daily:
|
||||
$ref: '#/components/schemas/agentLimitWindow'
|
||||
monthly:
|
||||
$ref: '#/components/schemas/agentLimitWindow'
|
||||
total:
|
||||
$ref: '#/components/schemas/agentLimitWindow'
|
||||
stored:
|
||||
type: object
|
||||
description: Raw cap values stored on the agent record.
|
||||
properties:
|
||||
costLimitDaily:
|
||||
type: number
|
||||
nullable: true
|
||||
costLimitMonthly:
|
||||
type: number
|
||||
nullable: true
|
||||
costLimitTotal:
|
||||
type: number
|
||||
nullable: true
|
||||
#
|
||||
agentLimitsPatch:
|
||||
type: object
|
||||
description: >
|
||||
Any subset of these keys may be passed. Use `null` to clear a cap.
|
||||
properties:
|
||||
costLimitDaily:
|
||||
type: number
|
||||
nullable: true
|
||||
costLimitMonthly:
|
||||
type: number
|
||||
nullable: true
|
||||
costLimitTotal:
|
||||
type: number
|
||||
nullable: true
|
||||
#
|
||||
agentSkillSummary:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -41,6 +41,13 @@ router
|
||||
.get(auth, validate.id, controller.getProviderModels)
|
||||
.patch(auth, validate.providerModelPatch, controller.updateProviderModel);
|
||||
|
||||
router.route('/agent/:id(\\d+)/usage').get(auth, validate.id, controller.getUsage);
|
||||
|
||||
router
|
||||
.route('/agent/:id(\\d+)/limits')
|
||||
.get(auth, validate.id, controller.getLimits)
|
||||
.patch(auth, validate.limitsPatch, controller.updateLimits);
|
||||
|
||||
router
|
||||
.route('/agent/:id(\\d+)/conversation/:conversationId(\\d+)/session-settings')
|
||||
.get(auth, controller.getSessionSettings)
|
||||
|
||||
@@ -143,6 +143,26 @@ export default {
|
||||
}),
|
||||
]),
|
||||
|
||||
limitsPatch: validate([
|
||||
param('id').isInt().withMessage('Incorrect request url'),
|
||||
body().custom((value) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Body must be an object of limit fields.');
|
||||
}
|
||||
const allowedKeys = ['costLimitDaily', 'costLimitMonthly', 'costLimitTotal'];
|
||||
Object.entries(value).forEach(([key, entryVal]) => {
|
||||
if (!allowedKeys.includes(key)) {
|
||||
throw new Error(`Unknown limit field: ${key}`);
|
||||
}
|
||||
if (entryVal === null) return;
|
||||
if (typeof entryVal !== 'number' || !Number.isFinite(entryVal) || entryVal < 0) {
|
||||
throw new Error(`"${key}" must be a non-negative number or null.`);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
]),
|
||||
|
||||
providerModelPatch: validate([
|
||||
param('id').isInt().withMessage('Incorrect request url'),
|
||||
body().custom((value) => {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/* eslint-disable no-console */
|
||||
import {
|
||||
AgentLimitWindow,
|
||||
AgentLimitWindowState,
|
||||
AgentLimitsResponse,
|
||||
AgentLimitsPatch,
|
||||
} from '../../@types/openclaw';
|
||||
import AppDataSource from '../../data-source';
|
||||
import { Agent } from '../../entities';
|
||||
import { fetchUsagePayload, invalidateAgentUsageCache, type RawSessionUsage } from './agentUsage';
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function isoMonth(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
return `${y}-${m}`;
|
||||
}
|
||||
|
||||
function num(v: unknown): number {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
interface PerWindowSpend {
|
||||
daily: number;
|
||||
monthly: number;
|
||||
total: number;
|
||||
today: string;
|
||||
thisMonth: string;
|
||||
}
|
||||
|
||||
function spendForAgent(usages: RawSessionUsage[]): PerWindowSpend {
|
||||
const now = new Date();
|
||||
const today = isoDate(now);
|
||||
const thisMonth = isoMonth(now);
|
||||
|
||||
return usages.reduce<PerWindowSpend>(
|
||||
(acc, u) => {
|
||||
const breakdown = u?.dailyBreakdown ?? [];
|
||||
breakdown.forEach((row) => {
|
||||
if (!row?.date) return;
|
||||
const cost = num(row.cost);
|
||||
if (row.date === today) acc.daily += cost;
|
||||
if (row.date.startsWith(thisMonth)) acc.monthly += cost;
|
||||
});
|
||||
acc.total += num(u?.totalCost);
|
||||
return acc;
|
||||
},
|
||||
{ daily: 0, monthly: 0, total: 0, today, thisMonth }
|
||||
);
|
||||
}
|
||||
|
||||
function buildWindow(limit: number | null, spent: number): AgentLimitWindowState {
|
||||
if (limit == null) {
|
||||
return { limit: null, spent, ratio: null, exceeded: false, nearLimit: false };
|
||||
}
|
||||
const ratio = limit > 0 ? spent / limit : 0;
|
||||
return {
|
||||
limit,
|
||||
spent,
|
||||
ratio,
|
||||
exceeded: spent >= limit,
|
||||
nearLimit: ratio >= 0.8 && spent < limit,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAgentLimits(agent: Agent): Promise<AgentLimitsResponse> {
|
||||
const payload = await fetchUsagePayload({ force: true });
|
||||
const sessions = (payload?.sessions ?? []).filter((s) => s?.agentId === agent.openclawAgentId);
|
||||
const usages = sessions
|
||||
.map((s) => s.usage)
|
||||
.filter((u): u is RawSessionUsage => !!u && typeof u === 'object');
|
||||
|
||||
const spend = spendForAgent(usages);
|
||||
|
||||
return {
|
||||
agentId: agent.openclawAgentId,
|
||||
today: spend.today,
|
||||
thisMonth: spend.thisMonth,
|
||||
windows: {
|
||||
daily: buildWindow(agent.costLimitDaily, spend.daily),
|
||||
monthly: buildWindow(agent.costLimitMonthly, spend.monthly),
|
||||
total: buildWindow(agent.costLimitTotal, spend.total),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const COLUMN_BY_WINDOW: Record<
|
||||
AgentLimitWindow,
|
||||
keyof Pick<Agent, 'costLimitDaily' | 'costLimitMonthly' | 'costLimitTotal'>
|
||||
> = {
|
||||
daily: 'costLimitDaily',
|
||||
monthly: 'costLimitMonthly',
|
||||
total: 'costLimitTotal',
|
||||
};
|
||||
|
||||
const ALLOWED_KEYS = new Set<string>(Object.values(COLUMN_BY_WINDOW));
|
||||
|
||||
export interface SetAgentLimitsResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
config?: AgentLimitsResponse;
|
||||
}
|
||||
|
||||
export async function setAgentLimits(
|
||||
agent: Agent,
|
||||
patch: AgentLimitsPatch
|
||||
): Promise<SetAgentLimitsResult> {
|
||||
const repo = AppDataSource.getRepository(Agent);
|
||||
const update: Partial<Agent> = {};
|
||||
|
||||
const entries = Object.entries(patch ?? {}) as [string, number | null | undefined][];
|
||||
const validation = entries.reduce<{ ok: false; error: string } | null>((acc, [key, value]) => {
|
||||
if (acc) return acc;
|
||||
if (!ALLOWED_KEYS.has(key)) {
|
||||
return { ok: false, error: `Unknown limit field: ${key}` };
|
||||
}
|
||||
if (value === undefined) return null; // skipped — leave column unchanged
|
||||
if (value === null) {
|
||||
(update as Record<string, unknown>)[key] = null;
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
return { ok: false, error: `"${key}" must be a non-negative number or null.` };
|
||||
}
|
||||
(update as Record<string, unknown>)[key] = value;
|
||||
return null;
|
||||
}, null);
|
||||
|
||||
if (validation) return validation;
|
||||
|
||||
if (Object.keys(update).length > 0) {
|
||||
await repo.update({ _id: agent._id }, { ...update, updatedAt: new Date() });
|
||||
}
|
||||
|
||||
invalidateAgentUsageCache();
|
||||
const fresh = await repo.findOneByOrFail({ _id: agent._id });
|
||||
const config = await getAgentLimits(fresh);
|
||||
return { ok: true, config };
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/* eslint-disable no-console */
|
||||
import {
|
||||
AgentUsageDailyPoint,
|
||||
AgentUsageLatency,
|
||||
AgentUsageMessageCounts,
|
||||
AgentUsageModelRow,
|
||||
AgentUsageResponse,
|
||||
AgentUsageSessionRow,
|
||||
AgentUsageToolRow,
|
||||
AgentUsageTotals,
|
||||
} from '../../@types/openclaw';
|
||||
import { gateway } from '../openclawGateway';
|
||||
import { errMsg } from '../../utils/errors';
|
||||
|
||||
interface RawDailyBreakdown {
|
||||
date?: string;
|
||||
tokens?: number;
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
interface RawDailyMessageCount {
|
||||
date?: string;
|
||||
total?: number;
|
||||
user?: number;
|
||||
assistant?: number;
|
||||
toolCalls?: number;
|
||||
toolResults?: number;
|
||||
errors?: number;
|
||||
}
|
||||
|
||||
interface RawDailyLatency {
|
||||
date?: string;
|
||||
count?: number;
|
||||
avgMs?: number;
|
||||
p95Ms?: number;
|
||||
}
|
||||
|
||||
interface RawModelUsageTotals {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
totalTokens?: number;
|
||||
totalCost?: number;
|
||||
}
|
||||
|
||||
interface RawModelUsageRow {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
count?: number;
|
||||
totals?: RawModelUsageTotals;
|
||||
}
|
||||
|
||||
interface RawToolUsage {
|
||||
totalCalls?: number;
|
||||
uniqueTools?: number;
|
||||
tools?: { name?: string; count?: number }[];
|
||||
}
|
||||
|
||||
interface RawSessionUsage {
|
||||
firstActivity?: number;
|
||||
lastActivity?: number;
|
||||
dailyBreakdown?: RawDailyBreakdown[];
|
||||
dailyMessageCounts?: RawDailyMessageCount[];
|
||||
dailyLatency?: RawDailyLatency[];
|
||||
messageCounts?: RawDailyMessageCount;
|
||||
toolUsage?: RawToolUsage;
|
||||
modelUsage?: RawModelUsageRow[];
|
||||
latency?: RawDailyLatency;
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
totalTokens?: number;
|
||||
totalCost?: number;
|
||||
}
|
||||
|
||||
interface RawUsageSession {
|
||||
key?: string;
|
||||
label?: string | null;
|
||||
channel?: string | null;
|
||||
agentId?: string;
|
||||
modelProvider?: string | null;
|
||||
model?: string | null;
|
||||
updatedAt?: number | null;
|
||||
usage?: RawSessionUsage;
|
||||
}
|
||||
|
||||
export interface RawUsagePayload {
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
sessions?: RawUsageSession[];
|
||||
}
|
||||
|
||||
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,
|
||||
known,
|
||||
range: { startDate: null, endDate: null },
|
||||
sessionCount: 0,
|
||||
firstActivity: null,
|
||||
lastActivity: null,
|
||||
totals: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messageCounts: { total: 0, user: 0, assistant: 0, toolCalls: 0, errors: 0 },
|
||||
latency: { count: 0, avgMs: 0, p95Ms: 0 },
|
||||
daily: [],
|
||||
models: [],
|
||||
tools: [],
|
||||
sessions: [],
|
||||
};
|
||||
}
|
||||
|
||||
function num(v: unknown): number {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
function aggregateDaily(rows: RawDailyBreakdown[][]): AgentUsageDailyPoint[] {
|
||||
const map = new Map<string, AgentUsageDailyPoint>();
|
||||
rows.flat().forEach((r) => {
|
||||
if (!r?.date) return;
|
||||
const prev = map.get(r.date) ?? { date: r.date, tokens: 0, cost: 0 };
|
||||
prev.tokens += num(r.tokens);
|
||||
prev.cost += num(r.cost);
|
||||
map.set(r.date, prev);
|
||||
});
|
||||
return [...map.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
function aggregateModels(rows: RawModelUsageRow[][]): AgentUsageModelRow[] {
|
||||
const map = new Map<string, AgentUsageModelRow>();
|
||||
rows.flat().forEach((r) => {
|
||||
const provider = r?.provider || '';
|
||||
const model = r?.model || '';
|
||||
if (!provider && !model) return;
|
||||
const key = `${provider}/${model}`;
|
||||
const prev =
|
||||
map.get(key) ??
|
||||
({
|
||||
provider,
|
||||
model,
|
||||
count: 0,
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
} as AgentUsageModelRow);
|
||||
prev.count += num(r.count);
|
||||
prev.input += num(r.totals?.input);
|
||||
prev.output += num(r.totals?.output);
|
||||
prev.cacheRead += num(r.totals?.cacheRead);
|
||||
prev.cacheWrite += num(r.totals?.cacheWrite);
|
||||
prev.totalTokens += num(r.totals?.totalTokens);
|
||||
prev.totalCost += num(r.totals?.totalCost);
|
||||
map.set(key, prev);
|
||||
});
|
||||
return [...map.values()].sort((a, b) => b.totalTokens - a.totalTokens);
|
||||
}
|
||||
|
||||
function aggregateTools(rows: RawToolUsage[]): AgentUsageToolRow[] {
|
||||
const map = new Map<string, number>();
|
||||
rows.forEach((tu) => {
|
||||
(tu?.tools ?? []).forEach((t) => {
|
||||
if (!t?.name) return;
|
||||
map.set(t.name, (map.get(t.name) ?? 0) + num(t.count));
|
||||
});
|
||||
});
|
||||
return [...map.entries()]
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
function aggregateMessageCounts(rows: RawDailyMessageCount[]): AgentUsageMessageCounts {
|
||||
return rows.reduce<AgentUsageMessageCounts>(
|
||||
(acc, r) => ({
|
||||
total: acc.total + num(r?.total),
|
||||
user: acc.user + num(r?.user),
|
||||
assistant: acc.assistant + num(r?.assistant),
|
||||
toolCalls: acc.toolCalls + num(r?.toolCalls),
|
||||
errors: acc.errors + num(r?.errors),
|
||||
}),
|
||||
{ total: 0, user: 0, assistant: 0, toolCalls: 0, errors: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
interface LatencyAcc {
|
||||
count: number;
|
||||
avgWeighted: number;
|
||||
p95Max: number;
|
||||
}
|
||||
|
||||
function aggregateLatency(rows: RawDailyLatency[]): AgentUsageLatency {
|
||||
const total = rows.reduce<LatencyAcc>(
|
||||
(acc, r) => {
|
||||
const c = num(r?.count);
|
||||
acc.count += c;
|
||||
acc.avgWeighted += c * num(r?.avgMs);
|
||||
acc.p95Max = Math.max(acc.p95Max, num(r?.p95Ms));
|
||||
return acc;
|
||||
},
|
||||
{ count: 0, avgWeighted: 0, p95Max: 0 }
|
||||
);
|
||||
return {
|
||||
count: total.count,
|
||||
avgMs: total.count > 0 ? total.avgWeighted / total.count : 0,
|
||||
p95Ms: total.p95Max,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAgentUsage(openclawAgentId: string): Promise<AgentUsageResponse> {
|
||||
const payload = await fetchUsagePayload();
|
||||
if (!payload || !Array.isArray(payload.sessions)) {
|
||||
return emptyResponse(openclawAgentId, false);
|
||||
}
|
||||
|
||||
const sessions = payload.sessions.filter((s) => s?.agentId === openclawAgentId);
|
||||
if (sessions.length === 0) {
|
||||
const empty = emptyResponse(openclawAgentId, true);
|
||||
empty.range = {
|
||||
startDate: payload.startDate ?? null,
|
||||
endDate: payload.endDate ?? null,
|
||||
};
|
||||
return empty;
|
||||
}
|
||||
|
||||
const totals: AgentUsageTotals = sessions.reduce<AgentUsageTotals>(
|
||||
(acc, s) => {
|
||||
const u = s.usage ?? {};
|
||||
acc.input += num(u.input);
|
||||
acc.output += num(u.output);
|
||||
acc.cacheRead += num(u.cacheRead);
|
||||
acc.cacheWrite += num(u.cacheWrite);
|
||||
acc.totalTokens += num(u.totalTokens);
|
||||
acc.totalCost += num(u.totalCost);
|
||||
return acc;
|
||||
},
|
||||
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, totalCost: 0 }
|
||||
);
|
||||
|
||||
const firstActivity = sessions
|
||||
.map((s) => num(s.usage?.firstActivity))
|
||||
.filter((n) => n > 0)
|
||||
.reduce((acc, n) => (acc === 0 ? n : Math.min(acc, n)), 0);
|
||||
const lastActivity = sessions
|
||||
.map((s) => num(s.usage?.lastActivity))
|
||||
.reduce((acc, n) => Math.max(acc, n), 0);
|
||||
|
||||
const daily = aggregateDaily(sessions.map((s) => s.usage?.dailyBreakdown ?? []));
|
||||
const models = aggregateModels(sessions.map((s) => s.usage?.modelUsage ?? []));
|
||||
const tools = aggregateTools(sessions.map((s) => s.usage?.toolUsage ?? {}));
|
||||
const messageCounts = aggregateMessageCounts(
|
||||
sessions.map((s) => s.usage?.messageCounts ?? {}).filter((x): x is RawDailyMessageCount => !!x)
|
||||
);
|
||||
const latency = aggregateLatency(
|
||||
sessions.map((s) => s.usage?.latency ?? {}).filter((x): x is RawDailyLatency => !!x)
|
||||
);
|
||||
|
||||
const sessionRows: AgentUsageSessionRow[] = sessions
|
||||
.map((s) => ({
|
||||
key: s.key ?? '',
|
||||
label: s.label ?? null,
|
||||
channel: s.channel ?? null,
|
||||
updatedAt: typeof s.updatedAt === 'number' ? s.updatedAt : null,
|
||||
totalTokens: num(s.usage?.totalTokens),
|
||||
totalCost: num(s.usage?.totalCost),
|
||||
modelProvider: s.modelProvider ?? null,
|
||||
model: s.model ?? null,
|
||||
}))
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
|
||||
return {
|
||||
agentId: openclawAgentId,
|
||||
known: true,
|
||||
range: {
|
||||
startDate: payload.startDate ?? null,
|
||||
endDate: payload.endDate ?? null,
|
||||
},
|
||||
sessionCount: sessions.length,
|
||||
firstActivity: firstActivity || null,
|
||||
lastActivity: lastActivity || null,
|
||||
totals,
|
||||
messageCounts,
|
||||
latency,
|
||||
daily,
|
||||
models,
|
||||
tools,
|
||||
sessions: sessionRows,
|
||||
};
|
||||
}
|
||||
|
||||
export function invalidateAgentUsageCache(): void {
|
||||
cachedPayload = null;
|
||||
}
|
||||
@@ -34,6 +34,8 @@ 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 { getAgentLimits, setAgentLimits } from './agentLimits';
|
||||
export { listPlugins, togglePlugin } from './plugins';
|
||||
export { listSkills } from './skills';
|
||||
export { listChannels, addChannel, removeChannel } from './channels';
|
||||
|
||||
@@ -151,6 +151,107 @@ export interface AgentProviderModelMutationResponse {
|
||||
config?: AgentProviderModelsResponse;
|
||||
}
|
||||
|
||||
export interface AgentUsageTotals {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
}
|
||||
|
||||
export interface AgentUsageMessageCounts {
|
||||
total: number;
|
||||
user: number;
|
||||
assistant: number;
|
||||
toolCalls: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface AgentUsageLatency {
|
||||
count: number;
|
||||
avgMs: number;
|
||||
p95Ms: number;
|
||||
}
|
||||
|
||||
export interface AgentUsageDailyPoint {
|
||||
date: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface AgentUsageModelRow {
|
||||
provider: string;
|
||||
model: string;
|
||||
count: number;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
}
|
||||
|
||||
export interface AgentUsageToolRow {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AgentUsageSessionRow {
|
||||
key: string;
|
||||
label: string | null;
|
||||
channel: string | null;
|
||||
updatedAt: number | null;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
modelProvider: string | null;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface AgentUsageResponse {
|
||||
agentId: string;
|
||||
known: boolean;
|
||||
range: { startDate: string | null; endDate: string | null };
|
||||
sessionCount: number;
|
||||
firstActivity: number | null;
|
||||
lastActivity: number | null;
|
||||
totals: AgentUsageTotals;
|
||||
messageCounts: AgentUsageMessageCounts;
|
||||
latency: AgentUsageLatency;
|
||||
daily: AgentUsageDailyPoint[];
|
||||
models: AgentUsageModelRow[];
|
||||
tools: AgentUsageToolRow[];
|
||||
sessions: AgentUsageSessionRow[];
|
||||
}
|
||||
|
||||
export type AgentLimitWindow = 'daily' | 'monthly' | 'total';
|
||||
|
||||
export interface AgentLimitWindowState {
|
||||
limit: number | null;
|
||||
spent: number;
|
||||
ratio: number | null;
|
||||
exceeded: boolean;
|
||||
nearLimit: boolean;
|
||||
}
|
||||
|
||||
export interface AgentLimitsResponse {
|
||||
agentId: string;
|
||||
today: string;
|
||||
thisMonth: string;
|
||||
windows: Record<AgentLimitWindow, AgentLimitWindowState>;
|
||||
stored: {
|
||||
costLimitDaily: number | null;
|
||||
costLimitMonthly: number | null;
|
||||
costLimitTotal: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentLimitsPatch {
|
||||
costLimitDaily?: number | null;
|
||||
costLimitMonthly?: number | null;
|
||||
costLimitTotal?: number | null;
|
||||
}
|
||||
|
||||
export const WORKSPACE_TAB_FILES = [
|
||||
{ label: 'AGENTS', file: 'AGENTS.md' },
|
||||
{ label: 'SOUL', file: 'SOUL.md' },
|
||||
@@ -302,6 +403,33 @@ export const agentsApi = baseApi.injectEndpoints({
|
||||
query: (agentId) => `/agent/${agentId}/provider-models`,
|
||||
providesTags: (_res, _err, agentId) => [{ type: 'AgentProviderModels', id: agentId }],
|
||||
}),
|
||||
getAgentUsage: build.query<AgentUsageResponse, string>({
|
||||
query: (agentId) => `/agent/${agentId}/usage`,
|
||||
providesTags: (_res, _err, agentId) => [{ type: 'AgentUsage', id: agentId }],
|
||||
}),
|
||||
getAgentLimits: build.query<AgentLimitsResponse, string>({
|
||||
query: (agentId) => `/agent/${agentId}/limits`,
|
||||
providesTags: (_res, _err, agentId) => [{ type: 'AgentLimits', id: agentId }],
|
||||
// The chat-header ring should reflect the agent's true spend on every
|
||||
// navigation — caching a stale value would make the indicator
|
||||
// misleading after a turn completes. Drop the cached payload as soon
|
||||
// as no component subscribes so the next mount always re-fetches.
|
||||
keepUnusedDataFor: 0,
|
||||
}),
|
||||
updateAgentLimits: build.mutation<
|
||||
AgentLimitsResponse,
|
||||
{ agentId: string; patch: AgentLimitsPatch }
|
||||
>({
|
||||
query: ({ agentId, patch }) => ({
|
||||
url: `/agent/${agentId}/limits`,
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
}),
|
||||
invalidatesTags: (_res, _err, { agentId }) => [
|
||||
{ type: 'AgentLimits', id: agentId },
|
||||
{ type: 'AgentUsage', id: agentId },
|
||||
],
|
||||
}),
|
||||
updateAgentProviderModel: build.mutation<
|
||||
AgentProviderModelMutationResponse,
|
||||
{ agentId: string; model: string; conversationId?: number | string }
|
||||
@@ -339,4 +467,7 @@ export const {
|
||||
useUpdateAgentSubagentsMutation,
|
||||
useGetAgentProviderModelsQuery,
|
||||
useUpdateAgentProviderModelMutation,
|
||||
useGetAgentUsageQuery,
|
||||
useGetAgentLimitsQuery,
|
||||
useUpdateAgentLimitsMutation,
|
||||
} = agentsApi;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as AgentLimitsEditor } from './ui/AgentLimitsEditor';
|
||||
export { default as AgentUsageBar } from './ui/AgentUsageBar';
|
||||
export { default as AgentUsageRing } from './ui/AgentUsageRing';
|
||||
@@ -0,0 +1,382 @@
|
||||
import { useMemo, useState, type ChangeEvent } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
InputAdornment,
|
||||
LinearProgress,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ReportProblemOutlined } from '@mui/icons-material';
|
||||
import {
|
||||
useGetAgentLimitsQuery,
|
||||
useUpdateAgentLimitsMutation,
|
||||
type AgentLimitWindow,
|
||||
type AgentLimitWindowState,
|
||||
} from '../../../../entities/agent';
|
||||
|
||||
interface AgentLimitsEditorProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface WindowMeta {
|
||||
id: AgentLimitWindow;
|
||||
field: 'costLimitDaily' | 'costLimitMonthly' | 'costLimitTotal';
|
||||
label: string;
|
||||
caption: (today: string, thisMonth: string) => string;
|
||||
}
|
||||
|
||||
const WINDOWS: WindowMeta[] = [
|
||||
{
|
||||
id: 'daily',
|
||||
field: 'costLimitDaily',
|
||||
label: 'Daily cap',
|
||||
caption: (today) => `Resets each day · today is ${today}`,
|
||||
},
|
||||
{
|
||||
id: 'monthly',
|
||||
field: 'costLimitMonthly',
|
||||
label: 'Monthly cap',
|
||||
caption: (_, thisMonth) => `Resets on the 1st · current bucket ${thisMonth}`,
|
||||
},
|
||||
{
|
||||
id: 'total',
|
||||
field: 'costLimitTotal',
|
||||
label: 'All-time cap',
|
||||
caption: () => 'Lifetime spend; never resets',
|
||||
},
|
||||
];
|
||||
|
||||
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)}`;
|
||||
}
|
||||
|
||||
/** Map a 0..1+ ratio to a colour bucket. Matches `AgentUsageBar`. */
|
||||
function ratioColour(state: AgentLimitWindowState): 'secondary' | 'warning' | 'error' | 'primary' {
|
||||
if (state.exceeded) return 'error';
|
||||
if (state.nearLimit) return 'warning';
|
||||
if (state.ratio == null) return 'primary';
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
type DraftState = Record<WindowMeta['field'], string>;
|
||||
|
||||
function toDraft(
|
||||
stored:
|
||||
| Partial<{
|
||||
costLimitDaily: number | null;
|
||||
costLimitMonthly: number | null;
|
||||
costLimitTotal: number | null;
|
||||
}>
|
||||
| null
|
||||
| undefined
|
||||
): DraftState {
|
||||
const s = stored ?? {};
|
||||
const fmt = (v: number | null | undefined) => (v == null ? '' : String(v));
|
||||
return {
|
||||
costLimitDaily: fmt(s.costLimitDaily),
|
||||
costLimitMonthly: fmt(s.costLimitMonthly),
|
||||
costLimitTotal: fmt(s.costLimitTotal),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDraft(d: string): { ok: true; value: number | null } | { ok: false; error: string } {
|
||||
const trimmed = d.trim();
|
||||
if (!trimmed) return { ok: true, value: null };
|
||||
const n = Number(trimmed);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
return { ok: false, error: 'Must be a non-negative number' };
|
||||
}
|
||||
return { ok: true, value: n };
|
||||
}
|
||||
|
||||
export default function AgentLimitsEditor({ agentId }: AgentLimitsEditorProps) {
|
||||
const { data, isLoading, isFetching, isError, refetch } = useGetAgentLimitsQuery(agentId, {
|
||||
skip: !agentId,
|
||||
});
|
||||
const [updateLimits, { isLoading: isSaving }] = useUpdateAgentLimitsMutation();
|
||||
|
||||
const [edits, setEdits] = useState<Partial<DraftState>>({});
|
||||
const [feedback, setFeedback] = useState<{ kind: 'ok' | 'err'; msg: string } | null>(null);
|
||||
|
||||
const [prevData, setPrevData] = useState(data);
|
||||
if (data !== prevData) {
|
||||
setPrevData(data);
|
||||
setEdits({});
|
||||
}
|
||||
|
||||
const stored = useMemo(() => toDraft(data?.stored), [data]);
|
||||
const draft: DraftState = useMemo(() => ({ ...stored, ...edits }), [stored, edits]);
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (!data) return false;
|
||||
return WINDOWS.some((w) => {
|
||||
const storedValue = data.stored?.[w.field] ?? null;
|
||||
const parsed = parseDraft(draft[w.field]);
|
||||
if (!parsed.ok) return true;
|
||||
return parsed.value !== storedValue;
|
||||
});
|
||||
}, [data, draft]);
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress size={20} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
action={
|
||||
<Button size="small" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Could not load agent limits.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const handleChange = (field: WindowMeta['field']) => (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setEdits((prev) => ({ ...prev, [field]: value }));
|
||||
setFeedback(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const patch: Partial<Record<WindowMeta['field'], number | null>> = {};
|
||||
const errors: string[] = [];
|
||||
WINDOWS.forEach((w) => {
|
||||
const parsed = parseDraft(draft[w.field]);
|
||||
if (!parsed.ok) {
|
||||
errors.push(`${w.label}: ${parsed.error}`);
|
||||
return;
|
||||
}
|
||||
const storedValue = data.stored?.[w.field] ?? null;
|
||||
if (parsed.value !== storedValue) {
|
||||
patch[w.field] = parsed.value;
|
||||
}
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
setFeedback({ kind: 'err', msg: errors.join(' · ') });
|
||||
return;
|
||||
}
|
||||
if (Object.keys(patch).length === 0) {
|
||||
setFeedback({ kind: 'ok', msg: 'Nothing to save.' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateLimits({ agentId, patch }).unwrap();
|
||||
setFeedback({ kind: 'ok', msg: 'Saved.' });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Save failed';
|
||||
setFeedback({ kind: 'err', msg });
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setEdits({});
|
||||
setFeedback(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
p: 1.5,
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
spacing={1}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
Spend limits
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Cap this agent's USD spend per window. Leave empty for no limit.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
{isFetching && !isSaving && <CircularProgress size={14} />}
|
||||
{dirty && (
|
||||
<Button size="small" onClick={handleReset} disabled={isSaving}>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disableElevation
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || isSaving}
|
||||
startIcon={isSaving ? <CircularProgress size={12} color="inherit" /> : null}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 0.75,
|
||||
mb: 1.25,
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'error.main',
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'rgba(244, 67, 54, 0.08)' : 'rgba(244, 67, 54, 0.06)',
|
||||
}}
|
||||
>
|
||||
<ReportProblemOutlined sx={{ fontSize: 18, color: 'error.main', mt: '1px' }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'error.main',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.4,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
These limits do{' '}
|
||||
<Box component="span" sx={{ textDecoration: 'underline' }}>
|
||||
not
|
||||
</Box>{' '}
|
||||
stop the agent or throttle its performance — they're an advisory budget so you can watch
|
||||
your preferred spend per window. The agent will keep running even after a cap is reached.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5}>
|
||||
{WINDOWS.map((w) => {
|
||||
const state = data.windows[w.id];
|
||||
const colour = ratioColour(state);
|
||||
const ratioPct = state.ratio == null ? 0 : Math.min(100, state.ratio * 100);
|
||||
return (
|
||||
<Box
|
||||
key={w.id}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
p: 1.25,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.75,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.7rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{w.label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ display: 'block', color: 'text.disabled', fontSize: '0.65rem' }}
|
||||
>
|
||||
{w.caption(data.today, data.thisMonth)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
placeholder="No limit"
|
||||
value={draft[w.field]}
|
||||
onChange={handleChange(w.field)}
|
||||
disabled={isSaving}
|
||||
inputProps={{ min: 0, step: 0.01 }}
|
||||
InputProps={{
|
||||
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={
|
||||
state.limit == null
|
||||
? `Spent ${fmtUsd(state.spent)} so far · no cap`
|
||||
: `${fmtUsd(state.spent)} of ${fmtUsd(state.limit)} (${(
|
||||
(state.ratio ?? 0) * 100
|
||||
).toFixed(1)}%)`
|
||||
}
|
||||
>
|
||||
<Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={ratioPct}
|
||||
color={colour}
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
bgcolor: 'action.hover',
|
||||
...(state.limit == null && { opacity: 0.4 }),
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
display: 'block',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
fontSize: '0.7rem',
|
||||
color: state.exceeded
|
||||
? 'error.main'
|
||||
: state.nearLimit
|
||||
? 'warning.main'
|
||||
: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{fmtUsd(state.spent)} spent
|
||||
{state.limit != null && ` / ${fmtUsd(state.limit)}`}
|
||||
{state.ratio != null && ` · ${(state.ratio * 100).toFixed(0)}%`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{feedback && (
|
||||
<Alert
|
||||
severity={feedback.kind === 'ok' ? 'success' : 'error'}
|
||||
variant="outlined"
|
||||
sx={{ mt: 1.25 }}
|
||||
onClose={() => setFeedback(null)}
|
||||
>
|
||||
{feedback.msg}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Box, LinearProgress, Stack, Tooltip, Typography } from '@mui/material';
|
||||
import {
|
||||
useGetAgentLimitsQuery,
|
||||
type AgentLimitWindow,
|
||||
type AgentLimitWindowState,
|
||||
} from '../../../../entities/agent';
|
||||
|
||||
interface AgentUsageBarProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
export default function AgentUsageBar({ agentId }: AgentUsageBarProps) {
|
||||
const { data } = useGetAgentLimitsQuery(agentId, { skip: !agentId });
|
||||
|
||||
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]);
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
title={
|
||||
<Box sx={{ fontSize: '0.7rem', minWidth: 180 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, display: 'block', mb: 0.5 }}>
|
||||
Spend vs. cap
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{rows.map((r) => (
|
||||
<Box key={r.id} sx={{ display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||
<span>{r.label}</span>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{fmtUsd(r.state.spent)}
|
||||
{r.state.limit != null
|
||||
? ` / ${fmtUsd(r.state.limit)} (${((r.state.ratio ?? 0) * 100).toFixed(0)}%)`
|
||||
: ' / no cap'}
|
||||
</span>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box sx={{ width: '100%', cursor: 'help', userSelect: 'none' }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={pct}
|
||||
color={colour}
|
||||
sx={{
|
||||
height: 2,
|
||||
borderRadius: 0,
|
||||
bgcolor: 'action.hover',
|
||||
}}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
spacing={0.75}
|
||||
sx={{ px: 1, pt: 0.25 }}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: '0.65rem',
|
||||
color: 'text.disabled',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{hot.label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: '0.65rem',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color: hot.state.exceeded
|
||||
? 'error.main'
|
||||
: hot.state.nearLimit
|
||||
? 'warning.main'
|
||||
: 'text.secondary',
|
||||
fontWeight: hot.state.exceeded || hot.state.nearLimit ? 700 : 500,
|
||||
}}
|
||||
>
|
||||
{fmtUsd(hot.state.spent)}
|
||||
{hot.state.limit != null
|
||||
? ` / ${fmtUsd(hot.state.limit)} · ${((hot.state.ratio ?? 0) * 100).toFixed(0)}%`
|
||||
: ''}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Box, CircularProgress, Stack, Tooltip, Typography } from '@mui/material';
|
||||
import {
|
||||
useGetAgentLimitsQuery,
|
||||
type AgentLimitWindow,
|
||||
type AgentLimitWindowState,
|
||||
} from '../../../../entities/agent';
|
||||
|
||||
interface AgentUsageRingProps {
|
||||
agentId: string;
|
||||
conversationId?: string | number | 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';
|
||||
}
|
||||
|
||||
/** 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];
|
||||
return configured.reduce((acc, r) => ((r.state.ratio ?? 0) > (acc.state.ratio ?? 0) ? r : acc));
|
||||
}
|
||||
|
||||
export default function AgentUsageRing({
|
||||
agentId,
|
||||
conversationId,
|
||||
size = 32,
|
||||
}: AgentUsageRingProps) {
|
||||
const { data, refetch, isFetching } = useGetAgentLimitsQuery(agentId, {
|
||||
skip: !agentId,
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) return;
|
||||
refetch();
|
||||
}, [agentId, conversationId, refetch]);
|
||||
|
||||
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]);
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
flexShrink: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={size - 8} thickness={2} sx={{ color: 'text.disabled' }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
? '100+'
|
||||
: `${Math.round(hot.state.ratio == null ? 0 : 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>
|
||||
<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"
|
||||
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' : '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>
|
||||
</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',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={100}
|
||||
size={size}
|
||||
thickness={4}
|
||||
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>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AgentUsage } from './ui/AgentUsage';
|
||||
@@ -0,0 +1,45 @@
|
||||
export function formatTokens(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return '0';
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`;
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatCost(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return '$0.00';
|
||||
if (n < 0.01) return '<$0.01';
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '—';
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||
const sec = ms / 1000;
|
||||
if (sec < 60) return `${sec.toFixed(1)} s`;
|
||||
const min = sec / 60;
|
||||
if (min < 60) return `${min.toFixed(1)} min`;
|
||||
return `${(min / 60).toFixed(1)} h`;
|
||||
}
|
||||
|
||||
/** Renders 'YYYY-MM-DD' as 'Apr 28' style for compact axis/tooltip labels. */
|
||||
export function formatDate(date: string): string {
|
||||
const parsed = new Date(`${date}T00:00:00Z`);
|
||||
if (Number.isNaN(parsed.getTime())) return date;
|
||||
return parsed.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function formatRelative(ms: number | null): string {
|
||||
if (!ms) return '—';
|
||||
const diff = Date.now() - ms;
|
||||
if (diff < 0) return 'in the future';
|
||||
const sec = diff / 1000;
|
||||
if (sec < 60) return 'just now';
|
||||
const min = sec / 60;
|
||||
if (min < 60) return `${Math.round(min)} min ago`;
|
||||
const hr = min / 60;
|
||||
if (hr < 24) return `${Math.round(hr)} h ago`;
|
||||
const day = hr / 24;
|
||||
if (day < 30) return `${Math.round(day)} d ago`;
|
||||
return new Date(ms).toLocaleDateString();
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Alert, Box, Button, CircularProgress, Stack, Typography } from '@mui/material';
|
||||
import { useGetAgentUsageQuery } from '../../../../entities/agent';
|
||||
import { AgentLimitsEditor } from '../../limits';
|
||||
import {
|
||||
formatCost,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
formatRelative,
|
||||
formatTokens,
|
||||
} from '../lib/format';
|
||||
import StatCard from './StatCard';
|
||||
import DailyChart from './DailyChart';
|
||||
import ModelTable from './ModelTable';
|
||||
import ToolList from './ToolList';
|
||||
import SessionList from './SessionList';
|
||||
|
||||
interface AgentUsageProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export default function AgentUsage({ agentId }: AgentUsageProps) {
|
||||
const { data, isLoading, isFetching, isError, refetch } = useGetAgentUsageQuery(agentId, {
|
||||
skip: !agentId,
|
||||
});
|
||||
|
||||
const totalsCard = useMemo(() => {
|
||||
if (!data) return null;
|
||||
return {
|
||||
tokens: formatTokens(data.totals.totalTokens),
|
||||
cost: formatCost(data.totals.totalCost),
|
||||
sessions: data.sessionCount.toLocaleString(),
|
||||
messages: data.messageCounts.total.toLocaleString(),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress size={20} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
action={
|
||||
<Button size="small" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Could not load agent usage.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const rangeLabel =
|
||||
data.range.startDate && data.range.endDate
|
||||
? `${formatDate(data.range.startDate)} – ${formatDate(data.range.endDate)}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', minWidth: 0 }}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
alignItems={{ xs: 'flex-start', sm: 'center' }}
|
||||
justifyContent="space-between"
|
||||
spacing={1}
|
||||
sx={{ mb: 1.5 }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
Agent usage
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Token, cost, and activity totals across this agent's sessions
|
||||
{rangeLabel ? ` (${rangeLabel})` : ''}.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
startIcon={isFetching ? <CircularProgress size={12} /> : null}
|
||||
>
|
||||
{isFetching ? 'Refreshing' : 'Refresh'}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<AgentLimitsEditor agentId={agentId} />
|
||||
</Box>
|
||||
|
||||
{data.sessionCount === 0 ? (
|
||||
<Alert severity="info" variant="outlined">
|
||||
No sessions recorded for this agent yet. Send a message to start collecting usage stats.
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', rowGap: 1 }}>
|
||||
<StatCard
|
||||
label="Total tokens"
|
||||
value={totalsCard?.tokens ?? '0'}
|
||||
hint={`${formatTokens(data.totals.input)} in · ${formatTokens(
|
||||
data.totals.output
|
||||
)} out${data.totals.cacheRead > 0 ? ` · ${formatTokens(data.totals.cacheRead)} cache` : ''}`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Estimated cost"
|
||||
value={totalsCard?.cost ?? '$0.00'}
|
||||
hint={data.totals.totalCost > 0 ? 'Includes cache reads/writes' : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Sessions"
|
||||
value={totalsCard?.sessions ?? '0'}
|
||||
hint={data.lastActivity ? `Last: ${formatRelative(data.lastActivity)}` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Messages"
|
||||
value={totalsCard?.messages ?? '0'}
|
||||
hint={`${data.messageCounts.user.toLocaleString()} user · ${data.messageCounts.assistant.toLocaleString()} assistant`}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
p: 1.5,
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
spacing={1}
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
Tokens per day
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
|
||||
{data.daily.length} day{data.daily.length === 1 ? '' : 's'} of activity
|
||||
</Typography>
|
||||
</Stack>
|
||||
<DailyChart points={data.daily} />
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={1.5}
|
||||
alignItems="stretch"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 1 }}>
|
||||
Models
|
||||
</Typography>
|
||||
<ModelTable rows={data.models} totalTokens={data.totals.totalTokens} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 1 }}>
|
||||
Tools
|
||||
</Typography>
|
||||
<ToolList rows={data.tools} />
|
||||
{data.latency.count > 0 && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 0.5 }}>
|
||||
Latency
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
avg {formatDuration(data.latency.avgMs)} · p95{' '}
|
||||
{formatDuration(data.latency.p95Ms)} · {data.latency.count.toLocaleString()}{' '}
|
||||
turn{data.latency.count === 1 ? '' : 's'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 1 }}>
|
||||
Recent sessions
|
||||
</Typography>
|
||||
<SessionList rows={data.sessions.slice(0, 12)} />
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Box, Tooltip, Typography } from '@mui/material';
|
||||
import { type AgentUsageDailyPoint } from '../../../../entities/agent';
|
||||
import { formatCost, formatDate, formatTokens } from '../lib/format';
|
||||
|
||||
interface DailyChartProps {
|
||||
points: AgentUsageDailyPoint[];
|
||||
}
|
||||
|
||||
export default function DailyChart({ points }: DailyChartProps) {
|
||||
const max = points.reduce((acc, p) => Math.max(acc, p.tokens), 0);
|
||||
if (points.length === 0 || max === 0) {
|
||||
return (
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
No daily activity yet.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
gap: 0.5,
|
||||
height: 120,
|
||||
overflowX: 'auto',
|
||||
py: 0.5,
|
||||
px: 0.25,
|
||||
}}
|
||||
>
|
||||
{points.map((p) => {
|
||||
const ratio = max > 0 ? p.tokens / max : 0;
|
||||
const heightPct = Math.max(p.tokens > 0 ? 4 : 0, ratio * 100);
|
||||
return (
|
||||
<Tooltip
|
||||
key={p.date}
|
||||
placement="top"
|
||||
title={
|
||||
<Box sx={{ fontSize: '0.7rem' }}>
|
||||
<Box>{formatDate(p.date)}</Box>
|
||||
<Box>{formatTokens(p.tokens)} tokens</Box>
|
||||
<Box>{formatCost(p.cost)}</Box>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
minWidth: 22,
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
width: 14,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: `${heightPct}%`,
|
||||
bgcolor: 'primary.main',
|
||||
opacity: 0.7,
|
||||
borderRadius: 0.5,
|
||||
transition: 'height 0.2s',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: '0.6rem',
|
||||
color: 'text.disabled',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{formatDate(p.date)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
import { type AgentUsageModelRow } from '../../../../entities/agent';
|
||||
import { formatCost, formatTokens } from '../lib/format';
|
||||
|
||||
interface ModelTableProps {
|
||||
rows: AgentUsageModelRow[];
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export default function ModelTable({ rows, totalTokens }: ModelTableProps) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
No model usage recorded.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack spacing={0.75}>
|
||||
{rows.map((row) => {
|
||||
const share = totalTokens > 0 ? (row.totalTokens / totalTokens) * 100 : 0;
|
||||
return (
|
||||
<Box
|
||||
key={`${row.provider}/${row.model}`}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
p: 1,
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 0.75,
|
||||
flexWrap: 'wrap',
|
||||
rowGap: 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={600}
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{row.model || row.provider || '—'}
|
||||
</Typography>
|
||||
{row.provider && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.disabled',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
{row.provider}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
{row.count.toLocaleString()} turn{row.count === 1 ? '' : 's'} · in{' '}
|
||||
{formatTokens(row.input)} · out {formatTokens(row.output)}
|
||||
{row.cacheRead > 0 ? ` · cache ${formatTokens(row.cacheRead)}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={700}
|
||||
sx={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{formatTokens(row.totalTokens)}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.secondary', fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{formatCost(row.totalCost)}
|
||||
{share > 0 ? ` · ${share.toFixed(0)}%` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
import { type AgentUsageResponse } from '../../../../entities/agent';
|
||||
import { formatCost, formatRelative, formatTokens } from '../lib/format';
|
||||
|
||||
interface SessionListProps {
|
||||
rows: AgentUsageResponse['sessions'];
|
||||
}
|
||||
|
||||
export default function SessionList({ rows }: SessionListProps) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
No sessions yet.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack spacing={0.5}>
|
||||
{rows.map((s) => (
|
||||
<Box
|
||||
key={s.key}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
p: 1,
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={600}
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{s.label || 'Untitled session'}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.disabled', fontSize: '0.7rem' }}
|
||||
>
|
||||
{[s.channel, s.modelProvider && s.model ? `${s.modelProvider}/${s.model}` : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || '—'}
|
||||
{' · '}
|
||||
{formatRelative(s.updatedAt)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={600}
|
||||
sx={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{formatTokens(s.totalTokens)}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.secondary', fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{formatCost(s.totalCost)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export default function StatCard({ label, value, hint }: StatCardProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 140,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.paper',
|
||||
p: 1.5,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.7rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: 700, lineHeight: 1.2, fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
{hint && (
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.7rem' }}>
|
||||
{hint}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Chip, Stack, Typography } from '@mui/material';
|
||||
|
||||
interface ToolListProps {
|
||||
rows: { name: string; count: number }[];
|
||||
}
|
||||
|
||||
export default function ToolList({ rows }: ToolListProps) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
No tool calls in this range.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack direction="row" spacing={0.75} sx={{ flexWrap: 'wrap', rowGap: 0.75 }}>
|
||||
{rows.map((t) => (
|
||||
<Chip
|
||||
key={t.name}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={`${t.name} · ${t.count.toLocaleString()}`}
|
||||
sx={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -103,6 +103,8 @@ export const baseApi = createApi({
|
||||
'AgentSkills',
|
||||
'AgentSubagents',
|
||||
'AgentProviderModels',
|
||||
'AgentUsage',
|
||||
'AgentLimits',
|
||||
],
|
||||
endpoints: () => ({}),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Edit, Check, Settings, TuneOutlined } from '@mui/icons-material';
|
||||
import { Link } from 'react-router';
|
||||
import { useGetAgentQuery, useUpdateAgentMutation } from '../../../entities/agent';
|
||||
import { AgentModelPicker } from '../../../features/agent/provider-model';
|
||||
import { AgentUsageRing } from '../../../features/agent/limits';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
agentId: string;
|
||||
@@ -46,25 +47,30 @@ export default function ChatHeader({
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
px: {
|
||||
xs: 1.5,
|
||||
md: 2,
|
||||
},
|
||||
py: 1.5,
|
||||
pl: {
|
||||
xs: 7,
|
||||
md: 2,
|
||||
},
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
minWidth: 0,
|
||||
flexShrink: 0,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{editing ? (
|
||||
<Box
|
||||
sx={{
|
||||
px: {
|
||||
xs: 1.5,
|
||||
md: 2,
|
||||
},
|
||||
py: 1.5,
|
||||
pl: {
|
||||
xs: 7,
|
||||
md: 2,
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{editing ? (
|
||||
<>
|
||||
<TextField
|
||||
variant="standard"
|
||||
@@ -99,6 +105,7 @@ export default function ChatHeader({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AgentUsageRing agentId={agentId} conversationId={conversationId} />
|
||||
<Stack sx={{ flex: 1, minWidth: 0 }} spacing={0.25}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
@@ -148,6 +155,7 @@ export default function ChatHeader({
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useState, type ReactElement } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Box, IconButton, Typography, CircularProgress, Tab, Tabs } from '@mui/material';
|
||||
import { ArrowBack, Extension, FolderOpen, Group, Tune } from '@mui/icons-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' | 'budgets' | 'skills' | 'subagents';
|
||||
type SectionId = 'files' | 'usage' | 'budgets' | 'skills' | 'subagents';
|
||||
|
||||
const SECTIONS: { id: SectionId; label: string; icon: ReactElement; caption: string }[] = [
|
||||
{
|
||||
@@ -21,6 +22,12 @@ const SECTIONS: { id: SectionId; label: string; icon: ReactElement; caption: str
|
||||
icon: <FolderOpen sx={{ fontSize: 18 }} />,
|
||||
caption: 'Workspace files',
|
||||
},
|
||||
{
|
||||
id: 'usage',
|
||||
label: 'Usage',
|
||||
icon: <Insights sx={{ fontSize: 18 }} />,
|
||||
caption: 'Token usage and cost across this agent',
|
||||
},
|
||||
{
|
||||
id: 'budgets',
|
||||
label: 'Budgets',
|
||||
@@ -145,6 +152,7 @@ export default function Workspace({ agentId }: WorkspaceProps) {
|
||||
}}
|
||||
>
|
||||
{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)} />}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw-client",
|
||||
"version": "2.4.7",
|
||||
"version": "2.4.8",
|
||||
"description": "Web-based chat interface for OpenClaw AI agents",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
Reference in New Issue
Block a user