Web UI: add token usage dashboard (#10072)
* feat(ui): Token Usage dashboard with session analytics Adds a comprehensive Token Usage view to the dashboard: Backend: - Extended session-cost-usage.ts with per-session daily breakdown - Added date range filtering (startMs/endMs) to API endpoints - New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints - Cost breakdown by token type (input/output/cache read/write) Frontend: - Two-column layout: Daily chart + breakdown | Sessions list - Interactive daily bar chart with click-to-filter and shift-click range select - Session detail panel with usage timeline, conversation logs, context weight - Filter chips for active day/session selections - Toggle between tokens/cost view modes (default: cost) - Responsive design for smaller screens UX improvements: - 21-day default date range - Debounced date input (400ms) - Session list shows filtered totals when days selected - Context weight breakdown shows skills, tools, files contribution * fix(ui): restore gatewayUrl validation and syncUrlWithSessionKey signature - Restore normalizeGatewayUrl() to validate ws:/wss: protocol - Restore isTopLevelWindow() guard for iframe security - Revert syncUrlWithSessionKey signature (host param was unused) * feat(ui): Token Usage dashboard with session analytics Adds a comprehensive Token Usage view to the dashboard: Backend: - Extended session-cost-usage.ts with per-session daily breakdown - Added date range filtering (startMs/endMs) to API endpoints - New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints - Cost breakdown by token type (input/output/cache read/write) Frontend: - Two-column layout: Daily chart + breakdown | Sessions list - Interactive daily bar chart with click-to-filter and shift-click range select - Session detail panel with usage timeline, conversation logs, context weight - Filter chips for active day/session selections - Toggle between tokens/cost view modes (default: cost) - Responsive design for smaller screens UX improvements: - 21-day default date range - Debounced date input (400ms) - Session list shows filtered totals when days selected - Context weight breakdown shows skills, tools, files contribution * fix: usage dashboard data + cost handling (#8462) (thanks @mcinteerj) * Usage: enrich metrics dashboard * Usage: add latency + model trends * Gateway: improve usage log parsing * UI: add usage query helpers * UI: client-side usage filter + debounce * Build: harden write-cli-compat timing * UI: add conversation log filters * UI: fix usage dashboard lint + state * Web UI: default usage dates to local day * Protocol: sync session usage params (#8462) (thanks @mcinteerj, @TakHoffman) --------- Co-authored-by: Jake McInteer <mcinteerj@gmail.com>
This commit is contained in:
@@ -105,7 +105,11 @@ export function renderChatControls(state: AppViewState) {
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
syncUrlWithSessionKey(next, true);
|
||||
syncUrlWithSessionKey(
|
||||
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
|
||||
next,
|
||||
true,
|
||||
);
|
||||
void loadChatHistory(state as unknown as ChatState);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { AppViewState } from "./app-view-state.ts";
|
||||
import type { UsageState } from "./controllers/usage.ts";
|
||||
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
|
||||
import { ChatHost, refreshChatAvatar } from "./app-chat.ts";
|
||||
import { refreshChatAvatar } from "./app-chat.ts";
|
||||
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers.ts";
|
||||
import { OpenClawApp } from "./app.ts";
|
||||
import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts";
|
||||
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
|
||||
import { loadAgentSkills } from "./controllers/agent-skills.ts";
|
||||
import { loadAgents } from "./controllers/agents.ts";
|
||||
import { loadChannels } from "./controllers/channels.ts";
|
||||
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
|
||||
import { loadChatHistory } from "./controllers/chat.ts";
|
||||
import {
|
||||
applyConfig,
|
||||
ConfigState,
|
||||
loadConfig,
|
||||
runUpdate,
|
||||
saveConfig,
|
||||
@@ -40,7 +39,7 @@ import {
|
||||
saveExecApprovals,
|
||||
updateExecApprovalsFormValue,
|
||||
} from "./controllers/exec-approvals.ts";
|
||||
import { loadLogs, LogsState } from "./controllers/logs.ts";
|
||||
import { loadLogs } from "./controllers/logs.ts";
|
||||
import { loadNodes } from "./controllers/nodes.ts";
|
||||
import { loadPresence } from "./controllers/presence.ts";
|
||||
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions.ts";
|
||||
@@ -51,9 +50,18 @@ import {
|
||||
updateSkillEdit,
|
||||
updateSkillEnabled,
|
||||
} from "./controllers/skills.ts";
|
||||
import { loadUsage, loadSessionTimeSeries, loadSessionLogs } from "./controllers/usage.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
|
||||
import { ConfigUiHints } from "./types.ts";
|
||||
|
||||
// Module-scope debounce for usage date changes (avoids type-unsafe hacks on state object)
|
||||
let usageDateDebounceTimeout: number | null = null;
|
||||
const debouncedLoadUsage = (state: UsageState) => {
|
||||
if (usageDateDebounceTimeout) {
|
||||
clearTimeout(usageDateDebounceTimeout);
|
||||
}
|
||||
usageDateDebounceTimeout = window.setTimeout(() => void loadUsage(state), 400);
|
||||
};
|
||||
import { renderAgents } from "./views/agents.ts";
|
||||
import { renderChannels } from "./views/channels.ts";
|
||||
import { renderChat } from "./views/chat.ts";
|
||||
@@ -68,6 +76,7 @@ import { renderNodes } from "./views/nodes.ts";
|
||||
import { renderOverview } from "./views/overview.ts";
|
||||
import { renderSessions } from "./views/sessions.ts";
|
||||
import { renderSkills } from "./views/skills.ts";
|
||||
import { renderUsage } from "./views/usage.ts";
|
||||
|
||||
const AVATAR_DATA_RE = /^data:/i;
|
||||
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
||||
@@ -98,36 +107,14 @@ export function renderApp(state: AppViewState) {
|
||||
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
|
||||
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
|
||||
const logoBase = normalizeBasePath(state.basePath);
|
||||
const logoHref = logoBase ? `${logoBase}/favicon.svg` : "/favicon.svg";
|
||||
const configValue =
|
||||
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
|
||||
const basePath = normalizeBasePath(state.basePath ?? "");
|
||||
const resolvedAgentId =
|
||||
state.agentsSelectedId ??
|
||||
state.agentsList?.defaultId ??
|
||||
state.agentsList?.agents?.[0]?.id ??
|
||||
null;
|
||||
const ensureAgentListEntry = (agentId: string) => {
|
||||
const snapshot = (state.configForm ??
|
||||
(state.configSnapshot?.config as Record<string, unknown> | null)) as {
|
||||
agents?: { list?: unknown[] };
|
||||
} | null;
|
||||
const listRaw = snapshot?.agents?.list;
|
||||
const list = Array.isArray(listRaw) ? listRaw : [];
|
||||
let index = list.findIndex(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
"id" in entry &&
|
||||
(entry as { id?: string }).id === agentId,
|
||||
);
|
||||
if (index < 0) {
|
||||
const nextList = [...list, { id: agentId }];
|
||||
updateConfigFormValue(state as unknown as ConfigState, ["agents", "list"], nextList);
|
||||
index = nextList.length - 1;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
|
||||
@@ -147,7 +134,7 @@ export function renderApp(state: AppViewState) {
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-logo">
|
||||
<img src="${logoHref}" alt="OpenClaw" />
|
||||
<img src=${basePath ? `${basePath}/favicon.svg` : "/favicon.svg"} alt="OpenClaw" />
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<div class="brand-title">OPENCLAW</div>
|
||||
@@ -212,8 +199,8 @@ export function renderApp(state: AppViewState) {
|
||||
<main class="content ${isChat ? "content--chat" : ""}">
|
||||
<section class="content-header">
|
||||
<div>
|
||||
<div class="page-title">${titleForTab(state.tab)}</div>
|
||||
<div class="page-sub">${subtitleForTab(state.tab)}</div>
|
||||
${state.tab === "usage" ? nothing : html`<div class="page-title">${titleForTab(state.tab)}</div>`}
|
||||
${state.tab === "usage" ? nothing : html`<div class="page-sub">${subtitleForTab(state.tab)}</div>`}
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing}
|
||||
@@ -239,7 +226,7 @@ export function renderApp(state: AppViewState) {
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
(state as unknown as OpenClawApp).resetToolStream();
|
||||
state.resetToolStream();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
@@ -268,7 +255,7 @@ export function renderApp(state: AppViewState) {
|
||||
configSchema: state.configSchema,
|
||||
configSchemaLoading: state.configSchemaLoading,
|
||||
configForm: state.configForm,
|
||||
configUiHints: state.configUiHints as ConfigUiHints,
|
||||
configUiHints: state.configUiHints,
|
||||
configSaving: state.configSaving,
|
||||
configFormDirty: state.configFormDirty,
|
||||
nostrProfileFormState: state.nostrProfileFormState,
|
||||
@@ -277,8 +264,7 @@ export function renderApp(state: AppViewState) {
|
||||
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
|
||||
onWhatsAppWait: () => state.handleWhatsAppWait(),
|
||||
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
|
||||
onConfigPatch: (path, value) =>
|
||||
updateConfigFormValue(state as unknown as ConfigState, path, value),
|
||||
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onConfigSave: () => state.handleChannelConfigSave(),
|
||||
onConfigReload: () => state.handleChannelConfigReload(),
|
||||
onNostrProfileEdit: (accountId, profile) =>
|
||||
@@ -329,6 +315,269 @@ export function renderApp(state: AppViewState) {
|
||||
: nothing
|
||||
}
|
||||
|
||||
${
|
||||
state.tab === "usage"
|
||||
? renderUsage({
|
||||
loading: state.usageLoading,
|
||||
error: state.usageError,
|
||||
startDate: state.usageStartDate,
|
||||
endDate: state.usageEndDate,
|
||||
sessions: state.usageResult?.sessions ?? [],
|
||||
sessionsLimitReached: (state.usageResult?.sessions?.length ?? 0) >= 1000,
|
||||
totals: state.usageResult?.totals ?? null,
|
||||
aggregates: state.usageResult?.aggregates ?? null,
|
||||
costDaily: state.usageCostSummary?.daily ?? [],
|
||||
selectedSessions: state.usageSelectedSessions,
|
||||
selectedDays: state.usageSelectedDays,
|
||||
selectedHours: state.usageSelectedHours,
|
||||
chartMode: state.usageChartMode,
|
||||
dailyChartMode: state.usageDailyChartMode,
|
||||
timeSeriesMode: state.usageTimeSeriesMode,
|
||||
timeSeriesBreakdownMode: state.usageTimeSeriesBreakdownMode,
|
||||
timeSeries: state.usageTimeSeries,
|
||||
timeSeriesLoading: state.usageTimeSeriesLoading,
|
||||
sessionLogs: state.usageSessionLogs,
|
||||
sessionLogsLoading: state.usageSessionLogsLoading,
|
||||
sessionLogsExpanded: state.usageSessionLogsExpanded,
|
||||
logFilterRoles: state.usageLogFilterRoles,
|
||||
logFilterTools: state.usageLogFilterTools,
|
||||
logFilterHasTools: state.usageLogFilterHasTools,
|
||||
logFilterQuery: state.usageLogFilterQuery,
|
||||
query: state.usageQuery,
|
||||
queryDraft: state.usageQueryDraft,
|
||||
sessionSort: state.usageSessionSort,
|
||||
sessionSortDir: state.usageSessionSortDir,
|
||||
recentSessions: state.usageRecentSessions,
|
||||
sessionsTab: state.usageSessionsTab,
|
||||
visibleColumns:
|
||||
state.usageVisibleColumns as import("./views/usage.ts").UsageColumnId[],
|
||||
timeZone: state.usageTimeZone,
|
||||
contextExpanded: state.usageContextExpanded,
|
||||
headerPinned: state.usageHeaderPinned,
|
||||
onStartDateChange: (date) => {
|
||||
state.usageStartDate = date;
|
||||
state.usageSelectedDays = [];
|
||||
state.usageSelectedHours = [];
|
||||
state.usageSelectedSessions = [];
|
||||
debouncedLoadUsage(state);
|
||||
},
|
||||
onEndDateChange: (date) => {
|
||||
state.usageEndDate = date;
|
||||
state.usageSelectedDays = [];
|
||||
state.usageSelectedHours = [];
|
||||
state.usageSelectedSessions = [];
|
||||
debouncedLoadUsage(state);
|
||||
},
|
||||
onRefresh: () => loadUsage(state),
|
||||
onTimeZoneChange: (zone) => {
|
||||
state.usageTimeZone = zone;
|
||||
},
|
||||
onToggleContextExpanded: () => {
|
||||
state.usageContextExpanded = !state.usageContextExpanded;
|
||||
},
|
||||
onToggleSessionLogsExpanded: () => {
|
||||
state.usageSessionLogsExpanded = !state.usageSessionLogsExpanded;
|
||||
},
|
||||
onLogFilterRolesChange: (next) => {
|
||||
state.usageLogFilterRoles = next;
|
||||
},
|
||||
onLogFilterToolsChange: (next) => {
|
||||
state.usageLogFilterTools = next;
|
||||
},
|
||||
onLogFilterHasToolsChange: (next) => {
|
||||
state.usageLogFilterHasTools = next;
|
||||
},
|
||||
onLogFilterQueryChange: (next) => {
|
||||
state.usageLogFilterQuery = next;
|
||||
},
|
||||
onLogFilterClear: () => {
|
||||
state.usageLogFilterRoles = [];
|
||||
state.usageLogFilterTools = [];
|
||||
state.usageLogFilterHasTools = false;
|
||||
state.usageLogFilterQuery = "";
|
||||
},
|
||||
onToggleHeaderPinned: () => {
|
||||
state.usageHeaderPinned = !state.usageHeaderPinned;
|
||||
},
|
||||
onSelectHour: (hour, shiftKey) => {
|
||||
if (shiftKey && state.usageSelectedHours.length > 0) {
|
||||
const allHours = Array.from({ length: 24 }, (_, i) => i);
|
||||
const lastSelected =
|
||||
state.usageSelectedHours[state.usageSelectedHours.length - 1];
|
||||
const lastIdx = allHours.indexOf(lastSelected);
|
||||
const thisIdx = allHours.indexOf(hour);
|
||||
if (lastIdx !== -1 && thisIdx !== -1) {
|
||||
const [start, end] =
|
||||
lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx];
|
||||
const range = allHours.slice(start, end + 1);
|
||||
state.usageSelectedHours = [
|
||||
...new Set([...state.usageSelectedHours, ...range]),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
if (state.usageSelectedHours.includes(hour)) {
|
||||
state.usageSelectedHours = state.usageSelectedHours.filter((h) => h !== hour);
|
||||
} else {
|
||||
state.usageSelectedHours = [...state.usageSelectedHours, hour];
|
||||
}
|
||||
}
|
||||
},
|
||||
onQueryDraftChange: (query) => {
|
||||
state.usageQueryDraft = query;
|
||||
if (state.usageQueryDebounceTimer) {
|
||||
window.clearTimeout(state.usageQueryDebounceTimer);
|
||||
}
|
||||
state.usageQueryDebounceTimer = window.setTimeout(() => {
|
||||
state.usageQuery = state.usageQueryDraft;
|
||||
state.usageQueryDebounceTimer = null;
|
||||
}, 250);
|
||||
},
|
||||
onApplyQuery: () => {
|
||||
if (state.usageQueryDebounceTimer) {
|
||||
window.clearTimeout(state.usageQueryDebounceTimer);
|
||||
state.usageQueryDebounceTimer = null;
|
||||
}
|
||||
state.usageQuery = state.usageQueryDraft;
|
||||
},
|
||||
onClearQuery: () => {
|
||||
if (state.usageQueryDebounceTimer) {
|
||||
window.clearTimeout(state.usageQueryDebounceTimer);
|
||||
state.usageQueryDebounceTimer = null;
|
||||
}
|
||||
state.usageQueryDraft = "";
|
||||
state.usageQuery = "";
|
||||
},
|
||||
onSessionSortChange: (sort) => {
|
||||
state.usageSessionSort = sort;
|
||||
},
|
||||
onSessionSortDirChange: (dir) => {
|
||||
state.usageSessionSortDir = dir;
|
||||
},
|
||||
onSessionsTabChange: (tab) => {
|
||||
state.usageSessionsTab = tab;
|
||||
},
|
||||
onToggleColumn: (column) => {
|
||||
if (state.usageVisibleColumns.includes(column)) {
|
||||
state.usageVisibleColumns = state.usageVisibleColumns.filter(
|
||||
(entry) => entry !== column,
|
||||
);
|
||||
} else {
|
||||
state.usageVisibleColumns = [...state.usageVisibleColumns, column];
|
||||
}
|
||||
},
|
||||
onSelectSession: (key, shiftKey) => {
|
||||
state.usageTimeSeries = null;
|
||||
state.usageSessionLogs = null;
|
||||
state.usageRecentSessions = [
|
||||
key,
|
||||
...state.usageRecentSessions.filter((entry) => entry !== key),
|
||||
].slice(0, 8);
|
||||
|
||||
if (shiftKey && state.usageSelectedSessions.length > 0) {
|
||||
// Shift-click: select range from last selected to this session
|
||||
// Sort sessions same way as displayed (by tokens or cost descending)
|
||||
const isTokenMode = state.usageChartMode === "tokens";
|
||||
const sortedSessions = [...(state.usageResult?.sessions ?? [])].toSorted(
|
||||
(a, b) => {
|
||||
const valA = isTokenMode
|
||||
? (a.usage?.totalTokens ?? 0)
|
||||
: (a.usage?.totalCost ?? 0);
|
||||
const valB = isTokenMode
|
||||
? (b.usage?.totalTokens ?? 0)
|
||||
: (b.usage?.totalCost ?? 0);
|
||||
return valB - valA;
|
||||
},
|
||||
);
|
||||
const allKeys = sortedSessions.map((s) => s.key);
|
||||
const lastSelected =
|
||||
state.usageSelectedSessions[state.usageSelectedSessions.length - 1];
|
||||
const lastIdx = allKeys.indexOf(lastSelected);
|
||||
const thisIdx = allKeys.indexOf(key);
|
||||
if (lastIdx !== -1 && thisIdx !== -1) {
|
||||
const [start, end] =
|
||||
lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx];
|
||||
const range = allKeys.slice(start, end + 1);
|
||||
const newSelection = [...new Set([...state.usageSelectedSessions, ...range])];
|
||||
state.usageSelectedSessions = newSelection;
|
||||
}
|
||||
} else {
|
||||
// Regular click: focus a single session (so details always open).
|
||||
// Click the focused session again to clear selection.
|
||||
if (
|
||||
state.usageSelectedSessions.length === 1 &&
|
||||
state.usageSelectedSessions[0] === key
|
||||
) {
|
||||
state.usageSelectedSessions = [];
|
||||
} else {
|
||||
state.usageSelectedSessions = [key];
|
||||
}
|
||||
}
|
||||
|
||||
// Load timeseries/logs only if exactly one session selected
|
||||
if (state.usageSelectedSessions.length === 1) {
|
||||
void loadSessionTimeSeries(state, state.usageSelectedSessions[0]);
|
||||
void loadSessionLogs(state, state.usageSelectedSessions[0]);
|
||||
}
|
||||
},
|
||||
onSelectDay: (day, shiftKey) => {
|
||||
if (shiftKey && state.usageSelectedDays.length > 0) {
|
||||
// Shift-click: select range from last selected to this day
|
||||
const allDays = (state.usageCostSummary?.daily ?? []).map((d) => d.date);
|
||||
const lastSelected =
|
||||
state.usageSelectedDays[state.usageSelectedDays.length - 1];
|
||||
const lastIdx = allDays.indexOf(lastSelected);
|
||||
const thisIdx = allDays.indexOf(day);
|
||||
if (lastIdx !== -1 && thisIdx !== -1) {
|
||||
const [start, end] =
|
||||
lastIdx < thisIdx ? [lastIdx, thisIdx] : [thisIdx, lastIdx];
|
||||
const range = allDays.slice(start, end + 1);
|
||||
// Merge with existing selection
|
||||
const newSelection = [...new Set([...state.usageSelectedDays, ...range])];
|
||||
state.usageSelectedDays = newSelection;
|
||||
}
|
||||
} else {
|
||||
// Regular click: toggle single day
|
||||
if (state.usageSelectedDays.includes(day)) {
|
||||
state.usageSelectedDays = state.usageSelectedDays.filter((d) => d !== day);
|
||||
} else {
|
||||
state.usageSelectedDays = [day];
|
||||
}
|
||||
}
|
||||
},
|
||||
onChartModeChange: (mode) => {
|
||||
state.usageChartMode = mode;
|
||||
},
|
||||
onDailyChartModeChange: (mode) => {
|
||||
state.usageDailyChartMode = mode;
|
||||
},
|
||||
onTimeSeriesModeChange: (mode) => {
|
||||
state.usageTimeSeriesMode = mode;
|
||||
},
|
||||
onTimeSeriesBreakdownChange: (mode) => {
|
||||
state.usageTimeSeriesBreakdownMode = mode;
|
||||
},
|
||||
onClearDays: () => {
|
||||
state.usageSelectedDays = [];
|
||||
},
|
||||
onClearHours: () => {
|
||||
state.usageSelectedHours = [];
|
||||
},
|
||||
onClearSessions: () => {
|
||||
state.usageSelectedSessions = [];
|
||||
state.usageTimeSeries = null;
|
||||
state.usageSessionLogs = null;
|
||||
},
|
||||
onClearFilters: () => {
|
||||
state.usageSelectedDays = [];
|
||||
state.usageSelectedHours = [];
|
||||
state.usageSelectedSessions = [];
|
||||
state.usageTimeSeries = null;
|
||||
state.usageSessionLogs = null;
|
||||
},
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
|
||||
${
|
||||
state.tab === "cron"
|
||||
? renderCron({
|
||||
@@ -444,17 +693,7 @@ export function renderApp(state: AppViewState) {
|
||||
void state.loadCron();
|
||||
}
|
||||
},
|
||||
onLoadFiles: (agentId) => {
|
||||
void (async () => {
|
||||
await loadAgentFiles(state, agentId);
|
||||
if (state.agentFileActive) {
|
||||
await loadAgentFileContent(state, agentId, state.agentFileActive, {
|
||||
force: true,
|
||||
preserveDraft: true,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
onLoadFiles: (agentId) => loadAgentFiles(state, agentId),
|
||||
onSelectFile: (name) => {
|
||||
state.agentFileActive = name;
|
||||
if (!resolvedAgentId) {
|
||||
@@ -497,19 +736,12 @@ export function renderApp(state: AppViewState) {
|
||||
}
|
||||
const basePath = ["agents", "list", index, "tools"];
|
||||
if (profile) {
|
||||
updateConfigFormValue(
|
||||
state as unknown as ConfigState,
|
||||
[...basePath, "profile"],
|
||||
profile,
|
||||
);
|
||||
updateConfigFormValue(state, [...basePath, "profile"], profile);
|
||||
} else {
|
||||
removeConfigFormValue(state as unknown as ConfigState, [
|
||||
...basePath,
|
||||
"profile",
|
||||
]);
|
||||
removeConfigFormValue(state, [...basePath, "profile"]);
|
||||
}
|
||||
if (clearAllow) {
|
||||
removeConfigFormValue(state as unknown as ConfigState, [...basePath, "allow"]);
|
||||
removeConfigFormValue(state, [...basePath, "allow"]);
|
||||
}
|
||||
},
|
||||
onToolsOverridesChange: (agentId, alsoAllow, deny) => {
|
||||
@@ -532,29 +764,18 @@ export function renderApp(state: AppViewState) {
|
||||
}
|
||||
const basePath = ["agents", "list", index, "tools"];
|
||||
if (alsoAllow.length > 0) {
|
||||
updateConfigFormValue(
|
||||
state as unknown as ConfigState,
|
||||
[...basePath, "alsoAllow"],
|
||||
alsoAllow,
|
||||
);
|
||||
updateConfigFormValue(state, [...basePath, "alsoAllow"], alsoAllow);
|
||||
} else {
|
||||
removeConfigFormValue(state as unknown as ConfigState, [
|
||||
...basePath,
|
||||
"alsoAllow",
|
||||
]);
|
||||
removeConfigFormValue(state, [...basePath, "alsoAllow"]);
|
||||
}
|
||||
if (deny.length > 0) {
|
||||
updateConfigFormValue(
|
||||
state as unknown as ConfigState,
|
||||
[...basePath, "deny"],
|
||||
deny,
|
||||
);
|
||||
updateConfigFormValue(state, [...basePath, "deny"], deny);
|
||||
} else {
|
||||
removeConfigFormValue(state as unknown as ConfigState, [...basePath, "deny"]);
|
||||
removeConfigFormValue(state, [...basePath, "deny"]);
|
||||
}
|
||||
},
|
||||
onConfigReload: () => loadConfig(state as unknown as ConfigState),
|
||||
onConfigSave: () => saveConfig(state as unknown as ConfigState),
|
||||
onConfigReload: () => loadConfig(state),
|
||||
onConfigSave: () => saveConfig(state),
|
||||
onChannelsRefresh: () => loadChannels(state, false),
|
||||
onCronRefresh: () => state.loadCron(),
|
||||
onSkillsFilterChange: (next) => (state.skillsFilter = next),
|
||||
@@ -599,11 +820,7 @@ export function renderApp(state: AppViewState) {
|
||||
} else {
|
||||
next.delete(normalizedSkill);
|
||||
}
|
||||
updateConfigFormValue(
|
||||
state as unknown as ConfigState,
|
||||
["agents", "list", index, "skills"],
|
||||
[...next],
|
||||
);
|
||||
updateConfigFormValue(state, ["agents", "list", index, "skills"], [...next]);
|
||||
},
|
||||
onAgentSkillsClear: (agentId) => {
|
||||
if (!configValue) {
|
||||
@@ -623,12 +840,7 @@ export function renderApp(state: AppViewState) {
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
removeConfigFormValue(state as unknown as ConfigState, [
|
||||
"agents",
|
||||
"list",
|
||||
index,
|
||||
"skills",
|
||||
]);
|
||||
removeConfigFormValue(state, ["agents", "list", index, "skills"]);
|
||||
},
|
||||
onAgentSkillsDisableAll: (agentId) => {
|
||||
if (!configValue) {
|
||||
@@ -648,58 +860,32 @@ export function renderApp(state: AppViewState) {
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
updateConfigFormValue(
|
||||
state as unknown as ConfigState,
|
||||
["agents", "list", index, "skills"],
|
||||
[],
|
||||
);
|
||||
updateConfigFormValue(state, ["agents", "list", index, "skills"], []);
|
||||
},
|
||||
onModelChange: (agentId, modelId) => {
|
||||
if (!configValue) {
|
||||
return;
|
||||
}
|
||||
const defaultId = state.agentsList?.defaultId ?? null;
|
||||
if (defaultId && agentId === defaultId) {
|
||||
const basePath = ["agents", "defaults", "model"];
|
||||
const defaults =
|
||||
(configValue as { agents?: { defaults?: { model?: unknown } } }).agents
|
||||
?.defaults ?? {};
|
||||
const existing = defaults.model;
|
||||
if (!modelId) {
|
||||
removeConfigFormValue(state as unknown as ConfigState, basePath);
|
||||
return;
|
||||
}
|
||||
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
||||
const fallbacks = (existing as { fallbacks?: unknown }).fallbacks;
|
||||
const next = {
|
||||
primary: modelId,
|
||||
...(Array.isArray(fallbacks) ? { fallbacks } : {}),
|
||||
};
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
|
||||
} else {
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, {
|
||||
primary: modelId,
|
||||
});
|
||||
}
|
||||
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
|
||||
if (!Array.isArray(list)) {
|
||||
return;
|
||||
}
|
||||
const index = list.findIndex(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
"id" in entry &&
|
||||
(entry as { id?: string }).id === agentId,
|
||||
);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = ensureAgentListEntry(agentId);
|
||||
const basePath = ["agents", "list", index, "model"];
|
||||
if (!modelId) {
|
||||
removeConfigFormValue(state as unknown as ConfigState, basePath);
|
||||
removeConfigFormValue(state, basePath);
|
||||
return;
|
||||
}
|
||||
const list = (
|
||||
(state.configForm ??
|
||||
(state.configSnapshot?.config as Record<string, unknown> | null)) as {
|
||||
agents?: { list?: unknown[] };
|
||||
}
|
||||
)?.agents?.list;
|
||||
const entry =
|
||||
Array.isArray(list) && list[index]
|
||||
? (list[index] as { model?: unknown })
|
||||
: null;
|
||||
const entry = list[index] as { model?: unknown };
|
||||
const existing = entry?.model;
|
||||
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
||||
const fallbacks = (existing as { fallbacks?: unknown }).fallbacks;
|
||||
@@ -707,70 +893,33 @@ export function renderApp(state: AppViewState) {
|
||||
primary: modelId,
|
||||
...(Array.isArray(fallbacks) ? { fallbacks } : {}),
|
||||
};
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
|
||||
updateConfigFormValue(state, basePath, next);
|
||||
} else {
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, modelId);
|
||||
updateConfigFormValue(state, basePath, modelId);
|
||||
}
|
||||
},
|
||||
onModelFallbacksChange: (agentId, fallbacks) => {
|
||||
if (!configValue) {
|
||||
return;
|
||||
}
|
||||
const normalized = fallbacks.map((name) => name.trim()).filter(Boolean);
|
||||
const defaultId = state.agentsList?.defaultId ?? null;
|
||||
if (defaultId && agentId === defaultId) {
|
||||
const basePath = ["agents", "defaults", "model"];
|
||||
const defaults =
|
||||
(configValue as { agents?: { defaults?: { model?: unknown } } }).agents
|
||||
?.defaults ?? {};
|
||||
const existing = defaults.model;
|
||||
const resolvePrimary = () => {
|
||||
if (typeof existing === "string") {
|
||||
return existing.trim() || null;
|
||||
}
|
||||
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
||||
const primary = (existing as { primary?: unknown }).primary;
|
||||
if (typeof primary === "string") {
|
||||
const trimmed = primary.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const primary = resolvePrimary();
|
||||
if (normalized.length === 0) {
|
||||
if (primary) {
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, {
|
||||
primary,
|
||||
});
|
||||
} else {
|
||||
removeConfigFormValue(state as unknown as ConfigState, basePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const next = primary
|
||||
? { primary, fallbacks: normalized }
|
||||
: { fallbacks: normalized };
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
|
||||
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
|
||||
if (!Array.isArray(list)) {
|
||||
return;
|
||||
}
|
||||
const index = list.findIndex(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
"id" in entry &&
|
||||
(entry as { id?: string }).id === agentId,
|
||||
);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = ensureAgentListEntry(agentId);
|
||||
const basePath = ["agents", "list", index, "model"];
|
||||
const list = (
|
||||
(state.configForm ??
|
||||
(state.configSnapshot?.config as Record<string, unknown> | null)) as {
|
||||
agents?: { list?: unknown[] };
|
||||
}
|
||||
)?.agents?.list;
|
||||
const entry =
|
||||
Array.isArray(list) && list[index]
|
||||
? (list[index] as { model?: unknown })
|
||||
: null;
|
||||
const existing = entry?.model;
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
const entry = list[index] as { model?: unknown };
|
||||
const normalized = fallbacks.map((name) => name.trim()).filter(Boolean);
|
||||
const existing = entry.model;
|
||||
const resolvePrimary = () => {
|
||||
if (typeof existing === "string") {
|
||||
return existing.trim() || null;
|
||||
@@ -787,16 +936,16 @@ export function renderApp(state: AppViewState) {
|
||||
const primary = resolvePrimary();
|
||||
if (normalized.length === 0) {
|
||||
if (primary) {
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, primary);
|
||||
updateConfigFormValue(state, basePath, primary);
|
||||
} else {
|
||||
removeConfigFormValue(state as unknown as ConfigState, basePath);
|
||||
removeConfigFormValue(state, basePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const next = primary
|
||||
? { primary, fallbacks: normalized }
|
||||
: { fallbacks: normalized };
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
|
||||
updateConfigFormValue(state, basePath, next);
|
||||
},
|
||||
})
|
||||
: nothing
|
||||
@@ -853,7 +1002,7 @@ export function renderApp(state: AppViewState) {
|
||||
onDeviceRotate: (deviceId, role, scopes) =>
|
||||
rotateDeviceToken(state, { deviceId, role, scopes }),
|
||||
onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }),
|
||||
onLoadConfig: () => loadConfig(state as unknown as ConfigState),
|
||||
onLoadConfig: () => loadConfig(state),
|
||||
onLoadExecApprovals: () => {
|
||||
const target =
|
||||
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
|
||||
@@ -863,28 +1012,20 @@ export function renderApp(state: AppViewState) {
|
||||
},
|
||||
onBindDefault: (nodeId) => {
|
||||
if (nodeId) {
|
||||
updateConfigFormValue(
|
||||
state as unknown as ConfigState,
|
||||
["tools", "exec", "node"],
|
||||
nodeId,
|
||||
);
|
||||
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
||||
} else {
|
||||
removeConfigFormValue(state as unknown as ConfigState, [
|
||||
"tools",
|
||||
"exec",
|
||||
"node",
|
||||
]);
|
||||
removeConfigFormValue(state, ["tools", "exec", "node"]);
|
||||
}
|
||||
},
|
||||
onBindAgent: (agentIndex, nodeId) => {
|
||||
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
|
||||
if (nodeId) {
|
||||
updateConfigFormValue(state as unknown as ConfigState, basePath, nodeId);
|
||||
updateConfigFormValue(state, basePath, nodeId);
|
||||
} else {
|
||||
removeConfigFormValue(state as unknown as ConfigState, basePath);
|
||||
removeConfigFormValue(state, basePath);
|
||||
}
|
||||
},
|
||||
onSaveBindings: () => saveConfig(state as unknown as ConfigState),
|
||||
onSaveBindings: () => saveConfig(state),
|
||||
onExecApprovalsTargetChange: (kind, nodeId) => {
|
||||
state.execApprovalsTarget = kind;
|
||||
state.execApprovalsTargetNodeId = nodeId;
|
||||
@@ -919,29 +1060,30 @@ export function renderApp(state: AppViewState) {
|
||||
state.chatMessage = "";
|
||||
state.chatAttachments = [];
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
|
||||
state.chatQueue = [];
|
||||
(state as unknown as OpenClawApp).resetToolStream();
|
||||
(state as unknown as OpenClawApp).resetChatScroll();
|
||||
state.resetToolStream();
|
||||
state.resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
void loadChatHistory(state as unknown as ChatState);
|
||||
void refreshChatAvatar(state as unknown as ChatHost);
|
||||
void loadChatHistory(state);
|
||||
void refreshChatAvatar(state);
|
||||
},
|
||||
thinkingLevel: state.chatThinkingLevel,
|
||||
showThinking,
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
compactionStatus: state.compactionStatus,
|
||||
assistantAvatarUrl: chatAvatarUrl,
|
||||
messages: state.chatMessages,
|
||||
toolMessages: state.chatToolMessages,
|
||||
stream: state.chatStream,
|
||||
streamStartedAt: null,
|
||||
streamStartedAt: state.chatStreamStartedAt,
|
||||
draft: state.chatMessage,
|
||||
queue: state.chatQueue,
|
||||
connected: state.connected,
|
||||
@@ -951,10 +1093,8 @@ export function renderApp(state: AppViewState) {
|
||||
sessions: state.sessionsResult,
|
||||
focusMode: chatFocus,
|
||||
onRefresh: () => {
|
||||
return Promise.all([
|
||||
loadChatHistory(state as unknown as ChatState),
|
||||
refreshChatAvatar(state as unknown as ChatHost),
|
||||
]);
|
||||
state.resetToolStream();
|
||||
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
|
||||
},
|
||||
onToggleFocusMode: () => {
|
||||
if (state.onboarding) {
|
||||
@@ -965,28 +1105,25 @@ export function renderApp(state: AppViewState) {
|
||||
chatFocusMode: !state.settings.chatFocusMode,
|
||||
});
|
||||
},
|
||||
onChatScroll: (event) => (state as unknown as OpenClawApp).handleChatScroll(event),
|
||||
onChatScroll: (event) => state.handleChatScroll(event),
|
||||
onDraftChange: (next) => (state.chatMessage = next),
|
||||
attachments: state.chatAttachments,
|
||||
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||
onSend: () => (state as unknown as OpenClawApp).handleSendChat(),
|
||||
onSend: () => state.handleSendChat(),
|
||||
canAbort: Boolean(state.chatRunId),
|
||||
onAbort: () => void (state as unknown as OpenClawApp).handleAbortChat(),
|
||||
onQueueRemove: (id) => (state as unknown as OpenClawApp).removeQueuedMessage(id),
|
||||
onNewSession: () =>
|
||||
(state as unknown as OpenClawApp).handleSendChat("/new", { restoreDraft: true }),
|
||||
onAbort: () => void state.handleAbortChat(),
|
||||
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
|
||||
showNewMessages: state.chatNewMessagesBelow,
|
||||
onScrollToBottom: () => state.scrollToBottom(),
|
||||
// Sidebar props for tool output viewing
|
||||
sidebarOpen: (state as unknown as OpenClawApp).sidebarOpen,
|
||||
sidebarContent: (state as unknown as OpenClawApp).sidebarContent,
|
||||
sidebarError: (state as unknown as OpenClawApp).sidebarError,
|
||||
splitRatio: (state as unknown as OpenClawApp).splitRatio,
|
||||
onOpenSidebar: (content: string) =>
|
||||
(state as unknown as OpenClawApp).handleOpenSidebar(content),
|
||||
onCloseSidebar: () => (state as unknown as OpenClawApp).handleCloseSidebar(),
|
||||
onSplitRatioChange: (ratio: number) =>
|
||||
(state as unknown as OpenClawApp).handleSplitRatioChange(ratio),
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
sidebarContent: state.sidebarContent,
|
||||
sidebarError: state.sidebarError,
|
||||
splitRatio: state.splitRatio,
|
||||
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
|
||||
onCloseSidebar: () => state.handleCloseSidebar(),
|
||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||
assistantName: state.assistantName,
|
||||
assistantAvatar: state.assistantAvatar,
|
||||
})
|
||||
@@ -1007,31 +1144,28 @@ export function renderApp(state: AppViewState) {
|
||||
connected: state.connected,
|
||||
schema: state.configSchema,
|
||||
schemaLoading: state.configSchemaLoading,
|
||||
uiHints: state.configUiHints as ConfigUiHints,
|
||||
uiHints: state.configUiHints,
|
||||
formMode: state.configFormMode,
|
||||
formValue: state.configForm,
|
||||
originalValue: state.configFormOriginal,
|
||||
searchQuery: (state as unknown as OpenClawApp).configSearchQuery,
|
||||
activeSection: (state as unknown as OpenClawApp).configActiveSection,
|
||||
activeSubsection: (state as unknown as OpenClawApp).configActiveSubsection,
|
||||
searchQuery: state.configSearchQuery,
|
||||
activeSection: state.configActiveSection,
|
||||
activeSubsection: state.configActiveSubsection,
|
||||
onRawChange: (next) => {
|
||||
state.configRaw = next;
|
||||
},
|
||||
onFormModeChange: (mode) => (state.configFormMode = mode),
|
||||
onFormPatch: (path, value) =>
|
||||
updateConfigFormValue(state as unknown as OpenClawApp, path, value),
|
||||
onSearchChange: (query) =>
|
||||
((state as unknown as OpenClawApp).configSearchQuery = query),
|
||||
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
|
||||
onSearchChange: (query) => (state.configSearchQuery = query),
|
||||
onSectionChange: (section) => {
|
||||
(state as unknown as OpenClawApp).configActiveSection = section;
|
||||
(state as unknown as OpenClawApp).configActiveSubsection = null;
|
||||
state.configActiveSection = section;
|
||||
state.configActiveSubsection = null;
|
||||
},
|
||||
onSubsectionChange: (section) =>
|
||||
((state as unknown as OpenClawApp).configActiveSubsection = section),
|
||||
onReload: () => loadConfig(state as unknown as OpenClawApp),
|
||||
onSave: () => saveConfig(state as unknown as OpenClawApp),
|
||||
onApply: () => applyConfig(state as unknown as OpenClawApp),
|
||||
onUpdate: () => runUpdate(state as unknown as OpenClawApp),
|
||||
onSubsectionChange: (section) => (state.configActiveSubsection = section),
|
||||
onReload: () => loadConfig(state),
|
||||
onSave: () => saveConfig(state),
|
||||
onApply: () => applyConfig(state),
|
||||
onUpdate: () => runUpdate(state),
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
@@ -1073,10 +1207,9 @@ export function renderApp(state: AppViewState) {
|
||||
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
||||
},
|
||||
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
||||
onRefresh: () => loadLogs(state as unknown as LogsState, { reset: true }),
|
||||
onExport: (lines, label) =>
|
||||
(state as unknown as OpenClawApp).exportLogs(lines, label),
|
||||
onScroll: (event) => (state as unknown as OpenClawApp).handleLogsScroll(event),
|
||||
onRefresh: () => loadLogs(state, { reset: true }),
|
||||
onExport: (lines, label) => state.exportLogs(lines, label),
|
||||
onScroll: (event) => state.handleLogsScroll(event),
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OpenClawApp } from "./app.ts";
|
||||
import type { AgentsListResult } from "./types.ts";
|
||||
import { refreshChat } from "./app-chat.ts";
|
||||
import {
|
||||
startLogsPolling,
|
||||
@@ -35,6 +36,7 @@ import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme.ts";
|
||||
|
||||
type SettingsHost = {
|
||||
settings: UiSettings;
|
||||
password?: string;
|
||||
theme: ThemeMode;
|
||||
themeResolved: ResolvedTheme;
|
||||
applySessionKey: string;
|
||||
@@ -46,35 +48,14 @@ type SettingsHost = {
|
||||
eventLog: unknown[];
|
||||
eventLogBuffer: unknown[];
|
||||
basePath: string;
|
||||
agentsList?: AgentsListResult | null;
|
||||
agentsSelectedId?: string | null;
|
||||
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
|
||||
themeMedia: MediaQueryList | null;
|
||||
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
|
||||
pendingGatewayUrl?: string | null;
|
||||
};
|
||||
|
||||
function isTopLevelWindow(): boolean {
|
||||
try {
|
||||
return window.top === window.self;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGatewayUrl(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function applySettings(host: SettingsHost, next: UiSettings) {
|
||||
const normalized = {
|
||||
...next,
|
||||
@@ -117,6 +98,10 @@ export function applySettingsFromUrl(host: SettingsHost) {
|
||||
}
|
||||
|
||||
if (passwordRaw != null) {
|
||||
const password = passwordRaw.trim();
|
||||
if (password) {
|
||||
(host as { password: string }).password = password;
|
||||
}
|
||||
params.delete("password");
|
||||
shouldCleanUrl = true;
|
||||
}
|
||||
@@ -134,8 +119,8 @@ export function applySettingsFromUrl(host: SettingsHost) {
|
||||
}
|
||||
|
||||
if (gatewayUrlRaw != null) {
|
||||
const gatewayUrl = normalizeGatewayUrl(gatewayUrlRaw);
|
||||
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl && isTopLevelWindow()) {
|
||||
const gatewayUrl = gatewayUrlRaw.trim();
|
||||
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
|
||||
host.pendingGatewayUrl = gatewayUrl;
|
||||
}
|
||||
params.delete("gatewayUrl");
|
||||
@@ -205,24 +190,23 @@ export async function refreshActiveTab(host: SettingsHost) {
|
||||
await loadSkills(host as unknown as OpenClawApp);
|
||||
}
|
||||
if (host.tab === "agents") {
|
||||
const app = host as unknown as OpenClawApp;
|
||||
await loadAgents(app);
|
||||
await loadConfig(app);
|
||||
const agentIds = app.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
||||
await loadAgents(host as unknown as OpenClawApp);
|
||||
await loadConfig(host as unknown as OpenClawApp);
|
||||
const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
||||
if (agentIds.length > 0) {
|
||||
void loadAgentIdentities(app, agentIds);
|
||||
void loadAgentIdentities(host as unknown as OpenClawApp, agentIds);
|
||||
}
|
||||
const agentId =
|
||||
app.agentsSelectedId ?? app.agentsList?.defaultId ?? app.agentsList?.agents?.[0]?.id;
|
||||
host.agentsSelectedId ?? host.agentsList?.defaultId ?? host.agentsList?.agents?.[0]?.id;
|
||||
if (agentId) {
|
||||
void loadAgentIdentity(app, agentId);
|
||||
if (app.agentsPanel === "skills") {
|
||||
void loadAgentSkills(app, agentId);
|
||||
void loadAgentIdentity(host as unknown as OpenClawApp, agentId);
|
||||
if (host.agentsPanel === "skills") {
|
||||
void loadAgentSkills(host as unknown as OpenClawApp, agentId);
|
||||
}
|
||||
if (app.agentsPanel === "channels") {
|
||||
void loadChannels(app, false);
|
||||
if (host.agentsPanel === "channels") {
|
||||
void loadChannels(host as unknown as OpenClawApp, false);
|
||||
}
|
||||
if (app.agentsPanel === "cron") {
|
||||
if (host.agentsPanel === "cron") {
|
||||
void loadCron(host);
|
||||
}
|
||||
}
|
||||
@@ -397,7 +381,7 @@ export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
export function syncUrlWithSessionKey(sessionKey: string, replace: boolean) {
|
||||
export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, replace: boolean) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EventLogEntry } from "./app-events.ts";
|
||||
import type { CompactionStatus } from "./app-tool-stream.ts";
|
||||
import type { DevicePairingList } from "./controllers/devices.ts";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
||||
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
|
||||
@@ -14,6 +15,7 @@ import type {
|
||||
AgentIdentityResult,
|
||||
ChannelsStatusSnapshot,
|
||||
ConfigSnapshot,
|
||||
ConfigUiHints,
|
||||
CronJob,
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
@@ -22,12 +24,16 @@ import type {
|
||||
LogLevel,
|
||||
NostrProfile,
|
||||
PresenceEntry,
|
||||
SessionsUsageResult,
|
||||
CostUsageSummary,
|
||||
SessionUsageTimeSeries,
|
||||
SessionsListResult,
|
||||
SkillStatusReport,
|
||||
StatusSummary,
|
||||
} from "./types.ts";
|
||||
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types.ts";
|
||||
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
|
||||
import type { SessionLogEntry } from "./views/usage.ts";
|
||||
|
||||
export type AppViewState = {
|
||||
settings: UiSettings;
|
||||
@@ -52,13 +58,19 @@ export type AppViewState = {
|
||||
chatMessages: unknown[];
|
||||
chatToolMessages: unknown[];
|
||||
chatStream: string | null;
|
||||
chatStreamStartedAt: number | null;
|
||||
chatRunId: string | null;
|
||||
compactionStatus: CompactionStatus | null;
|
||||
chatAvatarUrl: string | null;
|
||||
chatThinkingLevel: string | null;
|
||||
chatQueue: ChatQueueItem[];
|
||||
nodesLoading: boolean;
|
||||
nodes: Array<Record<string, unknown>>;
|
||||
chatNewMessagesBelow: boolean;
|
||||
sidebarOpen: boolean;
|
||||
sidebarContent: string | null;
|
||||
sidebarError: string | null;
|
||||
splitRatio: number;
|
||||
scrollToBottom: () => void;
|
||||
devicesLoading: boolean;
|
||||
devicesError: string | null;
|
||||
@@ -83,13 +95,18 @@ export type AppViewState = {
|
||||
configSaving: boolean;
|
||||
configApplying: boolean;
|
||||
updateRunning: boolean;
|
||||
applySessionKey: string;
|
||||
configSnapshot: ConfigSnapshot | null;
|
||||
configSchema: unknown;
|
||||
configSchemaVersion: string | null;
|
||||
configSchemaLoading: boolean;
|
||||
configUiHints: Record<string, unknown>;
|
||||
configUiHints: ConfigUiHints;
|
||||
configForm: Record<string, unknown> | null;
|
||||
configFormOriginal: Record<string, unknown> | null;
|
||||
configFormMode: "form" | "raw";
|
||||
configSearchQuery: string;
|
||||
configActiveSection: string | null;
|
||||
configActiveSubsection: string | null;
|
||||
channelsLoading: boolean;
|
||||
channelsSnapshot: ChannelsStatusSnapshot | null;
|
||||
channelsError: string | null;
|
||||
@@ -131,6 +148,39 @@ export type AppViewState = {
|
||||
sessionsFilterLimit: string;
|
||||
sessionsIncludeGlobal: boolean;
|
||||
sessionsIncludeUnknown: boolean;
|
||||
usageLoading: boolean;
|
||||
usageResult: SessionsUsageResult | null;
|
||||
usageCostSummary: CostUsageSummary | null;
|
||||
usageError: string | null;
|
||||
usageStartDate: string;
|
||||
usageEndDate: string;
|
||||
usageSelectedSessions: string[];
|
||||
usageSelectedDays: string[];
|
||||
usageSelectedHours: number[];
|
||||
usageChartMode: "tokens" | "cost";
|
||||
usageDailyChartMode: "total" | "by-type";
|
||||
usageTimeSeriesMode: "cumulative" | "per-turn";
|
||||
usageTimeSeriesBreakdownMode: "total" | "by-type";
|
||||
usageTimeSeries: SessionUsageTimeSeries | null;
|
||||
usageTimeSeriesLoading: boolean;
|
||||
usageSessionLogs: SessionLogEntry[] | null;
|
||||
usageSessionLogsLoading: boolean;
|
||||
usageSessionLogsExpanded: boolean;
|
||||
usageQuery: string;
|
||||
usageQueryDraft: string;
|
||||
usageQueryDebounceTimer: number | null;
|
||||
usageSessionSort: "tokens" | "cost" | "recent" | "messages" | "errors";
|
||||
usageSessionSortDir: "asc" | "desc";
|
||||
usageRecentSessions: string[];
|
||||
usageTimeZone: "local" | "utc";
|
||||
usageContextExpanded: boolean;
|
||||
usageHeaderPinned: boolean;
|
||||
usageSessionsTab: "all" | "recent";
|
||||
usageVisibleColumns: string[];
|
||||
usageLogFilterRoles: import("./views/usage.js").SessionLogRole[];
|
||||
usageLogFilterTools: string[];
|
||||
usageLogFilterHasTools: boolean;
|
||||
usageLogFilterQuery: string;
|
||||
cronLoading: boolean;
|
||||
cronJobs: CronJob[];
|
||||
cronStatus: CronStatus | null;
|
||||
@@ -163,7 +213,13 @@ export type AppViewState = {
|
||||
logsLevelFilters: Record<LogLevel, boolean>;
|
||||
logsAutoFollow: boolean;
|
||||
logsTruncated: boolean;
|
||||
logsCursor: number | null;
|
||||
logsLastFetchAt: number | null;
|
||||
logsLimit: number;
|
||||
logsMaxBytes: number;
|
||||
logsAtBottom: boolean;
|
||||
client: GatewayBrowserClient | null;
|
||||
refreshSessionsAfterChat: Set<string>;
|
||||
connect: () => void;
|
||||
setTab: (tab: Tab) => void;
|
||||
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||
@@ -214,13 +270,15 @@ export type AppViewState = {
|
||||
setPassword: (next: string) => void;
|
||||
setSessionKey: (next: string) => void;
|
||||
setChatMessage: (next: string) => void;
|
||||
handleChatSend: () => Promise<void>;
|
||||
handleChatAbort: () => Promise<void>;
|
||||
handleChatSelectQueueItem: (id: string) => void;
|
||||
handleChatDropQueueItem: (id: string) => void;
|
||||
handleChatClearQueue: () => void;
|
||||
handleLogsFilterChange: (next: string) => void;
|
||||
handleLogsLevelFilterToggle: (level: LogLevel) => void;
|
||||
handleLogsAutoFollowToggle: (next: boolean) => void;
|
||||
handleCallDebugMethod: (method: string, params: string) => Promise<void>;
|
||||
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
|
||||
handleAbortChat: () => Promise<void>;
|
||||
removeQueuedMessage: (id: string) => void;
|
||||
handleChatScroll: (event: Event) => void;
|
||||
resetToolStream: () => void;
|
||||
resetChatScroll: () => void;
|
||||
exportLogs: (lines: string[], label: string) => void;
|
||||
handleLogsScroll: (event: Event) => void;
|
||||
handleOpenSidebar: (content: string) => void;
|
||||
handleCloseSidebar: () => void;
|
||||
handleSplitRatioChange: (ratio: number) => void;
|
||||
};
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
import {
|
||||
resetToolStream as resetToolStreamInternal,
|
||||
type ToolStreamEntry,
|
||||
type CompactionStatus,
|
||||
} from "./app-tool-stream.ts";
|
||||
import { resolveInjectedAssistantIdentity } from "./assistant-identity.ts";
|
||||
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts";
|
||||
@@ -130,7 +131,7 @@ export class OpenClawApp extends LitElement {
|
||||
@state() chatStream: string | null = null;
|
||||
@state() chatStreamStartedAt: number | null = null;
|
||||
@state() chatRunId: string | null = null;
|
||||
@state() compactionStatus: import("./app-tool-stream.ts").CompactionStatus | null = null;
|
||||
@state() compactionStatus: CompactionStatus | null = null;
|
||||
@state() chatAvatarUrl: string | null = null;
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
@state() chatQueue: ChatQueueItem[] = [];
|
||||
@@ -226,6 +227,59 @@ export class OpenClawApp extends LitElement {
|
||||
@state() sessionsIncludeGlobal = true;
|
||||
@state() sessionsIncludeUnknown = false;
|
||||
|
||||
@state() usageLoading = false;
|
||||
@state() usageResult: import("./types.js").SessionsUsageResult | null = null;
|
||||
@state() usageCostSummary: import("./types.js").CostUsageSummary | null = null;
|
||||
@state() usageError: string | null = null;
|
||||
@state() usageStartDate = (() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
})();
|
||||
@state() usageEndDate = (() => {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
})();
|
||||
@state() usageSelectedSessions: string[] = [];
|
||||
@state() usageSelectedDays: string[] = [];
|
||||
@state() usageSelectedHours: number[] = [];
|
||||
@state() usageChartMode: "tokens" | "cost" = "tokens";
|
||||
@state() usageDailyChartMode: "total" | "by-type" = "by-type";
|
||||
@state() usageTimeSeriesMode: "cumulative" | "per-turn" = "per-turn";
|
||||
@state() usageTimeSeriesBreakdownMode: "total" | "by-type" = "by-type";
|
||||
@state() usageTimeSeries: import("./types.js").SessionUsageTimeSeries | null = null;
|
||||
@state() usageTimeSeriesLoading = false;
|
||||
@state() usageSessionLogs: import("./views/usage.js").SessionLogEntry[] | null = null;
|
||||
@state() usageSessionLogsLoading = false;
|
||||
@state() usageSessionLogsExpanded = false;
|
||||
// Applied query (used to filter the already-loaded sessions list client-side).
|
||||
@state() usageQuery = "";
|
||||
// Draft query text (updates immediately as the user types; applied via debounce or "Search").
|
||||
@state() usageQueryDraft = "";
|
||||
@state() usageSessionSort: "tokens" | "cost" | "recent" | "messages" | "errors" = "recent";
|
||||
@state() usageSessionSortDir: "desc" | "asc" = "desc";
|
||||
@state() usageRecentSessions: string[] = [];
|
||||
@state() usageTimeZone: "local" | "utc" = "local";
|
||||
@state() usageContextExpanded = false;
|
||||
@state() usageHeaderPinned = false;
|
||||
@state() usageSessionsTab: "all" | "recent" = "all";
|
||||
@state() usageVisibleColumns: string[] = [
|
||||
"channel",
|
||||
"agent",
|
||||
"provider",
|
||||
"model",
|
||||
"messages",
|
||||
"tools",
|
||||
"errors",
|
||||
"duration",
|
||||
];
|
||||
@state() usageLogFilterRoles: import("./views/usage.js").SessionLogRole[] = [];
|
||||
@state() usageLogFilterTools: string[] = [];
|
||||
@state() usageLogFilterHasTools = false;
|
||||
@state() usageLogFilterQuery = "";
|
||||
|
||||
// Non-reactive (don’t trigger renders just for timer bookkeeping).
|
||||
usageQueryDebounceTimer: number | null = null;
|
||||
|
||||
@state() cronLoading = false;
|
||||
@state() cronJobs: CronJob[] = [];
|
||||
@state() cronStatus: CronStatus | null = null;
|
||||
|
||||
107
ui/src/ui/controllers/usage.ts
Normal file
107
ui/src/ui/controllers/usage.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { SessionsUsageResult, CostUsageSummary, SessionUsageTimeSeries } from "../types.ts";
|
||||
import type { SessionLogEntry } from "../views/usage.ts";
|
||||
|
||||
export type UsageState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
usageLoading: boolean;
|
||||
usageResult: SessionsUsageResult | null;
|
||||
usageCostSummary: CostUsageSummary | null;
|
||||
usageError: string | null;
|
||||
usageStartDate: string;
|
||||
usageEndDate: string;
|
||||
usageSelectedSessions: string[];
|
||||
usageSelectedDays: string[];
|
||||
usageTimeSeries: SessionUsageTimeSeries | null;
|
||||
usageTimeSeriesLoading: boolean;
|
||||
usageSessionLogs: SessionLogEntry[] | null;
|
||||
usageSessionLogsLoading: boolean;
|
||||
};
|
||||
|
||||
export async function loadUsage(
|
||||
state: UsageState,
|
||||
overrides?: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
},
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.usageLoading) {
|
||||
return;
|
||||
}
|
||||
state.usageLoading = true;
|
||||
state.usageError = null;
|
||||
try {
|
||||
const startDate = overrides?.startDate ?? state.usageStartDate;
|
||||
const endDate = overrides?.endDate ?? state.usageEndDate;
|
||||
|
||||
// Load both endpoints in parallel
|
||||
const [sessionsRes, costRes] = await Promise.all([
|
||||
state.client.request("sessions.usage", {
|
||||
startDate,
|
||||
endDate,
|
||||
limit: 1000, // Cap at 1000 sessions
|
||||
includeContextWeight: true,
|
||||
}),
|
||||
state.client.request("usage.cost", { startDate, endDate }),
|
||||
]);
|
||||
|
||||
if (sessionsRes) {
|
||||
state.usageResult = sessionsRes as SessionsUsageResult;
|
||||
}
|
||||
if (costRes) {
|
||||
state.usageCostSummary = costRes as CostUsageSummary;
|
||||
}
|
||||
} catch (err) {
|
||||
state.usageError = String(err);
|
||||
} finally {
|
||||
state.usageLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSessionTimeSeries(state: UsageState, sessionKey: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.usageTimeSeriesLoading) {
|
||||
return;
|
||||
}
|
||||
state.usageTimeSeriesLoading = true;
|
||||
state.usageTimeSeries = null;
|
||||
try {
|
||||
const res = await state.client.request("sessions.usage.timeseries", { key: sessionKey });
|
||||
if (res) {
|
||||
state.usageTimeSeries = res as SessionUsageTimeSeries;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - time series is optional
|
||||
state.usageTimeSeries = null;
|
||||
} finally {
|
||||
state.usageTimeSeriesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSessionLogs(state: UsageState, sessionKey: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.usageSessionLogsLoading) {
|
||||
return;
|
||||
}
|
||||
state.usageSessionLogsLoading = true;
|
||||
state.usageSessionLogs = null;
|
||||
try {
|
||||
const res = await state.client.request("sessions.usage.logs", { key: sessionKey, limit: 500 });
|
||||
if (res && Array.isArray((res as { logs: SessionLogEntry[] }).logs)) {
|
||||
state.usageSessionLogs = (res as { logs: SessionLogEntry[] }).logs;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - logs are optional
|
||||
state.usageSessionLogs = null;
|
||||
} finally {
|
||||
state.usageSessionLogsLoading = false;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ export const TAB_GROUPS = [
|
||||
{ label: "Chat", tabs: ["chat"] },
|
||||
{
|
||||
label: "Control",
|
||||
tabs: ["overview", "channels", "instances", "sessions", "cron"],
|
||||
tabs: ["overview", "channels", "instances", "sessions", "usage", "cron"],
|
||||
},
|
||||
{ label: "Agent", tabs: ["agents", "skills", "nodes"] },
|
||||
{ label: "Settings", tabs: ["config", "debug", "logs"] },
|
||||
@@ -16,6 +16,7 @@ export type Tab =
|
||||
| "channels"
|
||||
| "instances"
|
||||
| "sessions"
|
||||
| "usage"
|
||||
| "cron"
|
||||
| "skills"
|
||||
| "nodes"
|
||||
@@ -30,6 +31,7 @@ const TAB_PATHS: Record<Tab, string> = {
|
||||
channels: "/channels",
|
||||
instances: "/instances",
|
||||
sessions: "/sessions",
|
||||
usage: "/usage",
|
||||
cron: "/cron",
|
||||
skills: "/skills",
|
||||
nodes: "/nodes",
|
||||
@@ -134,6 +136,8 @@ export function iconForTab(tab: Tab): IconName {
|
||||
return "radio";
|
||||
case "sessions":
|
||||
return "fileText";
|
||||
case "usage":
|
||||
return "barChart";
|
||||
case "cron":
|
||||
return "loader";
|
||||
case "skills":
|
||||
@@ -163,6 +167,8 @@ export function titleForTab(tab: Tab) {
|
||||
return "Instances";
|
||||
case "sessions":
|
||||
return "Sessions";
|
||||
case "usage":
|
||||
return "Usage";
|
||||
case "cron":
|
||||
return "Cron Jobs";
|
||||
case "skills":
|
||||
@@ -194,6 +200,8 @@ export function subtitleForTab(tab: Tab) {
|
||||
return "Presence beacons from connected clients and nodes.";
|
||||
case "sessions":
|
||||
return "Inspect active sessions and adjust per-session defaults.";
|
||||
case "usage":
|
||||
return "";
|
||||
case "cron":
|
||||
return "Schedule wakeups and recurring agent runs.";
|
||||
case "skills":
|
||||
|
||||
@@ -302,20 +302,20 @@ export type ConfigSchemaResponse = {
|
||||
};
|
||||
|
||||
export type PresenceEntry = {
|
||||
deviceFamily?: string | null;
|
||||
host?: string | null;
|
||||
instanceId?: string | null;
|
||||
host?: string | null;
|
||||
ip?: string | null;
|
||||
lastInputSeconds?: number | null;
|
||||
mode?: string | null;
|
||||
modelIdentifier?: string | null;
|
||||
version?: string | null;
|
||||
platform?: string | null;
|
||||
deviceFamily?: string | null;
|
||||
modelIdentifier?: string | null;
|
||||
roles?: string[] | null;
|
||||
scopes?: string[] | null;
|
||||
mode?: string | null;
|
||||
lastInputSeconds?: number | null;
|
||||
reason?: string | null;
|
||||
roles?: Array<string | null> | null;
|
||||
scopes?: Array<string | null> | null;
|
||||
text?: string | null;
|
||||
ts?: number | null;
|
||||
version?: string | null;
|
||||
};
|
||||
|
||||
export type GatewaySessionsDefaults = {
|
||||
@@ -424,6 +424,223 @@ export type SessionsPatchResult = {
|
||||
};
|
||||
};
|
||||
|
||||
export type SessionsUsageEntry = {
|
||||
key: string;
|
||||
label?: string;
|
||||
sessionId?: string;
|
||||
updatedAt?: number;
|
||||
agentId?: string;
|
||||
channel?: string;
|
||||
chatType?: string;
|
||||
origin?: {
|
||||
label?: string;
|
||||
provider?: string;
|
||||
surface?: string;
|
||||
chatType?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
modelOverride?: string;
|
||||
providerOverride?: string;
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
usage: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
inputCost?: number;
|
||||
outputCost?: number;
|
||||
cacheReadCost?: number;
|
||||
cacheWriteCost?: number;
|
||||
missingCostEntries: number;
|
||||
firstActivity?: number;
|
||||
lastActivity?: number;
|
||||
durationMs?: number;
|
||||
activityDates?: string[]; // YYYY-MM-DD dates when session had activity
|
||||
dailyBreakdown?: Array<{ date: string; tokens: number; cost: number }>;
|
||||
dailyMessageCounts?: Array<{
|
||||
date: string;
|
||||
total: number;
|
||||
user: number;
|
||||
assistant: number;
|
||||
toolCalls: number;
|
||||
toolResults: number;
|
||||
errors: number;
|
||||
}>;
|
||||
dailyLatency?: Array<{
|
||||
date: string;
|
||||
count: number;
|
||||
avgMs: number;
|
||||
p95Ms: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
}>;
|
||||
dailyModelUsage?: Array<{
|
||||
date: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
count: number;
|
||||
}>;
|
||||
messageCounts?: {
|
||||
total: number;
|
||||
user: number;
|
||||
assistant: number;
|
||||
toolCalls: number;
|
||||
toolResults: number;
|
||||
errors: number;
|
||||
};
|
||||
toolUsage?: {
|
||||
totalCalls: number;
|
||||
uniqueTools: number;
|
||||
tools: Array<{ name: string; count: number }>;
|
||||
};
|
||||
modelUsage?: Array<{
|
||||
provider?: string;
|
||||
model?: string;
|
||||
count: number;
|
||||
totals: SessionsUsageTotals;
|
||||
}>;
|
||||
latency?: {
|
||||
count: number;
|
||||
avgMs: number;
|
||||
p95Ms: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
};
|
||||
} | null;
|
||||
contextWeight?: {
|
||||
systemPrompt: { chars: number; projectContextChars: number; nonProjectContextChars: number };
|
||||
skills: { promptChars: number; entries: Array<{ name: string; blockChars: number }> };
|
||||
tools: {
|
||||
listChars: number;
|
||||
schemaChars: number;
|
||||
entries: Array<{ name: string; summaryChars: number; schemaChars: number }>;
|
||||
};
|
||||
injectedWorkspaceFiles: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
rawChars: number;
|
||||
injectedChars: number;
|
||||
truncated: boolean;
|
||||
}>;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type SessionsUsageTotals = {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
inputCost: number;
|
||||
outputCost: number;
|
||||
cacheReadCost: number;
|
||||
cacheWriteCost: number;
|
||||
missingCostEntries: number;
|
||||
};
|
||||
|
||||
export type SessionsUsageResult = {
|
||||
updatedAt: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
sessions: SessionsUsageEntry[];
|
||||
totals: SessionsUsageTotals;
|
||||
aggregates: {
|
||||
messages: {
|
||||
total: number;
|
||||
user: number;
|
||||
assistant: number;
|
||||
toolCalls: number;
|
||||
toolResults: number;
|
||||
errors: number;
|
||||
};
|
||||
tools: {
|
||||
totalCalls: number;
|
||||
uniqueTools: number;
|
||||
tools: Array<{ name: string; count: number }>;
|
||||
};
|
||||
byModel: Array<{
|
||||
provider?: string;
|
||||
model?: string;
|
||||
count: number;
|
||||
totals: SessionsUsageTotals;
|
||||
}>;
|
||||
byProvider: Array<{
|
||||
provider?: string;
|
||||
model?: string;
|
||||
count: number;
|
||||
totals: SessionsUsageTotals;
|
||||
}>;
|
||||
byAgent: Array<{ agentId: string; totals: SessionsUsageTotals }>;
|
||||
byChannel: Array<{ channel: string; totals: SessionsUsageTotals }>;
|
||||
latency?: {
|
||||
count: number;
|
||||
avgMs: number;
|
||||
p95Ms: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
};
|
||||
dailyLatency?: Array<{
|
||||
date: string;
|
||||
count: number;
|
||||
avgMs: number;
|
||||
p95Ms: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
}>;
|
||||
modelDaily?: Array<{
|
||||
date: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
count: number;
|
||||
}>;
|
||||
daily: Array<{
|
||||
date: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
messages: number;
|
||||
toolCalls: number;
|
||||
errors: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export type CostUsageDailyEntry = SessionsUsageTotals & { date: string };
|
||||
|
||||
export type CostUsageSummary = {
|
||||
updatedAt: number;
|
||||
days: number;
|
||||
daily: CostUsageDailyEntry[];
|
||||
totals: SessionsUsageTotals;
|
||||
};
|
||||
|
||||
export type SessionUsageTimePoint = {
|
||||
timestamp: number;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens: number;
|
||||
cost: number;
|
||||
cumulativeTokens: number;
|
||||
cumulativeCost: number;
|
||||
};
|
||||
|
||||
export type SessionUsageTimeSeries = {
|
||||
sessionId?: string;
|
||||
points: SessionUsageTimePoint[];
|
||||
};
|
||||
|
||||
export type CronSchedule =
|
||||
| { kind: "at"; at: string }
|
||||
| { kind: "every"; everyMs: number; anchorMs?: number }
|
||||
@@ -506,10 +723,10 @@ export type SkillStatusEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
source: string;
|
||||
bundled?: boolean;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
skillKey: string;
|
||||
bundled?: boolean;
|
||||
primaryEnv?: string;
|
||||
emoji?: string;
|
||||
homepage?: string;
|
||||
|
||||
43
ui/src/ui/usage-helpers.node.test.ts
Normal file
43
ui/src/ui/usage-helpers.node.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "./usage-helpers.ts";
|
||||
|
||||
describe("usage-helpers", () => {
|
||||
it("tokenizes query terms including quoted strings", () => {
|
||||
const terms = extractQueryTerms('agent:main "model:gpt-5.2" has:errors');
|
||||
expect(terms.map((t) => t.raw)).toEqual(["agent:main", "model:gpt-5.2", "has:errors"]);
|
||||
});
|
||||
|
||||
it("matches key: glob filters against session keys", () => {
|
||||
const session = {
|
||||
key: "agent:main:cron:16234bc?token=dev-token",
|
||||
label: "agent:main:cron:16234bc?token=dev-token",
|
||||
usage: { totalTokens: 100, totalCost: 0 },
|
||||
};
|
||||
const matches = filterSessionsByQuery([session], "key:agent:main:cron*");
|
||||
expect(matches.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("supports numeric filters like minTokens/maxTokens", () => {
|
||||
const a = { key: "a", label: "a", usage: { totalTokens: 100, totalCost: 0 } };
|
||||
const b = { key: "b", label: "b", usage: { totalTokens: 5, totalCost: 0 } };
|
||||
expect(filterSessionsByQuery([a, b], "minTokens:10").sessions).toEqual([a]);
|
||||
expect(filterSessionsByQuery([a, b], "maxTokens:10").sessions).toEqual([b]);
|
||||
});
|
||||
|
||||
it("warns on unknown keys and invalid numbers", () => {
|
||||
const session = { key: "a", usage: { totalTokens: 10, totalCost: 0 } };
|
||||
const res = filterSessionsByQuery([session], "wat:1 minTokens:wat");
|
||||
expect(res.warnings.some((w) => w.includes("Unknown filter"))).toBe(true);
|
||||
expect(res.warnings.some((w) => w.includes("Invalid number"))).toBe(true);
|
||||
});
|
||||
|
||||
it("parses tool summaries from compact session logs", () => {
|
||||
const res = parseToolSummary(
|
||||
"[Tool: read]\n[Tool Result]\n[Tool: exec]\n[Tool: read]\n[Tool Result]",
|
||||
);
|
||||
expect(res.summary).toContain("read");
|
||||
expect(res.summary).toContain("exec");
|
||||
expect(res.tools[0]?.[0]).toBe("read");
|
||||
expect(res.tools[0]?.[1]).toBe(2);
|
||||
});
|
||||
});
|
||||
321
ui/src/ui/usage-helpers.ts
Normal file
321
ui/src/ui/usage-helpers.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
export type UsageQueryTerm = {
|
||||
key?: string;
|
||||
value: string;
|
||||
raw: string;
|
||||
};
|
||||
|
||||
export type UsageQueryResult<TSession> = {
|
||||
sessions: TSession[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
// Minimal shape required for query filtering. The usage view's real session type contains more fields.
|
||||
export type UsageSessionQueryTarget = {
|
||||
key: string;
|
||||
label?: string;
|
||||
sessionId?: string;
|
||||
agentId?: string;
|
||||
channel?: string;
|
||||
chatType?: string;
|
||||
modelProvider?: string;
|
||||
providerOverride?: string;
|
||||
origin?: { provider?: string };
|
||||
model?: string;
|
||||
contextWeight?: unknown;
|
||||
usage?: {
|
||||
totalTokens?: number;
|
||||
totalCost?: number;
|
||||
messageCounts?: { total?: number; errors?: number };
|
||||
toolUsage?: { totalCalls?: number; tools?: Array<{ name: string }> };
|
||||
modelUsage?: Array<{ provider?: string; model?: string }>;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const QUERY_KEYS = new Set([
|
||||
"agent",
|
||||
"channel",
|
||||
"chat",
|
||||
"provider",
|
||||
"model",
|
||||
"tool",
|
||||
"label",
|
||||
"key",
|
||||
"session",
|
||||
"id",
|
||||
"has",
|
||||
"mintokens",
|
||||
"maxtokens",
|
||||
"mincost",
|
||||
"maxcost",
|
||||
"minmessages",
|
||||
"maxmessages",
|
||||
]);
|
||||
|
||||
const normalizeQueryText = (value: string): string => value.trim().toLowerCase();
|
||||
|
||||
const globToRegex = (pattern: string): RegExp => {
|
||||
const escaped = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
return new RegExp(`^${escaped}$`, "i");
|
||||
};
|
||||
|
||||
const parseQueryNumber = (value: string): number | null => {
|
||||
let raw = value.trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (raw.startsWith("$")) {
|
||||
raw = raw.slice(1);
|
||||
}
|
||||
let multiplier = 1;
|
||||
if (raw.endsWith("k")) {
|
||||
multiplier = 1_000;
|
||||
raw = raw.slice(0, -1);
|
||||
} else if (raw.endsWith("m")) {
|
||||
multiplier = 1_000_000;
|
||||
raw = raw.slice(0, -1);
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed * multiplier;
|
||||
};
|
||||
|
||||
export const extractQueryTerms = (query: string): UsageQueryTerm[] => {
|
||||
// Tokenize by whitespace, but allow quoted values with spaces.
|
||||
const rawTokens = query.match(/"[^"]+"|\S+/g) ?? [];
|
||||
return rawTokens.map((token) => {
|
||||
const cleaned = token.replace(/^"|"$/g, "");
|
||||
const idx = cleaned.indexOf(":");
|
||||
if (idx > 0) {
|
||||
const key = cleaned.slice(0, idx);
|
||||
const value = cleaned.slice(idx + 1);
|
||||
return { key, value, raw: cleaned };
|
||||
}
|
||||
return { value: cleaned, raw: cleaned };
|
||||
});
|
||||
};
|
||||
|
||||
const getSessionText = (session: UsageSessionQueryTarget): string[] => {
|
||||
const items: Array<string | undefined> = [session.label, session.key, session.sessionId];
|
||||
return items.filter((item): item is string => Boolean(item)).map((item) => item.toLowerCase());
|
||||
};
|
||||
|
||||
const getSessionProviders = (session: UsageSessionQueryTarget): string[] => {
|
||||
const providers = new Set<string>();
|
||||
if (session.modelProvider) {
|
||||
providers.add(session.modelProvider.toLowerCase());
|
||||
}
|
||||
if (session.providerOverride) {
|
||||
providers.add(session.providerOverride.toLowerCase());
|
||||
}
|
||||
if (session.origin?.provider) {
|
||||
providers.add(session.origin.provider.toLowerCase());
|
||||
}
|
||||
for (const entry of session.usage?.modelUsage ?? []) {
|
||||
if (entry.provider) {
|
||||
providers.add(entry.provider.toLowerCase());
|
||||
}
|
||||
}
|
||||
return Array.from(providers);
|
||||
};
|
||||
|
||||
const getSessionModels = (session: UsageSessionQueryTarget): string[] => {
|
||||
const models = new Set<string>();
|
||||
if (session.model) {
|
||||
models.add(session.model.toLowerCase());
|
||||
}
|
||||
for (const entry of session.usage?.modelUsage ?? []) {
|
||||
if (entry.model) {
|
||||
models.add(entry.model.toLowerCase());
|
||||
}
|
||||
}
|
||||
return Array.from(models);
|
||||
};
|
||||
|
||||
const getSessionTools = (session: UsageSessionQueryTarget): string[] =>
|
||||
(session.usage?.toolUsage?.tools ?? []).map((tool) => tool.name.toLowerCase());
|
||||
|
||||
export const matchesUsageQuery = (
|
||||
session: UsageSessionQueryTarget,
|
||||
term: UsageQueryTerm,
|
||||
): boolean => {
|
||||
const value = normalizeQueryText(term.value ?? "");
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
if (!term.key) {
|
||||
return getSessionText(session).some((text) => text.includes(value));
|
||||
}
|
||||
|
||||
const key = normalizeQueryText(term.key);
|
||||
switch (key) {
|
||||
case "agent":
|
||||
return session.agentId?.toLowerCase().includes(value) ?? false;
|
||||
case "channel":
|
||||
return session.channel?.toLowerCase().includes(value) ?? false;
|
||||
case "chat":
|
||||
return session.chatType?.toLowerCase().includes(value) ?? false;
|
||||
case "provider":
|
||||
return getSessionProviders(session).some((provider) => provider.includes(value));
|
||||
case "model":
|
||||
return getSessionModels(session).some((model) => model.includes(value));
|
||||
case "tool":
|
||||
return getSessionTools(session).some((tool) => tool.includes(value));
|
||||
case "label":
|
||||
return session.label?.toLowerCase().includes(value) ?? false;
|
||||
case "key":
|
||||
case "session":
|
||||
case "id":
|
||||
if (value.includes("*") || value.includes("?")) {
|
||||
const regex = globToRegex(value);
|
||||
return (
|
||||
regex.test(session.key) || (session.sessionId ? regex.test(session.sessionId) : false)
|
||||
);
|
||||
}
|
||||
return (
|
||||
session.key.toLowerCase().includes(value) ||
|
||||
(session.sessionId?.toLowerCase().includes(value) ?? false)
|
||||
);
|
||||
case "has":
|
||||
switch (value) {
|
||||
case "tools":
|
||||
return (session.usage?.toolUsage?.totalCalls ?? 0) > 0;
|
||||
case "errors":
|
||||
return (session.usage?.messageCounts?.errors ?? 0) > 0;
|
||||
case "context":
|
||||
return Boolean(session.contextWeight);
|
||||
case "usage":
|
||||
return Boolean(session.usage);
|
||||
case "model":
|
||||
return getSessionModels(session).length > 0;
|
||||
case "provider":
|
||||
return getSessionProviders(session).length > 0;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
case "mintokens": {
|
||||
const threshold = parseQueryNumber(value);
|
||||
if (threshold === null) {
|
||||
return true;
|
||||
}
|
||||
return (session.usage?.totalTokens ?? 0) >= threshold;
|
||||
}
|
||||
case "maxtokens": {
|
||||
const threshold = parseQueryNumber(value);
|
||||
if (threshold === null) {
|
||||
return true;
|
||||
}
|
||||
return (session.usage?.totalTokens ?? 0) <= threshold;
|
||||
}
|
||||
case "mincost": {
|
||||
const threshold = parseQueryNumber(value);
|
||||
if (threshold === null) {
|
||||
return true;
|
||||
}
|
||||
return (session.usage?.totalCost ?? 0) >= threshold;
|
||||
}
|
||||
case "maxcost": {
|
||||
const threshold = parseQueryNumber(value);
|
||||
if (threshold === null) {
|
||||
return true;
|
||||
}
|
||||
return (session.usage?.totalCost ?? 0) <= threshold;
|
||||
}
|
||||
case "minmessages": {
|
||||
const threshold = parseQueryNumber(value);
|
||||
if (threshold === null) {
|
||||
return true;
|
||||
}
|
||||
return (session.usage?.messageCounts?.total ?? 0) >= threshold;
|
||||
}
|
||||
case "maxmessages": {
|
||||
const threshold = parseQueryNumber(value);
|
||||
if (threshold === null) {
|
||||
return true;
|
||||
}
|
||||
return (session.usage?.messageCounts?.total ?? 0) <= threshold;
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const filterSessionsByQuery = <TSession extends UsageSessionQueryTarget>(
|
||||
sessions: TSession[],
|
||||
query: string,
|
||||
): UsageQueryResult<TSession> => {
|
||||
const terms = extractQueryTerms(query);
|
||||
if (terms.length === 0) {
|
||||
return { sessions, warnings: [] };
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
for (const term of terms) {
|
||||
if (!term.key) {
|
||||
continue;
|
||||
}
|
||||
const normalizedKey = normalizeQueryText(term.key);
|
||||
if (!QUERY_KEYS.has(normalizedKey)) {
|
||||
warnings.push(`Unknown filter: ${term.key}`);
|
||||
continue;
|
||||
}
|
||||
if (term.value === "") {
|
||||
warnings.push(`Missing value for ${term.key}`);
|
||||
}
|
||||
if (normalizedKey === "has") {
|
||||
const allowed = new Set(["tools", "errors", "context", "usage", "model", "provider"]);
|
||||
if (term.value && !allowed.has(normalizeQueryText(term.value))) {
|
||||
warnings.push(`Unknown has:${term.value}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
["mintokens", "maxtokens", "mincost", "maxcost", "minmessages", "maxmessages"].includes(
|
||||
normalizedKey,
|
||||
)
|
||||
) {
|
||||
if (term.value && parseQueryNumber(term.value) === null) {
|
||||
warnings.push(`Invalid number for ${term.key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = sessions.filter((session) =>
|
||||
terms.every((term) => matchesUsageQuery(session, term)),
|
||||
);
|
||||
return { sessions: filtered, warnings };
|
||||
};
|
||||
|
||||
export function parseToolSummary(content: string) {
|
||||
const lines = content.split("\n");
|
||||
const toolCounts = new Map<string, number>();
|
||||
const nonToolLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
const match = /^\[Tool:\s*([^\]]+)\]/.exec(line.trim());
|
||||
if (match) {
|
||||
const name = match[1];
|
||||
toolCounts.set(name, (toolCounts.get(name) ?? 0) + 1);
|
||||
continue;
|
||||
}
|
||||
if (line.trim().startsWith("[Tool Result]")) {
|
||||
continue;
|
||||
}
|
||||
nonToolLines.push(line);
|
||||
}
|
||||
const sortedTools = Array.from(toolCounts.entries()).toSorted((a, b) => b[1] - a[1]);
|
||||
const totalCalls = sortedTools.reduce((sum, [, count]) => sum + count, 0);
|
||||
const summary =
|
||||
sortedTools.length > 0
|
||||
? `Tools: ${sortedTools
|
||||
.map(([name, count]) => `${name}×${count}`)
|
||||
.join(", ")} (${totalCalls} calls)`
|
||||
: "";
|
||||
return {
|
||||
tools: sortedTools,
|
||||
summary,
|
||||
cleanContent: nonToolLines.join("\n").trim(),
|
||||
};
|
||||
}
|
||||
5432
ui/src/ui/views/usage.ts
Normal file
5432
ui/src/ui/views/usage.ts
Normal file
File diff suppressed because it is too large
Load Diff
9
ui/vitest.node.config.ts
Normal file
9
ui/vitest.node.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Node-only tests for pure logic (no Playwright/browser dependency).
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.node.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user